use crate::agents::PipelineStage; use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; use crate::slog_warn; use crate::worktree; use serde_json::{json, Value}; pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> 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()); let project_root = ctx.agents.get_project_root(&ctx.state)?; let info = ctx .agents .start_agent(&project_root, story_id, agent_name, None) .await?; // Snapshot coverage baseline from the most recent coverage report (best-effort). if let Some(pct) = read_coverage_percent_from_json(&project_root) && let Err(e) = crate::http::workflow::write_coverage_baseline_to_story_file( &project_root, story_id, pct, ) { slog_warn!("[start_agent] Could not write coverage baseline to story file: {e}"); } serde_json::to_string_pretty(&json!({ "story_id": info.story_id, "agent_name": info.agent_name, "status": info.status.to_string(), "session_id": info.session_id, "worktree_path": info.worktree_path, })) .map_err(|e| format!("Serialization error: {e}")) } /// Try to read the overall line coverage percentage from the llvm-cov JSON report. /// /// Expects the file at `{project_root}/.story_kit/coverage/server.json`. /// Returns `None` if the file is absent, unreadable, or cannot be parsed. pub(super) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option { let path = project_root .join(".story_kit") .join("coverage") .join("server.json"); let contents = std::fs::read_to_string(&path).ok()?; let json: Value = serde_json::from_str(&contents).ok()?; // cargo llvm-cov --json format: data[0].totals.lines.percent json.pointer("/data/0/totals/lines/percent") .and_then(|v| v.as_f64()) } pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> 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 project_root = ctx.agents.get_project_root(&ctx.state)?; ctx.agents .stop_agent(&project_root, story_id, agent_name) .await?; Ok(format!("Agent '{agent_name}' for story '{story_id}' stopped.")) } pub(super) fn tool_list_agents(ctx: &AppContext) -> Result { let project_root = ctx.agents.get_project_root(&ctx.state).ok(); let agents = ctx.agents.list_agents()?; serde_json::to_string_pretty(&json!(agents .iter() .filter(|a| { project_root .as_deref() .map(|root| !crate::http::agents::story_is_archived(root, &a.story_id)) .unwrap_or(true) }) .map(|a| json!({ "story_id": a.story_id, "agent_name": a.agent_name, "status": a.status.to_string(), "session_id": a.session_id, "worktree_path": a.worktree_path, })) .collect::>())) .map_err(|e| format!("Serialization error: {e}")) } pub(super) async fn tool_get_agent_output_poll(args: &Value, ctx: &AppContext) -> 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")?; // Try draining in-memory events first. match ctx.agents.drain_events(story_id, agent_name) { Ok(drained) => { let done = drained.iter().any(|e| { matches!( e, crate::agents::AgentEvent::Done { .. } | crate::agents::AgentEvent::Error { .. } ) }); let events: Vec = drained .into_iter() .filter_map(|e| serde_json::to_value(&e).ok()) .collect(); serde_json::to_string_pretty(&json!({ "events": events, "done": done, "event_count": events.len(), "message": if done { "Agent stream ended." } else if events.is_empty() { "No new events. Call again to continue." } else { "Events returned. Call again to continue." } })) .map_err(|e| format!("Serialization error: {e}")) } Err(_) => { // Agent not in memory — fall back to persistent log file. get_agent_output_from_log(story_id, agent_name, ctx) } } } /// Fall back to reading agent output from the persistent log file on disk. /// /// Tries to find the log file via the agent's stored log_session_id first, /// then falls back to `find_latest_log` scanning the log directory. pub(super) fn get_agent_output_from_log( story_id: &str, agent_name: &str, ctx: &AppContext, ) -> Result { use crate::agent_log; let project_root = ctx.agents.get_project_root(&ctx.state)?; // Try to find the log file: first from in-memory agent info, then by scanning. let log_path = ctx .agents .get_log_info(story_id, agent_name) .map(|(session_id, root)| agent_log::log_file_path(&root, story_id, agent_name, &session_id)) .filter(|p| p.exists()) .or_else(|| agent_log::find_latest_log(&project_root, story_id, agent_name)); let log_path = match log_path { Some(p) => p, None => { return serde_json::to_string_pretty(&json!({ "events": [], "done": true, "event_count": 0, "message": format!("No agent '{agent_name}' for story '{story_id}' and no log file found."), "source": "none", })) .map_err(|e| format!("Serialization error: {e}")); } }; match agent_log::read_log(&log_path) { Ok(entries) => { let events: Vec = entries .into_iter() .map(|e| { let mut val = e.event; if let serde_json::Value::Object(ref mut map) = val { map.insert( "timestamp".to_string(), serde_json::Value::String(e.timestamp), ); } val }) .collect(); let count = events.len(); serde_json::to_string_pretty(&json!({ "events": events, "done": true, "event_count": count, "message": "Events loaded from persistent log file.", "source": "log_file", "log_file": log_path.to_string_lossy(), })) .map_err(|e| format!("Serialization error: {e}")) } Err(e) => Err(format!("Failed to read log file: {e}")), } } pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result { let project_root = ctx.agents.get_project_root(&ctx.state)?; let config = ProjectConfig::load(&project_root)?; // Collect available (idle) agent names across all stages so the caller can // see at a glance which agents are free to start (story 190). let mut available_names: std::collections::HashSet = std::collections::HashSet::new(); for stage in &[ PipelineStage::Coder, PipelineStage::Qa, PipelineStage::Mergemaster, PipelineStage::Other, ] { if let Ok(names) = ctx.agents.available_agents_for_stage(&config, stage) { available_names.extend(names); } } serde_json::to_string_pretty(&json!(config .agent .iter() .map(|a| json!({ "name": a.name, "role": a.role, "model": a.model, "allowed_tools": a.allowed_tools, "max_turns": a.max_turns, "max_budget_usd": a.max_budget_usd, "available": available_names.contains(&a.name), })) .collect::>())) .map_err(|e| format!("Serialization error: {e}")) } pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> 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 timeout_ms = args .get("timeout_ms") .and_then(|v| v.as_u64()) .unwrap_or(300_000); // default: 5 minutes let info = ctx .agents .wait_for_agent(story_id, agent_name, timeout_ms) .await?; let commits = match (&info.worktree_path, &info.base_branch) { (Some(wt_path), Some(base)) => get_worktree_commits(wt_path, base).await, _ => 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, "status": info.status.to_string(), "session_id": info.session_id, "worktree_path": info.worktree_path, "base_branch": info.base_branch, "commits": commits, "completion": completion, })) .map_err(|e| format!("Serialization error: {e}")) } pub(super) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let project_root = ctx.agents.get_project_root(&ctx.state)?; let info = ctx.agents.create_worktree(&project_root, story_id).await?; serde_json::to_string_pretty(&json!({ "story_id": story_id, "worktree_path": info.path.to_string_lossy(), "branch": info.branch, "base_branch": info.base_branch, })) .map_err(|e| format!("Serialization error: {e}")) } pub(super) fn tool_list_worktrees(ctx: &AppContext) -> Result { let project_root = ctx.agents.get_project_root(&ctx.state)?; let entries = worktree::list_worktrees(&project_root)?; serde_json::to_string_pretty(&json!(entries .iter() .map(|e| json!({ "story_id": e.story_id, "path": e.path.to_string_lossy(), })) .collect::>())) .map_err(|e| format!("Serialization error: {e}")) } pub(super) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") .and_then(|v| v.as_str()) .ok_or("Missing required argument: story_id")?; let project_root = ctx.agents.get_project_root(&ctx.state)?; let config = ProjectConfig::load(&project_root)?; worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?; Ok(format!("Worktree for story '{story_id}' removed.")) } pub(super) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result { let worktree_path = args .get("worktree_path") .and_then(|v| v.as_str()) .ok_or("Missing required argument: worktree_path")?; let editor = get_editor_command_from_store(ctx) .ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?; Ok(format!("{editor} {worktree_path}")) } /// 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. pub(super) async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { let wt = worktree_path.to_string(); let base = base_branch.to_string(); tokio::task::spawn_blocking(move || { let output = std::process::Command::new("git") .args(["log", &format!("{base}..HEAD"), "--oneline"]) .current_dir(&wt) .output() .ok()?; if output.status.success() { let lines: Vec = String::from_utf8(output.stdout) .ok()? .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); Some(lines) } else { None } }) .await .ok() .flatten() } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use crate::store::StoreOps; fn test_ctx(dir: &std::path::Path) -> AppContext { AppContext::new_test(dir.to_path_buf()) } #[test] fn tool_list_agents_empty() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_list_agents(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); assert!(parsed.is_empty()); } #[test] fn tool_get_agent_config_no_project_toml_returns_default_agent() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); // No project.toml → default config with one fallback agent let result = tool_get_agent_config(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); // Default config contains one agent entry with default values assert_eq!(parsed.len(), 1, "default config should have one fallback agent"); assert!(parsed[0].get("name").is_some()); assert!(parsed[0].get("role").is_some()); } #[tokio::test] async fn tool_get_agent_output_poll_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_agent_output_poll(&json!({"agent_name": "bot"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_get_agent_output_poll_missing_agent_name() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_agent_output_poll(&json!({"story_id": "1_test"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("agent_name")); } #[tokio::test] async fn tool_get_agent_output_poll_no_agent_falls_back_to_empty_log() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); // No agent registered, no log file → returns empty response from log fallback let result = tool_get_agent_output_poll( &json!({"story_id": "99_nope", "agent_name": "bot"}), &ctx, ) .await .unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["done"], true); assert_eq!(parsed["event_count"], 0); assert!( parsed["message"].as_str().unwrap_or("").contains("No agent"), "expected 'No agent' message: {parsed}" ); } #[tokio::test] async fn tool_get_agent_output_poll_with_running_agent_returns_empty_events() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); // Inject a running agent — no events broadcast yet ctx.agents .inject_test_agent("10_story", "worker", crate::agents::AgentStatus::Running); let result = tool_get_agent_output_poll( &json!({"story_id": "10_story", "agent_name": "worker"}), &ctx, ) .await .unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["done"], false); assert_eq!(parsed["event_count"], 0); assert!(parsed["events"].is_array()); } #[tokio::test] async fn tool_stop_agent_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_stop_agent(&json!({"agent_name": "bot"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_stop_agent_missing_agent_name() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_stop_agent(&json!({"story_id": "1_test"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("agent_name")); } #[tokio::test] async fn tool_start_agent_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_start_agent(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_start_agent_no_agent_name_no_coder_returns_clear_error() { // Config has only a supervisor — start_agent without agent_name should // refuse rather than silently assigning supervisor. let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); std::fs::create_dir_all(&sk).unwrap(); std::fs::write( sk.join("project.toml"), r#" [[agent]] name = "supervisor" stage = "other" "#, ) .unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("coder"), "error should mention 'coder', got: {err}" ); } #[tokio::test] async fn tool_start_agent_no_agent_name_picks_coder_not_supervisor() { // Config has supervisor first, then coder-1. Without agent_name the // coder should be selected, not supervisor. The call will fail due to // missing git repo / worktree, but the error must NOT be about // "No coder agent configured". let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); std::fs::create_dir_all(&sk).unwrap(); std::fs::write( sk.join("project.toml"), r#" [[agent]] name = "supervisor" stage = "other" [[agent]] name = "coder-1" stage = "coder" "#, ) .unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await; // May succeed or fail for infrastructure reasons (no git repo), but // must NOT fail with "No coder agent configured". if let Err(err) = result { assert!( !err.contains("No coder agent configured"), "should not fail on agent selection, got: {err}" ); // Should also not complain about supervisor being absent. assert!( !err.contains("supervisor"), "should not select supervisor, got: {err}" ); } } #[tokio::test] async fn tool_create_worktree_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_create_worktree(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn tool_remove_worktree_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_remove_worktree(&json!({}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[test] fn tool_list_worktrees_empty_dir() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_list_worktrees(&ctx).unwrap(); let parsed: Vec = serde_json::from_str(&result).unwrap(); assert!(parsed.is_empty()); } // ── Editor command tool tests ───────────────────────────────── #[test] fn tool_get_editor_command_missing_worktree_path() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_editor_command(&json!({}), &ctx); assert!(result.is_err()); assert!(result.unwrap_err().contains("worktree_path")); } #[test] fn tool_get_editor_command_no_editor_configured() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_get_editor_command( &json!({"worktree_path": "/some/path"}), &ctx, ); assert!(result.is_err()); assert!(result.unwrap_err().contains("No editor configured")); } #[test] fn tool_get_editor_command_formats_correctly() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); ctx.store.set("editor_command", json!("zed")); let result = tool_get_editor_command( &json!({"worktree_path": "/home/user/worktrees/37_my_story"}), &ctx, ) .unwrap(); assert_eq!(result, "zed /home/user/worktrees/37_my_story"); } #[test] fn tool_get_editor_command_works_with_vscode() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); ctx.store.set("editor_command", json!("code")); let result = tool_get_editor_command( &json!({"worktree_path": "/path/to/worktree"}), &ctx, ) .unwrap(); assert_eq!(result, "code /path/to/worktree"); } #[test] fn get_editor_command_in_tools_list() { use super::super::{handle_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"] == "get_editor_command"); assert!(tool.is_some(), "get_editor_command missing from tools list"); let t = tool.unwrap(); assert!(t["description"].is_string()); let required = t["inputSchema"]["required"].as_array().unwrap(); let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); assert!(req_names.contains(&"worktree_path")); } #[tokio::test] async fn wait_for_agent_tool_missing_story_id() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_wait_for_agent(&json!({"agent_name": "bot"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("story_id")); } #[tokio::test] async fn wait_for_agent_tool_missing_agent_name() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_wait_for_agent(&json!({"story_id": "1_test"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("agent_name")); } #[tokio::test] async fn wait_for_agent_tool_nonexistent_agent_returns_error() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); let result = tool_wait_for_agent(&json!({"story_id": "99_nope", "agent_name": "bot", "timeout_ms": 50}), &ctx) .await; // No agent registered — should error assert!(result.is_err()); } #[tokio::test] async fn wait_for_agent_tool_returns_completed_agent() { use crate::agents::AgentStatus; let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); ctx.agents .inject_test_agent("41_story", "worker", AgentStatus::Completed); let result = tool_wait_for_agent( &json!({"story_id": "41_story", "agent_name": "worker"}), &ctx, ) .await .unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["status"], "completed"); assert_eq!(parsed["story_id"], "41_story"); 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()); } #[test] fn wait_for_agent_tool_in_list() { use super::super::{handle_tools_list}; let resp = handle_tools_list(Some(json!(1))); let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); let wait_tool = tools.iter().find(|t| t["name"] == "wait_for_agent"); assert!(wait_tool.is_some(), "wait_for_agent missing from tools list"); let t = wait_tool.unwrap(); assert!(t["description"].as_str().unwrap().contains("block") || t["description"].as_str().unwrap().contains("Block")); let required = t["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")); } #[test] fn read_coverage_percent_from_json_parses_llvm_cov_format() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let cov_dir = tmp.path().join(".story_kit/coverage"); fs::create_dir_all(&cov_dir).unwrap(); let json_content = r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#; fs::write(cov_dir.join("server.json"), json_content).unwrap(); let pct = read_coverage_percent_from_json(tmp.path()); assert_eq!(pct, Some(78.0)); } #[test] fn read_coverage_percent_from_json_returns_none_when_absent() { let tmp = tempfile::tempdir().unwrap(); let pct = read_coverage_percent_from_json(tmp.path()); assert!(pct.is_none()); } }