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:
@@ -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<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
pub completion: Option<CompletionReport>,
|
||||
}
|
||||
|
||||
struct StoryAgent {
|
||||
@@ -88,6 +98,8 @@ struct StoryAgent {
|
||||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
/// Accumulated events for polling via get_agent_output.
|
||||
event_log: Arc<Mutex<Vec<AgentEvent>>>,
|
||||
/// Set when the agent calls report_completion.
|
||||
completion: Option<CompletionReport>,
|
||||
}
|
||||
|
||||
/// 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<CompletionReport, String> {
|
||||
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<AgentEvent> {
|
||||
let (tx, _) = broadcast::channel::<AgentEvent>(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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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