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 <noreply@anthropic.com>
This commit is contained in:
@@ -577,6 +577,28 @@ fn handle_tools_list(id: Option<Value>) -> 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<String, S
|
||||
_ => 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<String, S
|
||||
"worktree_path": info.worktree_path,
|
||||
"base_branch": info.base_branch,
|
||||
"commits": commits,
|
||||
"completion": completion,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
@@ -976,6 +1007,40 @@ fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, Str
|
||||
Ok(format!("{editor} {worktree_path}"))
|
||||
}
|
||||
|
||||
async fn tool_report_completion(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
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 <base>..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<Vec<String>> {
|
||||
@@ -1044,6 +1109,7 @@ fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResult>, 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 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user