Skip to main content

leviath_core/blueprint/
mod.rs

1//! Agent blueprints and stage definitions.
2//!
3//! A blueprint is the complete definition of an agent type, including its
4//! execution stages, model selection, tool access, and context layout.
5//! Blueprints are typically defined in `leviath.toml` files and can be
6//! shared, installed, and versioned.
7
8use crate::error::ValidationError;
9use crate::layout::{ContextLayout, RegionSeed};
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13
14/// Regions every stage can see, whatever its own `[context.regions]` says.
15///
16/// The runtime adds the first three when a blueprint declares none, and carries
17/// all four visible through a stage's layout swap: the first two hold the typed
18/// tool_use/tool_result turns, an answer submitted early has to survive to the
19/// end, and the last holds the instructions of the stage being entered. Mirrors
20/// `context_setup::apply_layout`, which is where the rule is enforced.
21pub const ALWAYS_VISIBLE_REGIONS: [&str; 4] = [
22    "conversation",
23    "tool_results",
24    "final_output",
25    crate::layout::STAGE_INSTRUCTIONS_REGION,
26];
27
28/// When a run looks for tools again after it started.
29///
30/// Discovery happens either way: what this decides is whether it happens more
31/// than once, and how eagerly. Each value is strictly more eager than the one
32/// before it, so a later value does everything an earlier one does.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ToolRescan {
36    /// The set is fixed when the run starts. The default, and the only value
37    /// where an agent cannot grow its own toolchain.
38    #[default]
39    AtSpawn,
40    /// A `.rhai` written into a scanned directory makes the run look again
41    /// before its next turn, so the tool is advertised to the model.
42    AfterWrites,
43    /// As `AfterWrites`, and the run also looks at the scanned directories
44    /// themselves before each batch of tool calls it dispatches.
45    ///
46    /// The difference is *what* it notices. `AfterWrites` is told about a tool
47    /// only when this agent writes one with `write_file`, `edit_file` or
48    /// `install_tool`. A tool that appears any other way - written by a shell
49    /// command, by a script tool, by a sub-agent or fan-out worker sharing this
50    /// workdir, or by a person - is invisible to it for the rest of the run.
51    /// This value looks at the directories instead of waiting to be told, so it
52    /// sees all of those, and sees a tool that was edited or removed too.
53    ///
54    /// The cost is a `stat` per scanned directory per batch, and a re-scan only
55    /// when one of them changed.
56    BeforeDispatch,
57}
58
59impl ToolRescan {
60    /// Whether a run on this setting looks for tools again at all.
61    ///
62    /// What decides whether the workdir's `tools/` joins the scan set, and
63    /// whether the runtime watches the agent for a pending re-scan.
64    pub fn rescans(self) -> bool {
65        !matches!(self, Self::AtSpawn)
66    }
67
68    /// Whether a run on this setting looks again before dispatching a batch.
69    pub fn before_dispatch(self) -> bool {
70        matches!(self, Self::BeforeDispatch)
71    }
72
73    /// The word a manifest writes for this value.
74    pub fn wire(self) -> &'static str {
75        match self {
76            Self::AtSpawn => "at_spawn",
77            Self::AfterWrites => "after_writes",
78            Self::BeforeDispatch => "before_dispatch",
79        }
80    }
81
82    /// Read a manifest's word, or `None` for one nothing here names.
83    pub fn parse(word: &str) -> Option<Self> {
84        Some(match word {
85            "at_spawn" => Self::AtSpawn,
86            "after_writes" => Self::AfterWrites,
87            "before_dispatch" => Self::BeforeDispatch,
88            _ => return None,
89        })
90    }
91
92    /// Every value, in order of eagerness, for a refusal that lists them.
93    pub const ALL: [Self; 3] = [Self::AtSpawn, Self::AfterWrites, Self::BeforeDispatch];
94}
95
96/// An agent blueprint - the complete definition of an agent type.
97///
98/// Includes stages, model selection, tools, AND context layout. A blueprint
99/// defines everything needed to instantiate and run an agent with specific
100/// capabilities and memory structure.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Blueprint {
103    /// Unique name for this agent type
104    pub name: String,
105
106    /// Human-readable description
107    pub description: String,
108
109    /// Execution stages (e.g., analyze → implement → review)
110    pub stages: Vec<Stage>,
111
112    /// Context window layout defining memory regions
113    pub context_layout: ContextLayout,
114
115    /// Context transforms for inter-agent communication
116    pub transforms: Vec<ContextTransform>,
117
118    /// Version of this blueprint
119    pub version: String,
120
121    /// Configuration for LLM-based compaction
122    pub compaction_config: Option<CompactionConfig>,
123
124    /// Maximum depth of the sub-agent tree (default: 3)
125    pub max_child_depth: Option<usize>,
126
127    /// Which stage to start from (default: first defined)
128    pub entry_stage: Option<String>,
129
130    /// Additional metadata
131    pub metadata: HashMap<String, serde_json::Value>,
132
133    /// Security configuration for taint tracking.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub security: Option<crate::taint::SecurityConfig>,
136
137    /// Agent-level override for the batch-tool-calls system-prompt hint. `None`
138    /// inherits the global config toggle; a per-stage `batch_tool_hint` overrides
139    /// this. See [`crate::taint::resolve_batch_tool_hint`] for the cascade.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub batch_tool_hint: Option<bool>,
142
143    /// Agent-level override for the platform shell hint. `None` inherits the
144    /// global config toggle; a per-stage `shell_hint` overrides this. See
145    /// [`crate::taint::resolve_shell_hint`] for the cascade.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub shell_hint: Option<bool>,
148
149    /// Agent-level default for the empty-response nudge. `None` inherits the
150    /// global config's `[nudge]` section; a per-stage `[stages.<name>.nudge]`
151    /// overrides this. See [`resolve_nudge`] for the cascade.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub nudge: Option<NudgeConfig>,
154
155    /// Repetition detection configuration.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub repetition_detection: Option<RepetitionDetectionConfig>,
158
159    /// File tracking configuration.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub file_tracking: Option<FileTrackingConfig>,
162
163    /// Agent-level sandbox configuration for tool execution. Per-stage
164    /// `[stages.<name>.sandbox]` overrides this; both cascade through
165    /// [`crate::resolve_sandbox`].
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
168
169    /// When a run looks for tools again after it started.
170    ///
171    /// Anything but [`ToolRescan::AtSpawn`] puts the run workdir's `tools/`
172    /// directory in the scan set, so a script the agent writes there mid-run
173    /// can be found. The directory is the *workdir's*, not the blueprint's:
174    /// anything else running in that workdir sees the same tools, and a
175    /// sub-agent inherits the workdir verbatim.
176    ///
177    /// Defaults to [`ToolRescan::AtSpawn`], where the set is fixed when the run
178    /// starts and an agent cannot grow its own toolchain.
179    #[serde(default)]
180    pub tool_rescan: ToolRescan,
181
182    /// Read paths this agent *declares* beyond its workdir - directories a
183    /// planner-style agent needs to see, like run archives or design docs.
184    /// Declaring is not granting: entries only take effect when the user's
185    /// config also grants them (`[security] read_paths`,
186    /// `[agent_read_paths.<name>]`, or `allow_blueprint_read_paths = true`),
187    /// so an installed manifest cannot widen its own sandbox. Read-only in
188    /// every case; `write_file` and `edit_file` stay confined to the workdir.
189    /// Semantics live in [`crate::read_paths`].
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub read_paths: Option<ReadPathsConfig>,
192
193    /// The `[safe_commands]` section: tools and shell command prefixes this
194    /// agent would like to run without an approval prompt.
195    ///
196    /// Declaring is not granting, exactly as for [`Self::read_paths`]: entries
197    /// take effect only when the user opts in, per agent via
198    /// `[agent_safe_commands.<name>] allow_blueprint = true` or globally via
199    /// `[security] allow_blueprint_safe_commands`. Otherwise any agent package
200    /// could pre-approve its own shell with one TOML line.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub safe_commands: Option<SafeCommandsConfig>,
203
204    /// Agent-level default shape for the run's final output. A per-stage
205    /// `[stages.<name>.output]` narrows it, and whoever starts the run can
206    /// override it again. See [`crate::output::resolve_output_spec`].
207    ///
208    /// `None` means this agent declares no shape, which is not the same as
209    /// producing no output: a stage may still ask for one.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub output: Option<crate::output::OutputSpec>,
212
213    /// Rows this agent adds to the mime registry, `[mime_types]` in the
214    /// manifest: the types its tools produce and take, layered over the
215    /// operator's rows for this agent's runs only. Validated at parse; an
216    /// empty table is the common case and is not written back.
217    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
218    pub mime_types: toml::Table,
219
220    /// Things that must be in place before this agent can run, declared as
221    /// `[[dependencies]]` in the manifest: an MCP server, an environment
222    /// variable, a program on `PATH`, or a condition a Rhai script checks.
223    /// Declared, never granted. The operator is shown what is missing and how
224    /// to fix it, and an unmet required dependency fails the spawn before the
225    /// first billed inference. See [`Dependency`].
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub dependencies: Vec<Dependency>,
228}
229
230/// The `[safe_commands]` section of a manifest.
231///
232/// Entry syntax is not checked here. What counts as a usable shell prefix is
233/// defined by the key parser in the CLI (a program, optionally with the
234/// subcommand that narrows it), which this crate does not depend on. A bad
235/// entry is a lint finding and is skipped with a warning at spawn, rather than
236/// a parse error - the same place the check can be written once instead of
237/// twice.
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct SafeCommandsConfig {
240    /// Tools that need no prompt whatever their arguments.
241    #[serde(default)]
242    pub tools: Vec<String>,
243    /// Shell command prefixes that need no prompt: `"cargo test"`, not
244    /// `"cargo test --lib"` and never `"cargo"`.
245    #[serde(default)]
246    pub shell: Vec<String>,
247}
248
249/// The `[read_paths]` section of a manifest: raw declared entries, compiled
250/// against the run's workdir and home at spawn.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ReadPathsConfig {
253    /// Declared entries. Each may be:
254    /// - an exact path, granting its subtree: `"~/.leviath/runs"` or
255    ///   `"../shared-docs"` (relative to the run's workdir)
256    /// - a glob: `"glob:~/.leviath/runs/**"`
257    /// - a regex, auto-anchored: `"regex:/data/design-docs/.*"`
258    ///
259    /// Patterns are written with `/` separators on every OS and match the
260    /// symlink-resolved real path.
261    #[serde(default)]
262    pub allow: Vec<String>,
263}
264
265/// One `[[dependencies]]` entry: something that must be in place before an
266/// agent can run. Declared in the manifest, never granted - every surface that
267/// reports it (`lev validate`, `lev deps`, the spawn gate, the API) shows what
268/// is missing and the `remedy` for fixing it.
269///
270/// The `kind` field selects what must be present and carries its own fields
271/// (see [`DependencyKind`]); the optional [`install`](Self::install) block says
272/// how `lev deps install` can put it in place, and is never run automatically.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct Dependency {
275    /// A short identifier, unique within the blueprint.
276    pub name: String,
277
278    /// What must be present, and the fields describing it.
279    #[serde(flatten)]
280    pub kind: DependencyKind,
281
282    /// Whether an unmet dependency blocks the run. `true` (the default) fails
283    /// the spawn; `false` downgrades a miss to a warning the run proceeds past.
284    #[serde(default = "default_dependency_required")]
285    pub required: bool,
286
287    /// A human sentence telling the user how to satisfy the dependency, shown
288    /// wherever a miss is reported.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub remedy: Option<String>,
291
292    /// A one-line note on why the agent needs it.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub description: Option<String>,
295
296    /// How `lev deps install` can put this dependency in place. Optional and
297    /// never run automatically: installing runs commands or writes config on
298    /// the user's machine and always asks first. See [`DependencyInstall`].
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub install: Option<DependencyInstall>,
301}
302
303/// The default for [`Dependency::required`]: a declared dependency blocks the
304/// run unless the manifest says otherwise.
305fn default_dependency_required() -> bool {
306    true
307}
308
309/// What a [`Dependency`] requires, selected by the manifest's `kind` field.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(tag = "kind", rename_all = "snake_case")]
312pub enum DependencyKind {
313    /// An MCP server that must be configured in the user's config, plus any
314    /// environment variables or secrets it needs. The check confirms the named
315    /// server exists and every `env` var is set and non-empty.
316    McpServer {
317        /// The server name that must appear in the user's `[[mcp_servers]]`.
318        server: String,
319        /// Environment variables / secrets the server needs. Values are
320        /// prompted for at install, never stored in the blueprint.
321        #[serde(default, skip_serializing_if = "Vec::is_empty")]
322        env: Vec<String>,
323    },
324    /// An environment variable that must be set and non-empty.
325    Env {
326        /// The variable name.
327        var: String,
328    },
329    /// A program that must resolve on `PATH`.
330    Binary {
331        /// The program name, e.g. `blender`.
332        command: String,
333    },
334    /// A condition a Rhai script decides. The `check` script returns
335    /// `#{ ok: bool, remedy: string }`; the optional installer lives in
336    /// [`DependencyInstall::script`].
337    Script {
338        /// Path to the Rhai check script, relative to the blueprint directory.
339        check: String,
340    },
341}
342
343impl DependencyKind {
344    /// The manifest `kind` string for this variant (`"mcp_server"`, `"env"`,
345    /// `"binary"`, `"script"`), matching the serialized tag.
346    pub fn tag(&self) -> &'static str {
347        match self {
348            DependencyKind::McpServer { .. } => "mcp_server",
349            DependencyKind::Env { .. } => "env",
350            DependencyKind::Binary { .. } => "binary",
351            DependencyKind::Script { .. } => "script",
352        }
353    }
354}
355
356/// How a [`Dependency`] can be installed by `lev deps install`.
357///
358/// Every field is optional; a dependency may declare any combination. Nothing
359/// here runs without an explicit `lev deps install` and a confirmation, because
360/// each option changes the user's machine: running a command, executing a
361/// script, or writing an MCP server into their config.
362#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DependencyInstall {
364    /// A shell command that installs the dependency on any platform, e.g.
365    /// `"pip install trimesh"`. Run only after the user confirms.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub command: Option<String>,
368
369    /// Per-OS shell commands, keyed by `"macos"`, `"linux"` or `"windows"`,
370    /// preferred over [`command`](Self::command) on a matching host.
371    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
372    pub commands: BTreeMap<String, String>,
373
374    /// A Rhai install script (relative to the blueprint), run with the script
375    /// I/O surface and gated exactly like a script tool. For a `script`
376    /// dependency this is its installer.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub script: Option<String>,
379
380    /// For an `mcp_server` dependency: the non-user-specific server settings the
381    /// installer writes into the user's config. Secrets are never placed here -
382    /// they are named in the dependency's `env` and prompted for securely.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub server: Option<McpServerTemplate>,
385}
386
387/// The non-secret settings for an MCP server that a blueprint can ship so
388/// `lev deps install` can write it into the user's config. Mirrors the
389/// installable half of the CLI's MCP server config; the user-specific secrets
390/// (header and env values) are prompted for and stored separately.
391#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392pub struct McpServerTemplate {
393    /// `"stdio"` or `"http"`. Inferred from `command`/`url` when omitted.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub transport: Option<String>,
396    /// The program to launch for a stdio server.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub command: Option<String>,
399    /// The endpoint for an http server.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub url: Option<String>,
402    /// Arguments passed to `command`.
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub args: Vec<String>,
405    /// Non-secret headers, for an http server. A value may reference a secret
406    /// with `${VAR}`, where `VAR` is named in the dependency's `env`.
407    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
408    pub headers: BTreeMap<String, String>,
409    /// Environment for a stdio server's child process. A value may reference a
410    /// secret with `${VAR}` (expanded from the environment at connect time, so
411    /// the secret stays out of the config file), where `VAR` is named in the
412    /// dependency's `env`.
413    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
414    pub env: BTreeMap<String, String>,
415}
416
417impl Blueprint {
418    /// Create a new blueprint with the specified configuration.
419    pub fn new(
420        name: String,
421        description: String,
422        stages: Vec<Stage>,
423        context_layout: ContextLayout,
424    ) -> Self {
425        Self {
426            name,
427            description,
428            stages,
429            context_layout,
430            transforms: Vec::new(),
431            version: "0.1.0".to_string(),
432            compaction_config: None,
433            max_child_depth: None,
434            entry_stage: None,
435            metadata: HashMap::new(),
436            security: None,
437            batch_tool_hint: None,
438            shell_hint: None,
439            nudge: None,
440            repetition_detection: None,
441            file_tracking: None,
442            sandbox: None,
443            tool_rescan: ToolRescan::AtSpawn,
444            read_paths: None,
445            safe_commands: None,
446            output: None,
447            mime_types: toml::Table::new(),
448            dependencies: Vec::new(),
449        }
450    }
451
452    /// Whether any region is seeded from the caller's `task`.
453    ///
454    /// The blueprint's answer to "do you take a task?", which is a different
455    /// question from whether one was supplied. An agent driven by named regions
456    /// (`reviewer` takes `--diff` and `--criteria`) answers no, and handing it a
457    /// task would put that text nowhere at all - so both the CLI, before it asks
458    /// for one, and the daemon, before it spawns, ask this first.
459    pub fn accepts_task(&self) -> bool {
460        self.context_layout
461            .regions
462            .iter()
463            .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
464    }
465
466    /// Whether a run cannot start without a task: the region seeded from it is
467    /// `required`. An optional task region is what lets a blueprint driven by
468    /// its other inputs (`--diff`, an attachment) run with no task at all, and
469    /// still take one from a fan-out that spawns it as its own worker.
470    pub fn requires_task(&self) -> bool {
471        self.context_layout.regions.iter().any(|r| {
472            r.required
473                && matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task")
474        })
475    }
476
477    /// The caller input keys this blueprint does read, in declaration order.
478    ///
479    /// The mime type patterns `stage` takes as parts: its own
480    /// `[input] accepts` when it declares one, else the union of `accepts`
481    /// across the regions it sees. Text is always taken and never listed, so
482    /// an empty answer means "text only, unless a region takes anything".
483    /// A visible region with no `accepts` takes anything, and is reported as
484    /// `*/*`.
485    pub fn stage_inputs(&self, stage: &Stage) -> Vec<String> {
486        if !stage.input_accepts.is_empty() {
487            return stage.input_accepts.clone();
488        }
489        let layout = stage
490            .context_layout
491            .as_ref()
492            .unwrap_or(&self.context_layout);
493        let mut out: Vec<String> = Vec::new();
494        for region in &layout.regions {
495            if stage.context_hide.contains(&region.name) {
496                continue;
497            }
498            let patterns: Vec<String> = match region.accepts.is_empty() {
499                true => vec!["*/*".to_string()],
500                false => region.accepts.clone(),
501            };
502            for p in patterns {
503                if !p.starts_with("text/") && !out.contains(&p) {
504                    out.push(p);
505                }
506            }
507        }
508        out
509    }
510
511    /// Used to turn "that agent takes no task" into a message naming what it
512    /// takes instead, which is the difference between a dead end and a fix.
513    pub fn caller_inputs(&self) -> Vec<&str> {
514        self.context_layout
515            .regions
516            .iter()
517            .filter_map(|r| match &r.seed {
518                Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
519                _ => None,
520            })
521            .collect()
522    }
523
524    /// Why a task cannot be given to this blueprint, phrased for the user.
525    ///
526    /// One message rather than two, because the CLI refuses before it asks for a
527    /// task and the daemon refuses before it spawns, and a user who hit one and
528    /// then the other should not be told two different things.
529    pub fn task_refusal(&self) -> String {
530        let inputs = self.caller_inputs();
531        let takes = match inputs.is_empty() {
532            true => "it takes no caller input at all".to_string(),
533            false => format!("it takes: {}", inputs.join(", ")),
534        };
535        format!(
536            "agent '{}' was given a task but declares no region to put it in, so the task \
537             would be ignored - {takes}. Add a region seeded from the task, for example:\n\
538             [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
539             required = true, seed = \"task\" }}",
540            self.name,
541        )
542    }
543
544    /// Agent-level tool permissions, keyed by tool name.
545    ///
546    /// The manifest parser records a top-level `[tool_permissions]` block as
547    /// `tool_perm:<tool>` → policy-string entries in [`Self::metadata`]. This
548    /// projects them back into a tool-keyed map for the runtime's agent-level
549    /// permission layer. Non-`tool_perm:` keys and non-string values are ignored.
550    pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
551        self.metadata
552            .iter()
553            .filter_map(|(k, v)| {
554                Some((
555                    k.strip_prefix("tool_perm:")?.to_string(),
556                    v.as_str()?.to_string(),
557                ))
558            })
559            .collect()
560    }
561
562    /// Add context transforms to this blueprint.
563    pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
564        self.transforms = transforms;
565        self
566    }
567
568    /// Set the version of this blueprint.
569    pub fn with_version(mut self, version: String) -> Self {
570        self.version = version;
571        self
572    }
573
574    /// Validate that the blueprint is well-formed.
575    pub fn validate(&self) -> std::result::Result<(), ValidationError> {
576        // Validate context layout
577        self.context_layout.validate()?;
578
579        // Check that all stages have valid configurations
580        for stage in &self.stages {
581            stage.validate()?;
582        }
583
584        // Validate transforms reference real regions
585        for transform in &self.transforms {
586            transform.validate(&self.context_layout)?;
587        }
588
589        // Graph validation
590        self.validate_graph()?;
591
592        self.validate_region_references()?;
593
594        self.validate_dependencies()?;
595
596        Ok(())
597    }
598
599    /// Check every `[[dependencies]]` entry is well-formed. This validates the
600    /// declaration only - names are unique and non-empty, each kind's fields are
601    /// present, and an `install` block is shaped for its kind. Whether the
602    /// dependency is actually satisfied (the server exists, the var is set, the
603    /// binary is on `PATH`) is checked at spawn and by `lev deps check`, which
604    /// see the machine this crate does not touch.
605    fn validate_dependencies(&self) -> std::result::Result<(), ValidationError> {
606        let mut seen = std::collections::HashSet::new();
607        for dep in &self.dependencies {
608            let name = dep.name.trim();
609            if name.is_empty() {
610                return Err(ValidationError::Dependency {
611                    name: dep.name.clone(),
612                    message: "a dependency needs a non-empty name".to_string(),
613                });
614            }
615            if !seen.insert(name) {
616                return Err(ValidationError::Dependency {
617                    name: name.to_string(),
618                    message: "two dependencies share this name".to_string(),
619                });
620            }
621            let require = |field: &str, value: &str| -> std::result::Result<(), ValidationError> {
622                if value.trim().is_empty() {
623                    return Err(ValidationError::Dependency {
624                        name: name.to_string(),
625                        message: format!(
626                            "a '{}' dependency needs a non-empty '{field}'",
627                            dep.kind.tag()
628                        ),
629                    });
630                }
631                Ok(())
632            };
633            match &dep.kind {
634                DependencyKind::McpServer { server, .. } => require("server", server)?,
635                DependencyKind::Env { var } => require("var", var)?,
636                DependencyKind::Binary { command } => require("command", command)?,
637                DependencyKind::Script { check } => require("check", check)?,
638            }
639            if let Some(install) = &dep.install {
640                if install.server.is_some() && !matches!(dep.kind, DependencyKind::McpServer { .. })
641                {
642                    return Err(ValidationError::Dependency {
643                        name: name.to_string(),
644                        message: "install.server is only valid for a 'mcp_server' dependency"
645                            .to_string(),
646                    });
647                }
648                if let Some(transport) =
649                    install.server.as_ref().and_then(|s| s.transport.as_deref())
650                    && !matches!(transport, "stdio" | "http")
651                {
652                    return Err(ValidationError::Dependency {
653                        name: name.to_string(),
654                        message: format!(
655                            "install.server.transport must be \"stdio\" or \"http\", got \"{transport}\""
656                        ),
657                    });
658                }
659                for os in install.commands.keys() {
660                    if !matches!(os.as_str(), "macos" | "linux" | "windows") {
661                        return Err(ValidationError::Dependency {
662                            name: name.to_string(),
663                            message: format!(
664                                "install.commands key '{os}' must be \"macos\", \"linux\" or \"windows\""
665                            ),
666                        });
667                    }
668                }
669            }
670        }
671        Ok(())
672    }
673
674    /// Every region a stage can name, anywhere in this blueprint.
675    ///
676    /// The union of the global layout, every stage's own layout, and the three
677    /// the runtime adds if nobody declared them. It is a union rather than the
678    /// per-stage set on purpose: a stage that omits a region from its
679    /// `[context.regions]` hides it, it does not destroy it, so naming a region
680    /// another stage declared is legitimate. Only a name that exists nowhere is
681    /// a typo.
682    fn known_region_names(&self) -> std::collections::HashSet<&str> {
683        let mut names: std::collections::HashSet<&str> = self
684            .context_layout
685            .regions
686            .iter()
687            .map(|r| r.name.as_str())
688            .collect();
689        for stage in &self.stages {
690            if let Some(layout) = &stage.context_layout {
691                names.extend(layout.regions.iter().map(|r| r.name.as_str()));
692            }
693        }
694        // Added by `setup_context_window` when a blueprint does not declare
695        // them, so they are always addressable.
696        names.extend(ALWAYS_VISIBLE_REGIONS);
697        names
698    }
699
700    /// The regions `stage` can actually see while it runs.
701    ///
702    /// Its own `[context.regions]` when it declares one, the blueprint's
703    /// otherwise, plus the regions the runtime carries visible whatever a stage
704    /// says. Narrower than `known_region_names`, which asks only whether a name
705    /// exists somewhere - the difference being
706    /// that a region another stage declares exists, and is still not readable
707    /// from here.
708    ///
709    /// Public so the runtime can size each region's percentage budget against
710    /// the smallest window among the stages that actually see it - a region a
711    /// narrow-window stage never reads must not be shrunk to fit that stage.
712    pub fn regions_visible_to<'a>(
713        &'a self,
714        stage: &'a Stage,
715    ) -> std::collections::HashSet<&'a str> {
716        let layout = stage
717            .context_layout
718            .as_ref()
719            .unwrap_or(&self.context_layout);
720        let mut names: std::collections::HashSet<&str> =
721            layout.regions.iter().map(|r| r.name.as_str()).collect();
722        names.extend(ALWAYS_VISIBLE_REGIONS);
723        // `validate_region_references` has already refused a hide list that
724        // names an always-visible region, so nothing here can remove one.
725        for hidden in &stage.context_hide {
726            names.remove(hidden.as_str());
727        }
728        names
729    }
730
731    /// Refuse a region name that exists nowhere in the blueprint.
732    ///
733    /// Routing and gates are addressed by name, and a name that matches
734    /// nothing, accepted in silence, sends the routed tool result to the
735    /// default region and leaves the gate holding nothing back - both looking
736    /// exactly like a working config. A gate that silently never fires is the
737    /// expensive case: it reads as the model behaving well.
738    fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
739        let known = self.known_region_names();
740        let checklists: std::collections::HashSet<&str> = self
741            .context_layout
742            .regions
743            .iter()
744            .chain(
745                self.stages
746                    .iter()
747                    .filter_map(|s| s.context_layout.as_ref())
748                    .flat_map(|l| l.regions.iter()),
749            )
750            .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
751            .map(|r| r.name.as_str())
752            .collect();
753
754        for stage in &self.stages {
755            let bad = |message: String| ValidationError::Stage {
756                stage: stage.name.clone(),
757                message,
758            };
759
760            // A hidden region has to be one the stage would otherwise carry:
761            // a name that matches nothing is a typo, and a typo here is the
762            // silent kind (the large region stays in every prompt and the
763            // bill says so a month later). The always-visible four cannot be
764            // hidden at all - the model's own turns live there.
765            for hidden in &stage.context_hide {
766                if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
767                    return Err(bad(format!(
768                        "context.hide names '{hidden}', which every stage carries and cannot hide"
769                    )));
770                }
771                if !known.contains(hidden.as_str()) {
772                    return Err(bad(format!(
773                        "context.hide names region '{hidden}', which no layout in this \
774                         blueprint declares"
775                    )));
776                }
777            }
778
779            // `reset` empties a region on entry. `conversation` and the other
780            // always-visible regions can be reset (that is the point - a stage
781            // starting on a clean conversation), but a name no layout declares
782            // is the same silent typo `hide` guards against.
783            for name in &stage.context_reset {
784                if !known.contains(name.as_str()) {
785                    return Err(bad(format!(
786                        "context.reset names region '{name}', which no layout in this \
787                         blueprint declares"
788                    )));
789                }
790            }
791
792            if let Some(routing) = &stage.tool_result_routing {
793                // Routing is checked against what *this* stage can see, not
794                // against every name in the blueprint. A stage that omits a
795                // region from its own `[context.regions]` hides it, so a result
796                // routed there is written somewhere the stage cannot read - and
797                // the pointer left in `conversation` tells the model to go read
798                // it. There is no reading of a blueprint where that is
799                // intended.
800                let visible = self.regions_visible_to(stage);
801                let dead_drop = |key: &str, region: &str| ValidationError::Stage {
802                    stage: stage.name.clone(),
803                    message: format!(
804                        "tool_routing.{key} sends results to region '{region}', \
805                             which this stage's context does not include, so it \
806                             could not read them back. Add '{region}' to \
807                             [stages.{}.context.regions], or route somewhere the \
808                             stage can see.",
809                        stage.name
810                    ),
811                };
812                if !visible.contains(routing.default_region.as_str()) {
813                    return Err(dead_drop("default_region", &routing.default_region));
814                }
815                for (tool, region) in &routing.tool_overrides {
816                    if !visible.contains(region.as_str()) {
817                        return Err(dead_drop(&format!("overrides.{tool}"), region));
818                    }
819                }
820            }
821
822            // `output_routing` sends the model's produced parts to a region a
823            // *later* stage usually reads, so unlike `tool_routing` above it is
824            // checked against every region the blueprint declares, not only the
825            // ones this stage can see. A target no layout declares is still a
826            // dead drop - the part would land nowhere - so it is refused.
827            for (pattern, region) in &stage.output_routing {
828                if !known.contains(region.as_str()) {
829                    return Err(ValidationError::Stage {
830                        stage: stage.name.clone(),
831                        message: format!(
832                            "output_routing.\"{pattern}\" sends produced parts to region \
833                             '{region}', which no layout in this blueprint declares. Add it to a \
834                             [context.regions] table, or route to a region that exists."
835                        ),
836                    });
837                }
838            }
839
840            for edge in stage.transitions.iter().flat_map(|t| t.values()) {
841                let Some(gate) = &edge.gate else { continue };
842                for (key, region) in [
843                    ("region", gate.region.as_ref()),
844                    (
845                        "require_region_updated",
846                        gate.require_region_updated.as_ref(),
847                    ),
848                    ("require_no_open_items", gate.require_no_open_items.as_ref()),
849                    (
850                        "require_region_entries",
851                        gate.require_region_entries.as_ref().map(|c| &c.region),
852                    ),
853                ] {
854                    let Some(region) = region else { continue };
855                    if !known.contains(region.as_str()) {
856                        return Err(bad(format!(
857                            "transition to '{}': gate.{key} names region \
858                             '{region}', which no stage declares",
859                            edge.target
860                        )));
861                    }
862                }
863                // A checklist gate counts open items, which only a checklist
864                // region has. Pointed at any other kind it can only ever read
865                // zero, so it would pass on the first attempt every time.
866                if let Some(region) = &gate.require_no_open_items
867                    && !checklists.contains(region.as_str())
868                {
869                    return Err(bad(format!(
870                        "transition to '{}': gate.require_no_open_items names \
871                         region '{region}', which is not a checklist region \
872                         (set kind = \"checklist\" on it)",
873                        edge.target
874                    )));
875                }
876            }
877        }
878        Ok(())
879    }
880
881    /// Validate stage graph constraints.
882    fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
883        let stage_names: std::collections::HashSet<&str> =
884            self.stages.iter().map(|s| s.name.as_str()).collect();
885
886        // Entry stage must exist if set
887        if let Some(entry) = &self.entry_stage
888            && !stage_names.contains(entry.as_str())
889        {
890            return Err(ValidationError::Graph(format!(
891                "entry_stage '{}' does not match any defined stage",
892                entry
893            )));
894        }
895
896        // Fan-out stages reference a worker source + optional merge stage. These
897        // are checked even for otherwise-linear blueprints (before the early
898        // return below), since `worker_stage`/`merge_stage` name local stages.
899        // `worker_agent`/`worker_query` are environment-dependent (resolved
900        // against installed agents at run time), so they are not checked here.
901        for stage in &self.stages {
902            if let StageMode::FanOut { config } = &stage.mode {
903                let sources = [
904                    config.worker_agent.is_some(),
905                    config.worker_stage.is_some(),
906                    config.worker_query.is_some(),
907                ]
908                .iter()
909                .filter(|&&set| set)
910                .count();
911                if sources != 1 {
912                    return Err(ValidationError::Stage {
913                        stage: stage.name.clone(),
914                        message: "fan_out stage must set exactly one of worker_agent, \
915                                  worker_stage, or worker_query"
916                            .to_string(),
917                    });
918                }
919                if let Some(ws) = &config.worker_stage {
920                    match self.stages.iter().find(|s| &s.name == ws) {
921                        None => {
922                            return Err(ValidationError::Stage {
923                                stage: stage.name.clone(),
924                                message: format!("fan_out worker_stage '{}' does not exist", ws),
925                            });
926                        }
927                        Some(target) if !target.allow_as_worker => {
928                            return Err(ValidationError::Stage {
929                                stage: stage.name.clone(),
930                                message: format!(
931                                    "fan_out worker_stage '{}' must set allow_as_worker = true",
932                                    ws
933                                ),
934                            });
935                        }
936                        Some(_) => {}
937                    }
938                }
939                if let Some(ms) = &config.merge_stage
940                    && !stage_names.contains(ms.as_str())
941                {
942                    return Err(ValidationError::Stage {
943                        stage: stage.name.clone(),
944                        message: format!("fan_out merge_stage '{}' does not exist", ms),
945                    });
946                }
947            }
948        }
949
950        let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
951        if !has_any_transitions {
952            // Pure linear mode - no graph validation needed
953            return Ok(());
954        }
955
956        // All transition targets must exist
957        for stage in &self.stages {
958            if let Some(ref transitions) = stage.transitions {
959                for (target_name, edge) in transitions {
960                    if !stage_names.contains(target_name.as_str()) {
961                        return Err(ValidationError::Transition {
962                            from: stage.name.clone(),
963                            to: target_name.clone(),
964                            message: "target stage does not exist".to_string(),
965                        });
966                    }
967                    // A `stuck` edge with no threshold could never fire. Caught
968                    // here as well as in the manifest parser, so blueprints built
969                    // programmatically (API / `lev validate`) are held to it too.
970                    if edge.condition == TransitionCondition::Stuck
971                        && !edge.stuck.is_some_and(|c| c.is_armed())
972                    {
973                        return Err(ValidationError::Transition {
974                            from: stage.name.clone(),
975                            to: target_name.clone(),
976                            message: "condition = \"stuck\" requires at least one \
977                                      stuck_after_* threshold (the edge could never fire)"
978                                .to_string(),
979                        });
980                    }
981                }
982
983                // A `require_modifications` gate on a stage that advertises no
984                // file-modifying tool can never be satisfied - it would just
985                // burn the stage's re-run budget every time.
986                for (target_name, edge) in transitions {
987                    let Some(gate) = &edge.gate else { continue };
988                    if !gate.require_modifications {
989                        continue;
990                    }
991                    let can_modify = stage.grants_all_builtins()
992                        || stage.available_tools.iter().any(|t| {
993                            MODIFYING_TOOLS.contains(&t.as_str())
994                                || gate.tools.iter().any(|extra| extra == t)
995                        });
996                    if !can_modify {
997                        return Err(ValidationError::Transition {
998                            from: stage.name.clone(),
999                            to: target_name.clone(),
1000                            message: "gate requires modifications, but the stage has no \
1001                                      file-modifying tool in available_tools"
1002                                .to_string(),
1003                        });
1004                    }
1005                }
1006
1007                // Self-loop safety: stages that transition to themselves need max_revisits
1008                if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
1009                    return Err(ValidationError::Stage {
1010                        stage: stage.name.clone(),
1011                        message: "self-loop transition requires max_revisits".to_string(),
1012                    });
1013                }
1014            }
1015        }
1016
1017        // At least one terminal path must exist (a stage with no outgoing transitions,
1018        // or with only conditional transitions that may not fire)
1019        let entry = self.resolve_entry_stage_name();
1020        let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
1021        if !has_terminal {
1022            return Err(ValidationError::Graph(
1023                "no terminal path exists from entry stage - agent would never complete".to_string(),
1024            ));
1025        }
1026
1027        Ok(())
1028    }
1029
1030    /// Resolve the entry stage name.
1031    pub fn resolve_entry_stage_name(&self) -> String {
1032        self.entry_stage.clone().unwrap_or_else(|| {
1033            self.stages
1034                .first()
1035                .map(|s| s.name.clone())
1036                .unwrap_or_default()
1037        })
1038    }
1039
1040    /// Check if there is a terminal path reachable from `stage_name`.
1041    fn has_terminal_path(
1042        &self,
1043        stage_name: &str,
1044        visited: &mut std::collections::HashSet<String>,
1045    ) -> bool {
1046        if visited.contains(stage_name) {
1047            return false;
1048        }
1049        visited.insert(stage_name.to_string());
1050
1051        let stage = self.stages.iter().find(|s| s.name == stage_name);
1052        let stage = match stage {
1053            Some(s) => s,
1054            // Unreachable via this function's only call site (`validate_graph`,
1055            // below): it rejects any transition target that doesn't match a
1056            // real stage name *before* ever calling `has_terminal_path`, and
1057            // `has_terminal_path` is private, so no other caller can pass in
1058            // an unvalidated stage name.
1059            None => return false,
1060        };
1061
1062        // A fan-out stage with a merge stage hands off to it after workers
1063        // complete, so its terminal path runs through the merge stage.
1064        if let StageMode::FanOut {
1065            config:
1066                FanOutConfig {
1067                    merge_stage: Some(ms),
1068                    ..
1069                },
1070        } = &stage.mode
1071        {
1072            return self.has_terminal_path(ms, visited);
1073        }
1074
1075        match &stage.transitions {
1076            None => {
1077                // Linear mode: check if there's a next stage by index
1078                let idx = self
1079                    .stages
1080                    .iter()
1081                    .position(|s| s.name == stage_name)
1082                    .unwrap_or(0);
1083                if idx + 1 >= self.stages.len() {
1084                    return true; // terminal
1085                }
1086                self.has_terminal_path(&self.stages[idx + 1].name, visited)
1087            }
1088            Some(transitions) => {
1089                if transitions.is_empty() {
1090                    return true; // terminal stage
1091                }
1092                // Check if any transition leads to a terminal
1093                for target in transitions.keys() {
1094                    if self.has_terminal_path(target, visited) {
1095                        return true;
1096                    }
1097                }
1098                // No target reaches a terminal stage. Exhausting a stage's
1099                // edges is not a terminal path: running out of edges mid-graph
1100                // is a run *error* (StageResolution::DeadEnd in the runtime),
1101                // not a completion, so certifying it here would validate
1102                // blueprints that can never finish successfully.
1103                false
1104            }
1105        }
1106    }
1107
1108    /// Find a stage by name.
1109    pub fn find_stage(&self, name: &str) -> Option<&Stage> {
1110        self.stages.iter().find(|s| s.name == name)
1111    }
1112}
1113
1114// Sections of the former single-file blueprint, one per concept. Glob
1115// re-exported so every existing `blueprint::Stage` path keeps working and the
1116// split stays a pure move.
1117mod model;
1118pub use model::*;
1119mod stage;
1120pub use stage::*;
1121mod transition;
1122pub use transition::*;
1123mod tool_groups;
1124pub use tool_groups::*;
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use crate::layout::ContextLayout;
1130    use crate::layout::RegionDefinition;
1131    use crate::region::RegionKind;
1132
1133    /// Build a blueprint from a manifest, so these read as the TOML an author
1134    /// would actually write rather than as hand-assembled structs.
1135    fn bp_with_regions(regions_toml: &str) -> Blueprint {
1136        crate::manifest::parse_manifest(&format!(
1137            r#"
1138[agent]
1139name = "asked"
1140
1141[stages.main]
1142mode = "autonomous"
1143model = {{ provider = "anthropic", model = "m" }}
1144
1145[context.regions]
1146{regions_toml}
1147"#
1148        ))
1149        .expect("fixture parses")
1150    }
1151
1152    #[test]
1153    fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
1154        // Both spellings: the explicit seed and the region named `task`, which
1155        // gets the same seed implicitly.
1156        assert!(
1157            bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
1158                .accepts_task()
1159        );
1160        assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
1161    }
1162
1163    /// `requires_task` is the `required` flag on the task region, and nothing
1164    /// else: an optional task region takes one without insisting.
1165    #[test]
1166    fn a_blueprint_requires_a_task_only_when_its_task_region_is_required() {
1167        assert!(
1168            bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10, required = true }"#)
1169                .requires_task()
1170        );
1171        let optional = bp_with_regions(
1172            r#"task = { kind = "pinned", max_tokens = 10 }
1173diff = { kind = "pinned", max_tokens = 10, seed = "diff", required = true }"#,
1174        );
1175        assert!(optional.accepts_task());
1176        assert!(!optional.requires_task());
1177    }
1178
1179    #[test]
1180    fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
1181        let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
1182        assert!(!bp.accepts_task());
1183        assert_eq!(bp.caller_inputs(), ["diff"]);
1184        assert!(!bp.requires_task());
1185    }
1186
1187    #[test]
1188    fn the_refusal_names_what_the_agent_takes_instead() {
1189        let bp = bp_with_regions(
1190            r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
1191criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
1192        );
1193        let msg = bp.task_refusal();
1194        assert!(msg.contains("agent 'asked'"), "{msg}");
1195        assert!(msg.contains("it takes: diff, criteria"), "{msg}");
1196    }
1197
1198    #[test]
1199    fn the_refusal_says_so_when_the_agent_takes_nothing() {
1200        let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
1201        assert!(bp.caller_inputs().is_empty());
1202        // Bound rather than called inside the assert message: a message
1203        // expression only runs when the assert fails, so it would be an
1204        // uncovered region on every green run.
1205        let msg = bp.task_refusal();
1206        assert!(msg.contains("it takes no caller input at all"), "{msg}");
1207    }
1208
1209    #[test]
1210    fn resolve_nudge_defaults_when_nothing_is_configured() {
1211        // No config anywhere: on for a normal stage, off for a reviewed one,
1212        // with the built-in cap and text.
1213        let normal = resolve_nudge(None, None, None, false);
1214        assert!(normal.enabled);
1215        assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1216        assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1217        let reviewed = resolve_nudge(None, None, None, true);
1218        assert!(!reviewed.enabled);
1219        // The other fields don't depend on review status.
1220        assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1221        assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1222    }
1223
1224    #[test]
1225    fn resolve_nudge_cascades_each_field_independently() {
1226        let global = NudgeConfig {
1227            enabled: Some(true),
1228            max: Some(10),
1229            text: Some("global".to_string()),
1230        };
1231        let agent = NudgeConfig {
1232            max: Some(2),
1233            ..Default::default()
1234        };
1235        let stage = NudgeConfig {
1236            text: Some("stage".to_string()),
1237            ..Default::default()
1238        };
1239        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1240        // enabled from global, max from agent, text from stage.
1241        assert!(resolved.enabled);
1242        assert_eq!(resolved.max, 2);
1243        assert_eq!(resolved.text, "stage");
1244        // The stage level wins over both when it sets a field.
1245        let stage_all = NudgeConfig {
1246            enabled: Some(false),
1247            max: Some(0),
1248            text: Some("s".to_string()),
1249        };
1250        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1251        assert_eq!(
1252            resolved,
1253            ResolvedNudge {
1254                enabled: false,
1255                max: 0,
1256                text: "s".to_string()
1257            }
1258        );
1259    }
1260
1261    #[test]
1262    fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1263        // A reviewed stage is only *implicitly* exempt: any level that sets
1264        // `enabled` speaks for itself, in either direction.
1265        let on = NudgeConfig {
1266            enabled: Some(true),
1267            ..Default::default()
1268        };
1269        assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1270        assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1271        assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1272        let off = NudgeConfig {
1273            enabled: Some(false),
1274            ..Default::default()
1275        };
1276        assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1277    }
1278
1279    #[test]
1280    fn test_blueprint_creation() {
1281        let regions = vec![RegionDefinition::new(
1282            "test".to_string(),
1283            RegionKind::Pinned,
1284            5000,
1285        )];
1286        let layout = ContextLayout::new(regions, 10000);
1287
1288        let stages = vec![Stage::new(
1289            "analyze".to_string(),
1290            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1291        )];
1292
1293        let blueprint = Blueprint::new(
1294            "test-agent".to_string(),
1295            "A test agent".to_string(),
1296            stages,
1297            layout,
1298        );
1299
1300        assert_eq!(blueprint.name, "test-agent");
1301        assert_eq!(blueprint.stages.len(), 1);
1302    }
1303
1304    #[test]
1305    fn test_blueprint_with_transforms_version() {
1306        let stages = vec![Stage::new("plan".to_string(), make_model())];
1307        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1308            .with_transforms(vec![ContextTransform {
1309                from_blueprint: "a".to_string(),
1310                to_blueprint: "b".to_string(),
1311                mappings: vec![],
1312            }])
1313            .with_version("2.0.0".to_string());
1314
1315        assert_eq!(bp.transforms.len(), 1);
1316        assert_eq!(bp.version, "2.0.0");
1317    }
1318
1319    #[test]
1320    fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1321        let stages = vec![Stage::new("plan".to_string(), make_model())];
1322        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1323        // A well-formed tool_perm string entry - included.
1324        bp.metadata.insert(
1325            "tool_perm:bash".to_string(),
1326            serde_json::Value::String("deny".to_string()),
1327        );
1328        // A non-`tool_perm:` key - skipped (strip_prefix returns None).
1329        bp.metadata
1330            .insert("title".to_string(), serde_json::Value::String("x".into()));
1331        // A tool_perm key whose value isn't a string - skipped (as_str is None).
1332        bp.metadata
1333            .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1334
1335        let perms = bp.agent_tool_permissions();
1336        assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1337        assert!(!perms.contains_key("title"));
1338        assert!(!perms.contains_key("weird"));
1339        assert_eq!(perms.len(), 1);
1340    }
1341
1342    #[test]
1343    fn test_blueprint_validate_runs_transform_validation() {
1344        // A transform whose mapping targets a real region - validate() must
1345        // reach ContextTransform::validate() and succeed.
1346        let stages = vec![Stage::new("plan".to_string(), make_model())];
1347        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1348        bp.transforms.push(ContextTransform {
1349            from_blueprint: "a".to_string(),
1350            to_blueprint: "b".to_string(),
1351            mappings: vec![RegionMapping {
1352                from_region: "test".to_string(),
1353                to_region: "test".to_string(),
1354                transform: None,
1355            }],
1356        });
1357        assert!(bp.validate().is_ok());
1358    }
1359
1360    #[test]
1361    fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1362        let stages = vec![Stage::new("plan".to_string(), make_model())];
1363        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1364        bp.transforms.push(ContextTransform {
1365            from_blueprint: "a".to_string(),
1366            to_blueprint: "b".to_string(),
1367            mappings: vec![RegionMapping {
1368                from_region: "test".to_string(),
1369                to_region: "nonexistent".to_string(),
1370                transform: None,
1371            }],
1372        });
1373        let err = bp.validate().unwrap_err();
1374        assert_eq!(
1375            err,
1376            ValidationError::Region {
1377                region: "nonexistent".to_string(),
1378                message: "transform target region not found in layout".to_string(),
1379            }
1380        );
1381    }
1382
1383    #[test]
1384    fn test_mixed_linear_and_graph_mode_terminal_path() {
1385        // "plan" has explicit transitions (triggers graph-mode validation),
1386        // but "impl" and "review" have none - they must fall back to
1387        // linear (next-by-index) terminal-path resolution.
1388        let mut plan = Stage::new("plan".to_string(), make_model());
1389        let impl_stage = Stage::new("impl".to_string(), make_model());
1390        let review = Stage::new("review".to_string(), make_model());
1391
1392        let mut transitions = HashMap::new();
1393        transitions.insert(
1394            "impl".to_string(),
1395            TransitionEdge {
1396                target: "impl".to_string(),
1397                condition: TransitionCondition::Always,
1398                hint: None,
1399                transform: EdgeTransform::Direct,
1400                gate: None,
1401                stuck: None,
1402            },
1403        );
1404        plan.transitions = Some(transitions);
1405
1406        let bp = Blueprint::new(
1407            "t".into(),
1408            "".into(),
1409            vec![plan, impl_stage, review],
1410            make_layout(),
1411        );
1412        assert!(bp.validate().is_ok());
1413    }
1414
1415    #[test]
1416    fn test_stage_validation() {
1417        let stage = Stage::new(
1418            "test".to_string(),
1419            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1420        );
1421        assert!(stage.validate().is_ok());
1422
1423        let empty_stage = Stage::new(
1424            "".to_string(),
1425            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1426        );
1427        assert!(empty_stage.validate().is_err());
1428    }
1429
1430    #[test]
1431    fn test_stage_validate_with_valid_context_layout_is_ok() {
1432        let mut stage = Stage::new("test".to_string(), make_model());
1433        stage.context_layout = Some(make_layout());
1434        assert!(stage.validate().is_ok());
1435    }
1436
1437    #[test]
1438    fn test_stage_validate_with_invalid_context_layout_is_err() {
1439        // Duplicate region names make the layout itself invalid.
1440        let regions = vec![
1441            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1442            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1443        ];
1444        let mut stage = Stage::new("test".to_string(), make_model());
1445        stage.context_layout = Some(ContextLayout::new(regions, 200));
1446        assert!(stage.validate().is_err());
1447    }
1448
1449    #[test]
1450    fn test_stage_with_tools_context_layout_description() {
1451        let stage = Stage::new("test".to_string(), make_model())
1452            .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1453            .with_context_layout(make_layout())
1454            .with_description("does things".to_string());
1455
1456        assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1457        assert!(stage.context_layout.is_some());
1458        assert_eq!(stage.description.as_deref(), Some("does things"));
1459    }
1460
1461    #[test]
1462    fn test_stage_with_mode() {
1463        let stage = Stage::new("test".to_string(), make_model())
1464            .with_mode(StageMode::InteractivePoints { points: vec![] });
1465        assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1466    }
1467
1468    #[test]
1469    fn test_stage_allow_complete_defaults_false() {
1470        let stage = Stage::new("review".to_string(), make_model());
1471        assert!(!stage.allow_complete);
1472    }
1473
1474    #[test]
1475    fn test_stage_allow_complete_serde_default_when_missing() {
1476        // A serialized stage from before allow_complete existed must still
1477        // deserialize, defaulting to false.
1478        let json = r#"{
1479            "name": "review",
1480            "description": null,
1481            "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1482            "available_tools": [],
1483            "max_iterations": null,
1484            "context_layout": null,
1485            "config": {},
1486            "transitions": null,
1487            "max_revisits": null,
1488            "transition_prompt": null
1489        }"#;
1490        let stage: Stage = serde_json::from_str(json).unwrap();
1491        assert!(!stage.allow_complete);
1492        assert!(stage.accepts_messages);
1493    }
1494
1495    #[test]
1496    fn test_stage_allow_complete_roundtrip() {
1497        let mut stage = Stage::new("review".to_string(), make_model());
1498        stage.allow_complete = true;
1499        let json = serde_json::to_string(&stage).unwrap();
1500        let back: Stage = serde_json::from_str(&json).unwrap();
1501        assert!(back.allow_complete);
1502    }
1503
1504    #[test]
1505    fn test_interaction_point_directives_default_empty() {
1506        let point = InteractionPoint {
1507            name: "plan_approval".to_string(),
1508            prompt: "Approve?".to_string(),
1509            required: true,
1510            unattended: UnattendedPolicy::AutoApprove,
1511            style: InteractionStyle::MultipleChoice,
1512            options: vec!["Approve".to_string(), "Revise".to_string()],
1513            directives: HashMap::new(),
1514            abort_options: Vec::new(),
1515            edit_options: Vec::new(),
1516            document_region: None,
1517        };
1518        assert!(point.directives.is_empty());
1519        assert!(point.abort_options.is_empty());
1520        assert!(point.edit_options.is_empty());
1521    }
1522
1523    #[test]
1524    fn test_interaction_point_directives_roundtrip() {
1525        let mut directives = HashMap::new();
1526        directives.insert(
1527            "Revise".to_string(),
1528            "Ask what to change, then re-plan.".to_string(),
1529        );
1530        let point = InteractionPoint {
1531            name: "plan_approval".to_string(),
1532            prompt: "Approve?".to_string(),
1533            required: true,
1534            unattended: UnattendedPolicy::Ask,
1535            style: InteractionStyle::MultipleChoice,
1536            options: vec!["Approve".to_string(), "Revise".to_string()],
1537            directives,
1538            abort_options: vec!["Abort".to_string()],
1539            edit_options: vec!["Add detail".to_string()],
1540            document_region: Some("plan".to_string()),
1541        };
1542        let json = serde_json::to_string(&point).unwrap();
1543        let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1544        assert_eq!(
1545            back.directives.get("Revise").map(|s| s.as_str()),
1546            Some("Ask what to change, then re-plan.")
1547        );
1548        assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1549        assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1550        // A point that holds for a person under `--yolo` has to survive the
1551        // round trip: this is what a restored run re-arms from.
1552        assert_eq!(back.unattended, UnattendedPolicy::Ask);
1553    }
1554
1555    #[test]
1556    fn test_interaction_point_directives_serde_default_when_missing() {
1557        let json = r#"{
1558            "name": "plan_approval",
1559            "prompt": "Approve?",
1560            "required": true,
1561            "style": "multiple_choice",
1562            "options": ["Approve", "Revise"]
1563        }"#;
1564        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1565        assert!(point.directives.is_empty());
1566        assert!(point.abort_options.is_empty());
1567    }
1568
1569    #[test]
1570    fn test_interaction_point_followups_alias_still_deserializes() {
1571        // Backward compat: old serialized blueprints used "followups".
1572        let json = r#"{
1573            "name": "plan_approval",
1574            "prompt": "Approve?",
1575            "required": true,
1576            "style": "multiple_choice",
1577            "options": ["Approve", "Revise"],
1578            "followups": { "Revise": "What to change?" }
1579        }"#;
1580        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1581        assert_eq!(
1582            point.directives.get("Revise").map(|s| s.as_str()),
1583            Some("What to change?")
1584        );
1585    }
1586
1587    #[test]
1588    fn test_model_config_new_creates_single_entry() {
1589        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1590        assert_eq!(mc.models.len(), 1);
1591        assert_eq!(mc.models[0].provider, "anthropic");
1592        assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1593        assert!(mc.allow_user_default);
1594    }
1595
1596    #[test]
1597    fn test_model_config_with_multiple_models() {
1598        let mc = ModelConfig {
1599            models: vec![
1600                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1601                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1602                ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1603            ],
1604            allow_user_default: true,
1605            parameters: HashMap::new(),
1606            request_timeout_secs: None,
1607        };
1608        assert_eq!(mc.models.len(), 3);
1609        assert_eq!(mc.models[0].provider, "anthropic");
1610        assert_eq!(mc.models[1].provider, "openai");
1611        assert_eq!(mc.models[2].provider, "ollama");
1612    }
1613
1614    #[test]
1615    fn test_model_config_serde_roundtrip() {
1616        let mc = ModelConfig {
1617            models: vec![
1618                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1619                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1620            ],
1621            allow_user_default: false,
1622            parameters: HashMap::new(),
1623            request_timeout_secs: None,
1624        };
1625        let json = serde_json::to_string(&mc).unwrap();
1626        let back: ModelConfig = serde_json::from_str(&json).unwrap();
1627        assert_eq!(back.models.len(), 2);
1628        assert_eq!(back.models[0].provider, "anthropic");
1629        assert_eq!(back.models[1].provider, "openai");
1630        assert!(!back.allow_user_default);
1631    }
1632
1633    #[test]
1634    fn test_model_config_serde_defaults_when_fields_missing() {
1635        // Minimal JSON - models defaults to empty, allow_user_default defaults to true
1636        let json = r#"{"parameters": {}}"#;
1637        let mc: ModelConfig = serde_json::from_str(json).unwrap();
1638        assert!(mc.models.is_empty());
1639        assert!(mc.allow_user_default);
1640    }
1641
1642    #[test]
1643    fn test_model_config_convenience_accessors() {
1644        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1645        assert_eq!(mc.provider(), "anthropic");
1646        assert_eq!(mc.model(), "claude-sonnet-4-6");
1647    }
1648
1649    #[test]
1650    fn test_model_config_convenience_accessors_empty_models() {
1651        let mc = ModelConfig {
1652            models: vec![],
1653            allow_user_default: true,
1654            parameters: HashMap::new(),
1655            request_timeout_secs: None,
1656        };
1657        assert_eq!(mc.provider(), "anthropic");
1658        assert_eq!(mc.model(), "claude-sonnet-4-6");
1659    }
1660
1661    fn make_model() -> ModelConfig {
1662        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1663    }
1664
1665    fn make_layout() -> ContextLayout {
1666        let regions = vec![RegionDefinition::new(
1667            "test".to_string(),
1668            RegionKind::Pinned,
1669            5000,
1670        )];
1671        ContextLayout::new(regions, 10000)
1672    }
1673
1674    #[test]
1675    fn test_graph_validation_entry_stage_exists() {
1676        let stages = vec![Stage::new("plan".to_string(), make_model())];
1677        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1678        bp.entry_stage = Some("nonexistent".to_string());
1679        assert!(bp.validate().is_err());
1680    }
1681
1682    #[test]
1683    fn test_graph_validation_entry_stage_valid() {
1684        let stages = vec![Stage::new("plan".to_string(), make_model())];
1685        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1686        bp.entry_stage = Some("plan".to_string());
1687        assert!(bp.validate().is_ok());
1688    }
1689
1690    #[test]
1691    fn test_graph_validation_transition_target_missing() {
1692        let mut stage = Stage::new("plan".to_string(), make_model());
1693        let mut transitions = HashMap::new();
1694        transitions.insert(
1695            "nonexistent".to_string(),
1696            TransitionEdge {
1697                target: "nonexistent".to_string(),
1698                condition: TransitionCondition::Always,
1699                hint: None,
1700                transform: EdgeTransform::Direct,
1701                gate: None,
1702                stuck: None,
1703            },
1704        );
1705        stage.transitions = Some(transitions);
1706        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1707        assert!(bp.validate().is_err());
1708    }
1709
1710    /// A `require_modifications` gate on a stage that can't modify anything
1711    /// could never be satisfied - it would just burn the stage's re-run budget
1712    /// on every pass. Reject it at load time instead.
1713    #[test]
1714    fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1715        let gated = |tools: &[&str], extra: &[&str]| {
1716            let mut stage = Stage::new("impl".to_string(), make_model());
1717            stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1718            let mut transitions = HashMap::new();
1719            transitions.insert(
1720                "review".to_string(),
1721                TransitionEdge {
1722                    target: "review".to_string(),
1723                    condition: TransitionCondition::Always,
1724                    hint: None,
1725                    transform: EdgeTransform::Direct,
1726                    stuck: None,
1727                    gate: Some(TransitionGate {
1728                        require_modifications: true,
1729                        tools: extra.iter().map(|t| t.to_string()).collect(),
1730                        ..Default::default()
1731                    }),
1732                },
1733            );
1734            stage.transitions = Some(transitions);
1735            Blueprint::new(
1736                "t".into(),
1737                "".into(),
1738                vec![stage, Stage::new("review".to_string(), make_model())],
1739                make_layout(),
1740            )
1741        };
1742        let err = gated(&["read_file"], &[]).validate().unwrap_err();
1743        assert!(err.to_string().contains("no file-modifying tool"));
1744        // A built-in write tool satisfies it...
1745        assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1746        // ...so does a group that carries one, with neither name written...
1747        assert!(gated(&["@builtin"], &[]).validate().is_ok());
1748        assert!(gated(&["@all"], &[]).validate().is_ok());
1749        // ...but not a group that carries none.
1750        assert!(gated(&["@scripts"], &[]).validate().is_err());
1751        // ...as does one the gate itself declares (MCP / script toolchains).
1752        assert!(
1753            gated(&["read_file", "patch_file"], &["patch_file"])
1754                .validate()
1755                .is_ok()
1756        );
1757        // A gate that doesn't require modifications is never checked.
1758        let mut off = gated(&["read_file"], &[]);
1759        off.stages[0]
1760            .transitions
1761            .as_mut()
1762            .unwrap()
1763            .get_mut("review")
1764            .unwrap()
1765            .gate = Some(TransitionGate::default());
1766        assert!(off.validate().is_ok());
1767        // Neither is an edge with no gate at all.
1768        off.stages[0]
1769            .transitions
1770            .as_mut()
1771            .unwrap()
1772            .get_mut("review")
1773            .unwrap()
1774            .gate = None;
1775        assert!(off.validate().is_ok());
1776    }
1777
1778    #[test]
1779    fn test_graph_validation_self_loop_requires_max_revisits() {
1780        let mut stage = Stage::new("impl".to_string(), make_model());
1781        let mut transitions = HashMap::new();
1782        transitions.insert(
1783            "impl".to_string(),
1784            TransitionEdge {
1785                target: "impl".to_string(),
1786                condition: TransitionCondition::Always,
1787                hint: None,
1788                transform: EdgeTransform::Direct,
1789                gate: None,
1790                stuck: None,
1791            },
1792        );
1793        stage.transitions = Some(transitions);
1794        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1795        assert!(bp.validate().is_err());
1796    }
1797
1798    #[test]
1799    fn test_graph_validation_self_loop_with_max_revisits_ok() {
1800        let mut stage = Stage::new("impl".to_string(), make_model());
1801        stage.max_revisits = Some(3);
1802        let mut transitions = HashMap::new();
1803        transitions.insert(
1804            "impl".to_string(),
1805            TransitionEdge {
1806                target: "impl".to_string(),
1807                condition: TransitionCondition::Always,
1808                hint: None,
1809                transform: EdgeTransform::Direct,
1810                gate: None,
1811                stuck: None,
1812            },
1813        );
1814        stage.transitions = Some(transitions);
1815        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1816        // Must fail: a self-loop exhausting its max_revisits leaves zero
1817        // edges, which is a run error (StageResolution::DeadEnd) and not a
1818        // terminal path, so a blueprint whose only ending is exhaustion can
1819        // never finish successfully.
1820        let err = bp
1821            .validate()
1822            .expect_err("an exhaustion-only graph is invalid");
1823        assert!(err.to_string().contains("no terminal path"), "{err}");
1824    }
1825
1826    #[test]
1827    fn test_graph_validation_terminal_path_exists() {
1828        let mut plan = Stage::new("plan".to_string(), make_model());
1829        let mut review = Stage::new("review".to_string(), make_model());
1830        review.transitions = Some(HashMap::new()); // terminal: no outgoing
1831
1832        let mut transitions = HashMap::new();
1833        transitions.insert(
1834            "review".to_string(),
1835            TransitionEdge {
1836                target: "review".to_string(),
1837                condition: TransitionCondition::Always,
1838                hint: None,
1839                transform: EdgeTransform::Direct,
1840                gate: None,
1841                stuck: None,
1842            },
1843        );
1844        plan.transitions = Some(transitions);
1845
1846        let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1847        assert!(bp.validate().is_ok());
1848    }
1849
1850    #[test]
1851    fn test_graph_no_terminal_path() {
1852        // Two stages that only transition to each other with no terminal
1853        let mut a = Stage::new("a".to_string(), make_model());
1854        let mut b = Stage::new("b".to_string(), make_model());
1855
1856        let mut a_transitions = HashMap::new();
1857        a_transitions.insert(
1858            "b".to_string(),
1859            TransitionEdge {
1860                target: "b".to_string(),
1861                condition: TransitionCondition::Always,
1862                hint: None,
1863                transform: EdgeTransform::Direct,
1864                gate: None,
1865                stuck: None,
1866            },
1867        );
1868        a.transitions = Some(a_transitions);
1869
1870        let mut b_transitions = HashMap::new();
1871        b_transitions.insert(
1872            "a".to_string(),
1873            TransitionEdge {
1874                target: "a".to_string(),
1875                condition: TransitionCondition::Always,
1876                hint: None,
1877                transform: EdgeTransform::Direct,
1878                gate: None,
1879                stuck: None,
1880            },
1881        );
1882        b.transitions = Some(b_transitions);
1883
1884        let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1885        assert!(bp.validate().is_err());
1886    }
1887
1888    #[test]
1889    fn test_linear_stages_still_validate() {
1890        // No transitions set at all - pure linear mode
1891        let stages = vec![
1892            Stage::new("plan".to_string(), make_model()),
1893            Stage::new("impl".to_string(), make_model()),
1894            Stage::new("review".to_string(), make_model()),
1895        ];
1896        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1897        assert!(bp.validate().is_ok());
1898    }
1899
1900    #[test]
1901    fn test_resolve_entry_stage_name() {
1902        let stages = vec![
1903            Stage::new("plan".to_string(), make_model()),
1904            Stage::new("impl".to_string(), make_model()),
1905        ];
1906        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1907        assert_eq!(bp.resolve_entry_stage_name(), "plan");
1908
1909        bp.entry_stage = Some("impl".to_string());
1910        assert_eq!(bp.resolve_entry_stage_name(), "impl");
1911    }
1912
1913    #[test]
1914    fn test_find_stage() {
1915        let stages = vec![
1916            Stage::new("plan".to_string(), make_model()),
1917            Stage::new("impl".to_string(), make_model()),
1918        ];
1919        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1920        assert!(bp.find_stage("plan").is_some());
1921        assert!(bp.find_stage("impl").is_some());
1922        assert!(bp.find_stage("nonexistent").is_none());
1923    }
1924
1925    #[test]
1926    fn test_transition_condition_default() {
1927        let cond = TransitionCondition::default();
1928        assert_eq!(cond, TransitionCondition::Always);
1929    }
1930
1931    #[test]
1932    fn test_edge_transform_default() {
1933        let t = EdgeTransform::default();
1934        assert_eq!(t, EdgeTransform::Direct);
1935    }
1936
1937    #[test]
1938    fn test_stage_mode_equality() {
1939        assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1940        assert_eq!(StageMode::Interactive, StageMode::Interactive);
1941        assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1942    }
1943
1944    #[test]
1945    fn test_interaction_style_equality() {
1946        assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1947        assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1948    }
1949
1950    // ─── stuck detection ────────────────────────────────────────────────────
1951
1952    #[test]
1953    fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1954        assert!(!StuckConfig::default().is_armed());
1955        for cfg in [
1956            StuckConfig {
1957                after_iterations: Some(1),
1958                ..Default::default()
1959            },
1960            StuckConfig {
1961                after_minutes: Some(1),
1962                ..Default::default()
1963            },
1964            StuckConfig {
1965                after_same_file_edits: Some(1),
1966                ..Default::default()
1967            },
1968            StuckConfig {
1969                after_tool_calls: Some(1),
1970                ..Default::default()
1971            },
1972        ] {
1973            assert!(cfg.is_armed(), "{cfg:?} should be armed");
1974        }
1975    }
1976
1977    #[test]
1978    fn transition_condition_stuck_round_trips_as_snake_case() {
1979        let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1980        assert_eq!(json, "\"stuck\"");
1981        let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1982        assert_eq!(back, TransitionCondition::Stuck);
1983        assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1984    }
1985
1986    #[test]
1987    fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1988        let plain = TransitionEdge {
1989            target: "b".to_string(),
1990            condition: TransitionCondition::Always,
1991            hint: None,
1992            transform: EdgeTransform::Direct,
1993            gate: None,
1994            stuck: None,
1995        };
1996        let json = serde_json::to_string(&plain).unwrap();
1997        assert!(
1998            !json.contains("stuck"),
1999            "absent config must be skipped: {json}"
2000        );
2001
2002        let armed = TransitionEdge {
2003            condition: TransitionCondition::Stuck,
2004            stuck: Some(StuckConfig {
2005                after_iterations: Some(20),
2006                after_minutes: Some(10),
2007                after_same_file_edits: Some(3),
2008                after_tool_calls: Some(60),
2009            }),
2010            ..plain
2011        };
2012        let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
2013            .expect("armed edge round-trips");
2014        assert_eq!(back.condition, TransitionCondition::Stuck);
2015        assert_eq!(back.stuck, armed.stuck);
2016    }
2017
2018    /// A blueprint built programmatically (API / `lev validate`) bypasses the
2019    /// manifest parser, so `validate` has to catch the dead-edge shape too.
2020    #[test]
2021    fn validate_rejects_a_stuck_edge_with_no_threshold() {
2022        let build = |stuck| {
2023            let mut a = Stage::new("a".to_string(), make_model());
2024            let b = Stage::new("b".to_string(), make_model());
2025            let mut transitions = std::collections::HashMap::new();
2026            transitions.insert(
2027                "b".to_string(),
2028                TransitionEdge {
2029                    target: "b".to_string(),
2030                    condition: TransitionCondition::Stuck,
2031                    hint: None,
2032                    transform: EdgeTransform::Direct,
2033                    gate: None,
2034                    stuck,
2035                },
2036            );
2037            a.transitions = Some(transitions);
2038            Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
2039        };
2040
2041        for dead in [None, Some(StuckConfig::default())] {
2042            let err = build(dead)
2043                .validate()
2044                .expect_err("dead stuck edge rejected");
2045            assert!(
2046                format!("{err:?}").contains("stuck_after_"),
2047                "unexpected error: {err:?}"
2048            );
2049        }
2050
2051        // The same graph with a real threshold is fine.
2052        assert!(
2053            build(Some(StuckConfig {
2054                after_iterations: Some(5),
2055                ..Default::default()
2056            }))
2057            .validate()
2058            .is_ok()
2059        );
2060    }
2061
2062    /// `required_tools` keeps a blocking human tool through an unattended run.
2063    /// Naming one the stage can't call keeps nothing, so it is rejected rather
2064    /// than quietly ignored - the author meant something by writing it.
2065    #[test]
2066    fn validate_rejects_a_required_tool_the_stage_cannot_call() {
2067        let mut stage = Stage::new("plan".to_string(), make_model());
2068        stage.available_tools = vec!["read_file".to_string()];
2069        stage.required_tools = vec!["ask_user_text".to_string()];
2070        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2071
2072        let err = bp.validate().expect_err("a tool it cannot call");
2073        let text = format!("{err:?}");
2074        assert!(text.contains("ask_user_text"), "names the tool: {text}");
2075        assert!(text.contains("available_tools"), "says why: {text}");
2076    }
2077
2078    #[test]
2079    fn validate_accepts_a_required_tool_the_stage_offers() {
2080        let mut stage = Stage::new("plan".to_string(), make_model());
2081        stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2082        stage.required_tools = vec!["ask_user_text".to_string()];
2083        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2084
2085        bp.validate().expect("the tool is on offer");
2086    }
2087
2088    /// With a group in the list the membership question belongs to the
2089    /// install, so validation takes the author's word and the lint checks.
2090    #[test]
2091    fn validate_accepts_a_required_tool_a_group_could_cover() {
2092        let mut stage = Stage::new("plan".to_string(), make_model());
2093        stage.available_tools = vec!["@builtin".to_string()];
2094        stage.required_tools = vec!["ask_user_text".to_string()];
2095        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2096
2097        bp.validate().expect("the group may cover it");
2098    }
2099
2100    #[test]
2101    fn validate_rejects_a_group_shaped_entry_that_names_no_group() {
2102        let mut stage = Stage::new("plan".to_string(), make_model());
2103        stage.available_tools = vec!["read_file".to_string(), "@builtins".to_string()];
2104        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2105
2106        let err = bp.validate().expect_err("not a group");
2107        let text = format!("{err:?}");
2108        assert!(text.contains("@builtins"), "names the entry: {text}");
2109        assert!(text.contains("@builtin,"), "lists the groups: {text}");
2110    }
2111
2112    #[test]
2113    fn stage_reports_its_groups_and_named_tools_separately() {
2114        let mut stage = Stage::new("plan".to_string(), make_model());
2115        stage.available_tools = vec![
2116            "read_file".to_string(),
2117            "@scripts".to_string(),
2118            "github__create_issue".to_string(),
2119        ];
2120        assert_eq!(stage.tool_groups(), vec![ToolGroup::Scripts]);
2121        assert!(stage.grants_group(ToolGroup::Scripts));
2122        assert!(!stage.grants_group(ToolGroup::Mcp));
2123        assert!(!stage.grants_all_builtins());
2124        let named: Vec<&String> = stage.named_tools().collect();
2125        assert_eq!(named, vec!["read_file", "github__create_issue"]);
2126
2127        stage.available_tools = vec!["@all".to_string()];
2128        assert!(stage.grants_all_builtins());
2129        assert!(stage.grants_group(ToolGroup::Mcp));
2130        assert_eq!(stage.named_tools().count(), 0);
2131    }
2132
2133    /// A stage required to produce an output, without the tool that produces
2134    /// one, would spend its whole re-entry budget being nudged toward a tool it
2135    /// was never offered and then give up. Caught at load instead.
2136    #[test]
2137    fn validate_rejects_require_output_without_the_submit_tool() {
2138        let mut stage = Stage::new("summary".to_string(), make_model());
2139        stage.available_tools = vec!["read_file".to_string()];
2140        stage.require_output = true;
2141        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2142
2143        let err = bp.validate().expect_err("no way to submit");
2144        let text = format!("{err:?}");
2145        assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
2146        assert!(text.contains("require_output"), "says why: {text}");
2147    }
2148
2149    #[test]
2150    fn validate_accepts_require_output_when_the_stage_can_submit() {
2151        let mut stage = Stage::new("summary".to_string(), make_model());
2152        stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
2153        stage.require_output = true;
2154        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2155
2156        bp.validate().expect("the stage can submit");
2157    }
2158
2159    /// Declaring a shape is not the same as demanding one, so a stage carrying
2160    /// only an `output` block needs no tool grant.
2161    #[test]
2162    fn validate_accepts_a_declared_shape_without_require_output() {
2163        let mut stage = Stage::new("summary".to_string(), make_model());
2164        stage.available_tools = vec!["read_file".to_string()];
2165        stage.output = Some(crate::output::OutputSpec {
2166            format: Some("a2ui".to_string()),
2167            ..Default::default()
2168        });
2169        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2170
2171        bp.validate().expect("declaring a shape demands nothing");
2172    }
2173
2174    #[test]
2175    fn output_mode_compares_equal_only_to_itself() {
2176        assert_eq!(StageMode::Output, StageMode::Output);
2177        assert_ne!(StageMode::Output, StageMode::Autonomous);
2178        assert_ne!(StageMode::Autonomous, StageMode::Output);
2179    }
2180
2181    #[test]
2182    fn test_transition_condition_equality() {
2183        assert_eq!(
2184            TransitionCondition::LlmChoice,
2185            TransitionCondition::LlmChoice
2186        );
2187        assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2188    }
2189
2190    #[test]
2191    fn test_edge_transform_compact_and_custom_equality() {
2192        let a = EdgeTransform::Compact {
2193            prompt: Some("p".to_string()),
2194        };
2195        let b = EdgeTransform::Compact {
2196            prompt: Some("p".to_string()),
2197        };
2198        assert_eq!(a, b);
2199
2200        let c1 = EdgeTransform::Custom {
2201            carry: vec!["a".to_string()],
2202            compact: vec!["b".to_string()],
2203            clear: vec!["c".to_string()],
2204            compact_prompt: Some("p".to_string()),
2205        };
2206        let c2 = c1.clone();
2207        assert_eq!(c1, c2);
2208
2209        assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2210    }
2211
2212    #[test]
2213    fn test_stage_accepts_messages_default_true() {
2214        let stage = Stage::new(
2215            "test".to_string(),
2216            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2217        );
2218        assert!(stage.accepts_messages);
2219    }
2220
2221    #[test]
2222    fn test_stage_accepts_messages_serde_roundtrip() {
2223        // Serialize a stage with accepts_messages = false, then deserialize
2224        let mut stage = Stage::new(
2225            "report".to_string(),
2226            ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2227        );
2228        stage.accepts_messages = false;
2229
2230        let json = serde_json::to_string(&stage).expect("should serialize");
2231        let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2232        assert!(!deserialized.accepts_messages);
2233    }
2234
2235    #[test]
2236    fn test_stage_accepts_messages_json_default() {
2237        // When accepts_messages is missing from JSON, it should default to true
2238        let json = r#"{
2239            "name": "analyze",
2240            "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2241            "available_tools": [],
2242            "mode": "Autonomous",
2243            "config": {},
2244            "tool_permissions": {},
2245            "requires_children": false
2246        }"#;
2247        let stage: Stage = serde_json::from_str(json).expect("should parse");
2248        assert!(stage.accepts_messages);
2249    }
2250
2251    #[test]
2252    fn test_has_terminal_path_unknown_stage_returns_false() {
2253        // `has_terminal_path` is private; this test is in the same module.
2254        // Calling it with a stage name that doesn't exist in the Blueprint
2255        // exercises the `None => return false` arm (blueprint.rs line 203).
2256        let stages = vec![Stage::new("start".to_string(), make_model())];
2257        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2258        let mut visited = std::collections::HashSet::new();
2259        assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2260    }
2261
2262    #[test]
2263    fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2264        let regions = vec![
2265            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2266            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2267        ];
2268        let layout = ContextLayout::new(regions, 200);
2269        let stages = vec![Stage::new("start".to_string(), make_model())];
2270        let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2271        assert_eq!(
2272            bp.validate().unwrap_err(),
2273            ValidationError::Region {
2274                region: "dup".to_string(),
2275                message: "duplicate region name".to_string(),
2276            }
2277        );
2278    }
2279
2280    #[test]
2281    fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2282        let stages = vec![Stage::new("".to_string(), make_model())];
2283        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2284        assert_eq!(
2285            bp.validate().unwrap_err(),
2286            ValidationError::Stage {
2287                stage: "(empty)".to_string(),
2288                message: "stage name cannot be empty".to_string(),
2289            }
2290        );
2291    }
2292
2293    #[test]
2294    fn test_file_tracking_config_defaults() {
2295        let json = r#"{"region": "files"}"#;
2296        let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2297        assert_eq!(config.region, "files");
2298        assert!(config.track_reads);
2299        assert!(config.track_writes);
2300        assert!(config.max_file_tokens.is_none());
2301    }
2302
2303    #[test]
2304    fn test_file_tracking_config_serde_roundtrip() {
2305        let config = FileTrackingConfig {
2306            region: "files".to_string(),
2307            track_reads: true,
2308            track_writes: false,
2309            max_file_tokens: Some(5000),
2310        };
2311        let json = serde_json::to_string(&config).unwrap();
2312        let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2313        assert_eq!(back.region, "files");
2314        assert!(back.track_reads);
2315        assert!(!back.track_writes);
2316        assert_eq!(back.max_file_tokens, Some(5000));
2317    }
2318
2319    #[test]
2320    fn test_blueprint_file_tracking_default_none() {
2321        let stages = vec![Stage::new("plan".to_string(), make_model())];
2322        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2323        assert!(bp.file_tracking.is_none());
2324    }
2325
2326    #[test]
2327    fn test_blueprint_file_tracking_serde_roundtrip() {
2328        let stages = vec![Stage::new("plan".to_string(), make_model())];
2329        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2330        bp.file_tracking = Some(FileTrackingConfig {
2331            region: "files".to_string(),
2332            track_reads: true,
2333            track_writes: true,
2334            max_file_tokens: Some(3000),
2335        });
2336        let json = serde_json::to_string(&bp).unwrap();
2337        let back: Blueprint = serde_json::from_str(&json).unwrap();
2338        let ft = back.file_tracking.unwrap();
2339        assert_eq!(ft.region, "files");
2340        assert_eq!(ft.max_file_tokens, Some(3000));
2341    }
2342
2343    #[test]
2344    fn test_tool_result_routing_default() {
2345        let routing = ToolResultRouting::default();
2346        assert_eq!(routing.default_region, "tool_results");
2347        assert!(routing.keep_results);
2348        assert!(routing.tool_overrides.is_empty());
2349        assert!(routing.max_result_tokens.is_none());
2350    }
2351
2352    #[test]
2353    fn test_stage_new_has_no_tool_result_routing() {
2354        let stage = Stage::new("plan".to_string(), make_model());
2355        assert!(stage.tool_result_routing.is_none());
2356    }
2357
2358    #[test]
2359    fn test_tool_result_routing_serde_roundtrip() {
2360        let mut routing = ToolResultRouting {
2361            default_region: "custom_region".to_string(),
2362            keep_results: false,
2363            max_result_tokens: Some(4096),
2364            ..Default::default()
2365        };
2366        routing
2367            .tool_overrides
2368            .insert("read_file".to_string(), "file_reads".to_string());
2369
2370        let json = serde_json::to_string(&routing).unwrap();
2371        let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2372
2373        assert_eq!(back.default_region, "custom_region");
2374        assert!(!back.keep_results);
2375        assert_eq!(back.max_result_tokens, Some(4096));
2376        assert_eq!(
2377            back.tool_overrides.get("read_file").map(String::as_str),
2378            Some("file_reads")
2379        );
2380    }
2381
2382    #[test]
2383    fn test_stage_with_tool_result_routing_serde_roundtrip() {
2384        let stages = vec![{
2385            let mut s = Stage::new("plan".to_string(), make_model());
2386            s.tool_result_routing = Some(ToolResultRouting {
2387                default_region: "results".to_string(),
2388                tool_overrides: HashMap::new(),
2389                keep_results: true,
2390                max_result_tokens: Some(2048),
2391                tool_max_result_tokens: HashMap::new(),
2392            });
2393            s
2394        }];
2395        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2396        let json = serde_json::to_string(&bp).unwrap();
2397        let back: Blueprint = serde_json::from_str(&json).unwrap();
2398
2399        let routing = back.stages[0]
2400            .tool_result_routing
2401            .as_ref()
2402            .expect("tool_result_routing should be Some");
2403        assert_eq!(routing.default_region, "results");
2404        assert!(routing.keep_results);
2405        assert_eq!(routing.max_result_tokens, Some(2048));
2406        assert!(routing.tool_overrides.is_empty());
2407    }
2408
2409    // ─── fan_out (StageMode::FanOut) ─────────────────────────────────────────
2410
2411    fn fanout_config() -> FanOutConfig {
2412        FanOutConfig {
2413            worker_agent: None,
2414            worker_stage: Some("fix_worker".to_string()),
2415            worker_query: None,
2416            merge_stage: Some("merge".to_string()),
2417            max_workers: 3,
2418            on_worker_failure: WorkerFailurePolicy::Continue,
2419            split_prompt: "split".to_string(),
2420            results_region: None,
2421            max_items: None,
2422            max_attempts: None,
2423        }
2424    }
2425
2426    /// Blueprint: fan_out stage (worker_stage=fix_worker) → merge → terminal.
2427    /// The merge stage carries an (empty) transitions table so the blueprint is
2428    /// in graph mode - this makes `validate_graph` run `has_terminal_path`,
2429    /// which walks the fan-out stage's merge hand-off.
2430    fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2431        let mut fan = Stage::new("parallel".to_string(), make_model());
2432        fan.mode = StageMode::FanOut { config };
2433        let mut worker = Stage::new("fix_worker".to_string(), make_model());
2434        worker.allow_as_worker = worker_allowed;
2435        let mut merge = Stage::new("merge".to_string(), make_model());
2436        merge.transitions = Some(HashMap::new()); // terminal, graph mode
2437        Blueprint::new(
2438            "t".into(),
2439            "d".into(),
2440            vec![fan, worker, merge],
2441            make_layout(),
2442        )
2443    }
2444
2445    #[test]
2446    fn fanout_stagemode_partial_eq_and_default_policy() {
2447        let a = StageMode::FanOut {
2448            config: fanout_config(),
2449        };
2450        let b = StageMode::FanOut {
2451            config: fanout_config(),
2452        };
2453        assert_eq!(a, b);
2454        let mut other = fanout_config();
2455        other.max_workers = 99;
2456        assert_ne!(a, StageMode::FanOut { config: other });
2457        assert_ne!(a, StageMode::Autonomous);
2458        assert_eq!(
2459            WorkerFailurePolicy::default(),
2460            WorkerFailurePolicy::Continue
2461        );
2462    }
2463
2464    #[test]
2465    fn fanout_config_serde_roundtrip_and_max_workers_default() {
2466        let toml = r#"
2467worker_agent = "fixer"
2468split_prompt = "go"
2469on_worker_failure = "fail_all"
2470"#;
2471        let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2472        assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2473        assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2474        assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2475        assert_eq!(
2476            FanOutConfig {
2477                max_workers: 0,
2478                ..fanout_config()
2479            }
2480            .worker_cap(),
2481            None
2482        );
2483        assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2484        // JSON round-trip preserves everything.
2485        let json = serde_json::to_string(&fanout_config()).unwrap();
2486        let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2487        assert_eq!(back, fanout_config());
2488    }
2489
2490    #[test]
2491    fn fanout_validate_ok_with_allowed_worker_stage() {
2492        assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2493    }
2494
2495    #[test]
2496    fn fanout_validate_rejects_worker_stage_not_opted_in() {
2497        let err = fanout_blueprint(false, fanout_config())
2498            .validate()
2499            .unwrap_err();
2500        assert!(err.to_string().contains("allow_as_worker"));
2501    }
2502
2503    #[test]
2504    fn fanout_validate_rejects_missing_worker_stage() {
2505        let mut cfg = fanout_config();
2506        cfg.worker_stage = Some("nope".to_string());
2507        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2508        assert!(err.to_string().contains("does not exist"));
2509    }
2510
2511    #[test]
2512    fn fanout_validate_rejects_missing_merge_stage() {
2513        let mut cfg = fanout_config();
2514        cfg.merge_stage = Some("nomerge".to_string());
2515        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2516        assert!(err.to_string().contains("merge_stage"));
2517    }
2518
2519    #[test]
2520    fn fanout_validate_rejects_wrong_worker_source_count() {
2521        // zero sources
2522        let mut cfg = fanout_config();
2523        cfg.worker_stage = None;
2524        assert!(fanout_blueprint(true, cfg).validate().is_err());
2525        // two sources
2526        let mut cfg2 = fanout_config();
2527        cfg2.worker_agent = Some("x".to_string()); // plus worker_stage
2528        assert!(fanout_blueprint(true, cfg2).validate().is_err());
2529    }
2530
2531    #[test]
2532    fn fanout_terminal_path_runs_through_merge_stage() {
2533        // worker_agent form (no local worker_stage), merge → terminal.
2534        let mut cfg = fanout_config();
2535        cfg.worker_stage = None;
2536        cfg.worker_agent = Some("external".to_string());
2537        assert!(fanout_blueprint(false, cfg).validate().is_ok());
2538    }
2539
2540    #[test]
2541    fn fanout_validate_ok_without_merge_stage() {
2542        // No merge stage: valid, and the fan-out stage falls through to the
2543        // linear next stage for its terminal path.
2544        let mut cfg = fanout_config();
2545        cfg.merge_stage = None;
2546        assert!(fanout_blueprint(true, cfg).validate().is_ok());
2547    }
2548}