//! 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 worktree; pub(crate) use worktree::{ get_worktree_commits, 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()?; serde_json::to_string_pretty(&json!( 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::>() )) .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()); } #[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(".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; 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_agent_remaining_turns_and_budget"); assert!( tool.is_some(), "get_agent_remaining_turns_and_budget missing from tools list" ); let t = tool.unwrap(); 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")); } }