b340aa97b0
The 13-file refactor pass (commitsdb00a5d4througheca15b4e) introduced ~89 clippy errors and 38 cargo fmt issues — every agent in every worktree hit them on script/test, burning their turn budget on cleanup before doing real story work. This is the silent kill behind 644, 652, 655, 664, 667 all hitting watchdog limits this round. Changes: - cargo fmt --all across 37 files (formatting normalisation only) - #![allow(unused_imports, dead_code)] on 24 split modules where the python-script splitter imported liberally to be safe; tighter cleanup per-import will happen as agents touch each module - Removed truly-dead re-exports (cleanup_merge_workspace, slog_warn from http/mcp/mod.rs, CliArgs/print_help from main.rs) - Prefixed _auth_msg in crdt_sync/server.rs (handshake helper return is bound but not consumed) - Converted dangling /// doc block in crdt_sync/mod.rs to //! so it attaches to the module - Removed empty lines after doc comments in 4 spots (clippy lint) All 2636 tests pass; clippy --all-targets -- -D warnings clean.
584 lines
20 KiB
Rust
584 lines
20 KiB
Rust
//! HTTP MCP server module.
|
|
|
|
use crate::http::context::AppContext;
|
|
use poem::handler;
|
|
use poem::http::StatusCode;
|
|
use poem::web::Data;
|
|
use poem::{Body, Request, Response};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use std::sync::Arc;
|
|
|
|
pub mod agent_tools;
|
|
pub mod diagnostics;
|
|
pub mod git_tools;
|
|
pub mod merge_tools;
|
|
pub mod qa_tools;
|
|
pub mod shell_tools;
|
|
pub mod status_tools;
|
|
pub mod story_tools;
|
|
pub mod wizard_tools;
|
|
|
|
mod dispatch;
|
|
mod tools_list;
|
|
|
|
use dispatch::handle_tools_call;
|
|
use tools_list::handle_tools_list;
|
|
|
|
/// Returns true when the Accept header includes text/event-stream.
|
|
fn wants_sse(req: &Request) -> bool {
|
|
req.header("accept")
|
|
.unwrap_or("")
|
|
.contains("text/event-stream")
|
|
}
|
|
|
|
// ── JSON-RPC structs ──────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct JsonRpcRequest {
|
|
jsonrpc: String,
|
|
id: Option<Value>,
|
|
method: String,
|
|
#[serde(default)]
|
|
params: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub(super) struct JsonRpcResponse {
|
|
jsonrpc: &'static str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
id: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
result: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<JsonRpcError>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct JsonRpcError {
|
|
code: i64,
|
|
message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
data: Option<Value>,
|
|
}
|
|
|
|
impl JsonRpcResponse {
|
|
pub(super) fn success(id: Option<Value>, result: Value) -> Self {
|
|
Self {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: Some(result),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn error(id: Option<Value>, code: i64, message: String) -> Self {
|
|
Self {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: None,
|
|
error: Some(JsonRpcError {
|
|
code,
|
|
message,
|
|
data: None,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Poem handlers ─────────────────────────────────────────────────
|
|
|
|
#[handler]
|
|
pub async fn mcp_get_handler() -> Response {
|
|
Response::builder()
|
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
|
.body(Body::empty())
|
|
}
|
|
|
|
#[handler]
|
|
pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response {
|
|
// Validate Content-Type
|
|
let content_type = req.header("content-type").unwrap_or("");
|
|
if !content_type.is_empty() && !content_type.contains("application/json") {
|
|
return json_rpc_error_response(
|
|
None,
|
|
-32700,
|
|
"Unsupported Content-Type; expected application/json".into(),
|
|
);
|
|
}
|
|
|
|
let bytes = match body.into_bytes().await {
|
|
Ok(b) => b,
|
|
Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()),
|
|
};
|
|
|
|
let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) {
|
|
Ok(r) => r,
|
|
Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()),
|
|
};
|
|
|
|
if rpc.jsonrpc != "2.0" {
|
|
return json_rpc_error_response(rpc.id, -32600, "Invalid JSON-RPC version".into());
|
|
}
|
|
|
|
// Notifications (no id) — accept silently
|
|
if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) {
|
|
if rpc.method.starts_with("notifications/") {
|
|
return Response::builder()
|
|
.status(StatusCode::ACCEPTED)
|
|
.body(Body::empty());
|
|
}
|
|
return json_rpc_error_response(None, -32600, "Missing id".into());
|
|
}
|
|
|
|
let sse = wants_sse(req);
|
|
|
|
// Streaming agent output over SSE
|
|
if sse && rpc.method == "tools/call" {
|
|
let tool_name = rpc
|
|
.params
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
if tool_name == "run_command" {
|
|
return shell_tools::handle_run_command_sse(rpc.id, &rpc.params, &ctx);
|
|
}
|
|
}
|
|
|
|
let resp = match rpc.method.as_str() {
|
|
"initialize" => handle_initialize(rpc.id, &rpc.params),
|
|
"tools/list" => handle_tools_list(rpc.id),
|
|
"tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await,
|
|
_ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)),
|
|
};
|
|
|
|
if sse {
|
|
to_sse_response(resp)
|
|
} else {
|
|
to_json_response(resp)
|
|
}
|
|
}
|
|
|
|
fn json_rpc_error_response(id: Option<Value>, code: i64, message: String) -> Response {
|
|
to_json_response(JsonRpcResponse::error(id, code, message))
|
|
}
|
|
|
|
fn to_json_response(resp: JsonRpcResponse) -> Response {
|
|
let body = serde_json::to_vec(&resp).unwrap_or_default();
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(body))
|
|
}
|
|
|
|
pub(super) fn to_sse_response(resp: JsonRpcResponse) -> Response {
|
|
let json = serde_json::to_string(&resp).unwrap_or_default();
|
|
let sse_body = format!("data: {json}\n\n");
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", "text/event-stream")
|
|
.header("Cache-Control", "no-cache")
|
|
.body(Body::from_string(sse_body))
|
|
}
|
|
|
|
// ── MCP protocol handlers ─────────────────────────────────────────
|
|
|
|
fn handle_initialize(id: Option<Value>, params: &Value) -> JsonRpcResponse {
|
|
let _protocol_version = params
|
|
.get("protocolVersion")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("2025-03-26");
|
|
|
|
JsonRpcResponse::success(
|
|
id,
|
|
json!({
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {
|
|
"tools": {}
|
|
},
|
|
"serverInfo": {
|
|
"name": "huskies",
|
|
"version": "1.0.0"
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::http::test_helpers::test_ctx;
|
|
|
|
#[test]
|
|
fn json_rpc_response_serializes_success() {
|
|
let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true}));
|
|
let s = serde_json::to_string(&resp).unwrap();
|
|
assert!(s.contains("\"result\""));
|
|
assert!(!s.contains("\"error\""));
|
|
}
|
|
|
|
#[test]
|
|
fn json_rpc_response_serializes_error() {
|
|
let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into());
|
|
let s = serde_json::to_string(&resp).unwrap();
|
|
assert!(s.contains("\"error\""));
|
|
assert!(!s.contains("\"result\""));
|
|
}
|
|
|
|
#[test]
|
|
fn initialize_returns_capabilities() {
|
|
let resp = handle_initialize(
|
|
Some(json!(1)),
|
|
&json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}),
|
|
);
|
|
let result = resp.result.unwrap();
|
|
assert_eq!(result["protocolVersion"], "2025-03-26");
|
|
assert!(result["capabilities"]["tools"].is_object());
|
|
assert_eq!(result["serverInfo"]["name"], "huskies");
|
|
}
|
|
|
|
#[test]
|
|
fn to_sse_response_wraps_in_data_prefix() {
|
|
let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true}));
|
|
let http_resp = to_sse_response(resp);
|
|
assert_eq!(
|
|
http_resp.headers().get("content-type").unwrap(),
|
|
"text/event-stream"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wants_sse_detects_accept_header() {
|
|
// Can't easily construct a Request in tests without TestClient,
|
|
// so test the logic indirectly via to_sse_response format
|
|
let resp = JsonRpcResponse::success(Some(json!(1)), json!("ok"));
|
|
let json_resp = to_json_response(resp);
|
|
assert_eq!(
|
|
json_resp.headers().get("content-type").unwrap(),
|
|
"application/json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_rpc_error_response_builds_json_response() {
|
|
let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into());
|
|
assert_eq!(resp.status(), poem::http::StatusCode::OK);
|
|
assert_eq!(
|
|
resp.headers().get("content-type").unwrap(),
|
|
"application/json"
|
|
);
|
|
}
|
|
|
|
// ── HTTP handler tests (TestClient) ───────────────────────────
|
|
|
|
fn test_mcp_app(ctx: std::sync::Arc<AppContext>) -> impl poem::Endpoint {
|
|
use poem::EndpointExt;
|
|
poem::Route::new()
|
|
.at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler))
|
|
.data(ctx)
|
|
}
|
|
|
|
async fn read_body_json(resp: poem::test::TestResponse) -> Value {
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
serde_json::from_str(&body).unwrap()
|
|
}
|
|
|
|
async fn post_json_mcp<E: poem::Endpoint>(
|
|
cli: &poem::test::TestClient<E>,
|
|
payload: &str,
|
|
) -> Value {
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.body(payload.to_string())
|
|
.send()
|
|
.await;
|
|
read_body_json(resp).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_get_handler_returns_405() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli.get("/mcp").send().await;
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_invalid_content_type_returns_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "text/plain")
|
|
.body("{}")
|
|
.send()
|
|
.await;
|
|
let body = read_body_json(resp).await;
|
|
assert!(body.get("error").is_some(), "expected error field: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_invalid_json_returns_parse_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.body("not-valid-json")
|
|
.send()
|
|
.await;
|
|
let body = read_body_json(resp).await;
|
|
assert!(body.get("error").is_some(), "expected error field: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_wrong_jsonrpc_version_returns_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let body = post_json_mcp(
|
|
&cli,
|
|
r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#,
|
|
)
|
|
.await;
|
|
assert!(
|
|
body["error"]["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("version"),
|
|
"expected version error: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_notification_with_null_id_returns_accepted() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#)
|
|
.send()
|
|
.await;
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_notification_with_explicit_null_id_returns_accepted() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#)
|
|
.send()
|
|
.await;
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_missing_id_non_notification_returns_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let body = post_json_mcp(
|
|
&cli,
|
|
r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#,
|
|
)
|
|
.await;
|
|
assert!(body.get("error").is_some(), "expected error: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_unknown_method_returns_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let body = post_json_mcp(
|
|
&cli,
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#,
|
|
)
|
|
.await;
|
|
assert!(
|
|
body["error"]["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("Unknown method"),
|
|
"expected unknown method error: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_initialize_returns_capabilities() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let body = post_json_mcp(
|
|
&cli,
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#,
|
|
)
|
|
.await;
|
|
assert_eq!(body["result"]["protocolVersion"], "2025-03-26");
|
|
assert_eq!(body["result"]["serverInfo"]["name"], "huskies");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_tools_list_returns_tools() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let body = post_json_mcp(
|
|
&cli,
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
|
|
)
|
|
.await;
|
|
assert!(body["result"]["tools"].is_array());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_sse_returns_event_stream_content_type() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
|
|
.send()
|
|
.await;
|
|
assert_eq!(
|
|
resp.0.headers().get("content-type").unwrap(),
|
|
"text/event-stream"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_sse_get_agent_output_missing_story_id() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#)
|
|
.send()
|
|
.await;
|
|
assert_eq!(
|
|
resp.0.headers().get("content-type").unwrap(),
|
|
"text/event-stream",
|
|
"expected SSE content-type"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_sse_get_agent_output_without_agent_name_returns_disk_content() {
|
|
// Without agent_name the SSE live-streaming intercept is skipped and
|
|
// the disk-based handler runs. The transport still wraps the result in
|
|
// SSE format (data: …\n\n) because the client sent Accept: text/event-stream,
|
|
// but the content should be a valid JSON-RPC result, not a subscribe error.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#)
|
|
.send()
|
|
.await;
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
// Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's
|
|
// a valid JSON-RPC result (not an error about missing agent_name).
|
|
let json_part = body
|
|
.trim_start_matches("data: ")
|
|
.trim_end_matches("\n\n")
|
|
.trim();
|
|
let parsed: serde_json::Value = serde_json::from_str(json_part)
|
|
.unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}"));
|
|
assert!(
|
|
parsed.get("result").is_some(),
|
|
"expected JSON-RPC result (disk-based handler ran): {parsed}"
|
|
);
|
|
// Must NOT be an error about missing agent_name (agent_name is now optional)
|
|
assert!(
|
|
parsed.get("error").is_none(),
|
|
"unexpected error when agent_name omitted: {parsed}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_sse_get_agent_output_no_agent_no_logs_returns_not_found() {
|
|
// Agent not in pool and no log files → SSE success with "No log files found" message.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = std::sync::Arc::new(test_ctx(tmp.path()));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#)
|
|
.send()
|
|
.await;
|
|
assert_eq!(
|
|
resp.0.headers().get("content-type").unwrap(),
|
|
"text/event-stream"
|
|
);
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
assert!(body.contains("data:"), "expected SSE data prefix: {body}");
|
|
// Must NOT return isError — should be a success result with "No log files found"
|
|
assert!(
|
|
!body.contains("isError"),
|
|
"expected no isError for missing agent: {body}"
|
|
);
|
|
assert!(
|
|
body.contains("No log files found"),
|
|
"expected not-found message: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_post_sse_get_agent_output_exited_agent_reads_disk_logs() {
|
|
use crate::agent_log::AgentLogWriter;
|
|
use crate::agents::AgentEvent;
|
|
// Agent has exited (not in pool) but wrote logs to disk.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap();
|
|
writer
|
|
.write_event(&AgentEvent::Output {
|
|
story_id: "42_story_foo".to_string(),
|
|
agent_name: "coder-1".to_string(),
|
|
text: "disk output".to_string(),
|
|
})
|
|
.unwrap();
|
|
drop(writer);
|
|
|
|
let ctx = std::sync::Arc::new(test_ctx(root));
|
|
let cli = poem::test::TestClient::new(test_mcp_app(ctx));
|
|
let resp = cli
|
|
.post("/mcp")
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"42_story_foo","agent_name":"coder-1"}}}"#)
|
|
.send()
|
|
.await;
|
|
let body = resp.0.into_body().into_string().await.unwrap();
|
|
assert!(
|
|
body.contains("disk output"),
|
|
"expected disk log content in SSE response: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("isError"),
|
|
"expected no error for exited agent with logs: {body}"
|
|
);
|
|
}
|
|
}
|