65a3767a7a
Five files in server/src/ exceeded 1500 lines, with 50–75% of the line
count being inline `#[cfg(test)] mod tests { ... }` blocks. Agents
working on these files have to navigate huge buffers via Read calls,
costing turn budget that could go toward actual work.
Pattern: convert `foo.rs` to `foo/mod.rs` + `foo/tests.rs`.
Rust resolves `mod foo;` to either form, so no parent-module changes
needed.
Before / after (production-code lines, what an agent has to navigate
when editing the module):
crdt_sync.rs: 3672 → 1003 (mod.rs) + 2667 (tests.rs)
crdt_state.rs: 2122 → 1263 (mod.rs) + 854 (tests.rs)
io/fs/scaffold.rs: 2045 → 702 (mod.rs) + 1342 (tests.rs)
http/mcp/mod.rs: 1882 → 1410 (mod.rs) + 472 (tests.rs)
http/mcp/story_tools.rs: 1864 → 725 (mod.rs) + 1137 (tests.rs)
Side change: scaffold/mod.rs's include_str! paths got an extra `../`
because the file moved one directory deeper.
Tests: full `cargo test` suite passes (2635 passed, 0 failed).
Formatting: cargo fmt --check clean.
Motivation: today's agent thrashing on 644 / 650 / 652 was partly due to
cumulative-counting (now fixed by 650) but also genuinely due to file
size — sonnet's 50-turn budget barely covers reading these files plus
making the change. Smaller production-code files mean more turn budget
left for the actual work.
Committed straight to master because this is an enabling refactor for
agent autonomy work; running it through the normal pipeline would
require an agent that has to navigate the very files it's about to
split, defeating the purpose.
473 lines
17 KiB
Rust
473 lines
17 KiB
Rust
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}"
|
|
);
|
|
}
|