1use std::time::Duration;
8
9use leviath_providers::ToolCall;
10use leviath_runtime::components::AgentStatus;
11use leviath_runtime::host::SubAgentOp;
12use tokio::sync::mpsc::UnboundedSender;
13use tokio::sync::oneshot;
14
15use crate::daemon::client::{never_interactive, resolve_spawn_args};
16
17#[derive(Clone)]
21pub struct SubAgentHandle {
22 pub sender: UnboundedSender<SubAgentOp>,
24 pub parent_run_id: String,
26 pub workdir: String,
28 pub max_depth: usize,
30 pub no_seed_commands: bool,
34 pub unattended: bool,
41}
42
43pub use leviath_tools::{SUBAGENT_TOOLS, is_subagent_tool};
47
48const WAIT_POLL: Duration = Duration::from_millis(500);
50
51pub async fn handle(h: &SubAgentHandle, tc: &ToolCall) -> String {
53 match tc.name.as_str() {
54 "spawn_agent" => spawn(h, &tc.arguments).await,
55 "check_agent" => check(h, str_arg(&tc.arguments, "agent_id")).await,
56 "wait_for_agent" => wait(h, str_arg(&tc.arguments, "agent_id")).await,
57 "send_to_agent" => send(h, &tc.arguments).await,
58 "kill_agent" => kill(h, str_arg(&tc.arguments, "agent_id")).await,
59 other => format!("[error] '{other}' is not a sub-agent tool"),
60 }
61}
62
63fn resolves_within_workdir(blueprint: &str, workdir: &str) -> bool {
69 let candidate = std::path::Path::new(blueprint);
70 let workdir = std::path::Path::new(workdir);
71 if !candidate.exists() {
73 let joined = workdir.join(blueprint);
74 return joined.exists() && leviath_core::resolves_within(&joined, workdir);
75 }
76 leviath_core::resolves_within(candidate, workdir)
77}
78
79fn str_arg<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
81 args.get(key).and_then(|v| v.as_str()).unwrap_or("")
82}
83
84async fn spawn(h: &SubAgentHandle, args: &serde_json::Value) -> String {
85 let blueprint = str_arg(args, "blueprint");
86 let task = str_arg(args, "task");
87 if blueprint.is_empty() || task.is_empty() {
88 return "[error] spawn_agent requires 'blueprint' and 'task'".to_string();
89 }
90 if resolves_within_workdir(blueprint, &h.workdir) {
106 return format!(
107 "[error] '{blueprint}' is inside this agent's own working directory. \
108 Spawn an installed agent by name, or a blueprint from outside the \
109 workspace - an agent may not author the blueprint it runs."
110 );
111 }
112
113 let full_task = match args.get("seed_context").and_then(|v| v.as_str()) {
116 Some(seed) if !seed.is_empty() => format!("{task}\n\nContext:\n{seed}"),
117 _ => task.to_string(),
118 };
119 let child_max_depth = args
120 .get("max_child_depth")
121 .and_then(|v| v.as_u64())
122 .map(|n| n as usize);
123 let wait_flag = args.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
124
125 let spawn_args = match resolve_spawn_args(
126 blueprint,
127 Some(&full_task),
128 &never_interactive,
129 None,
130 &h.workdir,
131 h.unattended,
132 Vec::new(),
133 child_max_depth,
134 std::collections::HashMap::new(),
136 h.no_seed_commands,
137 ) {
138 Ok(a) => a,
139 Err(e) => return format!("[error] cannot spawn '{blueprint}': {e}"),
140 };
141
142 let (tx, rx) = oneshot::channel();
143 if h.sender
144 .send(SubAgentOp::Spawn {
145 args: Box::new(spawn_args),
146 parent_run_id: h.parent_run_id.clone(),
147 max_depth: h.max_depth,
148 reply: tx,
149 })
150 .is_err()
151 {
152 return "[error] the daemon is shutting down".to_string();
153 }
154 match rx.await {
155 Ok(Ok(child_id)) if wait_flag => wait(h, &child_id).await,
156 Ok(Ok(child_id)) => format!("Spawned sub-agent '{child_id}'."),
157 Ok(Err(e)) => format!("[error] {e}"),
158 Err(_) => "[error] the daemon dropped the spawn request".to_string(),
159 }
160}
161
162async fn check(h: &SubAgentHandle, agent_id: &str) -> String {
163 match status_of(h, agent_id).await {
164 Some(status) => format!("Sub-agent '{agent_id}' status: {}", label(&status)),
165 None => format!("[error] no such sub-agent '{agent_id}'"),
166 }
167}
168
169async fn wait(h: &SubAgentHandle, agent_id: &str) -> String {
170 if agent_id.is_empty() {
171 return "[error] wait_for_agent requires 'agent_id'".to_string();
172 }
173 leviath_runtime::tool_bridge::off_lane(poll_until_finished(h, agent_id)).await
179}
180
181async fn poll_until_finished(h: &SubAgentHandle, agent_id: &str) -> String {
183 loop {
184 match status_of(h, agent_id).await {
185 None => return format!("[error] no such sub-agent '{agent_id}'"),
186 Some(status) if is_terminal(&status) => {
187 return format!(
188 "Sub-agent '{agent_id}' finished with status: {}",
189 label(&status)
190 );
191 }
192 Some(_) if caller_is_terminal(h).await => {
197 return format!("[error] cancelled while waiting for '{agent_id}'");
198 }
199 Some(_) => tokio::time::sleep(WAIT_POLL).await,
200 }
201 }
202}
203
204async fn caller_is_terminal(h: &SubAgentHandle) -> bool {
208 match status_of(h, &h.parent_run_id).await {
209 Some(status) => is_terminal(&status),
210 None => true,
211 }
212}
213
214async fn send(h: &SubAgentHandle, args: &serde_json::Value) -> String {
215 let agent_id = str_arg(args, "agent_id");
216 let message = str_arg(args, "message");
217 if agent_id.is_empty() || message.is_empty() {
218 return "[error] send_to_agent requires 'agent_id' and 'message'".to_string();
219 }
220 let target_region = Some(str_arg(args, "target_region"))
223 .filter(|s| !s.is_empty())
224 .map(str::to_string);
225 let (tx, rx) = oneshot::channel();
226 if h.sender
227 .send(SubAgentOp::Send {
228 run_id: agent_id.to_string(),
229 caller_run_id: h.parent_run_id.clone(),
230 content: message.to_string(),
231 target_region,
232 reply: tx,
233 })
234 .is_err()
235 {
236 return "[error] the daemon is shutting down".to_string();
237 }
238 match rx.await {
239 Ok(true) => format!("Delivered message to '{agent_id}'."),
240 Ok(false) => format!(
241 "[error] '{agent_id}' did not accept the message. An agent may only \
242 message itself or an agent it spawned."
243 ),
244 Err(_) => "[error] the daemon dropped the message".to_string(),
245 }
246}
247
248async fn kill(h: &SubAgentHandle, agent_id: &str) -> String {
249 if agent_id.is_empty() {
250 return "[error] kill_agent requires 'agent_id'".to_string();
251 }
252 let (tx, rx) = oneshot::channel();
253 if h.sender
254 .send(SubAgentOp::Kill {
255 run_id: agent_id.to_string(),
256 caller_run_id: h.parent_run_id.clone(),
257 reply: tx,
258 })
259 .is_err()
260 {
261 return "[error] the daemon is shutting down".to_string();
262 }
263 match rx.await {
264 Ok(true) => format!("Killed sub-agent '{agent_id}' and its descendants."),
265 Ok(false) => format!("[error] no such sub-agent '{agent_id}'"),
266 Err(_) => "[error] the daemon dropped the kill request".to_string(),
267 }
268}
269
270async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
273 let (tx, rx) = oneshot::channel();
274 h.sender
275 .send(SubAgentOp::Check {
276 run_id: agent_id.to_string(),
277 reply: tx,
278 })
279 .ok()?;
280 rx.await.ok().flatten()
281}
282
283fn is_terminal(status: &AgentStatus) -> bool {
284 matches!(
285 status,
286 AgentStatus::Complete | AgentStatus::Cancelled | AgentStatus::Error { .. }
287 )
288}
289
290fn label(status: &AgentStatus) -> String {
294 status.to_string()
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[tokio::test]
306 async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
307 let work = tempfile::tempdir().unwrap();
308 let planted = work.path().join("x");
310 std::fs::create_dir(&planted).unwrap();
311 std::fs::write(planted.join("agent.leviath"), "[agent]\nname = \"x\"\n").unwrap();
312
313 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
314 let h = SubAgentHandle {
315 sender: tx,
316 parent_run_id: "parent".to_string(),
317 workdir: work.path().to_string_lossy().to_string(),
318 max_depth: 3,
319 no_seed_commands: false,
320 unattended: false,
321 };
322
323 for bad in [
324 planted.to_string_lossy().to_string(),
325 "x".to_string(),
326 "x/agent.leviath".to_string(),
327 ] {
328 let out = spawn(&h, &serde_json::json!({"blueprint": bad, "task": "go"})).await;
329 assert!(
330 out.contains("own working directory"),
331 "{bad} must be refused: {out}"
332 );
333 }
334 }
335
336 #[tokio::test]
339 async fn spawn_allows_a_blueprint_outside_the_workdir() {
340 let work = tempfile::tempdir().unwrap();
341 let elsewhere = tempfile::tempdir().unwrap();
342 std::fs::write(
343 elsewhere.path().join("agent.leviath"),
344 "[agent]\nname = \"x\"\n",
345 )
346 .unwrap();
347
348 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
352 drop(rx);
353 let h = SubAgentHandle {
354 sender: tx,
355 parent_run_id: "parent".to_string(),
356 workdir: work.path().to_string_lossy().to_string(),
357 max_depth: 3,
358 no_seed_commands: false,
359 unattended: false,
360 };
361 let out = spawn(
362 &h,
363 &serde_json::json!({
364 "blueprint": elsewhere.path().to_string_lossy(),
365 "task": "go"
366 }),
367 )
368 .await;
369 assert!(
370 !out.contains("own working directory"),
371 "a blueprint outside the workspace must not be refused: {out}"
372 );
373 }
374 use leviath_runtime::host::SpawnArgs;
375 use serde_json::json;
376
377 fn handle_with(sender: UnboundedSender<SubAgentOp>) -> SubAgentHandle {
378 SubAgentHandle {
379 sender,
380 parent_run_id: "parent".to_string(),
381 workdir: env!("CARGO_MANIFEST_DIR").to_string(),
388 max_depth: 3,
389 no_seed_commands: false,
390 unattended: false,
391 }
392 }
393
394 #[allow(clippy::type_complexity)]
402 fn fake_host(
403 spawn_result: Result<String, String>,
404 statuses: Vec<Option<AgentStatus>>,
405 ok: bool,
406 ) -> (
407 SubAgentHandle,
408 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
409 tokio::task::JoinHandle<()>,
410 ) {
411 fake_host_with_parent(spawn_result, statuses, ok, Some(AgentStatus::Active))
412 }
413
414 #[allow(clippy::type_complexity)]
416 fn fake_host_with_parent(
417 spawn_result: Result<String, String>,
418 statuses: Vec<Option<AgentStatus>>,
419 ok: bool,
420 parent_status: Option<AgentStatus>,
421 ) -> (
422 SubAgentHandle,
423 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
424 tokio::task::JoinHandle<()>,
425 ) {
426 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
427 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
428 let seen_task = seen.clone();
429 let task = tokio::spawn(async move {
430 let mut checks = statuses.into_iter();
431 while let Some(op) = rx.recv().await {
432 match op {
433 SubAgentOp::Spawn { reply, args, .. } => {
434 seen_task.lock().unwrap().push(*args);
435 let _ = reply.send(spawn_result.clone());
436 }
437 SubAgentOp::Check { reply, run_id } if run_id == "parent" => {
442 let _ = reply.send(parent_status.clone());
443 }
444 SubAgentOp::Check { reply, .. } => {
445 let _ = reply.send(checks.next().flatten());
446 }
447 SubAgentOp::Send { reply, .. } => {
448 let _ = reply.send(ok);
449 }
450 SubAgentOp::Kill { reply, .. } => {
451 let _ = reply.send(ok);
452 }
453 }
454 }
455 });
456 (handle_with(tx), seen, task)
457 }
458
459 fn drop_host() -> (SubAgentHandle, tokio::task::JoinHandle<()>) {
462 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
463 let task = tokio::spawn(async move {
464 while let Some(op) = rx.recv().await {
465 drop(op);
466 }
467 });
468 (handle_with(tx), task)
469 }
470
471 fn dead_handle() -> SubAgentHandle {
473 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
474 handle_with(tx)
475 }
476
477 fn temp_blueprint() -> tempfile::TempDir {
480 let dir = tempfile::tempdir().unwrap();
481 std::fs::write(
482 dir.path().join("agent.leviath"),
483 r#"
484[agent]
485name = "child"
486version = "0.1.0"
487description = "child"
488
489[stages.main]
490model = { provider = "anthropic", model = "claude-sonnet-4-6" }
491"#,
492 )
493 .unwrap();
494 dir
495 }
496
497 fn tc(name: &str, args: serde_json::Value) -> ToolCall {
498 ToolCall {
499 id: "1".to_string(),
500 name: name.to_string(),
501 arguments: args,
502 thought_signature: None,
503 }
504 }
505
506 #[test]
507 fn is_subagent_tool_recognizes_the_five_names() {
508 for name in SUBAGENT_TOOLS {
509 assert!(is_subagent_tool(name));
510 }
511 assert!(!is_subagent_tool("read_file"));
512 }
513
514 #[test]
515 fn label_and_terminal_cover_all_statuses() {
516 assert_eq!(label(&AgentStatus::Idle), "idle");
517 assert_eq!(label(&AgentStatus::Active), "active");
518 assert_eq!(label(&AgentStatus::Paused), "paused");
519 assert_eq!(label(&AgentStatus::Waiting), "waiting");
520 assert_eq!(label(&AgentStatus::Complete), "complete");
521 assert_eq!(label(&AgentStatus::Cancelled), "cancelled");
522 assert_eq!(
523 label(&AgentStatus::Error {
524 message: "boom".to_string()
525 }),
526 "error: boom"
527 );
528 for s in [AgentStatus::Active, AgentStatus::Waiting, AgentStatus::Idle] {
529 assert!(!is_terminal(&s));
530 }
531 for s in [
532 AgentStatus::Complete,
533 AgentStatus::Cancelled,
534 AgentStatus::Error {
535 message: "x".to_string(),
536 },
537 ] {
538 assert!(is_terminal(&s));
539 }
540 }
541
542 #[tokio::test]
543 async fn spawn_resolves_blueprint_forwards_seed_and_reports_the_child_id() {
544 let bp = temp_blueprint();
545 let (h, seen, t) = fake_host(Ok("child-123".to_string()), vec![], false);
546 let out = handle(
547 &h,
548 &tc(
549 "spawn_agent",
550 json!({
551 "blueprint": bp.path().to_str().unwrap(),
552 "task": "do it",
553 "seed_context": "prior findings",
554 "max_child_depth": 2
555 }),
556 ),
557 )
558 .await;
559 assert!(out.contains("Spawned sub-agent 'child-123'"));
560 drop(h);
562 t.await.unwrap();
563 let seen = seen.lock().unwrap();
565 assert_eq!(seen.len(), 1);
566 assert!(seen[0].task.contains("do it") && seen[0].task.contains("prior findings"));
567 assert_eq!(seen[0].max_depth, Some(2));
568 }
569
570 #[tokio::test]
574 async fn spawn_hands_the_parents_unattended_setting_to_the_child() {
575 for unattended in [false, true] {
576 let bp = temp_blueprint();
577 let (mut h, seen, _t) = fake_host(Ok("child-1".to_string()), vec![], false);
578 h.unattended = unattended;
579 let out = handle(
580 &h,
581 &tc(
582 "spawn_agent",
583 json!({"blueprint": bp.path().to_str().unwrap(), "task": "go"}),
584 ),
585 )
586 .await;
587 assert!(out.contains("Spawned sub-agent"), "{out}");
588 let seen = seen.lock().unwrap();
589 assert_eq!(
590 seen[0].yolo, unattended,
591 "a child inherits the parent's unattended setting"
592 );
593 }
594 }
595
596 #[tokio::test]
597 async fn spawn_with_wait_blocks_until_the_child_finishes() {
598 let bp = temp_blueprint();
599 let (h, _seen, _t) = fake_host(
601 Ok("child-1".to_string()),
602 vec![Some(AgentStatus::Active), Some(AgentStatus::Complete)],
603 false,
604 );
605 let out = handle(
606 &h,
607 &tc(
608 "spawn_agent",
609 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t", "wait": true }),
610 ),
611 )
612 .await;
613 assert!(out.contains("finished with status: complete"));
614 }
615
616 #[tokio::test]
620 async fn wait_gives_up_when_the_calling_agent_is_cancelled() {
621 let (h, _seen, _t) = fake_host_with_parent(
622 Ok("child-1".to_string()),
623 vec![Some(AgentStatus::Active); 8],
625 false,
626 Some(AgentStatus::Cancelled),
627 );
628 let out = tokio::time::timeout(
629 std::time::Duration::from_secs(5),
630 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
631 )
632 .await
633 .expect("the wait returns instead of polling forever");
634 assert!(
635 out.contains("cancelled while waiting"),
636 "reports why it stopped, got: {out}"
637 );
638 }
639
640 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
647 async fn wait_does_not_hold_the_tool_lane() {
648 use leviath_runtime::tool_bridge::{ToolJob, ToolLane, ToolLaneStats};
649
650 let mut statuses = vec![Some(AgentStatus::Active); 6];
654 statuses.push(Some(AgentStatus::Complete));
655 let (h, _seen, _t) = fake_host(Ok("child-1".to_string()), statuses, false);
656
657 let (job_tx, job_rx) = tokio::sync::mpsc::unbounded_channel();
658 let (result_tx, mut results) = tokio::sync::mpsc::unbounded_channel();
659 let stats = std::sync::Arc::new(ToolLaneStats::new(1));
660 let lane = ToolLane::new(
661 tokio::runtime::Handle::current(),
662 result_tx,
663 std::sync::Arc::new(tokio::sync::Notify::new()),
664 1,
665 stats.clone(),
666 );
667 let _serving = lane.serve(job_rx);
668 let submit = |entity: u32, exec: leviath_runtime::tool_bridge::BoxedToolExec| {
669 stats.enqueued();
670 job_tx
671 .send(ToolJob {
672 entity: bevy_ecs::entity::Entity::from_raw_u32(entity)
673 .expect("a small index is a valid id"),
674 exec,
675 cancel: leviath_runtime::cancel::CancelToken::new(),
676 })
677 .expect("the lane is serving");
678 };
679
680 submit(
681 1,
682 Box::new(move || {
683 Box::pin(async move {
684 let out =
685 handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
686 vec![("wait".to_string(), out)]
687 })
688 }),
689 );
690 tokio::time::timeout(std::time::Duration::from_secs(30), async {
692 while stats.parked() == 0 {
693 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
694 }
695 })
696 .await
697 .expect("the wait stepped off the lane");
698
699 submit(
701 2,
702 Box::new(|| Box::pin(async { vec![("child".to_string(), "ran".to_string())] })),
703 );
704 let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
705 .await
706 .expect("the batch behind the waiter ran")
707 .expect("an outcome arrived");
708 assert_eq!(
709 outcome.results,
710 vec![("child".to_string(), "ran".to_string())]
711 );
712
713 let waited = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
715 .await
716 .expect("the wait finished")
717 .expect("an outcome arrived");
718 assert_eq!(waited.results.len(), 1);
719 let reported = waited.results[0].1.clone();
722 assert!(
723 reported.contains("finished with status: complete"),
724 "got: {reported}"
725 );
726 }
727
728 #[tokio::test]
732 async fn wait_gives_up_when_the_caller_is_unknown_to_the_host() {
733 let (h, _seen, _t) = fake_host_with_parent(
734 Ok("child-1".to_string()),
735 vec![Some(AgentStatus::Active); 8],
736 false,
737 None, );
739 let out = tokio::time::timeout(
740 std::time::Duration::from_secs(5),
741 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
742 )
743 .await
744 .expect("the wait returns instead of polling forever");
745 assert!(out.contains("cancelled while waiting"), "got: {out}");
746 }
747
748 #[tokio::test]
749 async fn spawn_requires_blueprint_and_task_and_reports_resolve_errors() {
750 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], false);
751 assert!(
752 handle(&h, &tc("spawn_agent", json!({ "task": "t" })))
753 .await
754 .contains("requires 'blueprint' and 'task'")
755 );
756 assert!(
757 handle(
758 &h,
759 &tc(
760 "spawn_agent",
761 json!({ "blueprint": "/no/such/agent", "task": "t" })
762 )
763 )
764 .await
765 .contains("cannot spawn")
766 );
767 }
768
769 #[tokio::test]
770 async fn spawn_reports_spawner_error_and_dead_host() {
771 let bp = temp_blueprint();
772 let (h, _seen, _t) = fake_host(Err("bad blueprint".to_string()), vec![], false);
773 assert!(
774 handle(
775 &h,
776 &tc(
777 "spawn_agent",
778 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
779 )
780 )
781 .await
782 .contains("bad blueprint")
783 );
784 assert!(
785 handle(
786 &dead_handle(),
787 &tc(
788 "spawn_agent",
789 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
790 )
791 )
792 .await
793 .contains("shutting down")
794 );
795 }
796
797 #[tokio::test]
798 async fn check_reports_status_or_missing() {
799 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![Some(AgentStatus::Active)], false);
800 assert!(
801 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
802 .await
803 .contains("status: active")
804 );
805 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
806 assert!(
807 handle(&h2, &tc("check_agent", json!({ "agent_id": "c" })))
808 .await
809 .contains("no such sub-agent")
810 );
811 assert!(
813 handle(
814 &dead_handle(),
815 &tc("check_agent", json!({ "agent_id": "c" }))
816 )
817 .await
818 .contains("no such sub-agent")
819 );
820 }
821
822 #[tokio::test]
823 async fn wait_requires_id_and_returns_when_terminal_or_missing() {
824 assert!(
825 handle(&dead_handle(), &tc("wait_for_agent", json!({})))
826 .await
827 .contains("requires 'agent_id'")
828 );
829 let (h, _seen, _t) = fake_host(
830 Ok(String::new()),
831 vec![Some(AgentStatus::Error {
832 message: "boom".to_string(),
833 })],
834 false,
835 );
836 assert!(
837 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "c" })))
838 .await
839 .contains("error: boom")
840 );
841 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
842 assert!(
843 handle(&h2, &tc("wait_for_agent", json!({ "agent_id": "c" })))
844 .await
845 .contains("no such sub-agent")
846 );
847 }
848
849 #[tokio::test]
850 async fn send_delivers_or_reports_failure() {
851 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
852 assert!(
853 handle(
854 &h,
855 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
856 )
857 .await
858 .contains("Delivered message")
859 );
860 assert!(
861 handle(&h, &tc("send_to_agent", json!({ "agent_id": "c" })))
862 .await
863 .contains("requires 'agent_id' and 'message'")
864 );
865 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
866 assert!(
867 handle(
868 &h2,
869 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
870 )
871 .await
872 .contains("did not accept")
873 );
874 assert!(
875 handle(
876 &dead_handle(),
877 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
878 )
879 .await
880 .contains("shutting down")
881 );
882 }
883
884 #[allow(clippy::type_complexity)]
886 fn send_recording_host() -> (
887 SubAgentHandle,
888 std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>>,
889 tokio::task::JoinHandle<()>,
890 ) {
891 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
892 let regions = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
893 let regions_task = regions.clone();
894 let task = tokio::spawn(async move {
895 while let Some(op) = rx.recv().await {
896 match op {
897 SubAgentOp::Send {
898 reply,
899 target_region,
900 ..
901 } => {
902 regions_task.lock().unwrap().push(target_region);
903 let _ = reply.send(true);
904 }
905 other => drop(other),
908 }
909 }
910 });
911 (handle_with(tx), regions, task)
912 }
913
914 #[tokio::test]
918 async fn send_forwards_target_region() {
919 let (h, regions, task) = send_recording_host();
920 for args in [
921 json!({ "agent_id": "c", "message": "hi", "target_region": "notes" }),
922 json!({ "agent_id": "c", "message": "hi" }),
923 json!({ "agent_id": "c", "message": "hi", "target_region": "" }),
924 ] {
925 assert!(
926 handle(&h, &tc("send_to_agent", args))
927 .await
928 .contains("Delivered message")
929 );
930 }
931 assert_eq!(
932 *regions.lock().unwrap(),
933 vec![Some("notes".to_string()), None, None]
934 );
935 handle(&h, &tc("check_agent", json!({ "agent_id": "c" }))).await;
937 drop(h);
939 task.await.unwrap();
940 }
941
942 #[tokio::test]
943 async fn kill_cancels_or_reports_missing() {
944 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
945 assert!(
946 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
947 .await
948 .contains("Killed sub-agent")
949 );
950 assert!(
951 handle(&h, &tc("kill_agent", json!({})))
952 .await
953 .contains("requires 'agent_id'")
954 );
955 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
956 assert!(
957 handle(&h2, &tc("kill_agent", json!({ "agent_id": "c" })))
958 .await
959 .contains("no such sub-agent")
960 );
961 assert!(
962 handle(
963 &dead_handle(),
964 &tc("kill_agent", json!({ "agent_id": "c" }))
965 )
966 .await
967 .contains("shutting down")
968 );
969 }
970
971 #[tokio::test]
972 async fn handle_rejects_a_non_subagent_tool() {
973 assert!(
974 handle(&dead_handle(), &tc("read_file", json!({})))
975 .await
976 .contains("is not a sub-agent tool")
977 );
978 }
979
980 #[tokio::test]
981 async fn dropped_reply_paths_are_handled() {
982 let (h, t) = drop_host();
983 assert!(
985 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
986 .await
987 .contains("no such sub-agent")
988 );
989 assert!(
990 handle(
991 &h,
992 &tc("send_to_agent", json!({ "agent_id": "c", "message": "m" }))
993 )
994 .await
995 .contains("dropped the message")
996 );
997 assert!(
998 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
999 .await
1000 .contains("dropped the kill request")
1001 );
1002 let bp = temp_blueprint();
1003 assert!(
1004 handle(
1005 &h,
1006 &tc(
1007 "spawn_agent",
1008 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
1009 )
1010 )
1011 .await
1012 .contains("dropped the spawn request")
1013 );
1014 drop(h);
1015 t.await.unwrap();
1016 }
1017}