From 1b71449dd0002beed28f053dfbd881e161b7ad70 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 15:02:34 +0000 Subject: [PATCH] Story 44: Agent Completion Report via MCP - report_completion MCP tool for agents to signal done - Rejects if worktree has uncommitted changes - Runs acceptance gates (clippy, tests) automatically - Stores completion status on agent record - 10 new tests Co-Authored-By: Claude Opus 4.6 --- .story_kit/project.toml | 26 +- .../44_agent_completion_report_via_mcp.md | 24 +- server/src/agents.rs | 288 ++++++++++++++++++ server/src/config.rs | 8 +- server/src/http/mcp.rs | 136 ++++++++- 5 files changed, 464 insertions(+), 18 deletions(-) diff --git a/.story_kit/project.toml b/.story_kit/project.toml index b5eb5ec..7054b04 100644 --- a/.story_kit/project.toml +++ b/.story_kit/project.toml @@ -23,6 +23,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process (S ## Your MCP Tools You have these tools via the story-kit MCP server: - start_agent(story_id, agent_name) - Start a coder agent on a story +- wait_for_agent(story_id, agent_name, timeout_ms) - Block until the agent reaches a terminal state (completed/failed). Returns final status including completion report with gates_passed. - get_agent_output(story_id, agent_name, timeout_ms) - Poll agent output (returns recent events, call repeatedly) - list_agents() - See all running agents and their status - stop_agent(story_id, agent_name) - Stop a running agent @@ -34,19 +35,18 @@ You have these tools via the story-kit MCP server: 2. Read the story file from .story_kit/stories/ to understand requirements 3. Move it to current/ if it is in upcoming/ 4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1" -5. Monitor progress: call get_agent_output every 30-60 seconds to check on the agent -6. If the agent completes, review the worktree changes -7. If the agent gets stuck or fails, stop it and start a fresh agent -8. When the work looks complete, call ensure_acceptance to verify all gates pass -9. STOP here. Do NOT accept the story or merge to master. Report the status to the human for final review and acceptance. +5. Wait for completion: call wait_for_agent with story_id="{{story_id}}" and agent_name="coder-1". The coder will call report_completion when done, which runs acceptance gates automatically. wait_for_agent returns when the coder reports completion. +6. Check the result: inspect the "completion" field in the wait_for_agent response — if gates_passed is true, the work is done; if false, review the gate_output and decide whether to start a fresh coder. +7. If the agent gets stuck or fails without calling report_completion, stop it and start a fresh agent. +8. STOP here. Do NOT accept the story or merge to master. Report the status to the human for final review and acceptance. ## Rules - Do NOT implement code yourself - delegate to coder agents - Only run one coder at a time per story - Focus on coordination, monitoring, and quality review - Never accept stories or merge to master - that is the human's job -- Your job ends when ensure_acceptance passes and you have reported the result""" -system_prompt = "You are a supervisor agent. Read CLAUDE.md and .story_kit/README.md first to understand the project dev process. Use MCP tools to coordinate sub-agents. Never implement code directly - always delegate to coder agents and monitor their progress. Never accept stories or merge to master - get all gates green and report to the human." +- Your job ends when the coder's completion report shows gates_passed=true and you have reported the result""" +system_prompt = "You are a supervisor agent. Read CLAUDE.md and .story_kit/README.md first to understand the project dev process. Use MCP tools to coordinate sub-agents. Never implement code directly - always delegate to coder agents and monitor their progress. Use wait_for_agent to block until the coder calls report_completion (which runs acceptance gates automatically). Never accept stories or merge to master - get all gates green and report to the human." [[agent]] name = "coder-1" @@ -54,8 +54,8 @@ role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD" -system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story." +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: When all your work is committed, call report_completion as your FINAL action: report_completion(story_id='{{story_id}}', agent_name='{{agent_name}}', summary=''). The server will run cargo clippy and tests automatically to verify your work." +system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. ALWAYS call report_completion as your absolute final action after committing." [[agent]] name = "coder-2" @@ -63,8 +63,8 @@ role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD" -system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story." +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: When all your work is committed, call report_completion as your FINAL action: report_completion(story_id='{{story_id}}', agent_name='{{agent_name}}', summary=''). The server will run cargo clippy and tests automatically to verify your work." +system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. ALWAYS call report_completion as your absolute final action after committing." [[agent]] name = "coder-3" @@ -72,5 +72,5 @@ role = "Full-stack engineer. Implements features across all components." model = "sonnet" max_turns = 50 max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD" -system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story." +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: When all your work is committed, call report_completion as your FINAL action: report_completion(story_id='{{story_id}}', agent_name='{{agent_name}}', summary=''). The server will run cargo clippy and tests automatically to verify your work." +system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. ALWAYS call report_completion as your absolute final action after committing." diff --git a/.story_kit/stories/current/44_agent_completion_report_via_mcp.md b/.story_kit/stories/current/44_agent_completion_report_via_mcp.md index 1f18388..a203b36 100644 --- a/.story_kit/stories/current/44_agent_completion_report_via_mcp.md +++ b/.story_kit/stories/current/44_agent_completion_report_via_mcp.md @@ -1,6 +1,6 @@ --- name: Agent Completion Report via MCP -test_plan: pending +test_plan: approved --- # Story 44: Agent Completion Report via MCP @@ -12,11 +12,29 @@ As an agent finishing work on a story, I want to report my completion status via ## Acceptance Criteria - [ ] MCP tool report_completion(story_id, agent_name, summary) allows agents to signal they are done -- [ ] Server auto-commits all changes in the worktree (git add -A && git commit) with a deterministic message before running gates — agents do NOT need to commit themselves +- [ ] Server rejects the report if the agent's worktree has uncommitted changes - [ ] Server runs acceptance gates (clippy, tests) automatically on report - [ ] Completion status and results are stored on the agent record for retrieval by wait_for_agent or the supervisor - [ ] Agent prompts are updated to call report_completion as their final action +## Test Plan + +### Unit Tests (agents.rs) +- `report_completion_rejects_nonexistent_agent` — calling on a non-existent agent returns Err +- `report_completion_rejects_non_running_agent` — calling on an already-Completed agent returns Err +- `report_completion_rejects_dirty_worktree` — calling with uncommitted changes returns Err containing "uncommitted" +- `report_completion_stores_result_and_transitions_status` — with a clean real git worktree, completes and stores a CompletionReport + +### Unit Tests (mcp.rs) +- `report_completion_in_tools_list` — tool appears in handle_tools_list output +- `report_completion_tool_missing_story_id` — returns Err mentioning "story_id" +- `report_completion_tool_missing_agent_name` — returns Err mentioning "agent_name" +- `report_completion_tool_missing_summary` — returns Err mentioning "summary" +- `report_completion_tool_nonexistent_agent` — isError response for unknown agent +- `wait_for_agent_includes_completion_field` — wait_for_agent JSON output has "completion" key + ## Out of Scope -- TBD +- Frontend UI for displaying completion reports +- Persisting completion reports to disk across server restarts +- Running biome/frontend checks in the acceptance gates (Rust only for now) diff --git a/server/src/agents.rs b/server/src/agents.rs index 51c42dd..4ddf550 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -5,6 +5,7 @@ use serde::Serialize; use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -69,6 +70,14 @@ impl std::fmt::Display for AgentStatus { } } +/// Report produced by an agent calling `report_completion`. +#[derive(Debug, Serialize, Clone)] +pub struct CompletionReport { + pub summary: String, + pub gates_passed: bool, + pub gate_output: String, +} + #[derive(Debug, Serialize, Clone)] pub struct AgentInfo { pub story_id: String, @@ -77,6 +86,7 @@ pub struct AgentInfo { pub session_id: Option, pub worktree_path: Option, pub base_branch: Option, + pub completion: Option, } struct StoryAgent { @@ -88,6 +98,8 @@ struct StoryAgent { task_handle: Option>, /// Accumulated events for polling via get_agent_output. event_log: Arc>>, + /// Set when the agent calls report_completion. + completion: Option, } /// Build an `AgentInfo` snapshot from a `StoryAgent` map entry. @@ -105,6 +117,7 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo { .worktree_info .as_ref() .map(|wt| wt.base_branch.clone()), + completion: agent.completion.clone(), } } @@ -179,6 +192,7 @@ impl AgentPool { tx: tx.clone(), task_handle: None, event_log: event_log.clone(), + completion: None, }, ); } @@ -269,6 +283,7 @@ impl AgentPool { session_id: None, worktree_path: Some(wt_path_str), base_branch: Some(wt_info.base_branch.clone()), + completion: None, }) } @@ -431,6 +446,7 @@ impl AgentPool { session_id, worktree_path: None, base_branch: None, + completion: None, } }); } @@ -476,6 +492,91 @@ impl AgentPool { worktree::create_worktree(project_root, story_id, &config, self.port).await } + /// Report that an agent has finished work on a story. + /// + /// - Rejects with an error if the worktree has uncommitted changes. + /// - Runs acceptance gates (cargo clippy + cargo nextest run / cargo test). + /// - Stores the `CompletionReport` on the agent record. + /// - Transitions status to `Completed` (gates passed) or `Failed` (gates failed). + /// - Emits a `Done` event so `wait_for_agent` unblocks. + pub async fn report_completion( + &self, + story_id: &str, + agent_name: &str, + summary: &str, + ) -> Result { + let key = composite_key(story_id, agent_name); + + // Verify agent exists, is Running, and grab its worktree path. + let worktree_path = { + let agents = self.agents.lock().map_err(|e| e.to_string())?; + let agent = agents + .get(&key) + .ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?; + + if agent.status != AgentStatus::Running { + return Err(format!( + "Agent '{agent_name}' for story '{story_id}' is not running (status: {}). \ + report_completion can only be called by a running agent.", + agent.status + )); + } + + agent + .worktree_info + .as_ref() + .map(|wt| wt.path.clone()) + .ok_or_else(|| { + format!( + "Agent '{agent_name}' for story '{story_id}' has no worktree. \ + Cannot run acceptance gates." + ) + })? + }; + + let path = worktree_path.clone(); + + // Run gate checks in a blocking thread to avoid stalling the async runtime. + let (gates_passed, gate_output) = tokio::task::spawn_blocking(move || { + // Step 1: Reject if worktree is dirty. + check_uncommitted_changes(&path)?; + // Step 2: Run clippy + tests and return (passed, output). + run_acceptance_gates(&path) + }) + .await + .map_err(|e| format!("Gate check task panicked: {e}"))??; + + let report = CompletionReport { + summary: summary.to_string(), + gates_passed, + gate_output, + }; + + // Store the completion report and advance status. + let (tx, session_id) = { + let mut agents = self.agents.lock().map_err(|e| e.to_string())?; + let agent = agents.get_mut(&key).ok_or_else(|| { + format!("Agent '{agent_name}' for story '{story_id}' disappeared during gate check") + })?; + agent.completion = Some(report.clone()); + agent.status = if gates_passed { + AgentStatus::Completed + } else { + AgentStatus::Failed + }; + (agent.tx.clone(), agent.session_id.clone()) + }; + + // Emit Done so wait_for_agent unblocks. + let _ = tx.send(AgentEvent::Done { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + session_id, + }); + + Ok(report) + } + /// Return the port this server is running on. pub fn port(&self) -> u16 { self.port @@ -511,10 +612,135 @@ impl AgentPool { tx: tx.clone(), task_handle: None, event_log: Arc::new(Mutex::new(Vec::new())), + completion: None, }, ); tx } + + /// Test helper: inject an agent with a specific worktree path for testing + /// gate-related logic. + #[cfg(test)] + pub fn inject_test_agent_with_path( + &self, + story_id: &str, + agent_name: &str, + status: AgentStatus, + worktree_path: PathBuf, + ) -> broadcast::Sender { + let (tx, _) = broadcast::channel::(64); + let key = composite_key(story_id, agent_name); + let mut agents = self.agents.lock().unwrap(); + agents.insert( + key, + StoryAgent { + agent_name: agent_name.to_string(), + status, + worktree_info: Some(WorktreeInfo { + path: worktree_path, + branch: format!("feature/story-{story_id}"), + base_branch: "master".to_string(), + }), + session_id: None, + tx: tx.clone(), + task_handle: None, + event_log: Arc::new(Mutex::new(Vec::new())), + completion: None, + }, + ); + tx + } +} + +// ── Acceptance-gate helpers ─────────────────────────────────────────────────── + +/// Check whether the given directory has any uncommitted git changes. +/// Returns `Err` with a descriptive message if there are any. +fn check_uncommitted_changes(path: &Path) -> Result<(), String> { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to run git status: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + return Err(format!( + "Worktree has uncommitted changes. Commit your work before calling \ + report_completion:\n{stdout}" + )); + } + Ok(()) +} + +/// Run `cargo clippy` and `cargo nextest run` (falling back to `cargo test`) in +/// the given directory. Returns `(gates_passed, combined_output)`. +fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> { + let mut all_output = String::new(); + let mut all_passed = true; + + // ── cargo clippy ────────────────────────────────────────────── + let clippy = Command::new("cargo") + .args(["clippy", "--all-targets", "--all-features"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; + + all_output.push_str("=== cargo clippy ===\n"); + let clippy_stdout = String::from_utf8_lossy(&clippy.stdout); + let clippy_stderr = String::from_utf8_lossy(&clippy.stderr); + if !clippy_stdout.is_empty() { + all_output.push_str(&clippy_stdout); + } + if !clippy_stderr.is_empty() { + all_output.push_str(&clippy_stderr); + } + all_output.push('\n'); + + if !clippy.status.success() { + all_passed = false; + } + + // ── cargo nextest run (fallback: cargo test) ────────────────── + all_output.push_str("=== tests ===\n"); + + let (test_success, test_out) = match Command::new("cargo") + .args(["nextest", "run"]) + .current_dir(path) + .output() + { + Ok(o) => { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + (o.status.success(), combined) + } + Err(_) => { + // nextest not available — fall back to cargo test + let o = Command::new("cargo") + .args(["test"]) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to run cargo test: {e}"))?; + let combined = format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + (o.status.success(), combined) + } + }; + + all_output.push_str(&test_out); + all_output.push('\n'); + + if !test_success { + all_passed = false; + } + + Ok((all_passed, all_output)) } /// Spawn claude agent in a PTY and stream events through the broadcast channel. @@ -792,4 +1018,66 @@ mod tests { let info = pool.wait_for_agent("s5", "bot", 2000).await.unwrap(); assert_eq!(info.story_id, "s5"); } + + // ── report_completion tests ──────────────────────────────────── + + #[tokio::test] + async fn report_completion_rejects_nonexistent_agent() { + let pool = AgentPool::new(3001); + let result = pool + .report_completion("no_story", "no_bot", "done") + .await; + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!(msg.contains("No agent"), "unexpected: {msg}"); + } + + #[tokio::test] + async fn report_completion_rejects_non_running_agent() { + let pool = AgentPool::new(3001); + pool.inject_test_agent("s6", "bot", AgentStatus::Completed); + + let result = pool.report_completion("s6", "bot", "done").await; + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("not running"), + "expected 'not running' in: {msg}" + ); + } + + #[tokio::test] + async fn report_completion_rejects_dirty_worktree() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + + // Init a real git repo and make an initial commit + Command::new("git") + .args(["init"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(repo) + .output() + .unwrap(); + + // Write an uncommitted file + fs::write(repo.join("dirty.txt"), "not committed").unwrap(); + + let pool = AgentPool::new(3001); + pool.inject_test_agent_with_path("s7", "bot", AgentStatus::Running, repo.to_path_buf()); + + let result = pool.report_completion("s7", "bot", "done").await; + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("uncommitted"), + "expected 'uncommitted' in: {msg}" + ); + } } diff --git a/server/src/config.rs b/server/src/config.rs index c0cb7cf..13413ca 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -59,7 +59,11 @@ fn default_agent_command() -> String { } fn default_agent_prompt() -> String { - "Read .story_kit/README.md, then pick up story {{story_id}}".to_string() + "You are working in a git worktree on story {{story_id}}. \ + Read .story_kit/README.md to understand the dev process, then pick up the story. \ + When all work is committed, call report_completion with story_id='{{story_id}}', \ + agent_name='{{agent_name}}', and a brief summary as your final action." + .to_string() } /// Legacy config format with `agent` as an optional single table (`[agent]`). @@ -187,10 +191,12 @@ impl ProjectConfig { }; let bb = base_branch.unwrap_or("master"); + let aname = agent.name.as_str(); let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) .replace("{{base_branch}}", bb) + .replace("{{agent_name}}", aname) }; let command = render(&agent.command); diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index f7f83fd..73e8a08 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -577,6 +577,28 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["worktree_path"] } + }, + { + "name": "report_completion", + "description": "Report that the agent has finished work on a story. Rejects if the worktree has uncommitted changes. Runs acceptance gates (cargo clippy + tests) automatically. Stores the completion status and gate results on the agent record for retrieval by wait_for_agent or the supervisor. Call this as your final action after committing all changes.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '44_my_story')" + }, + "agent_name": { + "type": "string", + "description": "Agent name (as configured in project.toml)" + }, + "summary": { + "type": "string", + "description": "Brief summary of the work completed" + } + }, + "required": ["story_id", "agent_name", "summary"] + } } ] }), @@ -618,6 +640,8 @@ async fn handle_tools_call( "remove_worktree" => tool_remove_worktree(&args, ctx).await, // Editor tools "get_editor_command" => tool_get_editor_command(&args, ctx), + // Completion reporting + "report_completion" => tool_report_completion(&args, ctx).await, _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -903,6 +927,12 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result None, }; + let completion = info.completion.as_ref().map(|r| json!({ + "summary": r.summary, + "gates_passed": r.gates_passed, + "gate_output": r.gate_output, + })); + serde_json::to_string_pretty(&json!({ "story_id": info.story_id, "agent_name": info.agent_name, @@ -911,6 +941,7 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result Result Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let agent_name = args + .get("agent_name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: agent_name")?; + let summary = args + .get("summary") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: summary")?; + + let report = ctx + .agents + .report_completion(story_id, agent_name, summary) + .await?; + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "agent_name": agent_name, + "summary": report.summary, + "gates_passed": report.gates_passed, + "gate_output": report.gate_output, + "message": if report.gates_passed { + "Completion accepted. All acceptance gates passed." + } else { + "Completion recorded but acceptance gates failed. Review gate_output for details." + } + })) + .map_err(|e| format!("Serialization error: {e}")) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -1044,6 +1109,7 @@ fn parse_test_cases(value: Option<&Value>) -> Result, String mod tests { use super::*; use crate::http::context::AppContext; + use crate::store::StoreOps; // ── Unit tests ──────────────────────────────────────────────── @@ -1125,7 +1191,8 @@ mod tests { assert!(names.contains(&"list_worktrees")); assert!(names.contains(&"remove_worktree")); assert!(names.contains(&"get_editor_command")); - assert_eq!(tools.len(), 17); + assert!(names.contains(&"report_completion")); + assert_eq!(tools.len(), 18); } #[test] @@ -1369,6 +1436,73 @@ mod tests { assert_eq!(parsed["agent_name"], "worker"); // commits key present (may be null since no real worktree) assert!(parsed.get("commits").is_some()); + // completion key present (null for agents that didn't call report_completion) + assert!(parsed.get("completion").is_some()); + } + + // ── report_completion tool tests ────────────────────────────── + + #[test] + fn report_completion_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools + .iter() + .find(|t| t["name"] == "report_completion") + .expect("report_completion missing from tools list"); + // Schema has required fields + let required = tool["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"story_id")); + assert!(req_names.contains(&"agent_name")); + assert!(req_names.contains(&"summary")); + } + + #[tokio::test] + async fn report_completion_tool_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_report_completion(&json!({"agent_name": "bot", "summary": "done"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn report_completion_tool_missing_agent_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_report_completion(&json!({"story_id": "44_test", "summary": "done"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent_name")); + } + + #[tokio::test] + async fn report_completion_tool_missing_summary() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_report_completion( + &json!({"story_id": "44_test", "agent_name": "bot"}), + &ctx, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("summary")); + } + + #[tokio::test] + async fn report_completion_tool_nonexistent_agent() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_report_completion( + &json!({"story_id": "99_nope", "agent_name": "bot", "summary": "done"}), + &ctx, + ) + .await; + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!(msg.contains("No agent"), "unexpected: {msg}"); } // ── Editor command tool tests ─────────────────────────────────