Skip to main content

leviath_cli/
bundled.rs

1//! The agent blueprints shipped inside the `lev` binary, and the planner that
2//! decides what to do with them.
3//!
4//! Embedding is what makes the blueprints under the workspace's `agents/`
5//! directory reachable outside a git checkout: `lev add` takes a local path,
6//! and an `agents/` directory next to the executable is a layout no real
7//! install has, so without the bundle a user who downloads a release binary
8//! gets a working runtime and zero agents to run on it.
9//!
10//! `build.rs` embeds every file of every blueprint via `include_str!` and
11//! generates the [`BUNDLED_AGENTS`] table included
12//! below. `lev setup` offers to install them; `lev list` reports them.
13
14include!(concat!(env!("OUT_DIR"), "/bundled_agents.rs"));
15
16use std::path::Path;
17
18/// What `lev setup` should do with one bundled blueprint, given what is
19/// currently installed.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum AgentAction {
22    /// Not installed.
23    Install,
24    /// Installed at a different version.
25    Update {
26        /// The version currently on disk, so the offer can say what it replaces.
27        from: String,
28    },
29    /// Installed at the bundled version, but the files on disk differ from the
30    /// bundled ones.
31    Modified,
32    /// Installed at the bundled version, byte for byte.
33    UpToDate,
34}
35
36impl AgentAction {
37    /// Whether applying this action would change anything on disk.
38    pub fn is_change(&self) -> bool {
39        !matches!(self, Self::UpToDate)
40    }
41
42    /// Whether the wizard should pre-check this row.
43    ///
44    /// Not the same question as [`Self::is_change`], and the difference is the
45    /// point of [`Self::Modified`]: reinstalling over a tree the user edited
46    /// destroys their work, and `install_bundled` removes the destination
47    /// first, so it destroys files they added too. Offered, never assumed.
48    pub fn preselect(&self) -> bool {
49        matches!(self, Self::Install | Self::Update { .. })
50    }
51
52    /// Short label for the wizard's blueprint list.
53    pub fn label(&self, to: &str) -> String {
54        match self {
55            Self::Install => format!("install {to}"),
56            Self::Update { from } => format!("update {from} → {to}"),
57            Self::Modified => format!("{to}, edited locally - reinstall overwrites"),
58            Self::UpToDate => "up to date".to_string(),
59        }
60    }
61}
62
63/// The installed version of `name` under `agents_dir`, if a readable manifest
64/// is there.
65///
66/// Deliberately lenient: a blueprint directory whose manifest is missing or
67/// unparseable reads as *not installed*, so the wizard offers a clean reinstall
68/// instead of refusing to plan. An unreadable manifest is exactly the state a
69/// half-finished copy leaves behind.
70pub fn installed_version(agents_dir: &Path, name: &str) -> Option<String> {
71    let manifest = std::fs::read_to_string(agents_dir.join(name).join("agent.leviath")).ok()?;
72    leviath_core::manifest::parse_manifest(&manifest)
73        .ok()
74        .map(|bp| bp.version)
75}
76
77/// Whether the installed copy of `agent` is byte-identical to the bundled one.
78///
79/// [`install_bundled`] removes the destination first, so a tree it wrote has
80/// exactly the bundle's files with exactly the bundle's bytes. Any difference -
81/// an edited manifest, a tool script the user added, one they deleted - means
82/// what is on disk is not what shipped.
83///
84/// An IO error reads as *differing*, which is the safe direction: the caller
85/// uses this to decide whether overwriting is safe, and a directory it cannot
86/// read is not one to clobber unasked.
87fn matches_bundled(agent: &BundledAgent, agents_dir: &Path) -> bool {
88    let dest = agents_dir.join(agent.name);
89    for (rel, contents) in agent.files {
90        match std::fs::read_to_string(dest.join(rel)) {
91            Ok(on_disk) if on_disk == *contents => {}
92            _ => return false,
93        }
94    }
95    // Every declared file was found and matched, so equal counts means the two
96    // sets are equal - which is what catches a file the user added.
97    installed_file_count(&dest) == agent.files.len()
98}
99
100/// How many files are under `dir`, recursively.
101///
102/// An entry that cannot be read counts as one file rather than aborting the
103/// walk. The only caller is asking whether the tree is exactly the bundled one,
104/// and something on disk it cannot read is already an answer of "no".
105fn installed_file_count(dir: &Path) -> usize {
106    let Ok(entries) = std::fs::read_dir(dir) else {
107        return 0;
108    };
109    entries
110        .map(|entry| match entry.map(|e| e.path()) {
111            Ok(path) if path.is_dir() => installed_file_count(&path),
112            _ => 1,
113        })
114        .sum()
115}
116
117/// Decide what to do with every bundled blueprint.
118///
119/// Version comparison is plain string inequality, not semver ordering: this
120/// crate has no semver dependency, and both versions are shown to the user
121/// anyway, so a downgrade and an upgrade both surface as an offered update they
122/// can decline.
123///
124/// A blueprint at the bundled version is only up to date if its files are the
125/// bundled files. Comparing versions alone meant a blueprint edited without a
126/// version bump read as current forever - and so did a stale install whose
127/// version happened to match, which is how an install could sit on an old
128/// checkpoint policy while believing itself current. Nothing is hashed and
129/// nothing is stored: the bundled bytes are in the binary, so the files
130/// themselves are the comparison.
131pub fn plan_agent_actions(agents_dir: &Path) -> Vec<(&'static BundledAgent, AgentAction)> {
132    BUNDLED_AGENTS
133        .iter()
134        .map(|agent| {
135            let action = match installed_version(agents_dir, agent.name) {
136                None => AgentAction::Install,
137                Some(v) if v != agent.version => AgentAction::Update { from: v },
138                Some(_) if matches_bundled(agent, agents_dir) => AgentAction::UpToDate,
139                Some(_) => AgentAction::Modified,
140            };
141            (agent, action)
142        })
143        .collect()
144}
145
146/// A note for a run about to start on an installed bundled blueprint that this
147/// binary ships a different version of.
148///
149/// `lev setup` is the only thing that has ever said this, and only when asked.
150/// Nothing said it at the moment it mattered, so an install could sit versions
151/// behind indefinitely - which is exactly how a run kept using an old
152/// checkpoint policy while the fix had shipped.
153///
154/// Deliberately narrow. It fires only for a manifest that *is* the installed
155/// copy, under `agents_dir/<name>/`, so a blueprint of the user's own that
156/// happens to share a name with a bundled one is never nagged about.
157pub fn stale_install_note(
158    manifest_path: &Path,
159    blueprint: &leviath_core::Blueprint,
160    agents_dir: Option<&Path>,
161) -> Option<String> {
162    let installed = agents_dir?.join(&blueprint.name);
163    if !manifest_path.starts_with(&installed) {
164        return None;
165    }
166    let bundled = BUNDLED_AGENTS.iter().find(|a| a.name == blueprint.name)?;
167    if bundled.version == blueprint.version {
168        return None;
169    }
170    Some(format!(
171        "note: '{}' is installed at {}, and this build ships {}. \
172         Run `lev setup` to update it.",
173        blueprint.name, blueprint.version, bundled.version
174    ))
175}
176
177/// Why an installed bundled blueprint would not load, when the reason is that
178/// it is old rather than that it is wrong.
179///
180/// The twin of [`stale_install_note`], for the path where there is no
181/// [`leviath_core::Blueprint`] to hand because parsing or validation is what
182/// failed. That is exactly when the user most needs to hear it: a graph rule
183/// added after their install turns their copy into "invalid blueprint", which
184/// reads as a bug in the agent rather than as an out-of-date file, and the
185/// version note they would have got on the success path never fires.
186///
187/// Narrow in the same way: the manifest must *be* the installed copy at
188/// `agents_dir/<name>/`, so a blueprint of the user's own is never blamed on a
189/// bundled one that shares its name.
190pub fn stale_install_hint(manifest_path: &Path, agents_dir: Option<&Path>) -> Option<String> {
191    let agents_dir = agents_dir?;
192    let bundled = BUNDLED_AGENTS
193        .iter()
194        .find(|a| manifest_path.starts_with(agents_dir.join(a.name)))?;
195    // Content, not the version field. A blueprint's `version` is authored by
196    // hand and routinely does not move when the file does, so an install can be
197    // months behind while claiming the same number: the two coder blueprints
198    // that started failing here were both `0.0.2`. Comparing bytes is the only
199    // answer that is always right.
200    if matches_bundled(bundled, agents_dir) {
201        // Byte-identical to what this build ships, so age is not the story and
202        // saying otherwise would send the user to reinstall the same file.
203        return None;
204    }
205    Some(format!(
206        "this is the installed copy of the bundled '{}' agent, and it differs from the one this \
207         build ships, so it is most likely out of date rather than broken. Run `lev setup` to \
208         reinstall it, or `lev add <path>` if you meant to keep your own edits.",
209        bundled.name
210    ))
211}
212
213/// [`stale_install_hint`] as a suffix ready to append to an error message, or
214/// an empty string when there is nothing to say.
215///
216/// Here rather than at each call site because both callers want the same
217/// "hint or nothing" shape and differ only in how they separate it from the
218/// error: `lev validate` prints a paragraph, the daemon writes one line.
219pub fn stale_install_suffix(
220    manifest_path: &Path,
221    agents_dir: Option<&Path>,
222    separator: &str,
223) -> String {
224    match stale_install_hint(manifest_path, agents_dir) {
225        Some(hint) => format!("{separator}{hint}"),
226        None => String::new(),
227    }
228}
229
230/// The agents directory of the real environment, for a caller that has no test
231/// seam of its own.
232///
233/// `None` when the home directory cannot be resolved, which
234/// [`stale_install_hint`] reads as "nowhere to check" and stays quiet about.
235pub fn real_agents_dir_opt() -> Option<std::path::PathBuf> {
236    dirs::home_dir().map(|h| crate::commands::setup::real_agents_dir(Some(&h)))
237}
238
239/// Write one bundled blueprint into `<agents_dir>/<name>/`, replacing whatever
240/// is there.
241///
242/// The existing tree is removed first rather than merged over: a stale file
243/// from an older version of the blueprint (a tool script that was dropped, say)
244/// would otherwise survive forever and keep being loaded. This mirrors what
245/// `lev add`'s directory install already does.
246pub fn install_bundled(agent: &BundledAgent, agents_dir: &Path) -> anyhow::Result<()> {
247    let dest = agents_dir.join(agent.name);
248    if dest.exists() {
249        std::fs::remove_dir_all(&dest)?;
250    }
251    for (rel, contents) in agent.files {
252        // Derive the parent from the *relative* path rather than calling
253        // `path.parent()`. `dest.join(rel)` always has a parent, so the `None`
254        // arm of `parent()` would be unreachable code pretending to be a
255        // handled case; splitting `rel` gives two arms that both actually
256        // happen - nested (`tools/web_fetch.rhai`) and flat (`agent.leviath`).
257        let parent = match rel.rsplit_once('/') {
258            Some((dir, _)) => dest.join(dir),
259            None => dest.clone(),
260        };
261        std::fs::create_dir_all(&parent)?;
262        std::fs::write(dest.join(rel), contents)?;
263    }
264    Ok(())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// Every assertion here is an invariant over *all* discovered blueprints.
272    /// Naming individual agents would turn adding or renaming one into a test
273    /// edit, and would stop testing the property the moment the list drifted.
274    #[test]
275    fn every_bundled_agent_has_a_name_version_and_manifest() {
276        assert!(
277            !BUNDLED_AGENTS.is_empty(),
278            "the binary shipped with no blueprints -- build.rs found no agents/ directory"
279        );
280        for agent in BUNDLED_AGENTS {
281            assert!(!agent.name.is_empty(), "a bundled agent has an empty name");
282            assert!(
283                !agent.version.is_empty(),
284                "bundled agent {} has an empty version",
285                agent.name
286            );
287            assert!(
288                agent.files.iter().any(|(rel, _)| *rel == "agent.leviath"),
289                "bundled agent {} has no agent.leviath",
290                agent.name
291            );
292            for (rel, contents) in agent.files {
293                assert!(
294                    !rel.is_empty(),
295                    "bundled agent {} has an empty path",
296                    agent.name
297                );
298                assert!(
299                    !contents.is_empty(),
300                    "bundled agent {} has an empty file {rel}",
301                    agent.name
302                );
303            }
304        }
305    }
306
307    /// A tool script shipped under the same filename by more than one agent
308    /// must be byte-identical everywhere.
309    ///
310    /// Each agent directory is self-contained - that is what lets `lev add
311    /// <dir>` and `lev pack` work - so `web_fetch.rhai` and `web_search.rhai`
312    /// exist as five copies each rather than one shared file. That is fine
313    /// until one copy is fixed and the others are not: these scripts are the
314    /// agents' network surface, so a hardening change applied to one of five is
315    /// four agents still carrying the unfixed behaviour, with nothing to say so.
316    ///
317    /// This turns that silent drift into a test failure. Deliberately keyed on
318    /// filename over *all* discovered agents rather than naming the five, so it
319    /// keeps holding as agents are added or renamed.
320    #[test]
321    fn a_tool_script_shared_by_several_agents_is_identical_in_all_of_them() {
322        use std::collections::HashMap;
323
324        // filename -> (first agent that shipped it, its contents)
325        let mut first_seen: HashMap<&str, (&str, &str)> = HashMap::new();
326        for agent in BUNDLED_AGENTS {
327            for (rel, contents) in agent.files {
328                let Some(filename) = rel.strip_prefix("tools/") else {
329                    continue;
330                };
331                match first_seen.get(filename) {
332                    Some((other, expected)) => assert!(
333                        expected == contents,
334                        "tools/{filename} differs between bundled agents {other} and {} - \
335                         a change to one copy was not applied to the others",
336                        agent.name
337                    ),
338                    None => {
339                        first_seen.insert(filename, (agent.name, contents));
340                    }
341                }
342            }
343        }
344        // Guard against a vacuous pass: if the scan found no tool scripts at
345        // all, the loop above asserts nothing.
346        assert!(
347            !first_seen.is_empty(),
348            "no bundled agent ships a tools/ script - this invariant is not being tested"
349        );
350    }
351
352    #[test]
353    fn every_bundled_manifest_parses_and_agrees_with_its_recorded_version() {
354        // The recorded version drives install/update planning, so a build.rs
355        // scan that disagreed with the manifest would make the wizard lie.
356        for agent in BUNDLED_AGENTS {
357            let manifest = agent
358                .files
359                .iter()
360                .find(|(rel, _)| *rel == "agent.leviath")
361                .map(|(_, c)| *c)
362                .expect("checked above");
363            // `.expect`, not `.unwrap_or_else(|e| panic!(...))`: the closure in
364            // the latter is a function that never runs on a passing test, which
365            // reads to llvm-cov as an uncovered region. For the same reason the
366            // message is a literal - a *call* in an `assert!`'s format args is
367            // also a region that only the failing path reaches.
368            let parsed = leviath_core::manifest::parse_manifest(manifest);
369            assert!(
370                parsed.is_ok(),
371                "bundled agent {} does not parse",
372                agent.name
373            );
374            let blueprint = parsed.expect("asserted Ok just above");
375            assert_eq!(blueprint.version, agent.version);
376            assert_eq!(blueprint.name, agent.name);
377        }
378    }
379
380    /// Every bundled agent ends in a stage that hands something back, and
381    /// nothing upstream can end the run before reaching it.
382    ///
383    /// The second half is the part that fails quietly. `allow_complete` on any
384    /// earlier stage offers the model a "DONE" it can pick instead of routing
385    /// onward - and it is appended even to a stage's custom `transition_prompt`,
386    /// so a blueprint can offer an exit its own prompt never mentions. A run
387    /// that takes it finishes with no answer, looking exactly like success.
388    /// That happened to a shipped blueprint while this was being written.
389    ///
390    /// Asserted over whatever is bundled rather than a hard-coded list, so a
391    /// new agent is held to it the day it lands.
392    #[test]
393    fn every_bundled_agent_ends_by_handing_something_back() {
394        for agent in BUNDLED_AGENTS {
395            let manifest = agent
396                .files
397                .iter()
398                .find(|(rel, _)| *rel == "agent.leviath")
399                .map(|(_, c)| *c)
400                .expect("checked above");
401            let blueprint = leviath_core::manifest::parse_manifest(manifest)
402                .expect("checked by every_bundled_manifest_parses");
403
404            let outputs: Vec<&leviath_core::Stage> = blueprint
405                .stages
406                .iter()
407                .filter(|s| s.mode == leviath_core::blueprint::StageMode::Output)
408                .collect();
409            assert!(
410                !outputs.is_empty(),
411                "bundled agent {} has no output stage, so a run of it hands back nothing",
412                agent.name
413            );
414
415            for stage in &outputs {
416                // The mode is meant to imply all three; a stage where it did
417                // not would advertise a tool it is not required to call.
418                assert!(stage.require_output, "{} output stage", agent.name);
419                assert!(
420                    stage
421                        .available_tools
422                        .iter()
423                        .any(|t| t == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL),
424                    "{} output stage cannot submit",
425                    agent.name
426                );
427                // A stage whose job is to report has no business writing files.
428                assert!(
429                    !stage.available_tools.iter().any(|t| {
430                        leviath_core::blueprint::MODIFYING_TOOLS
431                            .contains(&leviath_tools::canonical_tool_name(t))
432                    }),
433                    "{} output stage can modify files",
434                    agent.name
435                );
436            }
437
438            for stage in &blueprint.stages {
439                assert!(
440                    !stage.allow_complete
441                        || stage.mode == leviath_core::blueprint::StageMode::Output,
442                    "bundled agent {}: stage '{}' may end the run, skipping the output stage",
443                    agent.name,
444                    stage.name
445                );
446            }
447        }
448    }
449
450    /// Every provider `lev setup` can configure. Claude Code is a transport
451    /// rather than a provider a stage names, so it is not in this list.
452    const SETUP_PROVIDERS: &[&str] = &["anthropic", "openai", "google", "openrouter", "ollama"];
453
454    /// The published JSON Schema for `agent.leviath`.
455    ///
456    /// Compiled into the test so it cannot drift from the file that ships:
457    /// this is the same text served at
458    /// `https://leviath.dev/docs/<channel>/blueprint.schema.json`.
459    const BLUEPRINT_SCHEMA: &str = include_str!("../../../docs/schema/blueprint.schema.json");
460
461    /// Every way `value` fails `validator`, as readable lines.
462    ///
463    /// Shared by the positive and negative tests so the formatting closure runs
464    /// against real errors. Called only from the passing path of each, because
465    /// a call inside an `assert!` message is a region only failure reaches.
466    fn schema_problems(
467        validator: &jsonschema::Validator,
468        value: &serde_json::Value,
469    ) -> Vec<String> {
470        validator
471            .iter_errors(value)
472            .map(|e| format!("{}: {e}", e.instance_path()))
473            .collect()
474    }
475
476    /// Convert parsed TOML to JSON so a JSON Schema can be applied to it.
477    fn toml_to_json(value: &toml::Value) -> serde_json::Value {
478        match value {
479            toml::Value::String(s) => serde_json::Value::String(s.clone()),
480            toml::Value::Integer(i) => serde_json::Value::from(*i),
481            toml::Value::Float(f) => serde_json::Value::from(*f),
482            toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
483            // A TOML datetime has no JSON counterpart; the blueprint format has
484            // no datetime-valued key, so rendering it as its own text is enough
485            // for the schema to reject it wherever it appears.
486            toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
487            toml::Value::Array(items) => {
488                serde_json::Value::Array(items.iter().map(toml_to_json).collect())
489            }
490            toml::Value::Table(table) => serde_json::Value::Object(
491                table
492                    .iter()
493                    .map(|(k, v)| (k.clone(), toml_to_json(v)))
494                    .collect(),
495            ),
496        }
497    }
498
499    #[test]
500    fn toml_converts_to_json_for_every_value_kind() {
501        // Every arm, because a kind converted wrongly would be validated
502        // against the wrong JSON type and the schema would pass or fail for the
503        // wrong reason. `temperature` is a real float-valued blueprint key, so
504        // that arm is not hypothetical.
505        let source = concat!(
506            "s = \"text\"\n",
507            "i = 7\n",
508            "f = 0.5\n",
509            "b = true\n",
510            "d = 1979-05-27T07:32:00Z\n",
511            "a = [1, \"two\"]\n",
512            "[t]\n",
513            "nested = 1\n"
514        );
515        let parsed: toml::Value = toml::from_str(source).expect("valid TOML");
516        let json = toml_to_json(&parsed);
517        assert_eq!(json["s"], serde_json::json!("text"));
518        assert_eq!(json["i"], serde_json::json!(7));
519        assert_eq!(json["f"], serde_json::json!(0.5));
520        assert_eq!(json["b"], serde_json::json!(true));
521        // No JSON counterpart for a datetime, so it becomes its own text.
522        assert!(json["d"].is_string());
523        assert_eq!(json["a"], serde_json::json!([1, "two"]));
524        assert_eq!(json["t"]["nested"], serde_json::json!(1));
525    }
526
527    #[test]
528    fn every_bundled_blueprint_validates_against_the_published_schema() {
529        // The schema is the only machine-readable description of this format,
530        // and an agent authoring a blueprint will write against it. Nothing but
531        // this test keeps it honest: the parser is a hand-rolled toml::Value
532        // walker, so there is no derive to generate it from.
533        let schema: serde_json::Value =
534            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
535        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
536
537        for agent in BUNDLED_AGENTS {
538            let manifest = agent
539                .files
540                .iter()
541                .find(|(rel, _)| *rel == "agent.leviath")
542                .map(|(_, c)| *c)
543                .expect("every bundled agent has a manifest");
544            let parsed: toml::Value = toml::from_str(manifest).expect("the manifest is valid TOML");
545            let json = toml_to_json(&parsed);
546
547            assert_eq!(
548                schema_problems(&validator, &json),
549                Vec::<String>::new(),
550                "{} does not match blueprint.schema.json",
551                agent.name
552            );
553        }
554    }
555
556    #[test]
557    fn the_blueprint_schema_accepts_every_region_kind_the_parser_names() {
558        // The bundled agents between them use only some of the kinds, so the
559        // positive test above cannot notice one the schema forgot. `checklist`
560        // shipped that way: the parser took it, the published schema's closed
561        // enum refused it, and every blueprint using the feature failed to
562        // validate against the file we tell people to validate against.
563        //
564        // The parser's own error message enumerates the valid kinds, so read
565        // the list back from it rather than restating it here and drifting the
566        // same way twice.
567        let err = leviath_core::manifest::parse_manifest(
568            "[agent]\nname = \"a\"\n\n[context.regions]\nx = { kind = \"not-a-kind\" }\n",
569        )
570        .expect_err("an unknown region kind is a load error")
571        .to_string();
572        let listed = err
573            .split("valid kinds:")
574            .nth(1)
575            .expect("the error names the valid kinds")
576            .trim()
577            .trim_end_matches(')')
578            .split(',')
579            .map(str::trim)
580            .filter(|k| !k.is_empty())
581            .collect::<Vec<_>>();
582        assert!(
583            listed.len() > 5,
584            "the error should list every kind: {listed:?}"
585        );
586
587        let schema: serde_json::Value =
588            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
589        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
590        for kind in listed {
591            let manifest = format!(
592                "[agent]\nname = \"a\"\n\n[context.regions]\nx = {{ kind = \"{kind}\" }}\n"
593            );
594            let parsed: toml::Value = toml::from_str(&manifest).expect("valid TOML");
595            assert_eq!(
596                schema_problems(&validator, &toml_to_json(&parsed)),
597                Vec::<String>::new(),
598                "the schema rejects region kind \"{kind}\", which the parser accepts"
599            );
600        }
601    }
602
603    #[test]
604    fn the_blueprint_schema_accepts_every_transition_condition_the_parser_names() {
605        // The third instance of the same drift: `dead_end` parsed, the
606        // `dead-end-possible` lint told people to write it, and the published
607        // schema's closed enum rejected it. Same trick as the region kinds, for
608        // the same reason: read the list out of the parser's error rather than
609        // restating it here.
610        let err = leviath_core::manifest::parse_manifest(
611            "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"whenever\"\n",
612        )
613        .expect_err("an unknown condition is a load error")
614        .to_string();
615        let listed = err
616            .split("(valid:")
617            .nth(1)
618            .expect("the error names the valid conditions")
619            .trim()
620            .trim_end_matches(')')
621            .split(',')
622            .map(str::trim)
623            .filter(|c| !c.is_empty())
624            .collect::<Vec<_>>();
625        assert!(
626            listed.len() > 3,
627            "the error should list every condition: {listed:?}"
628        );
629
630        let schema: serde_json::Value =
631            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
632        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
633        for condition in listed {
634            let manifest = format!(
635                "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"{condition}\"\n"
636            );
637            let parsed: toml::Value = toml::from_str(&manifest).expect("valid TOML");
638            assert_eq!(
639                schema_problems(&validator, &toml_to_json(&parsed)),
640                Vec::<String>::new(),
641                "the schema rejects condition \"{condition}\", which the parser accepts"
642            );
643        }
644    }
645
646    #[test]
647    fn the_blueprint_schema_accepts_stage_hooks() {
648        // Same blind spot as the region kinds: no bundled agent declares hooks,
649        // so nothing noticed that the stage object is `additionalProperties:
650        // false` without a `hooks` property. Every blueprint following the Rhai
651        // hooks page was rejected by the published schema.
652        let schema: serde_json::Value =
653            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
654        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
655        let manifest = "[agent]\nname = \"a\"\n\n[stages.main.hooks]\n\
656                        on_stage_enter = \"hooks/enter.rhai\"\n\
657                        on_error = \"hooks/error.rhai\"\n";
658        let parsed: toml::Value = toml::from_str(manifest).expect("valid TOML");
659        assert_eq!(
660            schema_problems(&validator, &toml_to_json(&parsed)),
661            Vec::<String>::new(),
662            "the schema rejects [stages.<name>.hooks], which the parser accepts"
663        );
664    }
665
666    #[test]
667    fn the_blueprint_schema_rejects_what_the_parser_rejects() {
668        // A schema that accepts everything would pass the test above over any
669        // input at all. These are the mistakes it exists to catch before a run
670        // is ever spawned.
671        let schema: serde_json::Value =
672            serde_json::from_str(BLUEPRINT_SCHEMA).expect("the schema is valid JSON");
673        let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
674        // Goes through `schema_problems` rather than `is_valid`, so the same
675        // error-formatting path the positive test uses is actually exercised
676        // by something that produces errors.
677        let rejects = |manifest: &str| {
678            let parsed: toml::Value = toml::from_str(manifest).expect("valid TOML");
679            !schema_problems(&validator, &toml_to_json(&parsed)).is_empty()
680        };
681
682        assert!(
683            rejects("[stages.main]\nmode = \"autonomous\"\n"),
684            "no [agent]"
685        );
686        assert!(
687            rejects("[agent]\nname = \"a\"\n\n[context.regions]\nx = { kind = \"nonsense\" }\n"),
688            "unknown region kind"
689        );
690        assert!(
691            rejects(
692                "[agent]\nname = \"a\"\n\n[stages.main.transitions.other]\ncondition = \"whenever\"\n"
693            ),
694            "unknown transition condition"
695        );
696        assert!(
697            rejects("[agent]\nname = \"a\"\n\n[stages.main]\nmax_iteratoins = 5\n"),
698            "a typo'd stage key"
699        );
700        assert!(
701            rejects("[agent]\nname = \"a\"\n\n[tool_permissions]\nshell = \"maybe\"\n"),
702            "an invalid tool policy"
703        );
704        // And the minimum the parser accepts still passes, so the rules above
705        // are not rejecting everything.
706        assert!(!rejects("[agent]\nname = \"a\"\n"), "a minimal manifest");
707    }
708
709    #[test]
710    fn every_bundled_stage_offers_every_provider_setup_can_configure() {
711        // Getting Started promises that one provider is all you need. That is
712        // only true if each stage lists them all: a stage naming a subset fails
713        // at spawn on a machine holding a key for a provider it left out.
714        //
715        // Discovered from BUNDLED_AGENTS rather than enumerated, so a new
716        // blueprint is covered the day it lands.
717        for agent in BUNDLED_AGENTS {
718            let manifest = agent
719                .files
720                .iter()
721                .find(|(rel, _)| *rel == "agent.leviath")
722                .map(|(_, c)| *c)
723                .expect("every bundled agent has a manifest");
724            let blueprint =
725                leviath_core::manifest::parse_manifest(manifest).expect("manifest parses");
726
727            for stage in &blueprint.stages {
728                let stage_name = &stage.name;
729                let listed: Vec<&str> = stage
730                    .model
731                    .models
732                    .iter()
733                    .map(|entry| entry.provider.as_str())
734                    .collect();
735                for provider in SETUP_PROVIDERS {
736                    assert!(
737                        listed.contains(provider),
738                        "{}/{} omits provider {}",
739                        agent.name,
740                        stage_name,
741                        provider
742                    );
743                }
744                // Ollama needs no API key, so it registers on every machine. Any
745                // position but last makes it beat a provider the user actually
746                // configured, and the run then dies on its first inference.
747                assert_eq!(
748                    listed.last().copied(),
749                    Some("ollama"),
750                    "{}/{} must list ollama last",
751                    agent.name,
752                    stage_name
753                );
754            }
755        }
756    }
757
758    /// The lint env for a bundled agent: the built-ins, the sub-agent tools,
759    /// and the agent's own `tools/<name>.rhai`, each of which defines `<name>`.
760    ///
761    /// Built by hand rather than through `LintEnv::offline`, which discovers
762    /// script tools by reading a directory: a bundled agent's files are
763    /// compiled into the binary and there is no directory to read.
764    fn lint_env_for(agent: &BundledAgent) -> crate::lint::LintEnv {
765        let mut known_tools: std::collections::HashSet<String> = leviath_tools::BuiltinTools::new(
766            leviath_tools::ToolContext::new(std::path::PathBuf::from(".")),
767        )
768        .names()
769        .into_iter()
770        .collect();
771        known_tools.extend(leviath_tools::BuiltinTools::subagent_tool_names());
772        known_tools.extend(
773            agent
774                .files
775                .iter()
776                .filter_map(|(rel, _)| rel.strip_prefix("tools/"))
777                .filter_map(|f| f.strip_suffix(".rhai"))
778                .map(str::to_string),
779        );
780        crate::lint::LintEnv {
781            known_tools,
782            known_models: crate::commands::models::closed_catalog_models(),
783            available_providers: None,
784            read_paths: None,
785            safe_commands_granted: None,
786        }
787    }
788
789    /// No bundled agent ships a blueprint the linter calls broken.
790    ///
791    /// The errors this catches are the ones that are invisible on inspection: a
792    /// tool name matching nothing is silently dropped from what the stage
793    /// advertises, so the model is told the tool does not exist and the stage
794    /// cannot do its job. A permission for a tool the stage never granted is the
795    /// same drift from the other side, reading as a grant and not being one.
796    ///
797    /// Asserted by running the shipped linter rather than by a parallel copy of
798    /// its rules, and over all discovered agents rather than a list of names -
799    /// either would stop testing the property the moment it drifted.
800    #[test]
801    fn no_bundled_agent_has_a_lint_error() {
802        for agent in BUNDLED_AGENTS {
803            let manifest = agent
804                .files
805                .iter()
806                .find(|(rel, _)| *rel == "agent.leviath")
807                .map(|(_, c)| *c)
808                .expect("every bundled agent has a manifest");
809            let parsed = leviath_core::manifest::parse_manifest(manifest);
810            assert!(
811                parsed.is_ok(),
812                "bundled agent {} does not parse",
813                agent.name
814            );
815            let blueprint = parsed.expect("asserted Ok just above");
816            // Every finding is rendered up front, and the errors are then
817            // *counted* rather than collected. Any per-error work - a `.map`
818            // that formats, a `.collect` into a list of messages - sits in a
819            // closure that only runs when the test is about to fail, which
820            // llvm-cov reads as an uncovered region for as long as the
821            // invariant holds. Counting has no such body.
822            let rendered: Vec<(bool, String)> =
823                crate::lint::lint_manifest(manifest, &blueprint, &lint_env_for(agent))
824                    .iter()
825                    .map(|f| (f.is_error(), format!("{} [{}]", f.one_line(), f.code)))
826                    .collect();
827            let error_count = rendered.iter().filter(|(is_error, _)| *is_error).count();
828            assert_eq!(
829                error_count, 0,
830                "bundled agent {} has lint errors, among {rendered:?}",
831                agent.name
832            );
833        }
834    }
835
836    /// The invariant above can actually fail - a check over shipped data that
837    /// happens to pass says nothing about whether it would catch drift.
838    #[test]
839    fn the_lint_invariant_catches_a_typo_and_an_orphan_permission() {
840        let manifest = r#"
841[agent]
842name = "x"
843version = "0.1.0"
844description = "x"
845
846[stages.only]
847mode = "autonomous"
848model = { provider = "anthropic", model = "claude-sonnet-5" }
849max_iterations = 5
850available_tools = ["read_file", "raed_file"]
851
852[stages.only.tool_permissions]
853write_file = "allow"
854"#;
855        let bp = leviath_core::manifest::parse_manifest(manifest)
856            .expect("the fixture parses; it is the lint that should object");
857        // Reuse the same env shape a real bundled agent gets, minus any scripts.
858        let env = lint_env_for(&BundledAgent {
859            name: "x",
860            version: "0.1.0",
861            files: &[],
862        });
863        let codes: Vec<&str> = crate::lint::lint_manifest(manifest, &bp, &env)
864            .iter()
865            .filter(|f| f.is_error())
866            .map(|f| f.code)
867            .collect();
868        assert_eq!(codes, ["unknown-tool", "orphan-stage-permission"]);
869    }
870
871    #[test]
872    fn bundled_agent_names_are_unique() {
873        let mut names: Vec<&str> = BUNDLED_AGENTS.iter().map(|a| a.name).collect();
874        names.sort_unstable();
875        let count = names.len();
876        names.dedup();
877        assert_eq!(count, names.len(), "duplicate bundled agent names");
878    }
879
880    // ─── installed_version ──────────────────────────────────────────────────
881
882    #[test]
883    fn installed_version_reads_a_manifest() {
884        let dir = tempfile::tempdir().unwrap();
885        let agent = &BUNDLED_AGENTS[0];
886        install_bundled(agent, dir.path()).unwrap();
887
888        assert_eq!(
889            installed_version(dir.path(), agent.name).as_deref(),
890            Some(agent.version)
891        );
892    }
893
894    #[test]
895    fn installed_version_is_none_when_nothing_is_installed() {
896        let dir = tempfile::tempdir().unwrap();
897        assert!(installed_version(dir.path(), "not-installed").is_none());
898    }
899
900    #[test]
901    fn installed_version_is_none_for_an_unparseable_manifest() {
902        // A half-written install must read as "not installed" so the wizard
903        // offers a clean reinstall rather than refusing to plan.
904        let dir = tempfile::tempdir().unwrap();
905        std::fs::create_dir_all(dir.path().join("broken")).unwrap();
906        std::fs::write(
907            dir.path().join("broken/agent.leviath"),
908            "not valid toml {{{",
909        )
910        .unwrap();
911
912        assert!(installed_version(dir.path(), "broken").is_none());
913    }
914
915    // ─── plan_agent_actions ─────────────────────────────────────────────────
916
917    #[test]
918    fn plan_offers_to_install_everything_into_an_empty_dir() {
919        let dir = tempfile::tempdir().unwrap();
920
921        let plan = plan_agent_actions(dir.path());
922
923        assert_eq!(plan.len(), BUNDLED_AGENTS.len());
924        for (agent, action) in &plan {
925            assert_eq!(*action, AgentAction::Install);
926            assert!(action.is_change());
927            assert_eq!(
928                action.label(agent.version),
929                format!("install {}", agent.version)
930            );
931        }
932    }
933
934    #[test]
935    fn plan_reports_up_to_date_after_installing() {
936        let dir = tempfile::tempdir().unwrap();
937        for agent in BUNDLED_AGENTS {
938            install_bundled(agent, dir.path()).unwrap();
939        }
940
941        let plan = plan_agent_actions(dir.path());
942
943        for (agent, action) in &plan {
944            assert_eq!(*action, AgentAction::UpToDate, "{}", agent.name);
945            assert!(!action.is_change());
946            assert_eq!(action.label(agent.version), "up to date");
947        }
948    }
949
950    #[test]
951    fn plan_reports_an_update_when_the_installed_version_differs() {
952        let dir = tempfile::tempdir().unwrap();
953        let agent = &BUNDLED_AGENTS[0];
954        install_bundled(agent, dir.path()).unwrap();
955        // Rewrite the installed manifest at a different version.
956        let manifest_path = dir.path().join(agent.name).join("agent.leviath");
957        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
958        let bumped = manifest.replacen(
959            &format!("version = \"{}\"", agent.version),
960            "version = \"9.9.9\"",
961            1,
962        );
963        std::fs::write(&manifest_path, bumped).unwrap();
964
965        let plan = plan_agent_actions(dir.path());
966        let (_, action) = plan
967            .iter()
968            .find(|(a, _)| a.name == agent.name)
969            .expect("the bundled agent is in the plan");
970
971        assert_eq!(
972            *action,
973            AgentAction::Update {
974                from: "9.9.9".to_string()
975            }
976        );
977        assert!(action.is_change());
978        assert_eq!(
979            action.label(agent.version),
980            format!("update 9.9.9 → {}", agent.version)
981        );
982    }
983
984    /// The limitation this closes: comparing versions alone meant a blueprint
985    /// edited without a version bump read as current forever, so the user was
986    /// never told their copy had drifted from the one that shipped.
987    #[test]
988    fn plan_reports_an_edited_install_as_modified() {
989        let dir = tempfile::tempdir().unwrap();
990        let agent = &BUNDLED_AGENTS[0];
991        install_bundled(agent, dir.path()).unwrap();
992        let manifest_path = dir.path().join(agent.name).join("agent.leviath");
993        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
994        std::fs::write(&manifest_path, manifest + "\n# a local edit\n").unwrap();
995
996        let action = action_for(&plan_agent_actions(dir.path()), agent.name);
997        assert_eq!(action, AgentAction::Modified);
998        // It would change disk, so the wizard offers it - but never unasked,
999        // because reinstalling destroys the edit.
1000        assert!(action.is_change());
1001        assert!(!action.preselect());
1002        let label = action.label(agent.version);
1003        assert!(label.contains("edited locally"), "{label}");
1004    }
1005
1006    /// `install_bundled` removes the destination first, so a file the user
1007    /// added is destroyed by a reinstall too - which makes it exactly as
1008    /// important to notice as an edited one.
1009    #[test]
1010    fn a_file_the_user_added_or_removed_counts_as_modified() {
1011        let agent = &BUNDLED_AGENTS[0];
1012
1013        let added = tempfile::tempdir().unwrap();
1014        install_bundled(agent, added.path()).unwrap();
1015        std::fs::write(added.path().join(agent.name).join("notes.md"), "mine").unwrap();
1016        assert_eq!(
1017            action_for(&plan_agent_actions(added.path()), agent.name),
1018            AgentAction::Modified
1019        );
1020
1021        // A file deleted from a blueprint that ships more than the manifest.
1022        // The manifest still parses at the bundled version, so only the file
1023        // comparison can catch this.
1024        let multi = BUNDLED_AGENTS
1025            .iter()
1026            .find(|a| a.files.len() > 1)
1027            .expect("some bundled blueprint ships more than its manifest");
1028        let removed = tempfile::tempdir().unwrap();
1029        install_bundled(multi, removed.path()).unwrap();
1030        let extra = multi
1031            .files
1032            .iter()
1033            .map(|(rel, _)| *rel)
1034            .find(|rel| *rel != "agent.leviath")
1035            .expect("a file other than the manifest");
1036        std::fs::remove_file(removed.path().join(multi.name).join(extra)).unwrap();
1037        assert_eq!(
1038            action_for(&plan_agent_actions(removed.path()), multi.name),
1039            AgentAction::Modified
1040        );
1041    }
1042
1043    /// A directory that cannot be walked reads as differing, which is the safe
1044    /// direction: this decides whether overwriting is safe.
1045    #[test]
1046    fn an_unreadable_tree_is_not_up_to_date() {
1047        assert_eq!(installed_file_count(Path::new("/no/such/dir")), 0);
1048        let dir = tempfile::tempdir().unwrap();
1049        assert!(!matches_bundled(&BUNDLED_AGENTS[0], dir.path()));
1050    }
1051
1052    #[test]
1053    fn installed_file_count_walks_nested_directories() {
1054        let dir = tempfile::tempdir().unwrap();
1055        std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
1056        std::fs::write(dir.path().join("top.txt"), "x").unwrap();
1057        std::fs::write(dir.path().join("a/mid.txt"), "x").unwrap();
1058        std::fs::write(dir.path().join("a/b/leaf.txt"), "x").unwrap();
1059        assert_eq!(installed_file_count(dir.path()), 3);
1060    }
1061
1062    fn action_for(plan: &[(&'static BundledAgent, AgentAction)], name: &str) -> AgentAction {
1063        plan.iter()
1064            .find(|(a, _)| a.name == name)
1065            .expect("the bundled agent is in the plan")
1066            .1
1067            .clone()
1068    }
1069
1070    // ─── stale_install_hint ─────────────────────────────────────────────────
1071
1072    /// The report that prompted this: a user on alpha whose installed `coder`
1073    /// stopped loading, with an error about graph shape and nothing saying the
1074    /// file was simply old. A blueprint that predates a graph rule fails in a
1075    /// way that reads as a broken agent rather than an out-of-date one.
1076    #[test]
1077    fn an_installed_agent_that_will_not_load_is_named_as_out_of_date() {
1078        let dir = tempfile::tempdir().unwrap();
1079        let agent = &BUNDLED_AGENTS[0];
1080        install_bundled(agent, dir.path()).unwrap();
1081        let manifest = dir.path().join(agent.name).join("agent.leviath");
1082
1083        // Byte-identical to what this build ships: age is not the story, and
1084        // sending the user to reinstall the same file would waste their time.
1085        assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1086
1087        // Any difference is enough. The version field is deliberately not
1088        // consulted, because it routinely does not move when the file does:
1089        // both coder blueprints in the report were `0.0.2`.
1090        std::fs::write(&manifest, "[agent]\nname = \"x\"\nversion = \"0.0.2\"\n").unwrap();
1091        let hint =
1092            stale_install_hint(&manifest, Some(dir.path())).expect("a changed copy is named");
1093        assert!(hint.contains(agent.name), "{hint}");
1094        assert!(hint.contains("lev setup"), "{hint}");
1095    }
1096
1097    /// Narrow in the same way as the note: it speaks only for the installed
1098    /// copy, so a blueprint of the user's own is never blamed on a bundled one
1099    /// that shares its name, and neither is a path with no agents dir to check.
1100    /// The suffix form is what both call sites actually use, and the thing that
1101    /// must not decorate an error with a blank paragraph when there is no hint.
1102    #[test]
1103    fn the_suffix_carries_the_hint_or_nothing_at_all() {
1104        let dir = tempfile::tempdir().unwrap();
1105        let agent = &BUNDLED_AGENTS[0];
1106        install_bundled(agent, dir.path()).unwrap();
1107        let manifest = dir.path().join(agent.name).join("agent.leviath");
1108
1109        // Nothing to say: an empty string, not a separator with nothing after it.
1110        assert_eq!(
1111            stale_install_suffix(&manifest, Some(dir.path()), "\n\n"),
1112            ""
1113        );
1114
1115        std::fs::write(&manifest, "[agent]\nname = \"x\"\n").unwrap();
1116        let suffix = stale_install_suffix(&manifest, Some(dir.path()), "\n\n");
1117        assert!(suffix.starts_with("\n\n"), "{suffix:?}");
1118        assert!(suffix.contains(agent.name), "{suffix:?}");
1119        // The daemon writes one line rather than a paragraph, same hint.
1120        assert!(
1121            stale_install_suffix(&manifest, Some(dir.path()), ". ").starts_with(". "),
1122            "the separator is the caller's choice"
1123        );
1124    }
1125
1126    #[test]
1127    fn the_hint_stays_quiet_outside_the_installed_copy() {
1128        let dir = tempfile::tempdir().unwrap();
1129        let agent = &BUNDLED_AGENTS[0];
1130        install_bundled(agent, dir.path()).unwrap();
1131
1132        let elsewhere = dir.path().join("elsewhere").join(agent.name);
1133        std::fs::create_dir_all(&elsewhere).unwrap();
1134        let mine = elsewhere.join("agent.leviath");
1135        std::fs::write(&mine, "[agent]\nname = \"mine\"\n").unwrap();
1136        assert_eq!(stale_install_hint(&mine, Some(dir.path())), None);
1137
1138        // A name no bundled agent has, inside the agents dir.
1139        let other = dir.path().join("not-a-bundled-agent");
1140        std::fs::create_dir_all(&other).unwrap();
1141        let manifest = other.join("agent.leviath");
1142        std::fs::write(&manifest, "[agent]\nname = \"other\"\n").unwrap();
1143        assert_eq!(stale_install_hint(&manifest, Some(dir.path())), None);
1144
1145        // And with nowhere to look, it says nothing rather than guessing.
1146        assert_eq!(
1147            stale_install_hint(&dir.path().join(agent.name).join("agent.leviath"), None),
1148            None
1149        );
1150    }
1151
1152    // ─── stale_install_note ─────────────────────────────────────────────────
1153
1154    /// The case that prompted this: an install sitting versions behind, with
1155    /// nothing saying so at the moment it mattered.
1156    #[test]
1157    fn a_stale_install_is_named_when_the_run_starts() {
1158        let dir = tempfile::tempdir().unwrap();
1159        let agent = &BUNDLED_AGENTS[0];
1160        install_bundled(agent, dir.path()).unwrap();
1161        let manifest = dir.path().join(agent.name).join("agent.leviath");
1162        let mut blueprint =
1163            leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1164                .unwrap();
1165
1166        // At the bundled version there is nothing to say.
1167        assert_eq!(
1168            stale_install_note(&manifest, &blueprint, Some(dir.path())),
1169            None
1170        );
1171
1172        blueprint.version = "0.0.1".to_string();
1173        let note = stale_install_note(&manifest, &blueprint, Some(dir.path()))
1174            .expect("a behind install is named");
1175        assert!(note.contains("0.0.1"), "{note}");
1176        assert!(note.contains(agent.version), "{note}");
1177        assert!(note.contains("lev setup"), "{note}");
1178    }
1179
1180    /// Deliberately narrow: a blueprint of the user's own that happens to share
1181    /// a name with a bundled one is never nagged about, and neither is one this
1182    /// build does not ship.
1183    #[test]
1184    fn a_blueprint_that_is_not_the_installed_copy_is_left_alone() {
1185        let dir = tempfile::tempdir().unwrap();
1186        let agent = &BUNDLED_AGENTS[0];
1187        install_bundled(agent, dir.path()).unwrap();
1188        let manifest = dir.path().join(agent.name).join("agent.leviath");
1189        let mut blueprint =
1190            leviath_core::manifest::parse_manifest(&std::fs::read_to_string(&manifest).unwrap())
1191                .unwrap();
1192        blueprint.version = "0.0.1".to_string();
1193
1194        // Somewhere else on disk, under the same name.
1195        let elsewhere = tempfile::tempdir().unwrap();
1196        let copy = elsewhere.path().join(agent.name).join("agent.leviath");
1197        assert_eq!(
1198            stale_install_note(&copy, &blueprint, Some(dir.path())),
1199            None,
1200            "not the installed copy"
1201        );
1202
1203        // No agents dir resolves at all.
1204        assert_eq!(stale_install_note(&manifest, &blueprint, None), None);
1205
1206        // A name this build ships nothing for.
1207        blueprint.name = "not-a-bundled-agent".to_string();
1208        assert_eq!(
1209            stale_install_note(
1210                &dir.path().join("not-a-bundled-agent").join("agent.leviath"),
1211                &blueprint,
1212                Some(dir.path())
1213            ),
1214            None
1215        );
1216    }
1217
1218    // ─── install_bundled ────────────────────────────────────────────────────
1219
1220    #[test]
1221    fn install_writes_every_file_including_nested_ones() {
1222        let dir = tempfile::tempdir().unwrap();
1223        // Pick a blueprint that actually has a nested `tools/` file, so the
1224        // create_dir_all arm is exercised by a real shipped layout rather than
1225        // a fixture. If none ships nested files any more, the flat arm below
1226        // still covers the rest.
1227        for agent in BUNDLED_AGENTS {
1228            install_bundled(agent, dir.path()).unwrap();
1229            for (rel, contents) in agent.files {
1230                let written = std::fs::read_to_string(dir.path().join(agent.name).join(rel));
1231                assert!(written.is_ok(), "{}/{rel} was not written", agent.name);
1232                assert_eq!(written.expect("asserted Ok just above"), *contents);
1233            }
1234        }
1235        assert!(
1236            BUNDLED_AGENTS
1237                .iter()
1238                .any(|a| a.files.iter().any(|(rel, _)| rel.contains('/'))),
1239            "no bundled blueprint has a nested file, so install's mkdir path is untested"
1240        );
1241    }
1242
1243    #[test]
1244    fn install_replaces_an_existing_tree_and_drops_stale_files() {
1245        let dir = tempfile::tempdir().unwrap();
1246        let agent = &BUNDLED_AGENTS[0];
1247        install_bundled(agent, dir.path()).unwrap();
1248        let stale = dir
1249            .path()
1250            .join(agent.name)
1251            .join("stale-from-an-older-version");
1252        std::fs::write(&stale, "leftover").unwrap();
1253
1254        install_bundled(agent, dir.path()).unwrap();
1255
1256        assert!(
1257            !stale.exists(),
1258            "a reinstall must not leave files from the previous version behind"
1259        );
1260        assert!(dir.path().join(agent.name).join("agent.leviath").exists());
1261    }
1262
1263    #[test]
1264    fn install_surfaces_a_directory_creation_failure() {
1265        // `agents_dir` is itself a file, so creating the blueprint directory
1266        // under it fails.
1267        let dir = tempfile::tempdir().unwrap();
1268        let blocked = dir.path().join("not-a-dir");
1269        std::fs::write(&blocked, "").unwrap();
1270
1271        let result = install_bundled(&BUNDLED_AGENTS[0], &blocked);
1272
1273        assert!(result.is_err());
1274    }
1275
1276    #[test]
1277    fn install_surfaces_a_file_write_failure() {
1278        // Isolating the `write` error from the `create_dir_all` error needs a
1279        // layout where the directory step succeeds and only the write fails.
1280        // A synthetic blueprint whose second entry names a path the first entry
1281        // already created as a *directory* does exactly that: `create_dir_all`
1282        // sees an existing dir and returns Ok, then the write hits EISDIR.
1283        // No shipped blueprint has that shape, hence the hand-built one.
1284        let agent = BundledAgent {
1285            name: "collides-with-its-own-directory",
1286            version: "0.0.1",
1287            files: &[("tools/a.rhai", "nested first"), ("tools", "then the dir")],
1288        };
1289        let dir = tempfile::tempdir().unwrap();
1290
1291        let result = install_bundled(&agent, dir.path());
1292
1293        assert!(result.is_err());
1294    }
1295
1296    #[test]
1297    fn install_surfaces_a_remove_failure() {
1298        // The destination exists but is a *file*, so `remove_dir_all` fails
1299        // rather than the write.
1300        let dir = tempfile::tempdir().unwrap();
1301        let agent = &BUNDLED_AGENTS[0];
1302        std::fs::write(dir.path().join(agent.name), "").unwrap();
1303
1304        let result = install_bundled(agent, dir.path());
1305
1306        assert!(result.is_err());
1307    }
1308}