test: Added integration tests for the sub-agent spawning system and inter-agent communication mechanisms
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user