refactor(tools): split subagent.rs into folder module — start with tests (P1.1)
Promote `tools/subagent.rs` (4206 lines) to a folder module:
tools/subagent/
mod.rs — runtime types, manager, tool implementations (~3577 lines)
tests.rs — extracted test module (~631 lines)
This is the safe first step. The audit doc proposed a 4-way split
(mod / spec / executor / tests). I tried the 3-way (mod / tools / tests)
and the runtime <-> tool-impl coupling produces unresolved-symbol errors
because shared helpers (`SubAgentTask`, `run_subagent_task`,
`build_allowed_tools`, `normalize_role_alias`, `parse_spawn_request`,
the agent prompt constants) are referenced from both layers. Doing that
split right needs a small API design pass to decide which helpers
graduate to the manager API and which stay tool-private — out of scope
for a structural reorg. Pulled the test module out as the cleanest
no-API-change win and left a path open for the bigger split later.
Public API unchanged — `pub mod subagent;` still exports the same items
because `mod.rs` is a drop-in replacement for `subagent.rs`.
954 → 954 tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -973,8 +973,10 @@ pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> Sha
|
||||
Arc::new(Mutex::new(manager))
|
||||
}
|
||||
|
||||
|
||||
// === Tool Implementations ===
|
||||
|
||||
|
||||
/// Tool to spawn a background sub-agent.
|
||||
pub struct AgentSpawnTool {
|
||||
manager: SharedSubAgentManager,
|
||||
@@ -3568,639 +3570,8 @@ Otherwise return concise sections: SUMMARY, EVIDENCE, CHANGES, RISKS.
|
||||
Complete the task and provide your final result.
|
||||
";
|
||||
|
||||
|
||||
// === Tests ===
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_assignment() -> SubAgentAssignment {
|
||||
SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string()))
|
||||
}
|
||||
|
||||
fn make_snapshot(status: SubAgentStatus) -> SubAgentResult {
|
||||
SubAgentResult {
|
||||
agent_id: "agent_test".to_string(),
|
||||
agent_type: SubAgentType::General,
|
||||
assignment: make_assignment(),
|
||||
status,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_type_from_str() {
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("general"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("explore"),
|
||||
Some(SubAgentType::Explore)
|
||||
);
|
||||
assert_eq!(SubAgentType::from_str("PLAN"), Some(SubAgentType::Plan));
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("code-review"),
|
||||
Some(SubAgentType::Review)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("worker"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("default"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("explorer"),
|
||||
Some(SubAgentType::Explore)
|
||||
);
|
||||
assert_eq!(SubAgentType::from_str("awaiter"), Some(SubAgentType::Plan));
|
||||
assert_eq!(SubAgentType::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() {
|
||||
let input = json!({
|
||||
"message": "Find references to Foo",
|
||||
"agent_type": "explorer"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert_eq!(parsed.prompt, "Find references to Foo");
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Explore);
|
||||
assert_eq!(parsed.assignment.role.as_deref(), Some("explorer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_objective_and_role_alias() {
|
||||
let input = json!({
|
||||
"objective": "Coordinate and wait",
|
||||
"role": "awaiter"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert_eq!(parsed.prompt, "Coordinate and wait");
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Plan);
|
||||
assert_eq!(parsed.assignment.role.as_deref(), Some("awaiter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_items_payload() {
|
||||
let input = json!({
|
||||
"items": [
|
||||
{"type": "text", "text": "Analyze module"},
|
||||
{"type": "mention", "name": "drive", "path": "app://drive"}
|
||||
],
|
||||
"agent_name": "explorer"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert!(parsed.prompt.contains("Analyze module"));
|
||||
assert!(parsed.prompt.contains("[mention:$drive](app://drive)"));
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Explore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_text_and_items_together() {
|
||||
let input = json!({
|
||||
"prompt": "Analyze module",
|
||||
"items": [{"type": "text", "text": "dup"}]
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("text+items should fail");
|
||||
assert!(err.to_string().contains("either prompt text or items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_invalid_role() {
|
||||
let input = json!({
|
||||
"prompt": "do work",
|
||||
"role": "unknown_role"
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("invalid role should fail");
|
||||
assert!(err.to_string().contains("Invalid role alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_conflicting_type_and_role() {
|
||||
let input = json!({
|
||||
"prompt": "inspect internals",
|
||||
"type": "explore",
|
||||
"role": "worker"
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("conflicting type+role should fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Conflicting type/agent_type and role/agent_role")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_accepts_aliases() {
|
||||
let input = json!({
|
||||
"id": "agent_1234",
|
||||
"objective": "re-check failing tests",
|
||||
"agent_role": "explorer",
|
||||
"input": "focus on tests only",
|
||||
"interrupt": false
|
||||
});
|
||||
let request = parse_assign_request(&input).expect("assign request should parse");
|
||||
assert_eq!(request.agent_id, "agent_1234");
|
||||
assert_eq!(request.objective.as_deref(), Some("re-check failing tests"));
|
||||
assert_eq!(request.role.as_deref(), Some("explorer"));
|
||||
assert_eq!(request.message.as_deref(), Some("focus on tests only"));
|
||||
assert!(!request.interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_rejects_invalid_role() {
|
||||
let input = json!({
|
||||
"agent_id": "agent_1234",
|
||||
"role": "unknown"
|
||||
});
|
||||
let err = parse_assign_request(&input).expect_err("invalid role should fail");
|
||||
assert!(err.to_string().contains("Invalid role alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_requires_update_fields() {
|
||||
let input = json!({
|
||||
"agent_id": "agent_1234"
|
||||
});
|
||||
let err = parse_assign_request(&input).expect_err("missing update fields should fail");
|
||||
assert!(err.to_string().contains(
|
||||
"Provide at least one of objective, role/agent_role, message/input, or items"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_instruction_template_replaces_columns() {
|
||||
let mut values = HashMap::new();
|
||||
values.insert("name".to_string(), "alpha".to_string());
|
||||
values.insert("owner".to_string(), "hunter".to_string());
|
||||
|
||||
let rendered = render_instruction_template("Inspect {name} for {owner}", &values);
|
||||
assert_eq!(rendered, "Inspect alpha for hunter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_instruction_template_preserves_escaped_braces() {
|
||||
let mut values = HashMap::new();
|
||||
values.insert("name".to_string(), "alpha".to_string());
|
||||
|
||||
let rendered = render_instruction_template("literal {{x}} and {name}", &values);
|
||||
assert_eq!(rendered, "literal {x} and alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_accepts_first_report_only() {
|
||||
let job_id = "job_test_reports";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_1");
|
||||
|
||||
assert!(record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"ok"}),
|
||||
false,
|
||||
Some("agent_1")
|
||||
));
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"duplicate"}),
|
||||
true,
|
||||
Some("agent_1")
|
||||
));
|
||||
|
||||
let report = take_agent_job_result(job_id, "item-1").expect("report should exist");
|
||||
assert_eq!(report.result["status"], "ok");
|
||||
assert!(!report.stop);
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_rejects_wrong_agent_assignment() {
|
||||
let job_id = "job_test_reports_wrong_agent";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_good");
|
||||
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"bad"}),
|
||||
false,
|
||||
Some("agent_bad")
|
||||
));
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_rejects_missing_agent_assignment_context() {
|
||||
let job_id = "job_test_reports_missing_agent_context";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_good");
|
||||
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"bad"}),
|
||||
false,
|
||||
None
|
||||
));
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_output_schema_enforces_required_fields() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"required": ["status", "score"]
|
||||
});
|
||||
let ok_payload = json!({"status":"ok","score":1});
|
||||
assert!(validate_output_schema(&schema, &ok_payload).is_ok());
|
||||
|
||||
let missing = json!({"status":"ok"});
|
||||
let err = validate_output_schema(&schema, &missing).expect_err("missing required field");
|
||||
assert!(err.contains("missing required field 'score'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_results_csv_path_uses_input_stem() {
|
||||
let path = PathBuf::from("/tmp/inventory.csv");
|
||||
let output = default_results_csv_path(&path);
|
||||
assert_eq!(output, PathBuf::from("/tmp/inventory.results.csv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csv_concurrency_prefers_max_concurrency() {
|
||||
let input = json!({
|
||||
"max_workers": 3,
|
||||
"max_concurrency": 9
|
||||
});
|
||||
assert_eq!(parse_csv_concurrency(&input), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_uses_id_column_and_row_fallback() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,name\nalpha,First\n,Second\n").expect("write csv");
|
||||
|
||||
let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].item_id, "alpha");
|
||||
assert_eq!(rows[1].item_id, "row-2");
|
||||
assert_eq!(
|
||||
rows[1].values.get("name").map(String::as_str),
|
||||
Some("Second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_dedupes_item_ids() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,name\nfoo,First\nfoo,Second\n").expect("write csv");
|
||||
|
||||
let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].item_id, "foo");
|
||||
assert_eq!(rows[1].item_id, "foo-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_rejects_duplicate_headers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,id\nfoo,bar\n").expect("write csv");
|
||||
|
||||
let err = load_csv_rows(&csv_path, Some("id")).expect_err("duplicate headers should fail");
|
||||
assert!(err.to_string().contains("duplicate header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_input_schema_does_not_require_message_field() {
|
||||
let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 1)));
|
||||
let schema = AgentSendInputTool::new(manager, "send_input").input_schema();
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!required
|
||||
.iter()
|
||||
.any(|entry| entry.as_str().is_some_and(|name| name == "message")),
|
||||
"send_input schema should allow items-only payloads"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_tools_shell_filter() {
|
||||
let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap();
|
||||
assert!(!tools.contains(&"exec_shell".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_interact".to_string()));
|
||||
assert!(!tools.contains(&"exec_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_interact".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_tools_are_deduplicated() {
|
||||
let tools = build_allowed_tools(
|
||||
&SubAgentType::Custom,
|
||||
Some(vec![
|
||||
"read_file".to_string(),
|
||||
"read_file".to_string(),
|
||||
" ".to_string(),
|
||||
"grep_files".to_string(),
|
||||
]),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec!["read_file".to_string(), "grep_files".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_agent_requires_allowed_tools() {
|
||||
let err = build_allowed_tools(&SubAgentType::Custom, None, true).unwrap_err();
|
||||
assert!(err.to_string().contains("requires"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_mode_condition_any_and_all() {
|
||||
let one_done = vec![
|
||||
make_snapshot(SubAgentStatus::Running),
|
||||
make_snapshot(SubAgentStatus::Completed),
|
||||
];
|
||||
let all_done = vec![
|
||||
make_snapshot(SubAgentStatus::Completed),
|
||||
make_snapshot(SubAgentStatus::Cancelled),
|
||||
];
|
||||
|
||||
assert!(WaitMode::Any.condition_met(&one_done));
|
||||
assert!(!WaitMode::All.condition_met(&one_done));
|
||||
assert!(WaitMode::All.condition_met(&all_done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_mode() {
|
||||
assert_eq!(parse_wait_mode(&json!({})).unwrap(), WaitMode::Any);
|
||||
assert_eq!(
|
||||
parse_wait_mode(&json!({"wait_mode": "all"})).unwrap(),
|
||||
WaitMode::All
|
||||
);
|
||||
assert_eq!(
|
||||
parse_wait_mode(&json!({"wait_mode": "first"})).unwrap(),
|
||||
WaitMode::Any
|
||||
);
|
||||
assert!(parse_wait_mode(&json!({"wait_mode": "invalid"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_ids_accepts_aliases() {
|
||||
let ids = parse_wait_ids(&json!({
|
||||
"ids": ["agent_a", "agent_b"],
|
||||
"agent_id": "agent_c",
|
||||
"id": "agent_a"
|
||||
}));
|
||||
|
||||
assert_eq!(ids, vec!["agent_a", "agent_b", "agent_c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_ids_empty_when_omitted() {
|
||||
let ids = parse_wait_ids(&json!({}));
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_assignment_prompt_includes_metadata() {
|
||||
let assignment = SubAgentAssignment::new(
|
||||
"Inspect parser behavior".to_string(),
|
||||
Some("explorer".to_string()),
|
||||
);
|
||||
let prompt = build_assignment_prompt(
|
||||
"Inspect parser behavior",
|
||||
&assignment,
|
||||
&SubAgentType::Explore,
|
||||
);
|
||||
assert!(prompt.contains("Assignment metadata"));
|
||||
assert!(prompt.contains("resolved_type: explore"));
|
||||
assert!(prompt.contains("role: explorer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_tool_registry_reports_unavailable_tools() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let context = ToolContext::new(tmp.path().to_path_buf());
|
||||
let registry = SubAgentToolRegistry::new(
|
||||
context,
|
||||
vec!["read_file".to_string(), "missing_tool".to_string()],
|
||||
false,
|
||||
Arc::new(Mutex::new(TodoList::new())),
|
||||
Arc::new(Mutex::new(PlanState::default())),
|
||||
);
|
||||
assert_eq!(
|
||||
registry.unavailable_allowed_tools(),
|
||||
vec!["missing_tool".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for_result_reports_timeout_when_still_running() {
|
||||
let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 2)));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
{
|
||||
let mut guard = manager.lock().await;
|
||||
guard.agents.insert(agent_id.clone(), agent);
|
||||
}
|
||||
|
||||
let (snapshot, timed_out) = wait_for_result(&manager, &agent_id, Duration::from_millis(10))
|
||||
.await
|
||||
.expect("wait_for_result should succeed");
|
||||
assert!(timed_out);
|
||||
assert_eq!(snapshot.status, SubAgentStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_running_count_respects_limit() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_running_count_ignores_finished_task_handles() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
let handle = tokio::spawn(async {});
|
||||
handle.await.expect("dummy task should finish immediately");
|
||||
agent.task_handle = Some(tokio::spawn(async {}));
|
||||
if let Some(handle) = agent.task_handle.as_ref() {
|
||||
while !handle.is_finished() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_updates_running_agent_and_sends_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 2);
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let snapshot = manager
|
||||
.assign(
|
||||
&agent_id,
|
||||
Some("Re-check module boundaries".to_string()),
|
||||
Some("explorer".to_string()),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("assignment should succeed");
|
||||
assert_eq!(snapshot.assignment.objective, "Re-check module boundaries");
|
||||
assert_eq!(snapshot.assignment.role.as_deref(), Some("explorer"));
|
||||
|
||||
let dispatched = input_rx
|
||||
.try_recv()
|
||||
.expect("running agent should receive assignment update");
|
||||
assert!(dispatched.interrupt);
|
||||
assert!(dispatched.text.contains("Assignment updated"));
|
||||
assert!(dispatched.text.contains("objective"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_rejects_message_for_non_running_agent() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let err = manager
|
||||
.assign(&agent_id, None, None, Some("keep going".to_string()), true)
|
||||
.expect_err("non-running agent cannot receive assignment message");
|
||||
assert!(err.to_string().contains("is not running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_updates_non_running_metadata_without_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Plan,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let snapshot = manager
|
||||
.assign(
|
||||
&agent_id,
|
||||
Some("Draft retry plan".to_string()),
|
||||
Some("awaiter".to_string()),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("metadata update should succeed");
|
||||
assert_eq!(snapshot.assignment.objective, "Draft retry plan");
|
||||
assert_eq!(snapshot.assignment.role.as_deref(), Some("awaiter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persist_and_reload_marks_running_agent_as_interrupted() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let state_path = default_state_path(tmp.path());
|
||||
|
||||
let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let running = SubAgent::new(
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let running_id = running.id.clone();
|
||||
manager.agents.insert(running_id.clone(), running);
|
||||
manager.persist_state().expect("persist state");
|
||||
|
||||
let mut reloaded =
|
||||
SubAgentManager::new(workspace, 2).with_state_path(default_state_path(tmp.path()));
|
||||
reloaded.load_state().expect("load state");
|
||||
let snapshot = reloaded
|
||||
.get_result(&running_id)
|
||||
.expect("reloaded agent should exist");
|
||||
assert!(matches!(
|
||||
snapshot.status,
|
||||
SubAgentStatus::Interrupted(ref message)
|
||||
if message.contains(SUBAGENT_RESTART_REASON)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupted_status_name_and_summary() {
|
||||
let snapshot = make_snapshot(SubAgentStatus::Interrupted(
|
||||
SUBAGENT_RESTART_REASON.to_string(),
|
||||
));
|
||||
assert_eq!(subagent_status_name(&snapshot.status), "interrupted");
|
||||
assert!(summarize_subagent_result(&snapshot).contains(SUBAGENT_RESTART_REASON));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,631 @@
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_assignment() -> SubAgentAssignment {
|
||||
SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string()))
|
||||
}
|
||||
|
||||
fn make_snapshot(status: SubAgentStatus) -> SubAgentResult {
|
||||
SubAgentResult {
|
||||
agent_id: "agent_test".to_string(),
|
||||
agent_type: SubAgentType::General,
|
||||
assignment: make_assignment(),
|
||||
status,
|
||||
result: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_type_from_str() {
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("general"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("explore"),
|
||||
Some(SubAgentType::Explore)
|
||||
);
|
||||
assert_eq!(SubAgentType::from_str("PLAN"), Some(SubAgentType::Plan));
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("code-review"),
|
||||
Some(SubAgentType::Review)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("worker"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("default"),
|
||||
Some(SubAgentType::General)
|
||||
);
|
||||
assert_eq!(
|
||||
SubAgentType::from_str("explorer"),
|
||||
Some(SubAgentType::Explore)
|
||||
);
|
||||
assert_eq!(SubAgentType::from_str("awaiter"), Some(SubAgentType::Plan));
|
||||
assert_eq!(SubAgentType::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() {
|
||||
let input = json!({
|
||||
"message": "Find references to Foo",
|
||||
"agent_type": "explorer"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert_eq!(parsed.prompt, "Find references to Foo");
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Explore);
|
||||
assert_eq!(parsed.assignment.role.as_deref(), Some("explorer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_objective_and_role_alias() {
|
||||
let input = json!({
|
||||
"objective": "Coordinate and wait",
|
||||
"role": "awaiter"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert_eq!(parsed.prompt, "Coordinate and wait");
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Plan);
|
||||
assert_eq!(parsed.assignment.role.as_deref(), Some("awaiter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_items_payload() {
|
||||
let input = json!({
|
||||
"items": [
|
||||
{"type": "text", "text": "Analyze module"},
|
||||
{"type": "mention", "name": "drive", "path": "app://drive"}
|
||||
],
|
||||
"agent_name": "explorer"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert!(parsed.prompt.contains("Analyze module"));
|
||||
assert!(parsed.prompt.contains("[mention:$drive](app://drive)"));
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Explore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_text_and_items_together() {
|
||||
let input = json!({
|
||||
"prompt": "Analyze module",
|
||||
"items": [{"type": "text", "text": "dup"}]
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("text+items should fail");
|
||||
assert!(err.to_string().contains("either prompt text or items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_invalid_role() {
|
||||
let input = json!({
|
||||
"prompt": "do work",
|
||||
"role": "unknown_role"
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("invalid role should fail");
|
||||
assert!(err.to_string().contains("Invalid role alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_rejects_conflicting_type_and_role() {
|
||||
let input = json!({
|
||||
"prompt": "inspect internals",
|
||||
"type": "explore",
|
||||
"role": "worker"
|
||||
});
|
||||
let err = parse_spawn_request(&input).expect_err("conflicting type+role should fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Conflicting type/agent_type and role/agent_role")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_accepts_aliases() {
|
||||
let input = json!({
|
||||
"id": "agent_1234",
|
||||
"objective": "re-check failing tests",
|
||||
"agent_role": "explorer",
|
||||
"input": "focus on tests only",
|
||||
"interrupt": false
|
||||
});
|
||||
let request = parse_assign_request(&input).expect("assign request should parse");
|
||||
assert_eq!(request.agent_id, "agent_1234");
|
||||
assert_eq!(request.objective.as_deref(), Some("re-check failing tests"));
|
||||
assert_eq!(request.role.as_deref(), Some("explorer"));
|
||||
assert_eq!(request.message.as_deref(), Some("focus on tests only"));
|
||||
assert!(!request.interrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_rejects_invalid_role() {
|
||||
let input = json!({
|
||||
"agent_id": "agent_1234",
|
||||
"role": "unknown"
|
||||
});
|
||||
let err = parse_assign_request(&input).expect_err("invalid role should fail");
|
||||
assert!(err.to_string().contains("Invalid role alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_assign_request_requires_update_fields() {
|
||||
let input = json!({
|
||||
"agent_id": "agent_1234"
|
||||
});
|
||||
let err = parse_assign_request(&input).expect_err("missing update fields should fail");
|
||||
assert!(err.to_string().contains(
|
||||
"Provide at least one of objective, role/agent_role, message/input, or items"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_instruction_template_replaces_columns() {
|
||||
let mut values = HashMap::new();
|
||||
values.insert("name".to_string(), "alpha".to_string());
|
||||
values.insert("owner".to_string(), "hunter".to_string());
|
||||
|
||||
let rendered = render_instruction_template("Inspect {name} for {owner}", &values);
|
||||
assert_eq!(rendered, "Inspect alpha for hunter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_instruction_template_preserves_escaped_braces() {
|
||||
let mut values = HashMap::new();
|
||||
values.insert("name".to_string(), "alpha".to_string());
|
||||
|
||||
let rendered = render_instruction_template("literal {{x}} and {name}", &values);
|
||||
assert_eq!(rendered, "literal {x} and alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_accepts_first_report_only() {
|
||||
let job_id = "job_test_reports";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_1");
|
||||
|
||||
assert!(record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"ok"}),
|
||||
false,
|
||||
Some("agent_1")
|
||||
));
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"duplicate"}),
|
||||
true,
|
||||
Some("agent_1")
|
||||
));
|
||||
|
||||
let report = take_agent_job_result(job_id, "item-1").expect("report should exist");
|
||||
assert_eq!(report.result["status"], "ok");
|
||||
assert!(!report.stop);
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_rejects_wrong_agent_assignment() {
|
||||
let job_id = "job_test_reports_wrong_agent";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_good");
|
||||
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"bad"}),
|
||||
false,
|
||||
Some("agent_bad")
|
||||
));
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_agent_job_result_rejects_missing_agent_assignment_context() {
|
||||
let job_id = "job_test_reports_missing_agent_context";
|
||||
clear_agent_job_results(job_id);
|
||||
record_agent_job_assignment(job_id, "item-1", "agent_good");
|
||||
|
||||
assert!(!record_agent_job_result(
|
||||
job_id,
|
||||
"item-1",
|
||||
json!({"status":"bad"}),
|
||||
false,
|
||||
None
|
||||
));
|
||||
assert!(take_agent_job_result(job_id, "item-1").is_none());
|
||||
clear_agent_job_results(job_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_output_schema_enforces_required_fields() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"required": ["status", "score"]
|
||||
});
|
||||
let ok_payload = json!({"status":"ok","score":1});
|
||||
assert!(validate_output_schema(&schema, &ok_payload).is_ok());
|
||||
|
||||
let missing = json!({"status":"ok"});
|
||||
let err = validate_output_schema(&schema, &missing).expect_err("missing required field");
|
||||
assert!(err.contains("missing required field 'score'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_results_csv_path_uses_input_stem() {
|
||||
let path = PathBuf::from("/tmp/inventory.csv");
|
||||
let output = default_results_csv_path(&path);
|
||||
assert_eq!(output, PathBuf::from("/tmp/inventory.results.csv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csv_concurrency_prefers_max_concurrency() {
|
||||
let input = json!({
|
||||
"max_workers": 3,
|
||||
"max_concurrency": 9
|
||||
});
|
||||
assert_eq!(parse_csv_concurrency(&input), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_uses_id_column_and_row_fallback() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,name\nalpha,First\n,Second\n").expect("write csv");
|
||||
|
||||
let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].item_id, "alpha");
|
||||
assert_eq!(rows[1].item_id, "row-2");
|
||||
assert_eq!(
|
||||
rows[1].values.get("name").map(String::as_str),
|
||||
Some("Second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_dedupes_item_ids() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,name\nfoo,First\nfoo,Second\n").expect("write csv");
|
||||
|
||||
let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].item_id, "foo");
|
||||
assert_eq!(rows[1].item_id, "foo-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_csv_rows_rejects_duplicate_headers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let csv_path = tmp.path().join("rows.csv");
|
||||
std::fs::write(&csv_path, "id,id\nfoo,bar\n").expect("write csv");
|
||||
|
||||
let err = load_csv_rows(&csv_path, Some("id")).expect_err("duplicate headers should fail");
|
||||
assert!(err.to_string().contains("duplicate header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_input_schema_does_not_require_message_field() {
|
||||
let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 1)));
|
||||
let schema = AgentSendInputTool::new(manager, "send_input").input_schema();
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!required
|
||||
.iter()
|
||||
.any(|entry| entry.as_str().is_some_and(|name| name == "message")),
|
||||
"send_input schema should allow items-only payloads"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_tools_shell_filter() {
|
||||
let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap();
|
||||
assert!(!tools.contains(&"exec_shell".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_interact".to_string()));
|
||||
assert!(!tools.contains(&"exec_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_interact".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_tools_are_deduplicated() {
|
||||
let tools = build_allowed_tools(
|
||||
&SubAgentType::Custom,
|
||||
Some(vec![
|
||||
"read_file".to_string(),
|
||||
"read_file".to_string(),
|
||||
" ".to_string(),
|
||||
"grep_files".to_string(),
|
||||
]),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec!["read_file".to_string(), "grep_files".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_agent_requires_allowed_tools() {
|
||||
let err = build_allowed_tools(&SubAgentType::Custom, None, true).unwrap_err();
|
||||
assert!(err.to_string().contains("requires"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_mode_condition_any_and_all() {
|
||||
let one_done = vec![
|
||||
make_snapshot(SubAgentStatus::Running),
|
||||
make_snapshot(SubAgentStatus::Completed),
|
||||
];
|
||||
let all_done = vec![
|
||||
make_snapshot(SubAgentStatus::Completed),
|
||||
make_snapshot(SubAgentStatus::Cancelled),
|
||||
];
|
||||
|
||||
assert!(WaitMode::Any.condition_met(&one_done));
|
||||
assert!(!WaitMode::All.condition_met(&one_done));
|
||||
assert!(WaitMode::All.condition_met(&all_done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_mode() {
|
||||
assert_eq!(parse_wait_mode(&json!({})).unwrap(), WaitMode::Any);
|
||||
assert_eq!(
|
||||
parse_wait_mode(&json!({"wait_mode": "all"})).unwrap(),
|
||||
WaitMode::All
|
||||
);
|
||||
assert_eq!(
|
||||
parse_wait_mode(&json!({"wait_mode": "first"})).unwrap(),
|
||||
WaitMode::Any
|
||||
);
|
||||
assert!(parse_wait_mode(&json!({"wait_mode": "invalid"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_ids_accepts_aliases() {
|
||||
let ids = parse_wait_ids(&json!({
|
||||
"ids": ["agent_a", "agent_b"],
|
||||
"agent_id": "agent_c",
|
||||
"id": "agent_a"
|
||||
}));
|
||||
|
||||
assert_eq!(ids, vec!["agent_a", "agent_b", "agent_c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wait_ids_empty_when_omitted() {
|
||||
let ids = parse_wait_ids(&json!({}));
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_assignment_prompt_includes_metadata() {
|
||||
let assignment = SubAgentAssignment::new(
|
||||
"Inspect parser behavior".to_string(),
|
||||
Some("explorer".to_string()),
|
||||
);
|
||||
let prompt = build_assignment_prompt(
|
||||
"Inspect parser behavior",
|
||||
&assignment,
|
||||
&SubAgentType::Explore,
|
||||
);
|
||||
assert!(prompt.contains("Assignment metadata"));
|
||||
assert!(prompt.contains("resolved_type: explore"));
|
||||
assert!(prompt.contains("role: explorer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_tool_registry_reports_unavailable_tools() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let context = ToolContext::new(tmp.path().to_path_buf());
|
||||
let registry = SubAgentToolRegistry::new(
|
||||
context,
|
||||
vec!["read_file".to_string(), "missing_tool".to_string()],
|
||||
false,
|
||||
Arc::new(Mutex::new(TodoList::new())),
|
||||
Arc::new(Mutex::new(PlanState::default())),
|
||||
);
|
||||
assert_eq!(
|
||||
registry.unavailable_allowed_tools(),
|
||||
vec!["missing_tool".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for_result_reports_timeout_when_still_running() {
|
||||
let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 2)));
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
{
|
||||
let mut guard = manager.lock().await;
|
||||
guard.agents.insert(agent_id.clone(), agent);
|
||||
}
|
||||
|
||||
let (snapshot, timed_out) = wait_for_result(&manager, &agent_id, Duration::from_millis(10))
|
||||
.await
|
||||
.expect("wait_for_result should succeed");
|
||||
assert!(timed_out);
|
||||
assert_eq!(snapshot.status, SubAgentStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_running_count_respects_limit() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_running_count_ignores_finished_task_handles() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
let handle = tokio::spawn(async {});
|
||||
handle.await.expect("dummy task should finish immediately");
|
||||
agent.task_handle = Some(tokio::spawn(async {}));
|
||||
if let Some(handle) = agent.task_handle.as_ref() {
|
||||
while !handle.is_finished() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
|
||||
assert_eq!(manager.running_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_updates_running_agent_and_sends_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 2);
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
|
||||
let agent = SubAgent::new(
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let snapshot = manager
|
||||
.assign(
|
||||
&agent_id,
|
||||
Some("Re-check module boundaries".to_string()),
|
||||
Some("explorer".to_string()),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("assignment should succeed");
|
||||
assert_eq!(snapshot.assignment.objective, "Re-check module boundaries");
|
||||
assert_eq!(snapshot.assignment.role.as_deref(), Some("explorer"));
|
||||
|
||||
let dispatched = input_rx
|
||||
.try_recv()
|
||||
.expect("running agent should receive assignment update");
|
||||
assert!(dispatched.interrupt);
|
||||
assert!(dispatched.text.contains("Assignment updated"));
|
||||
assert!(dispatched.text.contains("objective"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_rejects_message_for_non_running_agent() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let err = manager
|
||||
.assign(&agent_id, None, None, Some("keep going".to_string()), true)
|
||||
.expect_err("non-running agent cannot receive assignment message");
|
||||
assert!(err.to_string().contains("is not running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_updates_non_running_metadata_without_message() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Plan,
|
||||
"prompt".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Completed;
|
||||
let agent_id = agent.id.clone();
|
||||
manager.agents.insert(agent_id.clone(), agent);
|
||||
|
||||
let snapshot = manager
|
||||
.assign(
|
||||
&agent_id,
|
||||
Some("Draft retry plan".to_string()),
|
||||
Some("awaiter".to_string()),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("metadata update should succeed");
|
||||
assert_eq!(snapshot.assignment.objective, "Draft retry plan");
|
||||
assert_eq!(snapshot.assignment.role.as_deref(), Some("awaiter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persist_and_reload_marks_running_agent_as_interrupted() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let state_path = default_state_path(tmp.path());
|
||||
|
||||
let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let running = SubAgent::new(
|
||||
SubAgentType::General,
|
||||
"work".to_string(),
|
||||
make_assignment(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
let running_id = running.id.clone();
|
||||
manager.agents.insert(running_id.clone(), running);
|
||||
manager.persist_state().expect("persist state");
|
||||
|
||||
let mut reloaded =
|
||||
SubAgentManager::new(workspace, 2).with_state_path(default_state_path(tmp.path()));
|
||||
reloaded.load_state().expect("load state");
|
||||
let snapshot = reloaded
|
||||
.get_result(&running_id)
|
||||
.expect("reloaded agent should exist");
|
||||
assert!(matches!(
|
||||
snapshot.status,
|
||||
SubAgentStatus::Interrupted(ref message)
|
||||
if message.contains(SUBAGENT_RESTART_REASON)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupted_status_name_and_summary() {
|
||||
let snapshot = make_snapshot(SubAgentStatus::Interrupted(
|
||||
SUBAGENT_RESTART_REASON.to_string(),
|
||||
));
|
||||
assert_eq!(subagent_status_name(&snapshot.status), "interrupted");
|
||||
assert!(summarize_subagent_result(&snapshot).contains(SUBAGENT_RESTART_REASON));
|
||||
}
|
||||
Reference in New Issue
Block a user