test: Added integration tests for the sub-agent spawning system and inter-agent communication mechanisms

This commit is contained in:
2026-05-01 12:53:26 -06:00
parent d442dff423
commit a4365928d7
6 changed files with 640 additions and 79 deletions
+61 -5
View File
@@ -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
+59 -31
View File
@@ -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
+14
View File
@@ -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<AppConfig>,
log_path: Option<PathBuf>,
+11 -33
View File
@@ -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<String> {
#[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<AppState> {
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);
}
+1 -10
View File
@@ -2422,16 +2422,7 @@ mod tests {
}
fn default_app_state() -> Arc<AppState> {
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 {
+494
View File
@@ -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<AppState> {
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: Future>(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));
}
}