Skip to main content

leviath_cli/daemon/
subagent.rs

1//! Sub-agent tool handlers: turn `spawn_agent` / `check_agent` /
2//! `wait_for_agent` / `send_to_agent` / `kill_agent` tool calls into
3//! [`SubAgentOp`]s serviced by the host (which owns the world + spawner). The
4//! tool lane runs off the world, so it blocks on the host applying each op via a
5//! oneshot - the same shape as an interaction.
6
7use 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::resolve_spawn_args;
16
17/// Per-agent state needed to service the sub-agent tools: a sender into the
18/// host's [`SubAgentOp`] channel plus the spawning agent's identity and the
19/// context children inherit.
20#[derive(Clone)]
21pub struct SubAgentHandle {
22    /// Sender into the host's sub-agent op channel.
23    pub sender: UnboundedSender<SubAgentOp>,
24    /// The run id of the agent that owns this handle (the would-be parent).
25    pub parent_run_id: String,
26    /// Working directory children inherit.
27    pub workdir: String,
28    /// Maximum allowed sub-agent tree depth.
29    pub max_depth: usize,
30    /// The parent run's `--no-seed-commands` setting, inherited by children so a
31    /// per-run opt-out can't be side-stepped by spawning a sub-agent whose
32    /// blueprint declares command seeds.
33    pub no_seed_commands: bool,
34}
35
36/// The tool names routed to the sub-agent handler.
37pub const SUBAGENT_TOOLS: &[&str] = &[
38    "spawn_agent",
39    "check_agent",
40    "wait_for_agent",
41    "send_to_agent",
42    "kill_agent",
43];
44
45/// Whether `name` is a sub-agent tool (routed here rather than to builtin/MCP).
46pub fn is_subagent_tool(name: &str) -> bool {
47    SUBAGENT_TOOLS.contains(&name)
48}
49
50/// How often `wait_for_agent` / `spawn_agent(wait=true)` polls the child.
51const WAIT_POLL: Duration = Duration::from_millis(500);
52
53/// Dispatch one sub-agent tool call, returning the textual result for the model.
54pub async fn handle(h: &SubAgentHandle, tc: &ToolCall) -> String {
55    match tc.name.as_str() {
56        "spawn_agent" => spawn(h, &tc.arguments).await,
57        "check_agent" => check(h, str_arg(&tc.arguments, "agent_id")).await,
58        "wait_for_agent" => wait(h, str_arg(&tc.arguments, "agent_id")).await,
59        "send_to_agent" => send(h, &tc.arguments).await,
60        "kill_agent" => kill(h, str_arg(&tc.arguments, "agent_id")).await,
61        other => format!("[error] '{other}' is not a sub-agent tool"),
62    }
63}
64
65/// Whether `blueprint`, read as a path, lands inside `workdir`.
66///
67/// Symlink-aware, so a link planted in the workspace cannot be used to point at
68/// something that only *looks* outside it. A bare agent name is not a path that
69/// exists here, so it is never caught by this.
70fn resolves_within_workdir(blueprint: &str, workdir: &str) -> bool {
71    let candidate = std::path::Path::new(blueprint);
72    let workdir = std::path::Path::new(workdir);
73    // Only an existing path can be one the agent just wrote.
74    if !candidate.exists() {
75        let joined = workdir.join(blueprint);
76        return joined.exists() && leviath_core::resolves_within(&joined, workdir);
77    }
78    leviath_core::resolves_within(candidate, workdir)
79}
80
81/// A required string argument, or `""` when missing/not a string.
82fn str_arg<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
83    args.get(key).and_then(|v| v.as_str()).unwrap_or("")
84}
85
86async fn spawn(h: &SubAgentHandle, args: &serde_json::Value) -> String {
87    let blueprint = str_arg(args, "blueprint");
88    let task = str_arg(args, "task");
89    if blueprint.is_empty() || task.is_empty() {
90        return "[error] spawn_agent requires 'blueprint' and 'task'".to_string();
91    }
92    // Never a blueprint the agent could have written itself.
93    //
94    // `blueprint` comes from model output and `find_manifest` accepts any path.
95    // `write_file` is confined to the workdir - but the spawner was not, so a
96    // model steered by injected content could write `x/agent.leviath` inside its
97    // own workdir and then spawn it. The child is built with seeds enforced, so
98    // that manifest's `seed = { command = ... }` ran on the host before its
99    // first inference, and its `[[mcp_servers]]` spawned arbitrary programs:
100    // a confined file write escalated to unconfined command execution.
101    //
102    // Refusing paths *inside the workdir* closes that exactly, and leaves
103    // everything legitimate working - an installed agent by name, or a path a
104    // human or the parent blueprint chose. A model that can already write
105    // outside the workdir has arbitrary execution by other means, so nothing
106    // here is the weak link.
107    if resolves_within_workdir(blueprint, &h.workdir) {
108        return format!(
109            "[error] '{blueprint}' is inside this agent's own working directory. \
110             Spawn an installed agent by name, or a blueprint from outside the \
111             workspace - an agent may not author the blueprint it runs."
112        );
113    }
114
115    // Optional seed context is prepended to the task (it lands in the child's
116    // pinned task region, which is exactly what the parent wants seeded).
117    let full_task = match args.get("seed_context").and_then(|v| v.as_str()) {
118        Some(seed) if !seed.is_empty() => format!("{task}\n\nContext:\n{seed}"),
119        _ => task.to_string(),
120    };
121    let child_max_depth = args
122        .get("max_child_depth")
123        .and_then(|v| v.as_u64())
124        .map(|n| n as usize);
125    let wait_flag = args.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
126
127    let spawn_args = match resolve_spawn_args(
128        blueprint,
129        &full_task,
130        None,
131        &h.workdir,
132        false,
133        Vec::new(),
134        child_max_depth,
135        // Sub-agents receive their whole task via `full_task`; no region flags.
136        std::collections::HashMap::new(),
137        h.no_seed_commands,
138    ) {
139        Ok(a) => a,
140        Err(e) => return format!("[error] cannot spawn '{blueprint}': {e}"),
141    };
142
143    let (tx, rx) = oneshot::channel();
144    if h.sender
145        .send(SubAgentOp::Spawn {
146            args: Box::new(spawn_args),
147            parent_run_id: h.parent_run_id.clone(),
148            max_depth: h.max_depth,
149            reply: tx,
150        })
151        .is_err()
152    {
153        return "[error] the daemon is shutting down".to_string();
154    }
155    match rx.await {
156        Ok(Ok(child_id)) if wait_flag => wait(h, &child_id).await,
157        Ok(Ok(child_id)) => format!("Spawned sub-agent '{child_id}'."),
158        Ok(Err(e)) => format!("[error] {e}"),
159        Err(_) => "[error] the daemon dropped the spawn request".to_string(),
160    }
161}
162
163async fn check(h: &SubAgentHandle, agent_id: &str) -> String {
164    match status_of(h, agent_id).await {
165        Some(status) => format!("Sub-agent '{agent_id}' status: {}", label(&status)),
166        None => format!("[error] no such sub-agent '{agent_id}'"),
167    }
168}
169
170async fn wait(h: &SubAgentHandle, agent_id: &str) -> String {
171    if agent_id.is_empty() {
172        return "[error] wait_for_agent requires 'agent_id'".to_string();
173    }
174    loop {
175        match status_of(h, agent_id).await {
176            None => return format!("[error] no such sub-agent '{agent_id}'"),
177            Some(status) if is_terminal(&status) => {
178                return format!(
179                    "Sub-agent '{agent_id}' finished with status: {}",
180                    label(&status)
181                );
182            }
183            // The caller itself was cancelled (or failed) while waiting. Give up
184            // rather than keep polling for a child that is being torn down with
185            // it - this loop has no other exit, so it would hold its tool-lane
186            // worker for as long as the daemon lived.
187            Some(_) if caller_is_terminal(h).await => {
188                return format!("[error] cancelled while waiting for '{agent_id}'");
189            }
190            Some(_) => tokio::time::sleep(WAIT_POLL).await,
191        }
192    }
193}
194
195/// Whether the agent that called `wait_for_agent` has itself reached a terminal
196/// state. A dropped request (daemon shutting down) counts as terminal - there is
197/// nothing left to wait for either way.
198async fn caller_is_terminal(h: &SubAgentHandle) -> bool {
199    match status_of(h, &h.parent_run_id).await {
200        Some(status) => is_terminal(&status),
201        None => true,
202    }
203}
204
205async fn send(h: &SubAgentHandle, args: &serde_json::Value) -> String {
206    let agent_id = str_arg(args, "agent_id");
207    let message = str_arg(args, "message");
208    if agent_id.is_empty() || message.is_empty() {
209        return "[error] send_to_agent requires 'agent_id' and 'message'".to_string();
210    }
211    let (tx, rx) = oneshot::channel();
212    if h.sender
213        .send(SubAgentOp::Send {
214            run_id: agent_id.to_string(),
215            caller_run_id: h.parent_run_id.clone(),
216            content: message.to_string(),
217            reply: tx,
218        })
219        .is_err()
220    {
221        return "[error] the daemon is shutting down".to_string();
222    }
223    match rx.await {
224        Ok(true) => format!("Delivered message to '{agent_id}'."),
225        Ok(false) => format!(
226            "[error] '{agent_id}' did not accept the message. An agent may only \
227             message itself or an agent it spawned."
228        ),
229        Err(_) => "[error] the daemon dropped the message".to_string(),
230    }
231}
232
233async fn kill(h: &SubAgentHandle, agent_id: &str) -> String {
234    if agent_id.is_empty() {
235        return "[error] kill_agent requires 'agent_id'".to_string();
236    }
237    let (tx, rx) = oneshot::channel();
238    if h.sender
239        .send(SubAgentOp::Kill {
240            run_id: agent_id.to_string(),
241            caller_run_id: h.parent_run_id.clone(),
242            reply: tx,
243        })
244        .is_err()
245    {
246        return "[error] the daemon is shutting down".to_string();
247    }
248    match rx.await {
249        Ok(true) => format!("Killed sub-agent '{agent_id}' and its descendants."),
250        Ok(false) => format!("[error] no such sub-agent '{agent_id}'"),
251        Err(_) => "[error] the daemon dropped the kill request".to_string(),
252    }
253}
254
255/// Query a child's status via the host, `None` if it dropped the request or the
256/// run is unknown.
257async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
258    let (tx, rx) = oneshot::channel();
259    h.sender
260        .send(SubAgentOp::Check {
261            run_id: agent_id.to_string(),
262            reply: tx,
263        })
264        .ok()?;
265    rx.await.ok().flatten()
266}
267
268fn is_terminal(status: &AgentStatus) -> bool {
269    matches!(
270        status,
271        AgentStatus::Complete | AgentStatus::Cancelled | AgentStatus::Error { .. }
272    )
273}
274
275fn label(status: &AgentStatus) -> String {
276    match status {
277        AgentStatus::Idle => "idle".to_string(),
278        AgentStatus::Active => "active".to_string(),
279        AgentStatus::Waiting => "waiting".to_string(),
280        AgentStatus::Complete => "complete".to_string(),
281        AgentStatus::Cancelled => "cancelled".to_string(),
282        AgentStatus::Error { message } => format!("error: {message}"),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    /// The escalation this closes: `write_file` is confined to the workdir, but
291    /// the spawner was not - so a model could author `x/agent.leviath` in its own
292    /// workspace and spawn it, and the child is built with seeds enforced, so
293    /// that manifest's command seeds ran on the host before its first inference.
294    #[tokio::test]
295    async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
296        let work = tempfile::tempdir().unwrap();
297        // Exactly what the model would produce: a manifest inside its workdir.
298        let planted = work.path().join("x");
299        std::fs::create_dir(&planted).unwrap();
300        std::fs::write(planted.join("agent.leviath"), "[agent]\nname = \"x\"\n").unwrap();
301
302        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
303        let h = SubAgentHandle {
304            sender: tx,
305            parent_run_id: "parent".to_string(),
306            workdir: work.path().to_string_lossy().to_string(),
307            max_depth: 3,
308            no_seed_commands: false,
309        };
310
311        for bad in [
312            planted.to_string_lossy().to_string(),
313            "x".to_string(),
314            "x/agent.leviath".to_string(),
315        ] {
316            let out = spawn(&h, &serde_json::json!({"blueprint": bad, "task": "go"})).await;
317            assert!(
318                out.contains("own working directory"),
319                "{bad} must be refused: {out}"
320            );
321        }
322    }
323
324    /// And a blueprint from outside the workspace is untouched - an installed
325    /// agent by name, or a path a human chose.
326    #[tokio::test]
327    async fn spawn_allows_a_blueprint_outside_the_workdir() {
328        let work = tempfile::tempdir().unwrap();
329        let elsewhere = tempfile::tempdir().unwrap();
330        std::fs::write(
331            elsewhere.path().join("agent.leviath"),
332            "[agent]\nname = \"x\"\n",
333        )
334        .unwrap();
335
336        // The receiver is dropped so the op fails fast rather than waiting on a
337        // reply no host is here to send. What this asserts is that the path
338        // check let the blueprint through, not that a spawn succeeded.
339        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
340        drop(rx);
341        let h = SubAgentHandle {
342            sender: tx,
343            parent_run_id: "parent".to_string(),
344            workdir: work.path().to_string_lossy().to_string(),
345            max_depth: 3,
346            no_seed_commands: false,
347        };
348        let out = spawn(
349            &h,
350            &serde_json::json!({
351                "blueprint": elsewhere.path().to_string_lossy(),
352                "task": "go"
353            }),
354        )
355        .await;
356        assert!(
357            !out.contains("own working directory"),
358            "a blueprint outside the workspace must not be refused: {out}"
359        );
360    }
361    use leviath_runtime::host::SpawnArgs;
362    use serde_json::json;
363
364    fn handle_with(sender: UnboundedSender<SubAgentOp>) -> SubAgentHandle {
365        SubAgentHandle {
366            sender,
367            parent_run_id: "parent".to_string(),
368            // This crate's own directory, deliberately *not* the system temp
369            // dir: `temp_blueprint()` writes under temp, and on Linux that is
370            // `/tmp` - so a workdir of `/tmp` made every fixture blueprint look
371            // like one the agent had planted in its own workspace, and the
372            // containment guard refused them all. macOS puts tempdirs under
373            // `$TMPDIR` in `/var/folders`, so nothing local caught it.
374            workdir: env!("CARGO_MANIFEST_DIR").to_string(),
375            max_depth: 3,
376            no_seed_commands: false,
377        }
378    }
379
380    /// A `SubAgentHandle` whose host answers each op from plain canned values -
381    /// no per-call-site closures, so this single service loop is the only region
382    /// (covered collectively across the suite). `spawn_result` answers `Spawn`
383    /// and the received args are recorded into the returned `Vec` for assertions;
384    /// `statuses` answers successive `Check`s for *children* in order (`None`
385    /// once exhausted); `ok` answers `Send`/`Kill`. The caller ("parent") is
386    /// reported `Active` - see [`fake_host_with_parent`] to script it.
387    #[allow(clippy::type_complexity)]
388    fn fake_host(
389        spawn_result: Result<String, String>,
390        statuses: Vec<Option<AgentStatus>>,
391        ok: bool,
392    ) -> (
393        SubAgentHandle,
394        std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
395        tokio::task::JoinHandle<()>,
396    ) {
397        fake_host_with_parent(spawn_result, statuses, ok, Some(AgentStatus::Active))
398    }
399
400    /// [`fake_host`] with the calling agent's own status scripted too.
401    #[allow(clippy::type_complexity)]
402    fn fake_host_with_parent(
403        spawn_result: Result<String, String>,
404        statuses: Vec<Option<AgentStatus>>,
405        ok: bool,
406        parent_status: Option<AgentStatus>,
407    ) -> (
408        SubAgentHandle,
409        std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
410        tokio::task::JoinHandle<()>,
411    ) {
412        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
413        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
414        let seen_task = seen.clone();
415        let task = tokio::spawn(async move {
416            let mut checks = statuses.into_iter();
417            while let Some(op) = rx.recv().await {
418                match op {
419                    SubAgentOp::Spawn { reply, args, .. } => {
420                        seen_task.lock().unwrap().push(*args);
421                        let _ = reply.send(spawn_result.clone());
422                    }
423                    // `wait` polls the *caller* as well as the child (to bail out
424                    // if the caller was itself cancelled), so the scripted queue
425                    // answers only for children - the caller is reported Active
426                    // unless a test scripts it otherwise.
427                    SubAgentOp::Check { reply, run_id } if run_id == "parent" => {
428                        let _ = reply.send(parent_status.clone());
429                    }
430                    SubAgentOp::Check { reply, .. } => {
431                        let _ = reply.send(checks.next().flatten());
432                    }
433                    SubAgentOp::Send { reply, .. } => {
434                        let _ = reply.send(ok);
435                    }
436                    SubAgentOp::Kill { reply, .. } => {
437                        let _ = reply.send(ok);
438                    }
439                }
440            }
441        });
442        (handle_with(tx), seen, task)
443    }
444
445    /// A host that drops every op without replying - the handler then sees a
446    /// dropped oneshot.
447    fn drop_host() -> (SubAgentHandle, tokio::task::JoinHandle<()>) {
448        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
449        let task = tokio::spawn(async move {
450            while let Some(op) = rx.recv().await {
451                drop(op);
452            }
453        });
454        (handle_with(tx), task)
455    }
456
457    /// A handle whose host is already gone (sends fail immediately).
458    fn dead_handle() -> SubAgentHandle {
459        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
460        handle_with(tx)
461    }
462
463    /// Write a minimal valid blueprint into a temp dir and return that dir (whose
464    /// path `find_manifest` resolves to `<dir>/agent.leviath`).
465    fn temp_blueprint() -> tempfile::TempDir {
466        let dir = tempfile::tempdir().unwrap();
467        std::fs::write(
468            dir.path().join("agent.leviath"),
469            r#"
470[agent]
471name = "child"
472version = "0.1.0"
473description = "child"
474
475[stages.main]
476model = { provider = "anthropic", model = "claude-sonnet-4-6" }
477"#,
478        )
479        .unwrap();
480        dir
481    }
482
483    fn tc(name: &str, args: serde_json::Value) -> ToolCall {
484        ToolCall {
485            id: "1".to_string(),
486            name: name.to_string(),
487            arguments: args,
488            thought_signature: None,
489        }
490    }
491
492    #[test]
493    fn is_subagent_tool_recognizes_the_five_names() {
494        for name in SUBAGENT_TOOLS {
495            assert!(is_subagent_tool(name));
496        }
497        assert!(!is_subagent_tool("read_file"));
498    }
499
500    #[test]
501    fn label_and_terminal_cover_all_statuses() {
502        assert_eq!(label(&AgentStatus::Idle), "idle");
503        assert_eq!(label(&AgentStatus::Active), "active");
504        assert_eq!(label(&AgentStatus::Waiting), "waiting");
505        assert_eq!(label(&AgentStatus::Complete), "complete");
506        assert_eq!(label(&AgentStatus::Cancelled), "cancelled");
507        assert_eq!(
508            label(&AgentStatus::Error {
509                message: "boom".to_string()
510            }),
511            "error: boom"
512        );
513        for s in [AgentStatus::Active, AgentStatus::Waiting, AgentStatus::Idle] {
514            assert!(!is_terminal(&s));
515        }
516        for s in [
517            AgentStatus::Complete,
518            AgentStatus::Cancelled,
519            AgentStatus::Error {
520                message: "x".to_string(),
521            },
522        ] {
523            assert!(is_terminal(&s));
524        }
525    }
526
527    #[tokio::test]
528    async fn spawn_resolves_blueprint_forwards_seed_and_reports_the_child_id() {
529        let bp = temp_blueprint();
530        let (h, seen, t) = fake_host(Ok("child-123".to_string()), vec![], false);
531        let out = handle(
532            &h,
533            &tc(
534                "spawn_agent",
535                json!({
536                    "blueprint": bp.path().to_str().unwrap(),
537                    "task": "do it",
538                    "seed_context": "prior findings",
539                    "max_child_depth": 2
540                }),
541            ),
542        )
543        .await;
544        assert!(out.contains("Spawned sub-agent 'child-123'"));
545        // Drop the handle and drain the host task - covers the loop's exit.
546        drop(h);
547        t.await.unwrap();
548        // The seed context was folded into the child's task.
549        let seen = seen.lock().unwrap();
550        assert_eq!(seen.len(), 1);
551        assert!(seen[0].task.contains("do it") && seen[0].task.contains("prior findings"));
552        assert_eq!(seen[0].max_depth, Some(2));
553    }
554
555    #[tokio::test]
556    async fn spawn_with_wait_blocks_until_the_child_finishes() {
557        let bp = temp_blueprint();
558        // Active on the first poll, Complete after.
559        let (h, _seen, _t) = fake_host(
560            Ok("child-1".to_string()),
561            vec![Some(AgentStatus::Active), Some(AgentStatus::Complete)],
562            false,
563        );
564        let out = handle(
565            &h,
566            &tc(
567                "spawn_agent",
568                json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t", "wait": true }),
569            ),
570        )
571        .await;
572        assert!(out.contains("finished with status: complete"));
573    }
574
575    /// `wait_for_agent` gives up when the *calling* agent is cancelled. The loop
576    /// has no other exit, and it runs on a tool-lane worker - of which there are
577    /// a fixed number - so a cancelled caller would otherwise poll for a child
578    /// that is being torn down with it until the daemon exits.
579    #[tokio::test]
580    async fn wait_gives_up_when_the_calling_agent_is_cancelled() {
581        let (h, _seen, _t) = fake_host_with_parent(
582            Ok("child-1".to_string()),
583            // The child never finishes on its own.
584            vec![Some(AgentStatus::Active); 8],
585            false,
586            Some(AgentStatus::Cancelled),
587        );
588        let out = tokio::time::timeout(
589            std::time::Duration::from_secs(5),
590            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
591        )
592        .await
593        .expect("the wait returns instead of polling forever");
594        assert!(
595            out.contains("cancelled while waiting"),
596            "reports why it stopped, got: {out}"
597        );
598    }
599
600    /// A caller the host no longer knows about (daemon shutting down, or the
601    /// run already reaped) also ends the wait - there is nothing left to wait
602    /// for either way.
603    #[tokio::test]
604    async fn wait_gives_up_when_the_caller_is_unknown_to_the_host() {
605        let (h, _seen, _t) = fake_host_with_parent(
606            Ok("child-1".to_string()),
607            vec![Some(AgentStatus::Active); 8],
608            false,
609            None, // the host has no such caller
610        );
611        let out = tokio::time::timeout(
612            std::time::Duration::from_secs(5),
613            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
614        )
615        .await
616        .expect("the wait returns instead of polling forever");
617        assert!(out.contains("cancelled while waiting"), "got: {out}");
618    }
619
620    #[tokio::test]
621    async fn spawn_requires_blueprint_and_task_and_reports_resolve_errors() {
622        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], false);
623        assert!(
624            handle(&h, &tc("spawn_agent", json!({ "task": "t" })))
625                .await
626                .contains("requires 'blueprint' and 'task'")
627        );
628        assert!(
629            handle(
630                &h,
631                &tc(
632                    "spawn_agent",
633                    json!({ "blueprint": "/no/such/agent", "task": "t" })
634                )
635            )
636            .await
637            .contains("cannot spawn")
638        );
639    }
640
641    #[tokio::test]
642    async fn spawn_reports_spawner_error_and_dead_host() {
643        let bp = temp_blueprint();
644        let (h, _seen, _t) = fake_host(Err("bad blueprint".to_string()), vec![], false);
645        assert!(
646            handle(
647                &h,
648                &tc(
649                    "spawn_agent",
650                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
651                )
652            )
653            .await
654            .contains("bad blueprint")
655        );
656        assert!(
657            handle(
658                &dead_handle(),
659                &tc(
660                    "spawn_agent",
661                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
662                )
663            )
664            .await
665            .contains("shutting down")
666        );
667    }
668
669    #[tokio::test]
670    async fn check_reports_status_or_missing() {
671        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![Some(AgentStatus::Active)], false);
672        assert!(
673            handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
674                .await
675                .contains("status: active")
676        );
677        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
678        assert!(
679            handle(&h2, &tc("check_agent", json!({ "agent_id": "c" })))
680                .await
681                .contains("no such sub-agent")
682        );
683        // A dead host: `status_of`'s send fails, so it returns `None` early.
684        assert!(
685            handle(
686                &dead_handle(),
687                &tc("check_agent", json!({ "agent_id": "c" }))
688            )
689            .await
690            .contains("no such sub-agent")
691        );
692    }
693
694    #[tokio::test]
695    async fn wait_requires_id_and_returns_when_terminal_or_missing() {
696        assert!(
697            handle(&dead_handle(), &tc("wait_for_agent", json!({})))
698                .await
699                .contains("requires 'agent_id'")
700        );
701        let (h, _seen, _t) = fake_host(
702            Ok(String::new()),
703            vec![Some(AgentStatus::Error {
704                message: "boom".to_string(),
705            })],
706            false,
707        );
708        assert!(
709            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "c" })))
710                .await
711                .contains("error: boom")
712        );
713        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
714        assert!(
715            handle(&h2, &tc("wait_for_agent", json!({ "agent_id": "c" })))
716                .await
717                .contains("no such sub-agent")
718        );
719    }
720
721    #[tokio::test]
722    async fn send_delivers_or_reports_failure() {
723        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
724        assert!(
725            handle(
726                &h,
727                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
728            )
729            .await
730            .contains("Delivered message")
731        );
732        assert!(
733            handle(&h, &tc("send_to_agent", json!({ "agent_id": "c" })))
734                .await
735                .contains("requires 'agent_id' and 'message'")
736        );
737        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
738        assert!(
739            handle(
740                &h2,
741                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
742            )
743            .await
744            .contains("did not accept")
745        );
746        assert!(
747            handle(
748                &dead_handle(),
749                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
750            )
751            .await
752            .contains("shutting down")
753        );
754    }
755
756    #[tokio::test]
757    async fn kill_cancels_or_reports_missing() {
758        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
759        assert!(
760            handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
761                .await
762                .contains("Killed sub-agent")
763        );
764        assert!(
765            handle(&h, &tc("kill_agent", json!({})))
766                .await
767                .contains("requires 'agent_id'")
768        );
769        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
770        assert!(
771            handle(&h2, &tc("kill_agent", json!({ "agent_id": "c" })))
772                .await
773                .contains("no such sub-agent")
774        );
775        assert!(
776            handle(
777                &dead_handle(),
778                &tc("kill_agent", json!({ "agent_id": "c" }))
779            )
780            .await
781            .contains("shutting down")
782        );
783    }
784
785    #[tokio::test]
786    async fn handle_rejects_a_non_subagent_tool() {
787        assert!(
788            handle(&dead_handle(), &tc("read_file", json!({})))
789                .await
790                .contains("is not a sub-agent tool")
791        );
792    }
793
794    #[tokio::test]
795    async fn dropped_reply_paths_are_handled() {
796        let (h, t) = drop_host();
797        // status_of returns None on a dropped reply → "no such sub-agent".
798        assert!(
799            handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
800                .await
801                .contains("no such sub-agent")
802        );
803        assert!(
804            handle(
805                &h,
806                &tc("send_to_agent", json!({ "agent_id": "c", "message": "m" }))
807            )
808            .await
809            .contains("dropped the message")
810        );
811        assert!(
812            handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
813                .await
814                .contains("dropped the kill request")
815        );
816        let bp = temp_blueprint();
817        assert!(
818            handle(
819                &h,
820                &tc(
821                    "spawn_agent",
822                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
823                )
824            )
825            .await
826            .contains("dropped the spawn request")
827        );
828        drop(h);
829        t.await.unwrap();
830    }
831}