huskies: merge 797
This commit is contained in:
@@ -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<String, String> {
|
||||||
|
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<String> = 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<String> = 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<String, String> {
|
||||||
|
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<String> = 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::<Vec<_>>()
|
||||||
|
))
|
||||||
|
.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<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let agent_name = args
|
||||||
|
.get("agent_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: agent_name")?;
|
||||||
|
|
||||||
|
// 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<Value> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
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<f64> {
|
||||||
|
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<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let agent_name = args
|
||||||
|
.get("agent_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: agent_name")?;
|
||||||
|
|
||||||
|
let 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<String, String> {
|
||||||
|
let project_root = ctx.services.agents.get_project_root(&ctx.state).ok();
|
||||||
|
let agents = ctx.services.agents.list_agents()?;
|
||||||
|
let mut entries: Vec<serde_json::Value> = 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<String, String> {
|
||||||
|
let story_id = args
|
||||||
|
.get("story_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
let agent_name = args
|
||||||
|
.get("agent_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: agent_name")?;
|
||||||
|
let 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::Value> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,774 +1,22 @@
|
|||||||
//! MCP agent tools — start, stop, wait, list, and inspect agents via MCP.
|
//! MCP agent tools — start, stop, wait, list, and inspect agents via MCP.
|
||||||
|
|
||||||
use crate::agents::PipelineStage;
|
mod inspection;
|
||||||
use crate::config::ProjectConfig;
|
mod lifecycle;
|
||||||
use crate::http::context::AppContext;
|
|
||||||
use crate::slog_warn;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
mod worktree;
|
mod worktree;
|
||||||
|
|
||||||
pub(crate) use worktree::{
|
pub(crate) use inspection::{
|
||||||
get_worktree_commits, tool_create_worktree, tool_get_editor_command, tool_list_worktrees,
|
tool_get_agent_config, tool_get_agent_output, tool_get_agent_remaining_turns_and_budget,
|
||||||
tool_remove_worktree,
|
};
|
||||||
|
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<String, String> {
|
|
||||||
let story_id = args
|
|
||||||
.get("story_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: story_id")?;
|
|
||||||
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
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<f64> {
|
|
||||||
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<String, String> {
|
|
||||||
let story_id = args
|
|
||||||
.get("story_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: story_id")?;
|
|
||||||
let agent_name = args
|
|
||||||
.get("agent_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: agent_name")?;
|
|
||||||
|
|
||||||
let 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<String, String> {
|
|
||||||
let project_root = ctx.services.agents.get_project_root(&ctx.state).ok();
|
|
||||||
let agents = ctx.services.agents.list_agents()?;
|
|
||||||
let mut entries: Vec<serde_json::Value> = 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<String, String> {
|
|
||||||
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<String> = 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<String> = 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<String, String> {
|
|
||||||
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<String> = 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::<Vec<_>>()
|
|
||||||
))
|
|
||||||
.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<String, String> {
|
|
||||||
let story_id = args
|
|
||||||
.get("story_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: story_id")?;
|
|
||||||
let agent_name = args
|
|
||||||
.get("agent_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: agent_name")?;
|
|
||||||
|
|
||||||
// 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<String, String> {
|
|
||||||
let story_id = args
|
|
||||||
.get("story_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: story_id")?;
|
|
||||||
let agent_name = args
|
|
||||||
.get("agent_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing required argument: agent_name")?;
|
|
||||||
let 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use serde_json::json;
|
||||||
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<Value> = 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<Value> = 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]
|
#[test]
|
||||||
fn wait_for_agent_tool_in_list() {
|
fn wait_for_agent_tool_in_list() {
|
||||||
@@ -791,122 +39,6 @@ stage = "coder"
|
|||||||
assert!(req_names.contains(&"agent_name"));
|
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]
|
#[test]
|
||||||
fn tool_get_agent_remaining_turns_and_budget_in_tools_list() {
|
fn tool_get_agent_remaining_turns_and_budget_in_tools_list() {
|
||||||
use super::super::handle_tools_list;
|
use super::super::handle_tools_list;
|
||||||
|
|||||||
Reference in New Issue
Block a user