huskies: merge 531_story_mcp_tool_to_read_agent_session_logs_from_disk_not_just_live_stream

This commit is contained in:
dave
2026-04-10 13:05:04 +00:00
parent 31388da609
commit 1dd675796b
3 changed files with 697 additions and 137 deletions
+38 -14
View File
@@ -134,7 +134,17 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
.and_then(|v| v.as_str())
.unwrap_or("");
if tool_name == "get_agent_output" {
return handle_agent_output_sse(rpc.id, &rpc.params, &ctx);
// Only use live SSE streaming when agent_name is explicitly provided.
// Without agent_name, fall through to the disk-based handler below.
let has_agent_name = rpc
.params
.get("arguments")
.and_then(|a| a.get("agent_name"))
.and_then(|v| v.as_str())
.is_some();
if has_agent_name {
return handle_agent_output_sse(rpc.id, &rpc.params, &ctx);
}
}
if tool_name == "run_command" {
return shell_tools::handle_run_command_sse(rpc.id, &rpc.params, &ctx);
@@ -502,24 +512,28 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "get_agent_output",
"description": "Poll recent output from a running agent. Subscribes to the agent's event stream and collects events for up to 2 seconds. Returns text output and status events. Call repeatedly to follow progress.",
"description": "Read agent session logs from disk as a human-readable timeline. Stitches all sessions for the story together in chronological order — text output, tool calls, tool results, errors. Works for both running and completed agents. If agent_name is omitted, returns logs from every agent that worked on the story. If the named agent is currently running, live buffered events are appended.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier"
"description": "Story identifier (e.g. '42_story_my_feature')"
},
"agent_name": {
"type": "string",
"description": "Agent name"
"description": "Optional: filter to a specific agent (e.g. 'mergemaster', 'coder-1'). Omit to see all agents."
},
"timeout_ms": {
"lines": {
"type": "integer",
"description": "How long to wait for events in milliseconds (default: 2000, max: 10000)"
"description": "Optional: return only the last N lines (tail). Useful for large logs."
},
"filter": {
"type": "string",
"description": "Optional: return only lines containing this substring (e.g. 'ERROR', 'TOOL:', a function name)."
}
},
"required": ["story_id", "agent_name"]
"required": ["story_id"]
}
},
{
@@ -1300,7 +1314,7 @@ async fn handle_tools_call(
"list_agents" => agent_tools::tool_list_agents(ctx),
"get_agent_config" => agent_tools::tool_get_agent_config(ctx),
"reload_agent_config" => agent_tools::tool_get_agent_config(ctx),
"get_agent_output" => agent_tools::tool_get_agent_output_poll(&args, ctx).await,
"get_agent_output" => agent_tools::tool_get_agent_output(&args, ctx).await,
"wait_for_agent" => agent_tools::tool_wait_for_agent(&args, ctx).await,
// Worktree tools
"create_worktree" => agent_tools::tool_create_worktree(&args, ctx).await,
@@ -1756,7 +1770,11 @@ mod tests {
}
#[tokio::test]
async fn mcp_post_sse_get_agent_output_missing_agent_name() {
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));
@@ -1767,11 +1785,17 @@ mod tests {
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#)
.send()
.await;
assert_eq!(
resp.0.headers().get("content-type").unwrap(),
"text/event-stream",
"expected SSE content-type"
);
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]