Skip to main content

leviath_cli/daemon/
client.rs

1//! Client-side helpers for talking to the shared-world daemon: building a spawn
2//! request from local inputs and exchanging it over the control socket. Shared by
3//! `lev run` (and reusable by other clients). The socket-path resolution + connect
4//! live in the binary; these cores are unit-testable against a fake socket server.
5
6use std::collections::HashMap;
7
8use anyhow::bail;
9use leviath_runtime::control_socket::{ControlClient, ControlResponse};
10use leviath_runtime::host::SpawnArgs;
11
12use crate::commands::run::manifest::find_manifest;
13use crate::commands::run::task::{read_region_value, resolve_task};
14use crate::runstate::new_run_id;
15
16/// Everything a spawn request needs from the agent's own files.
17pub struct AgentSource {
18    /// The resolved `agent.leviath` path.
19    pub manifest: std::path::PathBuf,
20    /// The manifest's parent directory name, which the run id is minted from.
21    /// Deliberately not `blueprint.name`: the run id is what `lev ps` shows and
22    /// what identifies the checkout on disk, while the blueprint's own name is
23    /// what the agent calls itself.
24    pub run_stem: String,
25    /// The parsed blueprint itself.
26    pub blueprint: leviath_core::Blueprint,
27}
28
29/// Find the agent's manifest and parse it, once.
30///
31/// The parse is unconditional. It used to happen only when there were region
32/// flags to validate, but the blueprint's name and description are now needed
33/// for the editor template too, and parsing here is strictly better regardless:
34/// it is the same parser the daemon runs on the same file moments later, so a
35/// manifest that fails here would have failed there, and `parse manifest: <toml
36/// error>` before the daemon is contacted beats a spawn rejection after.
37pub fn load_agent_source(path: &str) -> anyhow::Result<AgentSource> {
38    let found = find_manifest(path)?;
39    // Absolute, because this path is about to be handed to the daemon, which
40    // has its own working directory. `lev run .` and `lev run ./demo` resolve
41    // fine here and then arrive there as `./agent.leviath`, which the daemon
42    // reads relative to wherever it happens to have been started - so the spawn
43    // failed with "read manifest './agent.leviath': No such file or directory".
44    // `lev create` prints `lev run .` as its next step, so this was the first
45    // thing a new user hit.
46    //
47    // Best-effort rather than fallible: `find_manifest` only returns paths it
48    // has already confirmed resolve, so a failure here needs the file to vanish
49    // between the two calls. Falling back to what it found leaves the old
50    // behavior, which is a legible daemon-side error, rather than inventing an
51    // error arm no test can reach.
52    let manifest = std::fs::canonicalize(&found).unwrap_or(found);
53    let run_stem = manifest
54        .parent()
55        .and_then(|p| p.file_name())
56        .and_then(|n| n.to_str())
57        .unwrap_or("agent")
58        .to_string();
59    let content = std::fs::read_to_string(&manifest)
60        .map_err(|e| anyhow::anyhow!("read manifest '{}': {e}", manifest.display()))?;
61    let blueprint = leviath_core::manifest::parse_manifest(&content)
62        .map_err(|e| anyhow::anyhow!("parse manifest: {e}"))?;
63    Ok(AgentSource {
64        manifest,
65        run_stem,
66        blueprint,
67    })
68}
69
70/// Validate and resolve the dynamic `--<region>` flag values against the
71/// blueprint's declared caller-input regions.
72///
73/// An unknown region name (one the blueprint doesn't read as caller input) is a
74/// hard error - fast, local typo protection before the daemon is contacted.
75fn resolve_regions(
76    blueprint: &leviath_core::Blueprint,
77    regions: HashMap<String, String>,
78) -> anyhow::Result<HashMap<String, String>> {
79    let declared = blueprint.caller_inputs();
80    let mut out = HashMap::new();
81    for (name, raw) in regions {
82        if !declared.contains(&name.as_str()) {
83            bail!(
84                "unknown region '--{name}'; this agent's caller-input regions are: {}",
85                if declared.is_empty() {
86                    "(none)".to_string()
87                } else {
88                    declared.join(", ")
89                }
90            );
91        }
92        out.insert(name, read_region_value(&raw)?);
93    }
94    Ok(out)
95}
96
97/// The stdin probe for callers that build a spawn request from inside the
98/// daemon: fan-out workers and sub-agents. There is no terminal there, and an
99/// editor launched from a background process would block it forever with
100/// nobody to close the window.
101///
102/// Those callers always have a task in hand, so the probe is never actually
103/// consulted; passing this rather than a bare `|| false` states the reason at
104/// each call site.
105pub fn never_interactive() -> bool {
106    false
107}
108
109/// What `lev run` was asked for, before any of it is resolved.
110///
111/// One struct because these are one thing: the command line. Each field is a
112/// flag the user typed, and grouping them keeps the difference between "what was
113/// asked for" and "what that resolves to" visible - `resolve_spawn_args` turns
114/// this into a [`SpawnArgs`], and the two are deliberately different types.
115pub struct LaunchRequest<'a> {
116    /// The blueprint path or name, as given.
117    pub path: &'a str,
118    /// The task text, if it was given rather than read from stdin or an editor.
119    pub task: Option<&'a str>,
120    /// Whether stdin is a terminal, injected so the editor path is testable.
121    pub stdin_is_terminal: &'a dyn Fn() -> bool,
122    /// `--model`, overriding the blueprint's choice.
123    pub model: Option<String>,
124    /// The working directory tools run in.
125    pub workdir: &'a str,
126    /// `--yolo`: run unattended.
127    pub yolo: bool,
128    /// `--allow`: tools permitted outright.
129    pub allow: Vec<String>,
130    /// `--max-depth`: sub-agent tree cap.
131    pub max_depth: Option<usize>,
132    /// `--<region>` seeds, keyed by caller-input region name.
133    pub regions: HashMap<String, String>,
134    /// `--no-seed-commands`: refuse the blueprint's command seeds.
135    pub no_seed_commands: bool,
136    /// The output shape the caller asked for, overriding the blueprint's.
137    pub output_request: Option<leviath_core::output::OutputSpec>,
138}
139
140/// Resolve the local inputs of a spawn request: find and parse the manifest,
141/// resolve the `--<region>` flags, resolve the task, and mint a run id from the
142/// agent's directory name.
143///
144/// `task` is what `--task` was given, if anything. Left off, [`resolve_task`]
145/// opens the user's editor, which is why `stdin_is_terminal` is threaded
146/// through: the probe itself is real I/O and belongs to the binary, so callers
147/// inject it (tests pass a `fn` that always says no). None of that happens for a
148/// blueprint that takes no task: it is not asked for one, and giving it one is
149/// an error rather than text with nowhere to go.
150///
151/// Regions are resolved *before* the task on purpose. A typo'd `--foo` has to
152/// fail before the user is dropped into an editor and types a paragraph they
153/// are about to lose.
154pub fn resolve_spawn_args(req: LaunchRequest<'_>) -> anyhow::Result<SpawnArgs> {
155    let LaunchRequest {
156        path,
157        task,
158        stdin_is_terminal,
159        model,
160        workdir,
161        yolo,
162        allow,
163        max_depth,
164        regions,
165        no_seed_commands,
166        output_request,
167    } = req;
168    let source = load_agent_source(path)?;
169    let resolved_regions = resolve_regions(&source.blueprint, regions)?;
170    // An agent driven by named regions takes no task, so neither demanding one
171    // nor opening an editor to write one would make sense - `lev run reviewer
172    // --diff @x.patch` is a complete command line. Handing it one anyway is the
173    // error, and it is the same message the daemon would give.
174    let task = match source.blueprint.accepts_task() {
175        true => resolve_task(
176            task,
177            &source.blueprint.name,
178            &source.blueprint.description,
179            stdin_is_terminal,
180        )?,
181        false => match task.map(str::trim).unwrap_or("") {
182            "" => String::new(),
183            _ => anyhow::bail!(source.blueprint.task_refusal()),
184        },
185    };
186
187    Ok(SpawnArgs {
188        run_id: new_run_id(&source.run_stem),
189        blueprint_path: source.manifest.to_string_lossy().to_string(),
190        task,
191        regions: resolved_regions,
192        model,
193        workdir: workdir.to_string(),
194        metadata: Default::default(),
195        callback_url: None,
196        callback_secret: None,
197        yolo,
198        no_seed_commands,
199        allow,
200        max_depth,
201        // A top-level run (sub-agents/fan-out set this on the host side).
202        parent_run_id: None,
203        output: output_request,
204    })
205}
206
207/// Warn, on stderr, when the agent about to run declares `[read_paths]` the
208/// active config does not grant.
209///
210/// The daemon already logs this at spawn, but into its own log, where the
211/// person who just typed `lev run` never sees it - so the first sign of a
212/// missing grant was a refused read partway through a run. Everything needed to
213/// say it here is local: `lev run` resolves the manifest itself, and the config
214/// is the same file the daemon reads.
215///
216/// Best-effort by design. An unreadable manifest or config is the daemon's to
217/// report, and it will: this must never be the reason a run does not start.
218fn warn_ungranted_read_paths(spawn_args: &SpawnArgs) {
219    for line in read_path_warning_for_spawn(spawn_args) {
220        eprintln!("{line}");
221    }
222}
223
224/// The warning for a spawn request, read from the real manifest and config.
225/// Empty when there is nothing to say, and empty when either file cannot be
226/// read: see [`warn_ungranted_read_paths`] for why that is not an error here.
227fn read_path_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
228    let Ok(content) = std::fs::read_to_string(&spawn_args.blueprint_path) else {
229        return Vec::new();
230    };
231    let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
232        return Vec::new();
233    };
234    let Ok(config) = crate::config::Config::load() else {
235        return Vec::new();
236    };
237    spawn_warning_lines(
238        &blueprint,
239        &config,
240        std::path::Path::new(&spawn_args.workdir),
241    )
242}
243
244/// The warning itself: one line saying what is refused, then the stanza that
245/// would grant it. Pure, so the wording is testable without a daemon.
246fn spawn_warning_lines(
247    blueprint: &leviath_core::Blueprint,
248    config: &crate::config::Config,
249    workdir: &std::path::Path,
250) -> Vec<String> {
251    let Some(Ok(report)) = crate::read_path_report::build(blueprint, config, workdir) else {
252        return Vec::new();
253    };
254    let Some(warning) = report.warning_line() else {
255        return Vec::new();
256    };
257    let mut lines = vec![warning];
258    lines.push("  add to your config.toml:".to_string());
259    lines.extend(
260        report
261            .grant_stanza()
262            .into_iter()
263            .map(|l| format!("    {l}")),
264    );
265    lines
266}
267
268/// Say, before the run starts, that `--yolo` will still stop for a person.
269///
270/// `--yolo` means "run without me", so a run that stops anyway reads as a hang.
271/// The daemon does lint the blueprint at spawn, but only into `daemon.log`,
272/// which the person typing the command never sees.
273///
274/// Best-effort for the same reason as [`warn_ungranted_read_paths`]: an
275/// unreadable manifest or config is the daemon's to report, and this must never
276/// be why a run does not start.
277fn warn_held_checkpoints(spawn_args: &SpawnArgs) {
278    for line in held_checkpoint_warning_for_spawn(spawn_args) {
279        eprintln!("{line}");
280    }
281}
282
283/// The pre-flight block for a spawn request: the checkpoints a `--yolo` run
284/// will still stop at, and whether the blueprint is behind the one this build
285/// ships.
286///
287/// The staleness note is not gated on `--yolo`. An install that is versions
288/// behind is worth saying however the run was launched, and it is the reason
289/// this exists: nothing said it at the moment it mattered, so a run could keep
290/// using an old blueprint long after the fix had shipped.
291fn held_checkpoint_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
292    let path = std::path::Path::new(&spawn_args.blueprint_path);
293    let Ok(content) = std::fs::read_to_string(path) else {
294        return Vec::new();
295    };
296    let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
297        return Vec::new();
298    };
299    let mut lines: Vec<String> =
300        crate::bundled::stale_install_note(path, &blueprint, leviath_core::agents_dir().as_deref())
301            .into_iter()
302            .collect();
303    if spawn_args.yolo {
304        let timeout = crate::config::Config::load()
305            .map(|c| c.limits.interaction_timeout_secs)
306            .unwrap_or(leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS);
307        lines.extend(crate::held_checkpoints::preflight_lines(
308            &blueprint, timeout,
309        ));
310    }
311    lines
312}
313
314/// What `lev run --json` prints on a successful spawn.
315///
316/// `lev run` hands the agent to the daemon and returns, so the run id is the
317/// only handle a caller gets on the work it just started. Parsing it back out of
318/// `spawned <id>` meant a caller had to match on prose; this is the same
319/// information in a shape that does not change when the sentence does.
320#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
321pub struct SpawnedRun {
322    /// The run id to poll with `lev ps --json` and stop with `lev cancel`.
323    pub run_id: String,
324    /// The manifest the run was resolved from.
325    pub blueprint_path: String,
326    /// The directory the agent's file tools are confined to.
327    pub workdir: String,
328    /// Whether the run was started unattended.
329    pub yolo: bool,
330}
331
332/// Render a spawn outcome for printing: JSON when `json`, else the sentence.
333///
334/// Split from [`send_spawn`] so both shapes are testable without a daemon.
335pub fn spawn_report(spawned: &SpawnedRun, json: bool) -> String {
336    match json {
337        // Four owned scalars with no map keys to reject, so this cannot fail.
338        true => serde_json::to_string_pretty(spawned).expect("a spawn report serializes"),
339        false => format!("spawned {}", spawned.run_id),
340    }
341}
342
343/// Render a batch spawn outcome: a JSON array when `json`, else one
344/// `spawned <id>` sentence per line. The single-run report keeps its own
345/// object/sentence shape via [`spawn_report`], so existing `--json` callers
346/// parse exactly what they always did.
347pub fn batch_report(spawned: &[SpawnedRun], json: bool) -> String {
348    match json {
349        true => serde_json::to_string_pretty(spawned).expect("spawn reports serialize"),
350        false => spawned
351            .iter()
352            .map(|s| format!("spawned {}", s.run_id))
353            .collect::<Vec<_>>()
354            .join("\n"),
355    }
356}
357
358/// A fresh run id for the same agent as `previous`.
359///
360/// Ids are minted `<stem>-<secs>-<hex12>` (see [`crate::runstate::new_run_id`]),
361/// so the stem is everything before the last two dash-separated components.
362/// The stem itself may contain dashes (`wide-researcher`), which is why this
363/// strips from the right. An id that does not have the minted shape is used as
364/// the stem wholesale - a fresh unique id still comes out.
365fn respawned_run_id(previous: &str) -> String {
366    let mut parts = previous.rsplitn(3, '-');
367    let _entropy = parts.next();
368    let _secs = parts.next();
369    let stem = parts.next().unwrap_or(previous);
370    crate::runstate::new_run_id(stem)
371}
372
373/// Send a resolved spawn request to the daemon and report the outcome, printing
374/// the new run id on success.
375///
376/// Warnings go to stderr, so `--json` leaves stdout parseable on its own.
377pub async fn send_spawn(
378    client: &ControlClient,
379    spawn_args: SpawnArgs,
380    json: bool,
381) -> anyhow::Result<()> {
382    warn_ungranted_read_paths(&spawn_args);
383    warn_held_checkpoints(&spawn_args);
384    let spawned = spawn_once(client, spawn_args).await?;
385    println!("{}", spawn_report(&spawned, json));
386    Ok(())
387}
388
389/// Send `count` copies of a resolved spawn request - the same agent, task, and
390/// flags, each under its own fresh run id - and print one combined report.
391///
392/// This exists because spawn throughput from the CLI is otherwise bounded by
393/// process startup: each `lev run` invocation pays binary launch plus a socket
394/// round trip (~60 spawns/second in measurement), while the daemon itself
395/// accepts spawns as fast as they arrive. One invocation carrying the whole
396/// batch removes that bound without introducing any daemon-side cap.
397///
398/// `count == 1` defers to [`send_spawn`], keeping today's single-run output
399/// shapes. A mid-batch failure stops the batch and says how many runs had
400/// already started - those runs keep running; `lev ps` lists them.
401pub async fn send_spawn_batch(
402    client: &ControlClient,
403    spawn_args: SpawnArgs,
404    count: usize,
405    json: bool,
406) -> anyhow::Result<()> {
407    if count == 0 {
408        bail!("--count must be at least 1");
409    }
410    if count == 1 {
411        return send_spawn(client, spawn_args, json).await;
412    }
413    // The warnings describe the blueprint, not the individual run: once.
414    warn_ungranted_read_paths(&spawn_args);
415    warn_held_checkpoints(&spawn_args);
416    let mut spawned = Vec::with_capacity(count);
417    for _ in 0..count {
418        let mut args = spawn_args.clone();
419        args.run_id = respawned_run_id(&spawn_args.run_id);
420        match spawn_once(client, args).await {
421            Ok(run) => spawned.push(run),
422            Err(e) => bail!(
423                "batch stopped after {} of {count} runs started (those keep \
424                 running; see `lev ps`): {e}",
425                spawned.len()
426            ),
427        }
428    }
429    println!("{}", batch_report(&spawned, json));
430    Ok(())
431}
432
433/// One spawn exchange with the daemon, warnings and printing left to callers.
434async fn spawn_once(client: &ControlClient, spawn_args: SpawnArgs) -> anyhow::Result<SpawnedRun> {
435    let blueprint_path = spawn_args.blueprint_path.clone();
436    let workdir = spawn_args.workdir.clone();
437    let yolo = spawn_args.yolo;
438    match client.spawn(spawn_args).await {
439        Ok(ControlResponse::Spawned { run_id }) => Ok(SpawnedRun {
440            run_id,
441            blueprint_path,
442            workdir,
443            yolo,
444        }),
445        Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
446        Ok(other) => bail!("unexpected daemon response: {other:?}"),
447        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
455    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
456    use tokio::task::JoinHandle;
457
458    fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
459        std::fs::write(
460            dir.join("agent.leviath"),
461            crate::test_support::inline_coder_manifest(),
462        )
463        .unwrap();
464        dir.join("agent.leviath")
465    }
466
467    #[test]
468    fn resolve_spawn_args_finds_manifest_and_builds_request() {
469        let dir = tempfile::tempdir().unwrap();
470        let agent_dir = dir.path().join("my-agent");
471        std::fs::create_dir_all(&agent_dir).unwrap();
472        let manifest = write_manifest(&agent_dir);
473
474        let args = resolve_spawn_args(LaunchRequest {
475            path: manifest.to_str().unwrap(),
476            task: Some("do it"),
477            stdin_is_terminal: &never_interactive,
478            model: Some("m".to_string()),
479            workdir: "/work",
480            yolo: false,
481            allow: Vec::new(),
482            max_depth: None,
483            regions: HashMap::new(),
484            no_seed_commands: false,
485            output_request: None,
486        })
487        .unwrap();
488        assert!(args.run_id.contains("my-agent"));
489        assert_eq!(args.task, "do it");
490        assert_eq!(args.model.as_deref(), Some("m"));
491        assert_eq!(
492            args.blueprint_path,
493            std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
494        );
495        assert_eq!(args.workdir, "/work");
496    }
497
498    /// The daemon has its own working directory, so a relative `PATH` has to be
499    /// resolved before the request leaves. `lev run .` used to reach the daemon
500    /// as `./agent.leviath` and fail there, which is the very command
501    /// `lev create` prints as the next step.
502    #[test]
503    fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
504        // Reading the CWD is enough to race the tests that *move* it: one of
505        // them chdirs into a directory it then deletes, and a relative path
506        // resolved against that instant cannot be found. Take the same lock
507        // they do, so this only ever reads a CWD that is standing still.
508        let _guard = crate::config::isolate_cwd_for_test();
509        // Rooted in the current directory rather than the system temp dir, so
510        // the relative path is trivially expressible. A temp dir is not
511        // guaranteed to share a drive with the cwd, and on the Windows runner
512        // it does not: the checkout is on D: and TEMP is on C:, between which
513        // no relative path exists at all.
514        let dir = tempfile::Builder::new()
515            .prefix("lev-relpath-")
516            .tempdir_in(".")
517            .unwrap();
518        let agent_dir = dir.path().join("my-agent");
519        std::fs::create_dir_all(&agent_dir).unwrap();
520        write_manifest(&agent_dir);
521
522        // `tempdir_in` hands back an absolute path even for a relative base, so
523        // the relative form is rebuilt from its name.
524        let relative = std::path::Path::new(".")
525            .join(dir.path().file_name().unwrap())
526            .join("my-agent");
527        // A static message on purpose: a `relative.display()` in here is only
528        // evaluated when the assertion fails, which leaves it as a permanently
529        // uncovered region under the 100% gate.
530        assert!(relative.is_relative(), "expected a relative path");
531
532        let args = resolve_spawn_args(LaunchRequest {
533            path: relative.to_str().unwrap(),
534            task: Some("do it"),
535            stdin_is_terminal: &never_interactive,
536            model: None,
537            workdir: "/work",
538            yolo: false,
539            allow: Vec::new(),
540            max_depth: None,
541            regions: HashMap::new(),
542            no_seed_commands: false,
543            output_request: None,
544        })
545        .unwrap();
546        assert!(
547            std::path::Path::new(&args.blueprint_path).is_absolute(),
548            "got: {}",
549            args.blueprint_path
550        );
551        assert!(args.blueprint_path.ends_with("agent.leviath"));
552    }
553
554    #[test]
555    fn resolve_spawn_args_errors_on_missing_manifest() {
556        assert!(
557            resolve_spawn_args(LaunchRequest {
558                path: "/no/such/agent",
559                task: Some("t"),
560                stdin_is_terminal: &never_interactive,
561                model: None,
562                workdir: "/work",
563                yolo: false,
564                allow: Vec::new(),
565                max_depth: None,
566                regions: HashMap::new(),
567                no_seed_commands: false,
568                output_request: None,
569            })
570            .is_err()
571        );
572    }
573
574    /// `--task <file>` end to end through the real wiring, not just through
575    /// `resolve_task` in isolation.
576    #[test]
577    fn resolve_spawn_args_reads_the_task_from_a_file() {
578        let dir = tempfile::tempdir().unwrap();
579        let agent_dir = dir.path().join("my-agent");
580        std::fs::create_dir_all(&agent_dir).unwrap();
581        let manifest = write_manifest(&agent_dir);
582        let task_file = dir.path().join("task.md");
583        std::fs::write(&task_file, "  summarize the README  \n").unwrap();
584
585        let args = resolve_spawn_args(LaunchRequest {
586            path: manifest.to_str().unwrap(),
587            task: Some(task_file.to_str().unwrap()),
588            stdin_is_terminal: &never_interactive,
589            model: None,
590            workdir: "/work",
591            yolo: false,
592            allow: Vec::new(),
593            max_depth: None,
594            regions: HashMap::new(),
595            no_seed_commands: false,
596            output_request: None,
597        })
598        .unwrap();
599        assert_eq!(args.task, "summarize the README");
600    }
601
602    /// No `--task` and no terminal to open an editor on: the run is refused
603    /// here, before the daemon is contacted.
604    #[test]
605    fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
606        let dir = tempfile::tempdir().unwrap();
607        let agent_dir = dir.path().join("my-agent");
608        std::fs::create_dir_all(&agent_dir).unwrap();
609        let manifest = write_manifest(&agent_dir);
610
611        let err = resolve_spawn_args(LaunchRequest {
612            path: manifest.to_str().unwrap(),
613            task: None,
614            stdin_is_terminal: &never_interactive,
615            model: None,
616            workdir: "/work",
617            yolo: false,
618            allow: Vec::new(),
619            max_depth: None,
620            regions: HashMap::new(),
621            no_seed_commands: false,
622            output_request: None,
623        })
624        .unwrap_err();
625        assert!(err.to_string().contains("No task provided"), "got: {err}");
626    }
627
628    /// A blueprint driven by named regions, taking no task at all.
629    fn write_taskless_manifest(dir: &std::path::Path) -> std::path::PathBuf {
630        std::fs::create_dir_all(dir).unwrap();
631        std::fs::write(
632            dir.join("agent.leviath"),
633            r#"
634[agent]
635name = "diffonly"
636
637[stages.main]
638mode = "autonomous"
639
640[stages.main.model]
641provider = "anthropic"
642model = "claude-sonnet-5"
643
644[context.regions]
645diff = { kind = "pinned", max_tokens = 4000, seed = "diff" }
646conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
647"#,
648        )
649        .unwrap();
650        dir.join("agent.leviath")
651    }
652
653    /// `lev run diffonly --diff ...` is a complete command line, so no task is
654    /// demanded and no editor is opened - which is the whole reason the demand
655    /// is conditional rather than unconditional.
656    #[test]
657    fn an_agent_that_takes_no_task_is_not_asked_for_one() {
658        let dir = tempfile::tempdir().unwrap();
659        let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
660        let mut regions = HashMap::new();
661        regions.insert("diff".to_string(), "a patch".to_string());
662
663        let args = resolve_spawn_args(LaunchRequest {
664            path: manifest.to_str().unwrap(),
665            task: None,
666            // Says stdin is not a TTY, so an unconditional demand would error
667            // here rather than fall through to the editor.
668            stdin_is_terminal: &never_interactive,
669            model: None,
670            workdir: "/work",
671            yolo: false,
672            allow: Vec::new(),
673            max_depth: None,
674            regions,
675            no_seed_commands: false,
676            output_request: None,
677        })
678        .expect("no task is required of an agent that takes none");
679        assert_eq!(args.task, "");
680        assert_eq!(
681            args.regions.get("diff").map(String::as_str),
682            Some("a patch")
683        );
684    }
685
686    /// The other half: handing that agent a task is the error, and the message
687    /// points at the input it does take.
688    #[test]
689    fn an_agent_that_takes_no_task_refuses_one() {
690        let dir = tempfile::tempdir().unwrap();
691        let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
692
693        let err = resolve_spawn_args(LaunchRequest {
694            path: manifest.to_str().unwrap(),
695            task: Some("review my code"),
696            stdin_is_terminal: &never_interactive,
697            model: None,
698            workdir: "/work",
699            yolo: false,
700            allow: Vec::new(),
701            max_depth: None,
702            regions: HashMap::new(),
703            no_seed_commands: false,
704            output_request: None,
705        })
706        .unwrap_err();
707        let msg = err.to_string();
708        assert!(
709            msg.contains("declares no region to put it in"),
710            "got: {msg}"
711        );
712        assert!(msg.contains("it takes: diff"), "got: {msg}");
713    }
714
715    /// A `--task` of nothing but whitespace is the same as none, so it must not
716    /// trip the refusal.
717    #[test]
718    fn a_blank_task_is_not_a_task() {
719        let dir = tempfile::tempdir().unwrap();
720        let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
721
722        let args = resolve_spawn_args(LaunchRequest {
723            path: manifest.to_str().unwrap(),
724            task: Some("   "),
725            stdin_is_terminal: &never_interactive,
726            model: None,
727            workdir: "/work",
728            yolo: false,
729            allow: Vec::new(),
730            max_depth: None,
731            regions: HashMap::new(),
732            no_seed_commands: false,
733            output_request: None,
734        })
735        .expect("blank is the same as absent");
736        assert_eq!(args.task, "");
737    }
738
739    /// Pins the ordering: a typo'd region flag must fail *before* the user is
740    /// dropped into an editor, or they type a paragraph and then lose it.
741    #[test]
742    fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
743        let dir = tempfile::tempdir().unwrap();
744        let manifest = write_region_manifest(&dir.path().join("reviewer"));
745        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
746
747        let err = resolve_spawn_args(LaunchRequest {
748            path: manifest.to_str().unwrap(),
749            task: None,
750            stdin_is_terminal: &never_interactive,
751            model: None,
752            workdir: "/work",
753            yolo: false,
754            allow: Vec::new(),
755            max_depth: None,
756            regions,
757            no_seed_commands: false,
758            output_request: None,
759        })
760        .unwrap_err();
761        assert!(err.to_string().contains("unknown region"), "got: {err}");
762    }
763
764    /// Write a manifest declaring a `criteria` caller-input region, returning its
765    /// path.
766    fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
767        std::fs::create_dir_all(dir).unwrap();
768        std::fs::write(
769            dir.join("agent.leviath"),
770            r#"
771[agent]
772name = "reviewer"
773
774[stages.main]
775mode = "autonomous"
776
777[stages.main.model]
778provider = "anthropic"
779model = "claude-sonnet-5"
780
781[context.regions]
782task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
783criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
784conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
785"#,
786        )
787        .unwrap();
788        dir.join("agent.leviath")
789    }
790
791    #[test]
792    fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
793        let dir = tempfile::tempdir().unwrap();
794        let manifest = write_region_manifest(&dir.path().join("reviewer"));
795        let policy = dir.path().join("policy.md");
796        std::fs::write(&policy, "  focus on safety  ").unwrap();
797
798        let regions = HashMap::from([(
799            "criteria".to_string(),
800            format!("@{}", policy.to_string_lossy()),
801        )]);
802        let args = resolve_spawn_args(LaunchRequest {
803            path: manifest.to_str().unwrap(),
804            task: Some("review it"),
805            stdin_is_terminal: &never_interactive,
806            model: None,
807            workdir: "/work",
808            yolo: false,
809            allow: Vec::new(),
810            max_depth: None,
811            regions,
812            no_seed_commands: false,
813            output_request: None,
814        })
815        .unwrap();
816        // `@path` was read and trimmed.
817        assert_eq!(
818            args.regions.get("criteria").map(String::as_str),
819            Some("focus on safety")
820        );
821    }
822
823    #[test]
824    fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
825        // A blueprint with zero caller-input regions: the error lists "(none)".
826        let dir = tempfile::tempdir().unwrap();
827        let agent_dir = dir.path().join("noinput");
828        std::fs::create_dir_all(&agent_dir).unwrap();
829        std::fs::write(
830            agent_dir.join("agent.leviath"),
831            r#"
832[agent]
833name = "noinput"
834
835[stages.main]
836mode = "autonomous"
837
838[stages.main.model]
839provider = "anthropic"
840model = "claude-sonnet-5"
841
842[context.regions]
843data = { kind = "pinned", max_tokens = 2000 }
844conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
845"#,
846        )
847        .unwrap();
848        let manifest = agent_dir.join("agent.leviath");
849        let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
850        let err = resolve_spawn_args(LaunchRequest {
851            path: manifest.to_str().unwrap(),
852            task: Some("t"),
853            stdin_is_terminal: &never_interactive,
854            model: None,
855            workdir: "/work",
856            yolo: false,
857            allow: Vec::new(),
858            max_depth: None,
859            regions,
860            no_seed_commands: false,
861            output_request: None,
862        })
863        .unwrap_err();
864        assert!(err.to_string().contains("(none)"), "got: {err}");
865    }
866
867    #[test]
868    fn resolve_spawn_args_manifest_read_error_surfaces() {
869        // `find_manifest` accepts a dir whose `agent.leviath` merely *exists*; when
870        // that entry is itself a directory, the client-side read fails (EISDIR).
871        let dir = tempfile::tempdir().unwrap();
872        let agent_dir = dir.path().join("dirmanifest");
873        std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
874        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
875        let err = resolve_spawn_args(LaunchRequest {
876            path: agent_dir.to_str().unwrap(),
877            task: Some("t"),
878            stdin_is_terminal: &never_interactive,
879            model: None,
880            workdir: "/work",
881            yolo: false,
882            allow: Vec::new(),
883            max_depth: None,
884            regions,
885            no_seed_commands: false,
886            output_request: None,
887        })
888        .unwrap_err();
889        assert!(err.to_string().contains("read manifest"), "got: {err}");
890    }
891
892    #[test]
893    fn resolve_spawn_args_manifest_parse_error_surfaces() {
894        let dir = tempfile::tempdir().unwrap();
895        let agent_dir = dir.path().join("badtoml");
896        std::fs::create_dir_all(&agent_dir).unwrap();
897        std::fs::write(
898            agent_dir.join("agent.leviath"),
899            "this is : not = valid toml [[[",
900        )
901        .unwrap();
902        let regions = HashMap::from([("x".to_string(), "y".to_string())]);
903        let err = resolve_spawn_args(LaunchRequest {
904            path: agent_dir.join("agent.leviath").to_str().unwrap(),
905            task: Some("t"),
906            stdin_is_terminal: &never_interactive,
907            model: None,
908            workdir: "/work",
909            yolo: false,
910            allow: Vec::new(),
911            max_depth: None,
912            regions,
913            no_seed_commands: false,
914            output_request: None,
915        })
916        .unwrap_err();
917        assert!(err.to_string().contains("parse manifest"), "got: {err}");
918    }
919
920    #[test]
921    fn resolve_spawn_args_region_value_bad_file_errors() {
922        // A declared region whose `@file` value can't be read → the error from
923        // read_region_value propagates out of resolve_spawn_args.
924        let dir = tempfile::tempdir().unwrap();
925        let manifest = write_region_manifest(&dir.path().join("reviewer"));
926        let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
927        let err = resolve_spawn_args(LaunchRequest {
928            path: manifest.to_str().unwrap(),
929            task: Some("review it"),
930            stdin_is_terminal: &never_interactive,
931            model: None,
932            workdir: "/work",
933            yolo: false,
934            allow: Vec::new(),
935            max_depth: None,
936            regions,
937            no_seed_commands: false,
938            output_request: None,
939        })
940        .unwrap_err();
941        assert!(
942            err.to_string().contains("Failed to read region file"),
943            "got: {err}"
944        );
945    }
946
947    #[test]
948    fn resolve_spawn_args_rejects_unknown_region_flag() {
949        let dir = tempfile::tempdir().unwrap();
950        let manifest = write_region_manifest(&dir.path().join("reviewer"));
951        let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
952        let err = resolve_spawn_args(LaunchRequest {
953            path: manifest.to_str().unwrap(),
954            task: Some("review it"),
955            stdin_is_terminal: &never_interactive,
956            model: None,
957            workdir: "/work",
958            yolo: false,
959            allow: Vec::new(),
960            max_depth: None,
961            regions,
962            no_seed_commands: false,
963            output_request: None,
964        })
965        .unwrap_err();
966        assert!(
967            err.to_string().contains("unknown region '--bogus'"),
968            "got: {err}"
969        );
970    }
971
972    /// Bind a control listener at a fresh id under `dir` and serve one canned
973    /// response, returning the id clients connect to and the server task.
974    fn fake_daemon(
975        dir: &std::path::Path,
976        response_line: &'static str,
977    ) -> (ControlId, JoinHandle<()>) {
978        let id = control_id(dir);
979        let mut listener = bind_control_listener(&id).unwrap();
980        let handle = tokio::spawn(async move {
981            let stream = listener
982                .accept()
983                .await
984                .expect("accept succeeds")
985                .expect("our own connection is admitted");
986            let (read_half, mut write_half) = tokio::io::split(stream);
987            let mut lines = BufReader::new(read_half).lines();
988            let _request = lines.next_line().await.unwrap();
989            write_half
990                .write_all(response_line.as_bytes())
991                .await
992                .unwrap();
993            write_half.write_all(b"\n").await.unwrap();
994        });
995        (id, handle)
996    }
997
998    async fn send(response_line: &'static str) -> anyhow::Result<()> {
999        let dir = tempfile::tempdir().unwrap();
1000        let (id, server) = fake_daemon(dir.path(), response_line);
1001        let result = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false).await;
1002        server.await.unwrap();
1003        result
1004    }
1005
1006    /// Like [`fake_daemon`], but serves one canned response per connection, in
1007    /// order - the shape a batch spawn produces, since the client dials the
1008    /// socket once per request.
1009    fn fake_daemon_serving(
1010        dir: &std::path::Path,
1011        responses: Vec<&'static str>,
1012    ) -> (ControlId, JoinHandle<()>) {
1013        let id = control_id(dir);
1014        let mut listener = bind_control_listener(&id).unwrap();
1015        let handle = tokio::spawn(async move {
1016            for response_line in responses {
1017                let stream = listener
1018                    .accept()
1019                    .await
1020                    .expect("accept succeeds")
1021                    .expect("our own connection is admitted");
1022                let (read_half, mut write_half) = tokio::io::split(stream);
1023                let mut lines = BufReader::new(read_half).lines();
1024                let _request = lines.next_line().await.unwrap();
1025                write_half
1026                    .write_all(response_line.as_bytes())
1027                    .await
1028                    .unwrap();
1029                write_half.write_all(b"\n").await.unwrap();
1030            }
1031        });
1032        (id, handle)
1033    }
1034
1035    #[tokio::test]
1036    async fn a_batch_spawn_starts_count_runs_and_reports_them_all() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let (id, server) = fake_daemon_serving(
1039            dir.path(),
1040            vec![
1041                r#"{"result":"spawned","run_id":"a-1-000000000001"}"#,
1042                r#"{"result":"spawned","run_id":"a-1-000000000002"}"#,
1043                r#"{"result":"spawned","run_id":"a-1-000000000003"}"#,
1044            ],
1045        );
1046        let args = SpawnArgs {
1047            run_id: "wide-researcher-1785900000-0123456789ab".to_string(),
1048            ..SpawnArgs::default()
1049        };
1050        send_spawn_batch(&ControlClient::new(id), args, 3, false)
1051            .await
1052            .expect("all three spawn");
1053        server.await.unwrap();
1054    }
1055
1056    #[tokio::test]
1057    async fn a_batch_stopped_mid_way_says_how_many_runs_already_started() {
1058        let dir = tempfile::tempdir().unwrap();
1059        let (id, server) = fake_daemon_serving(
1060            dir.path(),
1061            vec![
1062                r#"{"result":"spawned","run_id":"a-1-000000000001"}"#,
1063                r#"{"result":"error","message":"the world is full"}"#,
1064            ],
1065        );
1066        let err = send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 3, false)
1067            .await
1068            .expect_err("the second spawn fails");
1069        // Asserts before the server join: a wrong error path makes fewer
1070        // connections than the server expects, and joining first would turn
1071        // that mismatch into a hang instead of a failure message.
1072        let text = err.to_string();
1073        assert!(text.contains("after 1 of 3"), "got: {text}");
1074        assert!(text.contains("the world is full"), "got: {text}");
1075        server.await.unwrap();
1076    }
1077
1078    #[tokio::test]
1079    async fn a_batch_of_one_is_exactly_a_single_spawn() {
1080        let dir = tempfile::tempdir().unwrap();
1081        let (id, server) = fake_daemon(dir.path(), r#"{"result":"spawned","run_id":"solo-1-0"}"#);
1082        send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 1, false)
1083            .await
1084            .expect("the single spawn succeeds");
1085        server.await.unwrap();
1086    }
1087
1088    #[tokio::test]
1089    async fn a_batch_of_zero_is_refused_before_any_daemon_contact() {
1090        let dir = tempfile::tempdir().unwrap();
1091        // No listener bound: reaching the daemon at all would error differently.
1092        let id = control_id(dir.path());
1093        let err = send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 0, false)
1094            .await
1095            .expect_err("zero runs is a refusal");
1096        assert!(err.to_string().contains("at least 1"), "got: {err}");
1097    }
1098
1099    /// The stem survives its own dashes: only the minted `-<secs>-<hex>` tail
1100    /// is replaced.
1101    #[test]
1102    fn a_respawned_id_keeps_the_dashed_agent_stem() {
1103        let id = respawned_run_id("wide-researcher-1785900000-0123456789ab");
1104        assert!(id.starts_with("wide-researcher-"), "got: {id}");
1105        assert_ne!(id, "wide-researcher-1785900000-0123456789ab");
1106        // The minted shape holds: stem + seconds + 12 hex chars.
1107        let tail: Vec<&str> = id.rsplitn(3, '-').collect();
1108        assert_eq!(tail[0].len(), 12, "got: {id}");
1109        assert!(tail[1].chars().all(|c| c.is_ascii_digit()), "got: {id}");
1110    }
1111
1112    /// An id without the minted tail is used as the stem wholesale - the
1113    /// result is still fresh and unique.
1114    #[test]
1115    fn a_respawned_id_falls_back_to_the_whole_previous_id_as_stem() {
1116        let id = respawned_run_id("x");
1117        assert!(id.starts_with("x-"), "got: {id}");
1118    }
1119
1120    #[test]
1121    fn a_batch_report_lists_one_sentence_per_run() {
1122        let runs = vec![
1123            SpawnedRun {
1124                run_id: "a-1-1".into(),
1125                blueprint_path: "/b".into(),
1126                workdir: "/w".into(),
1127                yolo: false,
1128            },
1129            SpawnedRun {
1130                run_id: "a-1-2".into(),
1131                blueprint_path: "/b".into(),
1132                workdir: "/w".into(),
1133                yolo: false,
1134            },
1135        ];
1136        assert_eq!(batch_report(&runs, false), "spawned a-1-1\nspawned a-1-2");
1137        let parsed: Vec<SpawnedRun> =
1138            serde_json::from_str(&batch_report(&runs, true)).expect("a JSON array");
1139        assert_eq!(parsed, runs);
1140    }
1141
1142    fn spawned() -> SpawnedRun {
1143        SpawnedRun {
1144            run_id: "run-abc".to_string(),
1145            blueprint_path: "/agents/coder/agent.leviath".to_string(),
1146            workdir: "/work".to_string(),
1147            yolo: true,
1148        }
1149    }
1150
1151    #[test]
1152    fn spawn_report_without_json_is_the_sentence() {
1153        assert_eq!(spawn_report(&spawned(), false), "spawned run-abc");
1154    }
1155
1156    #[test]
1157    fn spawn_report_with_json_round_trips_every_field() {
1158        // Parsing it back is the assertion that matters: a caller reads this to
1159        // learn the id it has to poll, so the keys are the contract.
1160        let parsed: SpawnedRun =
1161            serde_json::from_str(&spawn_report(&spawned(), true)).expect("valid JSON");
1162        assert_eq!(parsed, spawned());
1163    }
1164
1165    // ─── the client-side [read_paths] warning ──────────────────────────
1166
1167    /// A blueprint declaring one absolute read path, so the same entry
1168    /// compiles on every OS.
1169    fn read_paths_blueprint() -> leviath_core::Blueprint {
1170        leviath_core::manifest::parse_manifest(
1171            r#"
1172[agent]
1173name = "cto"
1174version = "0.1.0"
1175description = "test"
1176
1177[stages.main]
1178mode = "autonomous"
1179
1180[context.regions]
1181system = { kind = "pinned", max_tokens = 1000 }
1182
1183[read_paths]
1184allow = ["/data/runs"]
1185"#,
1186        )
1187        .expect("blueprint parses")
1188    }
1189
1190    /// The point of warning here at all: the person who typed `lev run` learns
1191    /// the declaration is inert now, not at the first refused read.
1192    #[test]
1193    fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
1194        let lines = spawn_warning_lines(
1195            &read_paths_blueprint(),
1196            &crate::config::Config::default(),
1197            std::path::Path::new("/work"),
1198        );
1199        let joined = lines.join("\n");
1200        assert!(joined.contains("agent 'cto'"), "{joined}");
1201        assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
1202        assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
1203    }
1204
1205    #[test]
1206    fn a_granted_declaration_says_nothing() {
1207        let mut config = crate::config::Config::default();
1208        config.security.read_paths = vec!["/data/runs".to_string()];
1209        assert!(
1210            spawn_warning_lines(
1211                &read_paths_blueprint(),
1212                &config,
1213                std::path::Path::new("/work")
1214            )
1215            .is_empty()
1216        );
1217    }
1218
1219    /// No declaration, nothing to say - and a config whose own grant list is
1220    /// broken is the daemon's error to report, not a warning to guess at.
1221    #[test]
1222    fn nothing_to_warn_about_produces_no_lines() {
1223        let plain =
1224            leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
1225                .expect("blueprint parses");
1226        assert!(
1227            spawn_warning_lines(
1228                &plain,
1229                &crate::config::Config::default(),
1230                std::path::Path::new("/work")
1231            )
1232            .is_empty()
1233        );
1234
1235        let mut broken = crate::config::Config::default();
1236        broken.security.read_paths = vec!["regex:relative/.*".to_string()];
1237        assert!(
1238            spawn_warning_lines(
1239                &read_paths_blueprint(),
1240                &broken,
1241                std::path::Path::new("/work")
1242            )
1243            .is_empty()
1244        );
1245    }
1246
1247    /// End to end over the real files: a manifest on disk plus an isolated
1248    /// config that grants nothing.
1249    #[tokio::test]
1250    async fn the_warning_reads_the_manifest_and_the_active_config() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let manifest = dir.path().join("agent.leviath");
1253        std::fs::write(
1254            &manifest,
1255            crate::test_support::inline_coder_manifest()
1256                + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
1257        )
1258        .unwrap();
1259        let args = SpawnArgs {
1260            blueprint_path: manifest.to_string_lossy().into_owned(),
1261            workdir: dir.path().to_string_lossy().into_owned(),
1262            ..SpawnArgs::default()
1263        };
1264        let lines = crate::config::with_isolated_config_path_async(
1265            "spawn-warn-read-paths",
1266            |_fake| async move {
1267                let lines = read_path_warning_for_spawn(&args);
1268                warn_ungranted_read_paths(&args);
1269                lines
1270            },
1271        )
1272        .await;
1273        let joined = lines.join("\n");
1274        assert!(joined.contains("1 declared, 0 granted"), "{joined}");
1275        assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
1276    }
1277
1278    /// Every way the warning can decline to run: a manifest that will not
1279    /// parse, and a config that will not load. Neither may stop a spawn.
1280    #[test]
1281    fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
1282        let dir = tempfile::tempdir().unwrap();
1283        let manifest = dir.path().join("agent.leviath");
1284        std::fs::write(&manifest, "not valid toml [[[").unwrap();
1285        assert!(
1286            read_path_warning_for_spawn(&SpawnArgs {
1287                blueprint_path: manifest.to_string_lossy().into_owned(),
1288                ..SpawnArgs::default()
1289            })
1290            .is_empty()
1291        );
1292
1293        std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1294        crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
1295            std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
1296            assert!(
1297                read_path_warning_for_spawn(&SpawnArgs {
1298                    blueprint_path: manifest.to_string_lossy().into_owned(),
1299                    ..SpawnArgs::default()
1300                })
1301                .is_empty()
1302            );
1303        });
1304    }
1305
1306    /// A manifest that declares a held checkpoint, written to disk, so the
1307    /// warning is exercised through the real read-and-parse path.
1308    fn manifest_with_a_held_checkpoint(dir: &std::path::Path) -> String {
1309        let manifest = dir.join("agent.leviath");
1310        std::fs::write(
1311            &manifest,
1312            r#"
1313[agent]
1314name = "held"
1315version = "0.1.0"
1316description = "holds a checkpoint"
1317entry_stage = "plan"
1318
1319[stages.plan]
1320mode = "interactive_points"
1321model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1322max_iterations = 5
1323available_tools = ["read_file"]
1324
1325[[stages.plan.interaction_points]]
1326name = "plan_approval"
1327prompt = "Review the plan"
1328style = "confirm"
1329unattended = "ask"
1330
1331[context.regions]
1332system = { kind = "pinned", max_tokens = 1000 }
1333conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1334"#,
1335        )
1336        .unwrap();
1337        manifest.to_string_lossy().into_owned()
1338    }
1339
1340    /// `--yolo` reads as "run without me", so a run that stops anyway has to say
1341    /// so before it starts rather than look like a hang twenty minutes in.
1342    #[test]
1343    fn a_yolo_spawn_announces_the_checkpoints_that_still_hold() {
1344        let dir = tempfile::tempdir().unwrap();
1345        let blueprint_path = manifest_with_a_held_checkpoint(dir.path());
1346        crate::config::with_isolated_config_path("spawn-warn-held", |_fake| {
1347            let args = SpawnArgs {
1348                blueprint_path: blueprint_path.clone(),
1349                yolo: true,
1350                ..SpawnArgs::default()
1351            };
1352            let joined = held_checkpoint_warning_for_spawn(&args).join("\n");
1353            assert!(joined.contains("plan: plan_approval"), "{joined}");
1354            warn_held_checkpoints(&args);
1355
1356            // An attended run stops for a person everywhere, so there is nothing
1357            // to announce.
1358            assert!(
1359                held_checkpoint_warning_for_spawn(&SpawnArgs {
1360                    blueprint_path: blueprint_path.clone(),
1361                    yolo: false,
1362                    ..SpawnArgs::default()
1363                })
1364                .is_empty()
1365            );
1366        });
1367    }
1368
1369    /// The same three lenient arms as the read-path warning: a manifest that is
1370    /// not there, one that will not parse, and a config that will not load.
1371    /// None of them may stop a spawn.
1372    #[test]
1373    fn the_held_checkpoint_warning_gives_up_quietly() {
1374        let dir = tempfile::tempdir().unwrap();
1375        let missing = dir.path().join("nope.leviath");
1376        assert!(
1377            held_checkpoint_warning_for_spawn(&SpawnArgs {
1378                blueprint_path: missing.to_string_lossy().into_owned(),
1379                yolo: true,
1380                ..SpawnArgs::default()
1381            })
1382            .is_empty()
1383        );
1384
1385        let unparseable = dir.path().join("agent.leviath");
1386        std::fs::write(&unparseable, "not valid toml [[[").unwrap();
1387        assert!(
1388            held_checkpoint_warning_for_spawn(&SpawnArgs {
1389                blueprint_path: unparseable.to_string_lossy().into_owned(),
1390                yolo: true,
1391                ..SpawnArgs::default()
1392            })
1393            .is_empty()
1394        );
1395
1396        // A config that will not load falls back to the default deadline rather
1397        // than saying nothing: the checkpoints still hold, and naming them
1398        // matters more than naming the exact timeout.
1399        let held = manifest_with_a_held_checkpoint(dir.path());
1400        crate::config::with_isolated_config_path("spawn-held-broken-config", |fake_dir| {
1401            std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
1402            let joined = held_checkpoint_warning_for_spawn(&SpawnArgs {
1403                blueprint_path: held.clone(),
1404                yolo: true,
1405                ..SpawnArgs::default()
1406            })
1407            .join("\n");
1408            assert!(joined.contains("plan_approval"), "{joined}");
1409            assert!(joined.contains("after 1h"), "{joined}");
1410        });
1411    }
1412
1413    #[tokio::test]
1414    async fn send_spawn_reports_success() {
1415        assert!(
1416            send(r#"{"result":"spawned","run_id":"run-9"}"#)
1417                .await
1418                .is_ok()
1419        );
1420    }
1421
1422    #[tokio::test]
1423    async fn send_spawn_reports_daemon_error() {
1424        let err = send(r#"{"result":"error","message":"boom"}"#)
1425            .await
1426            .unwrap_err();
1427        assert!(err.to_string().contains("boom"));
1428    }
1429
1430    #[tokio::test]
1431    async fn send_spawn_reports_unexpected_response() {
1432        let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
1433        assert!(err.to_string().contains("unexpected"));
1434    }
1435
1436    #[tokio::test]
1437    async fn send_spawn_errors_when_daemon_absent() {
1438        let dir = tempfile::tempdir().unwrap();
1439        // A control id with no daemon bound to it.
1440        let id = control_id(&dir.path().join("no-daemon"));
1441        let err = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false)
1442            .await
1443            .unwrap_err();
1444        assert!(err.to_string().contains("not reachable"));
1445    }
1446}