test: unit tests for the sub agent spawning system

This commit is contained in:
2026-05-01 12:20:00 -06:00
parent b6ad7a575d
commit 2469b713c7
6 changed files with 601 additions and 31 deletions
+119
View File
@@ -78,3 +78,122 @@ pub fn new_escalation_id() -> String {
let short = &Uuid::new_v4().to_string()[..8];
format!("esc_{short}")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(
id: &str,
agent_id: &str,
question: &str,
) -> (EscalationRequest, oneshot::Receiver<String>) {
let (tx, rx) = oneshot::channel();
let req = EscalationRequest {
id: id.to_string(),
from_agent_id: agent_id.to_string(),
from_agent_name: "test-agent".to_string(),
question: question.to_string(),
options: None,
reply_tx: tx,
};
(req, rx)
}
#[test]
fn queue_default_has_no_pending() {
let queue = EscalationQueue::default();
assert!(!queue.has_pending());
}
#[test]
fn submit_and_has_pending() {
let queue = EscalationQueue::new();
let (req, _rx) = make_request("esc_1", "agent_1", "What color?");
queue.submit(req);
assert!(queue.has_pending());
}
#[test]
fn submit_returns_id() {
let queue = EscalationQueue::new();
let (req, _rx) = make_request("esc_42", "agent_1", "question");
let id = queue.submit(req);
assert_eq!(id, "esc_42");
}
#[test]
fn take_removes_request() {
let queue = EscalationQueue::new();
let (req, _rx) = make_request("esc_1", "agent_1", "question");
queue.submit(req);
let taken = queue.take("esc_1");
assert!(taken.is_some());
assert!(!queue.has_pending());
}
#[test]
fn take_nonexistent_returns_none() {
let queue = EscalationQueue::new();
assert!(queue.take("esc_missing").is_none());
}
#[test]
fn pending_summary_contains_fields() {
let queue = EscalationQueue::new();
let (req, _rx) = make_request("esc_1", "agent_x", "What to do?");
queue.submit(req);
let summary = queue.pending_summary();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0]["escalation_id"], "esc_1");
assert_eq!(summary[0]["from_agent_id"], "agent_x");
assert_eq!(summary[0]["question"], "What to do?");
}
#[test]
fn pending_summary_includes_options_when_present() {
let queue = EscalationQueue::new();
let (tx, _rx) = oneshot::channel();
let req = EscalationRequest {
id: "esc_1".into(),
from_agent_id: "a".into(),
from_agent_name: "agent".into(),
question: "Pick one".into(),
options: Some(vec!["A".into(), "B".into()]),
reply_tx: tx,
};
queue.submit(req);
let summary = queue.pending_summary();
assert!(summary[0].get("options").is_some());
}
#[test]
fn pending_summary_empty_when_no_requests() {
let queue = EscalationQueue::new();
assert!(queue.pending_summary().is_empty());
}
#[test]
fn reply_reaches_receiver() {
let queue = EscalationQueue::new();
let (req, rx) = make_request("esc_1", "a", "question");
queue.submit(req);
let taken = queue.take("esc_1").unwrap();
taken.reply_tx.send("the answer".into()).unwrap();
assert_eq!(rx.blocking_recv().unwrap(), "the answer");
}
#[test]
fn new_escalation_id_has_prefix() {
let id = new_escalation_id();
assert!(id.starts_with("esc_"));
assert!(id.len() > 4);
}
#[test]
fn new_escalation_id_unique() {
let id1 = new_escalation_id();
let id2 = new_escalation_id();
assert_ne!(id1, id2);
}
}
+119
View File
@@ -58,3 +58,122 @@ impl Clone for Inbox {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn text_envelope(from: &str, to: &str, content: &str) -> Envelope {
Envelope {
from: from.to_string(),
to: to.to_string(),
payload: EnvelopePayload::Text {
content: content.to_string(),
},
timestamp: Utc::now(),
}
}
fn task_completed_envelope(from: &str, to: &str) -> Envelope {
Envelope {
from: from.to_string(),
to: to.to_string(),
payload: EnvelopePayload::TaskCompleted {
task_id: "t1".into(),
summary: "done".into(),
},
timestamp: Utc::now(),
}
}
fn shutdown_request_envelope(from: &str, to: &str) -> Envelope {
Envelope {
from: from.to_string(),
to: to.to_string(),
payload: EnvelopePayload::ShutdownRequest {
reason: "all done".into(),
},
timestamp: Utc::now(),
}
}
#[test]
fn inbox_new_is_empty() {
let inbox = Inbox::new();
assert!(inbox.drain().is_empty());
}
#[test]
fn inbox_default_is_empty() {
let inbox = Inbox::default();
assert!(inbox.drain().is_empty());
}
#[test]
fn deliver_and_drain() {
let inbox = Inbox::new();
inbox.deliver(text_envelope("a", "b", "hello"));
let msgs = inbox.drain();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].from, "a");
}
#[test]
fn drain_empties_inbox() {
let inbox = Inbox::new();
inbox.deliver(text_envelope("a", "b", "hello"));
inbox.drain();
assert!(inbox.drain().is_empty());
}
#[test]
fn drain_orders_shutdown_before_task_before_text() {
let inbox = Inbox::new();
inbox.deliver(text_envelope("a", "b", "msg"));
inbox.deliver(task_completed_envelope("a", "b"));
inbox.deliver(shutdown_request_envelope("a", "b"));
let msgs = inbox.drain();
assert_eq!(msgs.len(), 3);
assert!(matches!(
msgs[0].payload,
EnvelopePayload::ShutdownRequest { .. }
));
assert!(matches!(
msgs[1].payload,
EnvelopePayload::TaskCompleted { .. }
));
assert!(matches!(msgs[2].payload, EnvelopePayload::Text { .. }));
}
#[test]
fn clone_preserves_messages() {
let inbox = Inbox::new();
inbox.deliver(text_envelope("a", "b", "hello"));
let cloned = inbox.clone();
let msgs = cloned.drain();
assert_eq!(msgs.len(), 1);
}
#[test]
fn clone_is_independent() {
let inbox = Inbox::new();
inbox.deliver(text_envelope("a", "b", "hello"));
let cloned = inbox.clone();
inbox.deliver(text_envelope("a", "b", "second"));
let original_msgs = inbox.drain();
let cloned_msgs = cloned.drain();
assert_eq!(original_msgs.len(), 2);
assert_eq!(cloned_msgs.len(), 1);
}
#[test]
fn multiple_deliveries() {
let inbox = Inbox::new();
for i in 0..5 {
inbox.deliver(text_envelope("a", "b", &format!("msg {i}")));
}
assert_eq!(inbox.drain().len(), 5);
}
}
+127
View File
@@ -126,3 +126,130 @@ impl Debug for Supervisor {
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::create_abort_signal;
fn make_handle(id: &str, agent_name: &str, depth: usize) -> AgentHandle {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let join_handle = rt.spawn(async {
Ok(AgentResult {
id: "done".into(),
agent_name: "test".into(),
output: "result".into(),
exit_status: AgentExitStatus::Completed,
})
});
std::mem::forget(rt);
AgentHandle {
id: id.to_string(),
agent_name: agent_name.to_string(),
depth,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
}
}
#[test]
fn supervisor_new_empty() {
let sup = Supervisor::new(4, 3);
assert_eq!(sup.active_count(), 0);
assert_eq!(sup.max_concurrent(), 4);
assert_eq!(sup.max_depth(), 3);
}
#[test]
fn supervisor_register_increments_count() {
let mut sup = Supervisor::new(4, 3);
sup.register(make_handle("a1", "explore", 1)).unwrap();
assert_eq!(sup.active_count(), 1);
}
#[test]
fn supervisor_register_rejects_at_capacity() {
let mut sup = Supervisor::new(1, 3);
sup.register(make_handle("a1", "explore", 1)).unwrap();
let result = sup.register(make_handle("a2", "coder", 1));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("at capacity"));
}
#[test]
fn supervisor_register_rejects_exceeding_depth() {
let mut sup = Supervisor::new(4, 2);
let result = sup.register(make_handle("a1", "explore", 3));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("max depth"));
}
#[test]
fn supervisor_register_allows_at_max_depth() {
let mut sup = Supervisor::new(4, 2);
sup.register(make_handle("a1", "explore", 2)).unwrap();
assert_eq!(sup.active_count(), 1);
}
#[test]
fn supervisor_take_removes_handle() {
let mut sup = Supervisor::new(4, 3);
sup.register(make_handle("a1", "explore", 1)).unwrap();
let taken = sup.take("a1");
assert!(taken.is_some());
assert_eq!(sup.active_count(), 0);
}
#[test]
fn supervisor_take_nonexistent_returns_none() {
let mut sup = Supervisor::new(4, 3);
assert!(sup.take("missing").is_none());
}
#[test]
fn supervisor_list_agents() {
let mut sup = Supervisor::new(4, 3);
sup.register(make_handle("a1", "explore", 1)).unwrap();
sup.register(make_handle("a2", "coder", 1)).unwrap();
let list = sup.list_agents();
assert_eq!(list.len(), 2);
let ids: Vec<&str> = list.iter().map(|(id, _)| *id).collect();
assert!(ids.contains(&"a1"));
assert!(ids.contains(&"a2"));
}
#[test]
fn supervisor_inbox_returns_handle_inbox() {
let mut sup = Supervisor::new(4, 3);
sup.register(make_handle("a1", "explore", 1)).unwrap();
assert!(sup.inbox("a1").is_some());
assert!(sup.inbox("missing").is_none());
}
#[test]
fn supervisor_task_queue_accessible() {
let mut sup = Supervisor::new(4, 3);
let id = sup
.task_queue_mut()
.create("task".into(), "desc".into(), None, None);
assert!(!id.is_empty());
assert_eq!(sup.task_queue().list().len(), 1);
}
#[test]
fn agent_exit_status_equality() {
assert_eq!(AgentExitStatus::Completed, AgentExitStatus::Completed);
assert_ne!(
AgentExitStatus::Completed,
AgentExitStatus::Failed("err".into())
);
assert_eq!(
AgentExitStatus::Failed("x".into()),
AgentExitStatus::Failed("x".into())
);
}
}
+83
View File
@@ -268,4 +268,87 @@ mod tests {
assert!(!queue.claim(&id1, "worker-2"));
assert_eq!(queue.get(&id1).unwrap().status, TaskStatus::InProgress);
}
#[test]
fn test_fail_sets_status() {
let mut queue = TaskQueue::new();
let id = queue.create("Task".into(), "".into(), None, None);
queue.fail(&id);
assert_eq!(queue.get(&id).unwrap().status, TaskStatus::Failed);
}
#[test]
fn test_get_returns_none_for_missing() {
let queue = TaskQueue::new();
assert!(queue.get("nonexistent").is_none());
}
#[test]
fn test_dispatch_agent_stored() {
let mut queue = TaskQueue::new();
let id = queue.create(
"Auto task".into(),
"desc".into(),
Some("coder".into()),
Some("implement feature".into()),
);
let task = queue.get(&id).unwrap();
assert_eq!(task.dispatch_agent.as_deref(), Some("coder"));
assert_eq!(task.prompt.as_deref(), Some("implement feature"));
}
#[test]
fn test_claim_blocked_task_fails() {
let mut queue = TaskQueue::new();
let id1 = queue.create("A".into(), "".into(), None, None);
let id2 = queue.create("B".into(), "".into(), None, None);
queue.add_dependency(&id2, &id1).unwrap();
assert!(!queue.claim(&id2, "worker"));
}
#[test]
fn test_list_sorted_by_id() {
let mut queue = TaskQueue::new();
queue.create("Third".into(), "".into(), None, None);
queue.create("First".into(), "".into(), None, None);
queue.create("Second".into(), "".into(), None, None);
let tasks = queue.list();
let ids: Vec<&str> = tasks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["1", "2", "3"]);
}
#[test]
fn test_default_is_empty() {
let queue = TaskQueue::default();
assert!(queue.list().is_empty());
}
#[test]
fn test_dependency_on_nonexistent_task_errors() {
let mut queue = TaskQueue::new();
let id1 = queue.create("A".into(), "".into(), None, None);
let result = queue.add_dependency(&id1, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_complete_nonexistent_returns_empty() {
let mut queue = TaskQueue::new();
let unblocked = queue.complete("nonexistent");
assert!(unblocked.is_empty());
}
#[test]
fn test_task_node_is_runnable() {
let node = TaskNode::new("1".into(), "t".into(), "d".into(), None, None);
assert!(node.is_runnable());
}
#[test]
fn test_task_node_not_runnable_when_blocked() {
let mut node = TaskNode::new("1".into(), "t".into(), "d".into(), None, None);
node.blocked_by.insert("2".into());
node.status = TaskStatus::Blocked;
assert!(!node.is_runnable());
}
}