From 406642723e7ba8db45e036daa0780dd4b11506d9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 1 May 2026 12:53:26 -0600 Subject: [PATCH] test: Added integration tests for the sub-agent spawning system and inter-agent communication mechanisms --- docs/testing/notes/ITERATION-11-NOTES.md | 66 ++- docs/testing/plans/11-sub-agent-spawning.md | 90 ++-- src/config/app_state.rs | 14 + src/config/input.rs | 44 +- src/config/request_context.rs | 11 +- src/function/supervisor.rs | 494 ++++++++++++++++++++ 6 files changed, 640 insertions(+), 79 deletions(-) diff --git a/docs/testing/notes/ITERATION-11-NOTES.md b/docs/testing/notes/ITERATION-11-NOTES.md index 0e8b5a3..6e7cb8f 100644 --- a/docs/testing/notes/ITERATION-11-NOTES.md +++ b/docs/testing/notes/ITERATION-11-NOTES.md @@ -66,7 +66,53 @@ | `test_task_node_is_runnable` | Pending + unblocked = runnable | | `test_task_node_not_runnable_when_blocked` | Blocked = not runnable | -**Total: 40 new tests (382 total in suite)** +### src/function/supervisor.rs (36 new handler integration tests) + +| Test name | What it verifies | +|---|---| +| `handle_list_empty_supervisor` | Empty supervisor → 0 active, empty agents | +| `handle_list_with_agents` | Registered agents appear in list | +| `handle_list_no_supervisor_errors` | No supervisor → error | +| `handle_check_unknown_agent` | Check unknown → error status | +| `handle_check_pending_agent` | Check running agent → pending status | +| `handle_cancel_registered_agent` | Cancel removes and signals abort | +| `handle_cancel_unknown_agent` | Cancel unknown → error status | +| `handle_cancel_no_supervisor_errors` | No supervisor → error | +| `handle_send_message_to_registered_agent` | Message delivered to inbox | +| `handle_send_message_to_unknown_agent` | Unknown agent → error status | +| `handle_check_inbox_with_messages` | Inbox drains messages with count | +| `handle_check_inbox_no_inbox` | No inbox → count 0 | +| `handle_check_inbox_empty_inbox` | Empty inbox → count 0 | +| `handle_reply_escalation_success` | Reply delivered via oneshot | +| `handle_reply_escalation_missing_id` | Missing id → error status | +| `handle_reply_escalation_no_queue_errors` | No queue → error | +| `handle_task_create_simple` | Simple task created with id | +| `handle_task_create_with_dependencies` | Task with blocked_by | +| `handle_task_create_with_dispatch_agent` | Auto-dispatch flag set | +| `handle_task_create_agent_without_prompt_errors` | Agent without prompt → error | +| `handle_task_list_empty` | Empty queue → empty tasks array | +| `handle_task_list_with_tasks` | Tasks listed | +| `handle_task_complete_unblocks_dependents` | Complete unblocks with newly_runnable | +| `handle_task_fail_marks_failed` | Fail sets status | +| `handle_task_fail_reports_blocked_dependents` | Reports blocked deps | +| `handle_task_fail_missing_task` | Missing task → error status | +| `dispatch_unknown_action_errors` | Unknown action → error | +| `dispatch_routes_list` | agent__list → handle_list | +| `dispatch_routes_task_list` | agent__task_list → handle_task_list | +| `new_for_child_inherits_escalation_queue` | Shared Arc | +| `new_for_child_sets_depth_and_id` | Depth and self_agent_id | +| `new_for_child_has_inbox` | Shared inbox Arc | +| `new_for_child_inherits_parent_supervisor` | parent_supervisor set | +| `new_for_child_starts_with_empty_scope` | Empty functions, mcp, role, session | +| `ensure_root_escalation_queue_creates_on_first_call` | Lazy init | +| `ensure_root_escalation_queue_returns_same_on_second_call` | Same Arc | + +### Infrastructure + +- Added `AppState::test_default()` method for cross-module test construction +- Refactored `input.rs` and `request_context.rs` test helpers to use `test_default()` + +**Total: 76 new tests (418 total in suite)** ## Bugs discovered @@ -92,10 +138,20 @@ None. pattern — not ideal but necessary since JoinHandle can't be mocked. -5. **Most spawn/collect/check behaviors require integration tests**: - The actual agent__spawn handler needs a full RequestContext with - agent config on disk. The Supervisor struct itself is fully - testable in isolation. +5. **handle_spawn requires real agent config on disk**: This is the + only handler that calls Agent::init. All other handlers (list, + check, cancel, messaging, tasks, escalation) work with just a + RequestContext + Supervisor, which we can construct in tests. + +6. **Handler integration tests cover the full dispatch chain**: The + tests call handler functions with real RequestContext instances + containing real Supervisor/EscalationQueue/Inbox instances. This + verifies the JSON arg parsing, supervisor interactions, and + response formatting all at once. + +7. **AppState::test_default() centralizes test construction**: Added + a `#[cfg(test)]` constructor that avoids importing private + modules (mcp_factory, rag_cache) from outside the config module. ## Next iteration diff --git a/docs/testing/plans/11-sub-agent-spawning.md b/docs/testing/plans/11-sub-agent-spawning.md index 82977c8..a780455 100644 --- a/docs/testing/plans/11-sub-agent-spawning.md +++ b/docs/testing/plans/11-sub-agent-spawning.md @@ -10,50 +10,71 @@ to request user input through the parent. ## Behaviors to test ### Spawn -- [ ] agent__spawn creates child agent in background (integration) -- [ ] Child gets own RequestContext with incremented depth (integration) -- [ ] Child gets own session, model, functions (integration) -- [ ] Child gets shared root_escalation_queue (integration) -- [ ] Child gets inbox for teammate messaging (integration) -- [ ] Child MCP servers acquired if configured (integration) +- [ ] agent__spawn creates child agent in background (requires agent config on disk) +- [x] Child gets own RequestContext with incremented depth (new_for_child) +- [x] Child starts with empty scope (new_for_child) +- [x] Child gets shared root_escalation_queue (new_for_child) +- [x] Child gets inbox for teammate messaging (new_for_child) +- [x] Child inherits parent_supervisor (new_for_child) +- [ ] Child MCP servers acquired if configured (requires live MCP) - [x] Max concurrent agents enforced (Supervisor.register) - [x] Max depth enforced (Supervisor.register) -- [ ] Agent not found → error (integration) -- [ ] can_spawn_agents=false → no spawn tools available (integration) +- [ ] Agent not found → error (requires agent config on disk) +- [ ] can_spawn_agents=false → no spawn tools available (requires agent init) ### Collect/Check -- [ ] agent__check returns PENDING or result (integration) -- [ ] agent__collect blocks until done, returns output (integration) -- [ ] Output summarization when exceeds threshold (integration) -- [ ] Summarization uses configured model (integration) +- [x] agent__check returns PENDING for running agent +- [x] agent__check returns error for unknown agent +- [ ] agent__collect blocks until done, returns output (requires real child completion) +- [ ] Output summarization when exceeds threshold (requires LLM client) +- [ ] Summarization uses configured model (requires LLM client) -### Task queue -- [x] agent__task_create creates tasks with dependencies -- [x] agent__task_complete marks done, unblocks dependents -- [x] Auto-dispatch agent/prompt stored on task -- [x] agent__task_list shows all tasks with status +### Task queue (handler integration tests) +- [x] handle_task_create creates tasks (simple, with deps, with dispatch_agent) +- [x] handle_task_create errors when agent set without prompt +- [x] handle_task_complete unblocks dependents +- [x] handle_task_list shows all tasks +- [x] handle_task_fail marks failed and reports blocked dependents +- [x] handle_task_fail returns error for missing task -### Escalation -- [x] Escalation submitted and retrievable +### Escalation (handler integration tests) +- [x] handle_reply_escalation delivers reply via oneshot channel +- [x] handle_reply_escalation errors for missing escalation_id +- [x] handle_reply_escalation errors when no queue - [x] Pending summary contains correct fields - [x] Reply reaches receiver via oneshot channel -- [ ] Escalation timeout → fallback message (integration) +- [ ] Escalation timeout → fallback message (requires tokio timeout) -### Teammate messaging -- [x] Deliver to inbox -- [x] Drain empties inbox -- [x] Drain ordering: shutdown > task_completed > text +### Teammate messaging (handler integration tests) +- [x] handle_send_message delivers to registered agent's inbox +- [x] handle_send_message errors for unknown agent +- [x] handle_check_inbox returns messages with count +- [x] handle_check_inbox returns empty when no inbox +- [x] handle_check_inbox returns empty for empty inbox + +### Cancel/List (handler integration tests) +- [x] handle_list returns empty for fresh supervisor +- [x] handle_list returns registered agents +- [x] handle_list errors when no supervisor +- [x] handle_cancel removes agent and signals abort +- [x] handle_cancel errors for unknown agent +- [x] handle_cancel errors when no supervisor + +### Dispatch routing +- [x] Unknown action → error with "Unknown supervisor action" +- [x] agent__list routes to handle_list +- [x] agent__task_list routes to handle_task_list ### Child agent lifecycle -- [ ] run_child_agent loops (integration) -- [ ] Child uses before/after_chat_completion (integration) -- [ ] Child tool calls evaluated (integration) -- [ ] Child exits cleanly (integration) +- [ ] run_child_agent loops (requires LLM client) +- [ ] Child uses before/after_chat_completion (requires LLM client) +- [ ] Child tool calls evaluated (requires LLM client) +- [ ] Child exits cleanly (requires LLM client) ## Context switching scenarios -- [ ] Parent spawns child with MCP (integration) -- [ ] Parent exits agent → all children cancelled (integration) -- [ ] Multiple children share escalation queue correctly (integration) +- [ ] Parent spawns child with MCP (requires live MCP + agent config) +- [ ] Parent exits agent → all children cancelled (requires agent init) +- [x] Multiple children share escalation queue (new_for_child + ensure_root_escalation_queue) ## Additional behaviors tested (not in original plan) @@ -73,6 +94,13 @@ to request user input through the parent. - [x] TaskQueue: dependency on nonexistent errors, complete nonexistent - [x] TaskNode: is_runnable when pending+unblocked, not when blocked +## Integration handler tests added + +- [x] All handle_* functions tested via handler integration tests (36 tests) +- [x] new_for_child: depth, id, inbox, escalation queue, parent supervisor, empty scope +- [x] ensure_root_escalation_queue: lazy init, same Arc on repeated calls +- [x] AppState::test_default() helper added for cross-module test construction + ## Old code reference - `src/function/supervisor.rs` — all handler functions - `src/supervisor/` — Supervisor, EscalationQueue, Inbox, TaskQueue diff --git a/src/config/app_state.rs b/src/config/app_state.rs index fdc18b2..dea3c80 100644 --- a/src/config/app_state.rs +++ b/src/config/app_state.rs @@ -39,6 +39,20 @@ pub struct AppState { } impl AppState { + #[cfg(test)] + pub fn test_default() -> Self { + Self { + config: Arc::new(AppConfig::default()), + vault: Arc::new(Vault::default()), + mcp_factory: Arc::new(McpFactory::default()), + rag_cache: Arc::new(RagCache::default()), + mcp_config: None, + mcp_log_path: None, + mcp_registry: None, + functions: Functions::default(), + } + } + pub async fn init( config: Arc, log_path: Option, diff --git a/src/config/input.rs b/src/config/input.rs index 06d21c8..406b71c 100644 --- a/src/config/input.rs +++ b/src/config/input.rs @@ -1,13 +1,13 @@ use super::*; use crate::client::{ - ChatCompletionsData, Client, ImageUrl, Message, MessageContent, MessageContentPart, - MessageContentToolCalls, MessageRole, Model, init_client, patch_messages, + init_client, patch_messages, ChatCompletionsData, Client, ImageUrl, Message, + MessageContent, MessageContentPart, MessageContentToolCalls, MessageRole, Model, }; use crate::function::ToolResult; -use crate::utils::{AbortSignal, base64_encode, is_loader_protocol, sha256}; +use crate::utils::{base64_encode, is_loader_protocol, sha256, AbortSignal}; -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use indexmap::IndexSet; use std::{collections::HashMap, fs::File, io::Read, sync::Arc}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -582,24 +582,12 @@ fn read_media_to_data_url(image_path: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::config::mcp_factory::McpFactory; - use crate::config::rag_cache::RagCache; use crate::config::request_context::RequestContext; - use crate::config::{AppConfig, AppState, WorkingMode}; - use crate::function::Functions; + use crate::config::{AppState, WorkingMode}; use std::sync::Arc; fn default_app_state() -> Arc { - Arc::new(AppState { - config: Arc::new(AppConfig::default()), - vault: Arc::new(Vault::default()), - mcp_factory: Arc::new(McpFactory::default()), - rag_cache: Arc::new(RagCache::default()), - mcp_config: None, - mcp_log_path: None, - mcp_registry: None, - functions: Functions::default(), - }) + Arc::new(AppState::test_default()) } fn create_test_ctx() -> RequestContext { @@ -720,21 +708,11 @@ mod tests { #[test] fn input_from_str_captures_stream_from_config() { - let app_state = { - let mut config = AppConfig::default(); - config.stream = false; - Arc::new(AppState { - config: Arc::new(config), - vault: Arc::new(crate::vault::Vault::default()), - mcp_factory: Arc::new(McpFactory::default()), - rag_cache: Arc::new(RagCache::default()), - mcp_config: None, - mcp_log_path: None, - mcp_registry: None, - functions: Functions::default(), - }) - }; - let ctx = RequestContext::new(app_state, WorkingMode::Cmd); + let mut state = AppState::test_default(); + let mut config = (*state.config).clone(); + config.stream = false; + state.config = Arc::new(config); + let ctx = RequestContext::new(Arc::new(state), WorkingMode::Cmd); let input = Input::from_str(&ctx, "test", None); assert!(!input.stream_enabled); } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 3ce22e9..0d58e50 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -2422,16 +2422,7 @@ mod tests { } fn default_app_state() -> Arc { - Arc::new(AppState { - config: Arc::new(AppConfig::default()), - vault: Arc::new(Vault::default()), - mcp_factory: Arc::new(McpFactory::default()), - rag_cache: Arc::new(RagCache::default()), - mcp_config: None, - mcp_log_path: None, - mcp_registry: None, - functions: Functions::default(), - }) + Arc::new(AppState::test_default()) } fn create_test_ctx() -> RequestContext { diff --git a/src/function/supervisor.rs b/src/function/supervisor.rs index 741257a..0fe4ade 100644 --- a/src/function/supervisor.rs +++ b/src/function/supervisor.rs @@ -1087,3 +1087,497 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str) Ok(summary) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AppState, WorkingMode}; + use crate::supervisor::escalation::{EscalationQueue, EscalationRequest}; + use serde_json::json; + + fn default_app_state() -> Arc { + Arc::new(AppState::test_default()) + } + + fn ctx_with_supervisor(max_concurrent: usize, max_depth: usize) -> RequestContext { + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + ctx.supervisor = Some(Arc::new(RwLock::new(Supervisor::new( + max_concurrent, + max_depth, + )))); + ctx + } + + fn register_fake_agent(ctx: &mut RequestContext, id: &str, name: &str) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let id_owned = id.to_string(); + let name_owned = name.to_string(); + let join_handle = rt.spawn(async move { + Ok(AgentResult { + id: id_owned, + agent_name: name_owned, + output: "fake output".into(), + exit_status: AgentExitStatus::Completed, + }) + }); + std::mem::forget(rt); + + let handle = AgentHandle { + id: id.to_string(), + agent_name: name.to_string(), + depth: 1, + inbox: Arc::new(Inbox::new()), + abort_signal: create_abort_signal(), + join_handle, + }; + ctx.supervisor + .as_ref() + .unwrap() + .write() + .register(handle) + .unwrap(); + } + + fn run_async(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(f) + } + + #[test] + fn handle_list_empty_supervisor() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_list(&mut ctx).unwrap(); + assert_eq!(result["active_count"], 0); + assert_eq!(result["max_concurrent"], 4); + assert!(result["agents"].as_array().unwrap().is_empty()); + } + + #[test] + fn handle_list_with_agents() { + let mut ctx = ctx_with_supervisor(4, 3); + register_fake_agent(&mut ctx, "a1", "explore"); + register_fake_agent(&mut ctx, "a2", "coder"); + let result = handle_list(&mut ctx).unwrap(); + assert_eq!(result["active_count"], 2); + let agents = result["agents"].as_array().unwrap(); + assert_eq!(agents.len(), 2); + } + + #[test] + fn handle_list_no_supervisor_errors() { + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + let result = handle_list(&mut ctx); + assert!(result.is_err()); + } + + #[test] + fn handle_check_unknown_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = run_async(handle_check(&mut ctx, &json!({"id": "nonexistent"}))); + let val = result.unwrap(); + assert_eq!(val["status"], "error"); + } + + #[test] + fn handle_check_pending_agent() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let mut ctx = ctx_with_supervisor(4, 3); + let inbox = Arc::new(Inbox::new()); + let abort = create_abort_signal(); + let join_handle = tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(AgentResult { + id: "slow".into(), + agent_name: "test".into(), + output: String::new(), + exit_status: AgentExitStatus::Completed, + }) + }); + let handle = AgentHandle { + id: "slow".into(), + agent_name: "test".into(), + depth: 1, + inbox, + abort_signal: abort, + join_handle, + }; + ctx.supervisor + .as_ref() + .unwrap() + .write() + .register(handle) + .unwrap(); + + let result = handle_check(&mut ctx, &json!({"id": "slow"})) + .await + .unwrap(); + assert_eq!(result["status"], "pending"); + }); + } + + #[test] + fn handle_cancel_registered_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + register_fake_agent(&mut ctx, "a1", "explore"); + let result = handle_cancel(&mut ctx, &json!({"id": "a1"})).unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(ctx.supervisor.as_ref().unwrap().read().active_count(), 0); + } + + #[test] + fn handle_cancel_unknown_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_cancel(&mut ctx, &json!({"id": "missing"})).unwrap(); + assert_eq!(result["status"], "error"); + } + + #[test] + fn handle_cancel_no_supervisor_errors() { + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + let result = handle_cancel(&mut ctx, &json!({"id": "x"})); + assert!(result.is_err()); + } + + #[test] + fn handle_send_message_to_registered_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + register_fake_agent(&mut ctx, "a1", "explore"); + let result = handle_send_message( + &mut ctx, + &json!({"id": "a1", "message": "hello from parent"}), + ) + .unwrap(); + assert_eq!(result["status"], "ok"); + + let inbox = ctx + .supervisor + .as_ref() + .unwrap() + .read() + .inbox("a1") + .unwrap() + .clone(); + let msgs = inbox.drain(); + assert_eq!(msgs.len(), 1); + match &msgs[0].payload { + EnvelopePayload::Text { content } => assert_eq!(content, "hello from parent"), + _ => panic!("expected text payload"), + } + } + + #[test] + fn handle_send_message_to_unknown_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = + handle_send_message(&mut ctx, &json!({"id": "missing", "message": "hi"})).unwrap(); + assert_eq!(result["status"], "error"); + } + + #[test] + fn handle_check_inbox_with_messages() { + let mut ctx = ctx_with_supervisor(4, 3); + let inbox = Arc::new(Inbox::new()); + inbox.deliver(Envelope { + from: "sibling".into(), + to: "me".into(), + payload: EnvelopePayload::Text { + content: "hey".into(), + }, + timestamp: Utc::now(), + }); + ctx.inbox = Some(inbox); + + let result = handle_check_inbox(&mut ctx).unwrap(); + assert_eq!(result["count"], 1); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages[0]["from"], "sibling"); + } + + #[test] + fn handle_check_inbox_no_inbox() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_check_inbox(&mut ctx).unwrap(); + assert_eq!(result["count"], 0); + } + + #[test] + fn handle_check_inbox_empty_inbox() { + let mut ctx = ctx_with_supervisor(4, 3); + ctx.inbox = Some(Arc::new(Inbox::new())); + let result = handle_check_inbox(&mut ctx).unwrap(); + assert_eq!(result["count"], 0); + } + + #[test] + fn handle_reply_escalation_success() { + let mut ctx = ctx_with_supervisor(4, 3); + let queue = Arc::new(EscalationQueue::new()); + let (tx, rx) = tokio::sync::oneshot::channel(); + queue.submit(EscalationRequest { + id: "esc_1".into(), + from_agent_id: "a1".into(), + from_agent_name: "explore".into(), + question: "What do?".into(), + options: None, + reply_tx: tx, + }); + ctx.escalation_queue = Some(queue); + + let result = handle_reply_escalation( + &mut ctx, + &json!({"escalation_id": "esc_1", "reply": "do X"}), + ) + .unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(rx.blocking_recv().unwrap(), "do X"); + } + + #[test] + fn handle_reply_escalation_missing_id() { + let mut ctx = ctx_with_supervisor(4, 3); + ctx.escalation_queue = Some(Arc::new(EscalationQueue::new())); + let result = handle_reply_escalation( + &mut ctx, + &json!({"escalation_id": "missing", "reply": "whatever"}), + ) + .unwrap(); + assert_eq!(result["status"], "error"); + } + + #[test] + fn handle_reply_escalation_no_queue_errors() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = + handle_reply_escalation(&mut ctx, &json!({"escalation_id": "x", "reply": "y"})); + assert!(result.is_err()); + } + + #[test] + fn handle_task_create_simple() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_task_create(&mut ctx, &json!({"subject": "Do research"})).unwrap(); + assert_eq!(result["status"], "ok"); + assert!(result["task_id"].as_str().is_some()); + } + + #[test] + fn handle_task_create_with_dependencies() { + let mut ctx = ctx_with_supervisor(4, 3); + handle_task_create(&mut ctx, &json!({"subject": "Step 1"})).unwrap(); + let result = + handle_task_create(&mut ctx, &json!({"subject": "Step 2", "blocked_by": ["1"]})) + .unwrap(); + assert_eq!(result["status"], "ok"); + } + + #[test] + fn handle_task_create_with_dispatch_agent() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_task_create( + &mut ctx, + &json!({"subject": "Auto task", "agent": "coder", "prompt": "do it"}), + ) + .unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(result["auto_dispatch"], true); + } + + #[test] + fn handle_task_create_agent_without_prompt_errors() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_task_create(&mut ctx, &json!({"subject": "Bad", "agent": "coder"})); + assert!(result.is_err()); + } + + #[test] + fn handle_task_list_empty() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_task_list(&mut ctx).unwrap(); + assert!(result["tasks"].as_array().unwrap().is_empty()); + } + + #[test] + fn handle_task_list_with_tasks() { + let mut ctx = ctx_with_supervisor(4, 3); + handle_task_create(&mut ctx, &json!({"subject": "A"})).unwrap(); + handle_task_create(&mut ctx, &json!({"subject": "B"})).unwrap(); + let result = handle_task_list(&mut ctx).unwrap(); + assert_eq!(result["tasks"].as_array().unwrap().len(), 2); + } + + #[test] + fn handle_task_complete_unblocks_dependents() { + let mut ctx = ctx_with_supervisor(4, 3); + handle_task_create(&mut ctx, &json!({"subject": "Step 1"})).unwrap(); + handle_task_create(&mut ctx, &json!({"subject": "Step 2", "blocked_by": ["1"]})).unwrap(); + + let result = run_async(handle_task_complete(&mut ctx, &json!({"task_id": "1"}))).unwrap(); + assert_eq!(result["status"], "ok"); + let newly_runnable = result["newly_runnable"].as_array().unwrap(); + assert_eq!(newly_runnable.len(), 1); + assert_eq!(newly_runnable[0]["id"], "2"); + } + + #[test] + fn handle_task_fail_marks_failed() { + let mut ctx = ctx_with_supervisor(4, 3); + handle_task_create(&mut ctx, &json!({"subject": "Doomed"})).unwrap(); + let result = handle_task_fail(&mut ctx, &json!({"task_id": "1"})).unwrap(); + assert_eq!(result["status"], "ok"); + } + + #[test] + fn handle_task_fail_reports_blocked_dependents() { + let mut ctx = ctx_with_supervisor(4, 3); + handle_task_create(&mut ctx, &json!({"subject": "A"})).unwrap(); + handle_task_create(&mut ctx, &json!({"subject": "B", "blocked_by": ["1"]})).unwrap(); + let result = handle_task_fail(&mut ctx, &json!({"task_id": "1"})).unwrap(); + let deps = result["blocked_dependents"].as_array().unwrap(); + assert_eq!(deps.len(), 1); + } + + #[test] + fn handle_task_fail_missing_task() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = handle_task_fail(&mut ctx, &json!({"task_id": "nonexistent"})).unwrap(); + assert_eq!(result["status"], "error"); + } + + #[test] + fn dispatch_unknown_action_errors() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = run_async(handle_supervisor_tool(&mut ctx, "agent__bogus", &json!({}))); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown supervisor action") + ); + } + + #[test] + fn dispatch_routes_list() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = + run_async(handle_supervisor_tool(&mut ctx, "agent__list", &json!({}))).unwrap(); + assert!(result["active_count"].is_number()); + } + + #[test] + fn dispatch_routes_task_list() { + let mut ctx = ctx_with_supervisor(4, 3); + let result = run_async(handle_supervisor_tool( + &mut ctx, + "agent__task_list", + &json!({}), + )) + .unwrap(); + assert!(result["tasks"].is_array()); + } + + #[test] + fn new_for_child_inherits_escalation_queue() { + let mut parent = ctx_with_supervisor(4, 3); + let queue = parent.ensure_root_escalation_queue(); + + let child = RequestContext::new_for_child( + default_app_state(), + &parent, + 2, + Arc::new(Inbox::new()), + "child_1".into(), + ); + + assert!(child.escalation_queue.is_some()); + assert!(Arc::ptr_eq( + child.escalation_queue.as_ref().unwrap(), + &queue + )); + } + + #[test] + fn new_for_child_sets_depth_and_id() { + let parent = ctx_with_supervisor(4, 3); + let child = RequestContext::new_for_child( + default_app_state(), + &parent, + 3, + Arc::new(Inbox::new()), + "child_xyz".into(), + ); + assert_eq!(child.current_depth, 3); + assert_eq!(child.self_agent_id, Some("child_xyz".to_string())); + } + + #[test] + fn new_for_child_has_inbox() { + let parent = ctx_with_supervisor(4, 3); + let inbox = Arc::new(Inbox::new()); + let child = RequestContext::new_for_child( + default_app_state(), + &parent, + 1, + Arc::clone(&inbox), + "c1".into(), + ); + assert!(child.inbox.is_some()); + assert!(Arc::ptr_eq(child.inbox.as_ref().unwrap(), &inbox)); + } + + #[test] + fn new_for_child_inherits_parent_supervisor() { + let parent = ctx_with_supervisor(4, 3); + let child = RequestContext::new_for_child( + default_app_state(), + &parent, + 1, + Arc::new(Inbox::new()), + "c1".into(), + ); + assert!(child.parent_supervisor.is_some()); + assert!(child.supervisor.is_none()); + } + + #[test] + fn new_for_child_starts_with_empty_scope() { + let parent = ctx_with_supervisor(4, 3); + let child = RequestContext::new_for_child( + default_app_state(), + &parent, + 1, + Arc::new(Inbox::new()), + "c1".into(), + ); + assert!(child.tool_scope.functions.is_empty()); + assert!(child.tool_scope.mcp_runtime.is_empty()); + assert!(child.role.is_none()); + assert!(child.session.is_none()); + assert!(child.agent.is_none()); + } + + #[test] + fn ensure_root_escalation_queue_creates_on_first_call() { + let mut ctx = ctx_with_supervisor(4, 3); + assert!(ctx.escalation_queue.is_none()); + let q = ctx.ensure_root_escalation_queue(); + assert!(!q.has_pending()); + assert!(ctx.escalation_queue.is_some()); + } + + #[test] + fn ensure_root_escalation_queue_returns_same_on_second_call() { + let mut ctx = ctx_with_supervisor(4, 3); + let q1 = ctx.ensure_root_escalation_queue(); + let q2 = ctx.ensure_root_escalation_queue(); + assert!(Arc::ptr_eq(&q1, &q2)); + } +}