Revert "refactor: split top-5 largest files into mod.rs + tests.rs"
This reverts commit 65a3767a7a.
This commit is contained in:
+477
-1
@@ -1403,4 +1403,480 @@ async fn handle_tools_call(id: Option<Value>, params: &Value, ctx: &AppContext)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
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 tools_list_returns_all_tools() {
|
||||
let resp = handle_tools_list(Some(json!(2)));
|
||||
let result = resp.result.unwrap();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"create_story"));
|
||||
assert!(names.contains(&"validate_stories"));
|
||||
assert!(names.contains(&"list_upcoming"));
|
||||
assert!(names.contains(&"get_story_todos"));
|
||||
assert!(names.contains(&"record_tests"));
|
||||
assert!(names.contains(&"ensure_acceptance"));
|
||||
assert!(names.contains(&"start_agent"));
|
||||
assert!(names.contains(&"stop_agent"));
|
||||
assert!(names.contains(&"list_agents"));
|
||||
assert!(names.contains(&"get_agent_config"));
|
||||
assert!(names.contains(&"reload_agent_config"));
|
||||
assert!(names.contains(&"get_agent_output"));
|
||||
assert!(names.contains(&"wait_for_agent"));
|
||||
assert!(names.contains(&"get_agent_remaining_turns_and_budget"));
|
||||
assert!(names.contains(&"create_worktree"));
|
||||
assert!(names.contains(&"list_worktrees"));
|
||||
assert!(names.contains(&"remove_worktree"));
|
||||
assert!(names.contains(&"get_editor_command"));
|
||||
assert!(!names.contains(&"report_completion"));
|
||||
assert!(names.contains(&"accept_story"));
|
||||
assert!(names.contains(&"check_criterion"));
|
||||
assert!(names.contains(&"add_criterion"));
|
||||
assert!(names.contains(&"update_story"));
|
||||
assert!(names.contains(&"create_spike"));
|
||||
assert!(names.contains(&"create_bug"));
|
||||
assert!(names.contains(&"list_bugs"));
|
||||
assert!(names.contains(&"close_bug"));
|
||||
assert!(names.contains(&"create_refactor"));
|
||||
assert!(names.contains(&"list_refactors"));
|
||||
assert!(names.contains(&"merge_agent_work"));
|
||||
assert!(names.contains(&"get_merge_status"));
|
||||
assert!(names.contains(&"move_story_to_merge"));
|
||||
assert!(names.contains(&"report_merge_failure"));
|
||||
assert!(names.contains(&"request_qa"));
|
||||
assert!(names.contains(&"approve_qa"));
|
||||
assert!(names.contains(&"reject_qa"));
|
||||
assert!(names.contains(&"launch_qa_app"));
|
||||
assert!(names.contains(&"get_server_logs"));
|
||||
assert!(names.contains(&"prompt_permission"));
|
||||
assert!(names.contains(&"get_pipeline_status"));
|
||||
assert!(names.contains(&"rebuild_and_restart"));
|
||||
assert!(names.contains(&"get_token_usage"));
|
||||
assert!(names.contains(&"move_story"));
|
||||
assert!(names.contains(&"unblock_story"));
|
||||
assert!(names.contains(&"delete_story"));
|
||||
assert!(names.contains(&"run_command"));
|
||||
assert!(names.contains(&"run_tests"));
|
||||
assert!(names.contains(&"get_test_result"));
|
||||
assert!(names.contains(&"run_build"));
|
||||
assert!(names.contains(&"run_lint"));
|
||||
assert!(names.contains(&"git_status"));
|
||||
assert!(names.contains(&"git_diff"));
|
||||
assert!(names.contains(&"git_add"));
|
||||
assert!(names.contains(&"git_commit"));
|
||||
assert!(names.contains(&"git_log"));
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"loc_file"));
|
||||
assert!(names.contains(&"dump_crdt"));
|
||||
assert!(names.contains(&"get_version"));
|
||||
assert!(names.contains(&"remove_criterion"));
|
||||
assert_eq!(tools.len(), 66);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_list_schemas_have_required_fields() {
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
for tool in &tools {
|
||||
assert!(tool["name"].is_string(), "tool missing name");
|
||||
assert!(tool["description"].is_string(), "tool missing description");
|
||||
assert!(tool["inputSchema"].is_object(), "tool missing inputSchema");
|
||||
assert_eq!(tool["inputSchema"]["type"], "object");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_tools_call_unknown_tool() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let resp = rt.block_on(handle_tools_call(
|
||||
Some(json!(1)),
|
||||
&json!({"name": "bogus_tool", "arguments": {}}),
|
||||
&ctx,
|
||||
));
|
||||
let result = resp.result.unwrap();
|
||||
assert_eq!(result["isError"], true);
|
||||
assert!(
|
||||
result["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Unknown tool")
|
||||
);
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user