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) -> 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(cli: &poem::test::TestClient, 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}" ); }