diff --git a/server/src/http/mcp/agent_tools/inspection.rs b/server/src/http/mcp/agent_tools/inspection.rs new file mode 100644 index 00000000..bcb39d08 --- /dev/null +++ b/server/src/http/mcp/agent_tools/inspection.rs @@ -0,0 +1,552 @@ +//! MCP agent inspection tools — read logs, config, and resource usage. + +use serde_json::{Value, json}; + +use crate::agents::PipelineStage; +use crate::config::ProjectConfig; +use crate::http::context::AppContext; + +/// Read agent session logs from disk and return a human-readable timeline. +/// +/// Stitches all session log files for the story together in chronological +/// order. If `agent_name` is omitted, logs from every agent are included. +/// Supports `lines` (tail the last N lines) and `filter` (substring match). +/// If a named agent is currently running, its buffered in-memory events are +/// included when no disk logs exist yet. +pub(crate) async fn tool_get_agent_output( + args: &Value, + ctx: &AppContext, +) -> Result { + use crate::agent_log; + + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let agent_name_filter = args.get("agent_name").and_then(|v| v.as_str()); + let tail = args + .get("lines") + .and_then(|v| v.as_u64()) + .map(|n| n as usize); + let filter = args.get("filter").and_then(|v| v.as_str()); + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + + // Collect all matching log files, oldest first. + let log_files = agent_log::list_story_log_files(&project_root, story_id, agent_name_filter); + + let mut all_lines: Vec = Vec::new(); + + for path in &log_files { + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + all_lines.push(format!("=== {} ===", file_name.trim_end_matches(".log"))); + match agent_log::read_log_as_readable_lines(path) { + Ok(lines) => all_lines.extend(lines), + Err(e) => all_lines.push(format!("[ERROR reading log: {e}]")), + } + all_lines.push(String::new()); // blank line between sessions + } + + // Append buffered live events only when no disk logs exist yet. + // emit_event() writes to disk synchronously, so disk is always up-to-date + // when log files are present. Showing live events alongside disk logs would + // produce duplicates. Only fall back to in-memory events when the log + // writer failed and nothing was persisted to disk. + if log_files.is_empty() + && let Some(agent_name) = agent_name_filter + && let Ok(live_events) = ctx.services.agents.drain_events(story_id, agent_name) + && !live_events.is_empty() + { + all_lines.push(format!("=== {agent_name} (live) ===")); + let now = chrono::Utc::now().to_rfc3339(); + for event in &live_events { + if let Ok(event_value) = serde_json::to_value(event) + && let Some(line) = agent_log::format_log_entry_as_text(&now, &event_value) + { + all_lines.push(line); + } + } + all_lines.push(String::new()); + } + + if log_files.is_empty() { + let agent_hint = agent_name_filter + .map(|n| format!(" agent '{n}'")) + .unwrap_or_default(); + return Ok(format!( + "No log files found for story '{story_id}'{agent_hint}." + )); + } + + // Apply substring filter (always keep section headers). + let filtered: Vec = if let Some(f) = filter { + all_lines + .into_iter() + .filter(|l| l.starts_with("===") || l.contains(f)) + .collect() + } else { + all_lines + }; + + // Apply tail (last N lines). + let output = if let Some(n) = tail { + let start = filtered.len().saturating_sub(n); + filtered[start..].join("\n") + } else { + filtered.join("\n") + }; + + Ok(output) +} + +pub(crate) fn tool_get_agent_config(ctx: &AppContext) -> Result { + let project_root = ctx.services.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 + .services + .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}")) +} + +/// Get remaining turns and budget for a running agent. +/// +/// Returns turns used, max turns, remaining turns, budget used, max budget, +/// and remaining budget for the named agent. Fails if the agent is not +/// currently running or pending. +pub(crate) fn tool_get_agent_remaining_turns_and_budget( + 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")?; + + // Verify the agent exists and is running/pending. + let agents = ctx.services.agents.list_agents()?; + let agent_info = agents + .iter() + .find(|a| a.story_id == story_id && a.agent_name == agent_name) + .ok_or_else(|| format!("No agent '{agent_name}' found for story '{story_id}'"))?; + + if !matches!( + agent_info.status, + crate::agents::AgentStatus::Running | crate::agents::AgentStatus::Pending + ) { + let reason_suffix = agent_info + .termination_reason + .as_ref() + .map(|r| { + format!( + ", termination_reason: {}", + serde_json::to_string(r).unwrap_or_default() + ) + }) + .unwrap_or_default(); + return Err(format!( + "Agent '{agent_name}' for story '{story_id}' is not running (status: {}{reason_suffix})", + agent_info.status + )); + } + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let config = ProjectConfig::load(&project_root)?; + + // Find the agent config (max_turns, max_budget_usd). + let agent_config = config.agent.iter().find(|a| a.name == agent_name); + + let max_turns = agent_config.and_then(|a| a.max_turns); + let max_budget_usd = agent_config.and_then(|a| a.max_budget_usd); + + // ── Cumulative counters (all sessions) ───────────────────────────── + let log_files = + crate::agent_log::list_story_log_files(&project_root, story_id, Some(agent_name)); + let mut cumulative_turns: u64 = 0; + for path in &log_files { + if let Ok(entries) = crate::agent_log::read_log(path) { + for entry in &entries { + if entry.event.get("type").and_then(|v| v.as_str()) == Some("agent_json") + && let Some(data) = entry.event.get("data") + && data.get("type").and_then(|v| v.as_str()) == Some("assistant") + { + cumulative_turns += 1; + } + } + } + } + + let cumulative_log_cost = crate::agents::pool::auto_assign::watchdog::compute_budget_from_logs( + &project_root, + story_id, + agent_name, + ); + let all_records = crate::agents::token_usage::read_all(&project_root).unwrap_or_default(); + let record_cost: f64 = all_records + .iter() + .filter(|r| r.story_id == story_id && r.agent_name == agent_name) + .map(|r| r.usage.total_cost_usd) + .sum(); + let cumulative_budget: f64 = cumulative_log_cost.max(record_cost); + + // ── Per-session counters (current session only — enforcement basis) ── + use crate::agents::pool::auto_assign::watchdog::{ + compute_budget_from_single_log, count_turns_in_log, resolve_session_log, + }; + let session_log = resolve_session_log( + &project_root, + story_id, + agent_name, + &agent_info.log_session_id, + ); + let session_turns: u64 = session_log + .as_ref() + .map(|p| count_turns_in_log(p)) + .unwrap_or(0); + let session_budget: f64 = session_log + .as_ref() + .map(|p| compute_budget_from_single_log(p)) + .unwrap_or(0.0); + + let remaining_turns = max_turns.map(|max| (max as i64) - (session_turns as i64)); + let remaining_budget_usd = max_budget_usd.map(|max| max - session_budget); + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "agent_name": agent_name, + "status": agent_info.status.to_string(), + // Per-session values (watchdog enforcement basis): + "turns_used": session_turns, + "budget_used_usd": session_budget, + // Cumulative values (all sessions, useful for cost analysis): + "cumulative_turns_used": cumulative_turns, + "cumulative_budget_used_usd": cumulative_budget, + // Limits and remaining (computed from per-session values): + "max_turns": max_turns, + "remaining_turns": remaining_turns, + "max_budget_usd": max_budget_usd, + "remaining_budget_usd": remaining_budget_usd, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_helpers::test_ctx; + use serde_json::json; + + #[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_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_agent_output(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_get_agent_output_no_logs_returns_not_found_message() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // No agent registered, no log file → returns "no log files found" message + let result = + tool_get_agent_output(&json!({"story_id": "99_nope", "agent_name": "bot"}), &ctx) + .await + .unwrap(); + assert!( + result.contains("No log files found"), + "expected 'No log files found' message: {result}" + ); + } + + #[tokio::test] + async fn tool_get_agent_output_agent_name_is_optional() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // No agent_name provided — should succeed (no error) + let result = tool_get_agent_output(&json!({"story_id": "99_nope"}), &ctx) + .await + .unwrap(); + assert!(result.contains("No log files found")); + } + + #[tokio::test] + async fn tool_get_agent_output_reads_from_disk() { + use crate::agent_log::AgentLogWriter; + use crate::agents::AgentEvent; + use crate::store::StoreOps; + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // Point the store at the tmp root so the tool can find log files. + ctx.store + .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); + + // Write a log file + let mut writer = + AgentLogWriter::new(tmp.path(), "42_story_foo", "coder-1", "sess-test").unwrap(); + writer + .write_event(&AgentEvent::Output { + story_id: "42_story_foo".to_string(), + agent_name: "coder-1".to_string(), + text: "My readable output line".to_string(), + }) + .unwrap(); + writer + .write_event(&AgentEvent::Done { + story_id: "42_story_foo".to_string(), + agent_name: "coder-1".to_string(), + session_id: Some("sess-test".to_string()), + }) + .unwrap(); + drop(writer); + + let result = tool_get_agent_output( + &json!({"story_id": "42_story_foo", "agent_name": "coder-1"}), + &ctx, + ) + .await + .unwrap(); + + assert!( + result.contains("My readable output line"), + "expected output text in result: {result}" + ); + assert!(result.contains("DONE"), "expected DONE marker: {result}"); + } + + #[tokio::test] + async fn tool_get_agent_output_tail_limits_lines() { + use crate::agent_log::AgentLogWriter; + use crate::agents::AgentEvent; + use crate::store::StoreOps; + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store + .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); + + let mut writer = + AgentLogWriter::new(tmp.path(), "42_story_bar", "coder-1", "sess-tail").unwrap(); + for i in 0..10 { + writer + .write_event(&AgentEvent::Output { + story_id: "42_story_bar".to_string(), + agent_name: "coder-1".to_string(), + text: format!("line {i}"), + }) + .unwrap(); + } + drop(writer); + + let result = tool_get_agent_output( + &json!({"story_id": "42_story_bar", "agent_name": "coder-1", "lines": 3}), + &ctx, + ) + .await + .unwrap(); + + // Should contain "line 7", "line 8", "line 9" but NOT "line 0" + assert!( + result.contains("line 9"), + "should contain last line: {result}" + ); + assert!( + !result.contains("line 0"), + "should not contain early lines: {result}" + ); + } + + #[tokio::test] + async fn tool_get_agent_output_filter_narrows_results() { + use crate::agent_log::AgentLogWriter; + use crate::agents::AgentEvent; + use crate::store::StoreOps; + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store + .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); + + let mut writer = + AgentLogWriter::new(tmp.path(), "42_story_baz", "coder-1", "sess-filter").unwrap(); + writer + .write_event(&AgentEvent::Output { + story_id: "42_story_baz".to_string(), + agent_name: "coder-1".to_string(), + text: "needle line here".to_string(), + }) + .unwrap(); + writer + .write_event(&AgentEvent::Output { + story_id: "42_story_baz".to_string(), + agent_name: "coder-1".to_string(), + text: "haystack only".to_string(), + }) + .unwrap(); + drop(writer); + + let result = tool_get_agent_output( + &json!({"story_id": "42_story_baz", "agent_name": "coder-1", "filter": "needle"}), + &ctx, + ) + .await + .unwrap(); + + assert!( + result.contains("needle"), + "filter should keep matching lines: {result}" + ); + assert!( + !result.contains("haystack"), + "filter should remove non-matching lines: {result}" + ); + } + + // ── get_agent_remaining_turns_and_budget tests ────────────────────────── + + #[test] + fn tool_get_agent_remaining_turns_and_budget_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_get_agent_remaining_turns_and_budget(&json!({"agent_name": "coder-1"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_get_agent_remaining_turns_and_budget_missing_agent_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_get_agent_remaining_turns_and_budget(&json!({"story_id": "1_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent_name")); + } + + #[test] + fn tool_get_agent_remaining_turns_and_budget_no_agent_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_agent_remaining_turns_and_budget( + &json!({"story_id": "99_nope", "agent_name": "coder-1"}), + &ctx, + ); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("No agent"), + "expected 'No agent' error, got: {err}" + ); + } + + #[test] + fn tool_get_agent_remaining_turns_and_budget_completed_agent_returns_error() { + use crate::agents::AgentStatus; + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.services + .agents + .inject_test_agent("42_story", "coder-1", AgentStatus::Completed); + + let result = tool_get_agent_remaining_turns_and_budget( + &json!({"story_id": "42_story", "agent_name": "coder-1"}), + &ctx, + ); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("not running"), + "expected 'not running' error, got: {err}" + ); + } + + #[test] + fn tool_get_agent_remaining_turns_and_budget_running_agent_returns_data() { + use crate::agents::AgentStatus; + use crate::store::StoreOps; + + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store + .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); + ctx.services + .agents + .inject_test_agent("42_story", "coder-1", AgentStatus::Running); + + let result = tool_get_agent_remaining_turns_and_budget( + &json!({"story_id": "42_story", "agent_name": "coder-1"}), + &ctx, + ) + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + + assert_eq!(parsed["story_id"], "42_story"); + assert_eq!(parsed["agent_name"], "coder-1"); + assert_eq!(parsed["status"], "running"); + // Per-session values (enforcement basis). + assert!(parsed.get("turns_used").is_some()); + assert!(parsed.get("budget_used_usd").is_some()); + // Cumulative values (all sessions, for cost analysis). + assert!(parsed.get("cumulative_turns_used").is_some()); + assert!(parsed.get("cumulative_budget_used_usd").is_some()); + // Limits and remaining. + assert!(parsed.get("max_turns").is_some()); + assert!(parsed.get("remaining_turns").is_some()); + assert!(parsed.get("max_budget_usd").is_some()); + assert!(parsed.get("remaining_budget_usd").is_some()); + } +} diff --git a/server/src/http/mcp/agent_tools/lifecycle.rs b/server/src/http/mcp/agent_tools/lifecycle.rs new file mode 100644 index 00000000..edef0f3c --- /dev/null +++ b/server/src/http/mcp/agent_tools/lifecycle.rs @@ -0,0 +1,347 @@ +//! MCP agent lifecycle tools — start, stop, wait, and list agents. + +use serde_json::{Value, json}; + +use crate::http::context::AppContext; +use crate::slog_warn; + +use super::worktree::get_worktree_commits; + +pub(crate) 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.services.agents.get_project_root(&ctx.state)?; + let info = ctx + .services + .agents + .start_agent(&project_root, story_id, agent_name, None, 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}/.huskies/coverage/server.json`. +pub(crate) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option { + let path = project_root + .join(".huskies") + .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(crate) 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.services.agents.get_project_root(&ctx.state)?; + ctx.services + .agents + .stop_agent(&project_root, story_id, agent_name) + .await?; + + Ok(format!( + "Agent '{agent_name}' for story '{story_id}' stopped." + )) +} + +pub(crate) fn tool_list_agents(ctx: &AppContext) -> Result { + let project_root = ctx.services.agents.get_project_root(&ctx.state).ok(); + let agents = ctx.services.agents.list_agents()?; + let mut entries: Vec = agents + .iter() + .filter(|a| { + project_root + .as_deref() + .map(|root| !crate::service::agents::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(); + + // Append a synthetic entry for each deterministic merge holding the merge lock. + let running_merges = ctx.services.agents.list_running_merges()?; + for story_id in running_merges { + entries.push(json!({ + "story_id": story_id, + "agent_name": "deterministic-merge", + "status": "Running", + "session_id": null, + "worktree_path": null, + })); + } + + serde_json::to_string_pretty(&json!(entries)).map_err(|e| format!("Serialization error: {e}")) +} + +pub(crate) 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 + .services + .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}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_helpers::test_ctx; + use serde_json::json; + + #[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()); + } + + #[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(".huskies"); + 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(".huskies"); + 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 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.services + .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: serde_json::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 read_coverage_percent_from_json_parses_llvm_cov_format() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let cov_dir = tmp.path().join(".huskies/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()); + } +} diff --git a/server/src/http/mcp/agent_tools/mod.rs b/server/src/http/mcp/agent_tools/mod.rs index df3d5e03..8887d7b5 100644 --- a/server/src/http/mcp/agent_tools/mod.rs +++ b/server/src/http/mcp/agent_tools/mod.rs @@ -1,774 +1,22 @@ //! MCP agent tools — start, stop, wait, list, and inspect agents via MCP. -use crate::agents::PipelineStage; -use crate::config::ProjectConfig; -use crate::http::context::AppContext; -use crate::slog_warn; -use serde_json::{Value, json}; - +mod inspection; +mod lifecycle; mod worktree; -pub(crate) use worktree::{ - get_worktree_commits, tool_create_worktree, tool_get_editor_command, tool_list_worktrees, - tool_remove_worktree, +pub(crate) use inspection::{ + tool_get_agent_config, tool_get_agent_output, tool_get_agent_remaining_turns_and_budget, +}; +pub(crate) use lifecycle::{ + tool_list_agents, tool_start_agent, tool_stop_agent, tool_wait_for_agent, +}; +pub(crate) use worktree::{ + tool_create_worktree, tool_get_editor_command, tool_list_worktrees, tool_remove_worktree, }; - -pub(crate) 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.services.agents.get_project_root(&ctx.state)?; - let info = ctx - .services - .agents - .start_agent(&project_root, story_id, agent_name, None, 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}/.huskies/coverage/server.json`. -pub(crate) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option { - let path = project_root - .join(".huskies") - .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(crate) 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.services.agents.get_project_root(&ctx.state)?; - ctx.services - .agents - .stop_agent(&project_root, story_id, agent_name) - .await?; - - Ok(format!( - "Agent '{agent_name}' for story '{story_id}' stopped." - )) -} - -pub(crate) fn tool_list_agents(ctx: &AppContext) -> Result { - let project_root = ctx.services.agents.get_project_root(&ctx.state).ok(); - let agents = ctx.services.agents.list_agents()?; - let mut entries: Vec = agents - .iter() - .filter(|a| { - project_root - .as_deref() - .map(|root| !crate::service::agents::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(); - - // Append a synthetic entry for each deterministic merge holding the merge lock. - let running_merges = ctx.services.agents.list_running_merges()?; - for story_id in running_merges { - entries.push(json!({ - "story_id": story_id, - "agent_name": "deterministic-merge", - "status": "Running", - "session_id": null, - "worktree_path": null, - })); - } - - serde_json::to_string_pretty(&json!(entries)).map_err(|e| format!("Serialization error: {e}")) -} - -/// Read agent session logs from disk and return a human-readable timeline. -/// -/// Stitches all session log files for the story together in chronological -/// order. If `agent_name` is omitted, logs from every agent are included. -/// Supports `lines` (tail the last N lines) and `filter` (substring match). -/// If a named agent is currently running, its buffered in-memory events are -pub(crate) async fn tool_get_agent_output( - args: &Value, - ctx: &AppContext, -) -> Result { - use crate::agent_log; - - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let agent_name_filter = args.get("agent_name").and_then(|v| v.as_str()); - let tail = args - .get("lines") - .and_then(|v| v.as_u64()) - .map(|n| n as usize); - let filter = args.get("filter").and_then(|v| v.as_str()); - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - - // Collect all matching log files, oldest first. - let log_files = agent_log::list_story_log_files(&project_root, story_id, agent_name_filter); - - let mut all_lines: Vec = Vec::new(); - - for path in &log_files { - let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); - all_lines.push(format!("=== {} ===", file_name.trim_end_matches(".log"))); - match agent_log::read_log_as_readable_lines(path) { - Ok(lines) => all_lines.extend(lines), - Err(e) => all_lines.push(format!("[ERROR reading log: {e}]")), - } - all_lines.push(String::new()); // blank line between sessions - } - - // Append buffered live events only when no disk logs exist yet. - // emit_event() writes to disk synchronously, so disk is always up-to-date - // when log files are present. Showing live events alongside disk logs would - // produce duplicates. Only fall back to in-memory events when the log - // writer failed and nothing was persisted to disk. - if log_files.is_empty() - && let Some(agent_name) = agent_name_filter - && let Ok(live_events) = ctx.services.agents.drain_events(story_id, agent_name) - && !live_events.is_empty() - { - all_lines.push(format!("=== {agent_name} (live) ===")); - let now = chrono::Utc::now().to_rfc3339(); - for event in &live_events { - if let Ok(event_value) = serde_json::to_value(event) - && let Some(line) = agent_log::format_log_entry_as_text(&now, &event_value) - { - all_lines.push(line); - } - } - all_lines.push(String::new()); - } - - if log_files.is_empty() { - let agent_hint = agent_name_filter - .map(|n| format!(" agent '{n}'")) - .unwrap_or_default(); - return Ok(format!( - "No log files found for story '{story_id}'{agent_hint}." - )); - } - - // Apply substring filter (always keep section headers). - let filtered: Vec = if let Some(f) = filter { - all_lines - .into_iter() - .filter(|l| l.starts_with("===") || l.contains(f)) - .collect() - } else { - all_lines - }; - - // Apply tail (last N lines). - let output = if let Some(n) = tail { - let start = filtered.len().saturating_sub(n); - filtered[start..].join("\n") - } else { - filtered.join("\n") - }; - - Ok(output) -} - -pub(crate) fn tool_get_agent_config(ctx: &AppContext) -> Result { - let project_root = ctx.services.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 - .services - .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}")) -} - -/// Get remaining turns and budget for a running agent. -/// -/// Returns turns used, max turns, remaining turns, budget used, max budget, -/// and remaining budget for the named agent. Fails if the agent is not -pub(crate) fn tool_get_agent_remaining_turns_and_budget( - 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")?; - - // Verify the agent exists and is running/pending. - let agents = ctx.services.agents.list_agents()?; - let agent_info = agents - .iter() - .find(|a| a.story_id == story_id && a.agent_name == agent_name) - .ok_or_else(|| format!("No agent '{agent_name}' found for story '{story_id}'"))?; - - if !matches!( - agent_info.status, - crate::agents::AgentStatus::Running | crate::agents::AgentStatus::Pending - ) { - let reason_suffix = agent_info - .termination_reason - .as_ref() - .map(|r| { - format!( - ", termination_reason: {}", - serde_json::to_string(r).unwrap_or_default() - ) - }) - .unwrap_or_default(); - return Err(format!( - "Agent '{agent_name}' for story '{story_id}' is not running (status: {}{reason_suffix})", - agent_info.status - )); - } - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - let config = ProjectConfig::load(&project_root)?; - - // Find the agent config (max_turns, max_budget_usd). - let agent_config = config.agent.iter().find(|a| a.name == agent_name); - - let max_turns = agent_config.and_then(|a| a.max_turns); - let max_budget_usd = agent_config.and_then(|a| a.max_budget_usd); - - // ── Cumulative counters (all sessions) ───────────────────────────── - let log_files = - crate::agent_log::list_story_log_files(&project_root, story_id, Some(agent_name)); - let mut cumulative_turns: u64 = 0; - for path in &log_files { - if let Ok(entries) = crate::agent_log::read_log(path) { - for entry in &entries { - if entry.event.get("type").and_then(|v| v.as_str()) == Some("agent_json") - && let Some(data) = entry.event.get("data") - && data.get("type").and_then(|v| v.as_str()) == Some("assistant") - { - cumulative_turns += 1; - } - } - } - } - - let cumulative_log_cost = crate::agents::pool::auto_assign::watchdog::compute_budget_from_logs( - &project_root, - story_id, - agent_name, - ); - let all_records = crate::agents::token_usage::read_all(&project_root).unwrap_or_default(); - let record_cost: f64 = all_records - .iter() - .filter(|r| r.story_id == story_id && r.agent_name == agent_name) - .map(|r| r.usage.total_cost_usd) - .sum(); - let cumulative_budget: f64 = cumulative_log_cost.max(record_cost); - - // ── Per-session counters (current session only — enforcement basis) ── - use crate::agents::pool::auto_assign::watchdog::{ - compute_budget_from_single_log, count_turns_in_log, resolve_session_log, - }; - let session_log = resolve_session_log( - &project_root, - story_id, - agent_name, - &agent_info.log_session_id, - ); - let session_turns: u64 = session_log - .as_ref() - .map(|p| count_turns_in_log(p)) - .unwrap_or(0); - let session_budget: f64 = session_log - .as_ref() - .map(|p| compute_budget_from_single_log(p)) - .unwrap_or(0.0); - - let remaining_turns = max_turns.map(|max| (max as i64) - (session_turns as i64)); - let remaining_budget_usd = max_budget_usd.map(|max| max - session_budget); - - serde_json::to_string_pretty(&json!({ - "story_id": story_id, - "agent_name": agent_name, - "status": agent_info.status.to_string(), - // Per-session values (watchdog enforcement basis): - "turns_used": session_turns, - "budget_used_usd": session_budget, - // Cumulative values (all sessions, useful for cost analysis): - "cumulative_turns_used": cumulative_turns, - "cumulative_budget_used_usd": cumulative_budget, - // Limits and remaining (computed from per-session values): - "max_turns": max_turns, - "remaining_turns": remaining_turns, - "max_budget_usd": max_budget_usd, - "remaining_budget_usd": remaining_budget_usd, - })) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(crate) 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 - .services - .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}")) -} #[cfg(test)] mod tests { - use super::*; - use crate::http::test_helpers::test_ctx; - - #[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_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_get_agent_output(&json!({}), &ctx).await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[tokio::test] - async fn tool_get_agent_output_no_logs_returns_not_found_message() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - // No agent registered, no log file → returns "no log files found" message - let result = - tool_get_agent_output(&json!({"story_id": "99_nope", "agent_name": "bot"}), &ctx) - .await - .unwrap(); - assert!( - result.contains("No log files found"), - "expected 'No log files found' message: {result}" - ); - } - - #[tokio::test] - async fn tool_get_agent_output_agent_name_is_optional() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - // No agent_name provided — should succeed (no error) - let result = tool_get_agent_output(&json!({"story_id": "99_nope"}), &ctx) - .await - .unwrap(); - assert!(result.contains("No log files found")); - } - - #[tokio::test] - async fn tool_get_agent_output_reads_from_disk() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - use crate::store::StoreOps; - - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - // Point the store at the tmp root so the tool can find log files. - ctx.store - .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); - - // Write a log file - let mut writer = - AgentLogWriter::new(tmp.path(), "42_story_foo", "coder-1", "sess-test").unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - text: "My readable output line".to_string(), - }) - .unwrap(); - writer - .write_event(&AgentEvent::Done { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - session_id: Some("sess-test".to_string()), - }) - .unwrap(); - drop(writer); - - let result = tool_get_agent_output( - &json!({"story_id": "42_story_foo", "agent_name": "coder-1"}), - &ctx, - ) - .await - .unwrap(); - - assert!( - result.contains("My readable output line"), - "expected output text in result: {result}" - ); - assert!(result.contains("DONE"), "expected DONE marker: {result}"); - } - - #[tokio::test] - async fn tool_get_agent_output_tail_limits_lines() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - use crate::store::StoreOps; - - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.store - .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); - - let mut writer = - AgentLogWriter::new(tmp.path(), "42_story_bar", "coder-1", "sess-tail").unwrap(); - for i in 0..10 { - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_bar".to_string(), - agent_name: "coder-1".to_string(), - text: format!("line {i}"), - }) - .unwrap(); - } - drop(writer); - - let result = tool_get_agent_output( - &json!({"story_id": "42_story_bar", "agent_name": "coder-1", "lines": 3}), - &ctx, - ) - .await - .unwrap(); - - // Should contain "line 7", "line 8", "line 9" but NOT "line 0" - assert!( - result.contains("line 9"), - "should contain last line: {result}" - ); - assert!( - !result.contains("line 0"), - "should not contain early lines: {result}" - ); - } - - #[tokio::test] - async fn tool_get_agent_output_filter_narrows_results() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - use crate::store::StoreOps; - - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.store - .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); - - let mut writer = - AgentLogWriter::new(tmp.path(), "42_story_baz", "coder-1", "sess-filter").unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_baz".to_string(), - agent_name: "coder-1".to_string(), - text: "needle line here".to_string(), - }) - .unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_baz".to_string(), - agent_name: "coder-1".to_string(), - text: "haystack only".to_string(), - }) - .unwrap(); - drop(writer); - - let result = tool_get_agent_output( - &json!({"story_id": "42_story_baz", "agent_name": "coder-1", "filter": "needle"}), - &ctx, - ) - .await - .unwrap(); - - assert!( - result.contains("needle"), - "filter should keep matching lines: {result}" - ); - assert!( - !result.contains("haystack"), - "filter should remove non-matching lines: {result}" - ); - } - - #[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(".huskies"); - 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(".huskies"); - 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 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.services - .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()); - } + use serde_json::json; #[test] fn wait_for_agent_tool_in_list() { @@ -791,122 +39,6 @@ stage = "coder" 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(".huskies/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()); - } - - // ── get_agent_remaining_turns_and_budget tests ────────────────────────── - - #[test] - fn tool_get_agent_remaining_turns_and_budget_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = - tool_get_agent_remaining_turns_and_budget(&json!({"agent_name": "coder-1"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_get_agent_remaining_turns_and_budget_missing_agent_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = - tool_get_agent_remaining_turns_and_budget(&json!({"story_id": "1_test"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("agent_name")); - } - - #[test] - fn tool_get_agent_remaining_turns_and_budget_no_agent_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_get_agent_remaining_turns_and_budget( - &json!({"story_id": "99_nope", "agent_name": "coder-1"}), - &ctx, - ); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.contains("No agent"), - "expected 'No agent' error, got: {err}" - ); - } - - #[test] - fn tool_get_agent_remaining_turns_and_budget_completed_agent_returns_error() { - use crate::agents::AgentStatus; - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.services - .agents - .inject_test_agent("42_story", "coder-1", AgentStatus::Completed); - - let result = tool_get_agent_remaining_turns_and_budget( - &json!({"story_id": "42_story", "agent_name": "coder-1"}), - &ctx, - ); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.contains("not running"), - "expected 'not running' error, got: {err}" - ); - } - - #[test] - fn tool_get_agent_remaining_turns_and_budget_running_agent_returns_data() { - use crate::agents::AgentStatus; - use crate::store::StoreOps; - - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - ctx.store - .set("project_root", json!(tmp.path().to_string_lossy().as_ref())); - ctx.services - .agents - .inject_test_agent("42_story", "coder-1", AgentStatus::Running); - - let result = tool_get_agent_remaining_turns_and_budget( - &json!({"story_id": "42_story", "agent_name": "coder-1"}), - &ctx, - ) - .unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - assert_eq!(parsed["story_id"], "42_story"); - assert_eq!(parsed["agent_name"], "coder-1"); - assert_eq!(parsed["status"], "running"); - // Per-session values (enforcement basis). - assert!(parsed.get("turns_used").is_some()); - assert!(parsed.get("budget_used_usd").is_some()); - // Cumulative values (all sessions, for cost analysis). - assert!(parsed.get("cumulative_turns_used").is_some()); - assert!(parsed.get("cumulative_budget_used_usd").is_some()); - // Limits and remaining. - assert!(parsed.get("max_turns").is_some()); - assert!(parsed.get("remaining_turns").is_some()); - assert!(parsed.get("max_budget_usd").is_some()); - assert!(parsed.get("remaining_budget_usd").is_some()); - } - #[test] fn tool_get_agent_remaining_turns_and_budget_in_tools_list() { use super::super::handle_tools_list;