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::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/// An agent blueprint - the complete definition of an agent type.
29///
30/// Includes stages, model selection, tools, AND context layout. A blueprint
31/// defines everything needed to instantiate and run an agent with specific
32/// capabilities and memory structure.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Blueprint {
35    /// Unique name for this agent type
36    pub name: String,
37
38    /// Human-readable description
39    pub description: String,
40
41    /// Execution stages (e.g., analyze → implement → review)
42    pub stages: Vec<Stage>,
43
44    /// Context window layout defining memory regions
45    pub context_layout: ContextLayout,
46
47    /// Context transforms for inter-agent communication
48    pub transforms: Vec<ContextTransform>,
49
50    /// Version of this blueprint
51    pub version: String,
52
53    /// Configuration for LLM-based compaction
54    pub compaction_config: Option<CompactionConfig>,
55
56    /// Maximum depth of the sub-agent tree (default: 3)
57    pub max_child_depth: Option<usize>,
58
59    /// Which stage to start from (default: first defined)
60    pub entry_stage: Option<String>,
61
62    /// Additional metadata
63    pub metadata: HashMap<String, serde_json::Value>,
64
65    /// Security configuration for taint tracking.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub security: Option<crate::taint::SecurityConfig>,
68
69    /// Agent-level override for the batch-tool-calls system-prompt hint. `None`
70    /// inherits the global config toggle; a per-stage `batch_tool_hint` overrides
71    /// this. See [`crate::taint::resolve_batch_tool_hint`] for the cascade.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub batch_tool_hint: Option<bool>,
74
75    /// Agent-level override for the platform shell hint. `None` inherits the
76    /// global config toggle; a per-stage `shell_hint` overrides this. See
77    /// [`crate::taint::resolve_shell_hint`] for the cascade.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub shell_hint: Option<bool>,
80
81    /// Agent-level default for the empty-response nudge. `None` inherits the
82    /// global config's `[nudge]` section; a per-stage `[stages.<name>.nudge]`
83    /// overrides this. See [`resolve_nudge`] for the cascade.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub nudge: Option<NudgeConfig>,
86
87    /// Repetition detection configuration.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub repetition_detection: Option<RepetitionDetectionConfig>,
90
91    /// File tracking configuration.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub file_tracking: Option<FileTrackingConfig>,
94
95    /// Agent-level sandbox configuration for tool execution. Per-stage
96    /// `[stages.<name>.sandbox]` overrides this; both cascade through
97    /// [`crate::resolve_sandbox`].
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
100
101    /// Opt-in escape hatch: when `true`, the agent may add tools to
102    /// its own `tools/` directory mid-run and have them re-discovered and
103    /// re-advertised for its next turn. **Off by default** - tools are otherwise
104    /// discovered once at spawn and an agent cannot grow its own toolchain.
105    #[serde(default)]
106    pub dynamic_tools: bool,
107
108    /// Read paths this agent *declares* beyond its workdir - directories a
109    /// planner-style agent needs to see, like run archives or design docs.
110    /// Declaring is not granting: entries only take effect when the user's
111    /// config also grants them (`[security] read_paths`,
112    /// `[agent_read_paths.<name>]`, or `allow_blueprint_read_paths = true`),
113    /// so an installed manifest cannot widen its own sandbox. Read-only in
114    /// every case; `write_file` and `edit_file` stay confined to the workdir.
115    /// Semantics live in [`crate::read_paths`].
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub read_paths: Option<ReadPathsConfig>,
118
119    /// The `[safe_commands]` section: tools and shell command prefixes this
120    /// agent would like to run without an approval prompt.
121    ///
122    /// Declaring is not granting, exactly as for [`Self::read_paths`]: entries
123    /// take effect only when the user opts in, per agent via
124    /// `[agent_safe_commands.<name>] allow_blueprint = true` or globally via
125    /// `[security] allow_blueprint_safe_commands`. Otherwise any agent package
126    /// could pre-approve its own shell with one TOML line.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub safe_commands: Option<SafeCommandsConfig>,
129
130    /// Agent-level default shape for the run's final output. A per-stage
131    /// `[stages.<name>.output]` narrows it, and whoever starts the run can
132    /// override it again. See [`crate::output::resolve_output_spec`].
133    ///
134    /// `None` means this agent declares no shape, which is not the same as
135    /// producing no output: a stage may still ask for one.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub output: Option<crate::output::OutputSpec>,
138}
139
140/// The `[safe_commands]` section of a manifest.
141///
142/// Entry syntax is not checked here. What counts as a usable shell prefix is
143/// defined by the key parser in the CLI (a program, optionally with the
144/// subcommand that narrows it), which this crate does not depend on. A bad
145/// entry is a lint finding and is skipped with a warning at spawn, rather than
146/// a parse error - the same place the check can be written once instead of
147/// twice.
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SafeCommandsConfig {
150    /// Tools that need no prompt whatever their arguments.
151    #[serde(default)]
152    pub tools: Vec<String>,
153    /// Shell command prefixes that need no prompt: `"cargo test"`, not
154    /// `"cargo test --lib"` and never `"cargo"`.
155    #[serde(default)]
156    pub shell: Vec<String>,
157}
158
159/// The `[read_paths]` section of a manifest: raw declared entries, compiled
160/// against the run's workdir and home at spawn.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ReadPathsConfig {
163    /// Declared entries. Each may be:
164    /// - an exact path, granting its subtree: `"~/.leviath/runs"` or
165    ///   `"../shared-docs"` (relative to the run's workdir)
166    /// - a glob: `"glob:~/.leviath/runs/**"`
167    /// - a regex, auto-anchored: `"regex:/data/design-docs/.*"`
168    ///
169    /// Patterns are written with `/` separators on every OS and match the
170    /// symlink-resolved real path.
171    #[serde(default)]
172    pub allow: Vec<String>,
173}
174
175impl Blueprint {
176    /// Create a new blueprint with the specified configuration.
177    pub fn new(
178        name: String,
179        description: String,
180        stages: Vec<Stage>,
181        context_layout: ContextLayout,
182    ) -> Self {
183        Self {
184            name,
185            description,
186            stages,
187            context_layout,
188            transforms: Vec::new(),
189            version: "0.1.0".to_string(),
190            compaction_config: None,
191            max_child_depth: None,
192            entry_stage: None,
193            metadata: HashMap::new(),
194            security: None,
195            batch_tool_hint: None,
196            shell_hint: None,
197            nudge: None,
198            repetition_detection: None,
199            file_tracking: None,
200            sandbox: None,
201            dynamic_tools: false,
202            read_paths: None,
203            safe_commands: None,
204            output: None,
205        }
206    }
207
208    /// Whether any region is seeded from the caller's `task`.
209    ///
210    /// The blueprint's answer to "do you take a task?", which is a different
211    /// question from whether one was supplied. An agent driven by named regions
212    /// (`reviewer` takes `--diff` and `--criteria`) answers no, and handing it a
213    /// task would put that text nowhere at all - so both the CLI, before it asks
214    /// for one, and the daemon, before it spawns, ask this first.
215    pub fn accepts_task(&self) -> bool {
216        self.context_layout
217            .regions
218            .iter()
219            .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
220    }
221
222    /// The caller input keys this blueprint does read, in declaration order.
223    ///
224    /// Used to turn "that agent takes no task" into a message naming what it
225    /// takes instead, which is the difference between a dead end and a fix.
226    pub fn caller_inputs(&self) -> Vec<&str> {
227        self.context_layout
228            .regions
229            .iter()
230            .filter_map(|r| match &r.seed {
231                Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
232                _ => None,
233            })
234            .collect()
235    }
236
237    /// Why a task cannot be given to this blueprint, phrased for the user.
238    ///
239    /// One message rather than two, because the CLI refuses before it asks for a
240    /// task and the daemon refuses before it spawns, and a user who hit one and
241    /// then the other should not be told two different things.
242    pub fn task_refusal(&self) -> String {
243        let inputs = self.caller_inputs();
244        let takes = match inputs.is_empty() {
245            true => "it takes no caller input at all".to_string(),
246            false => format!("it takes: {}", inputs.join(", ")),
247        };
248        format!(
249            "agent '{}' was given a task but declares no region to put it in, so the task \
250             would be ignored - {takes}. Add a region seeded from the task, for example:\n\
251             [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
252             required = true, seed = \"task\" }}",
253            self.name,
254        )
255    }
256
257    /// Agent-level tool permissions, keyed by tool name.
258    ///
259    /// The manifest parser records a top-level `[tool_permissions]` block as
260    /// `tool_perm:<tool>` → policy-string entries in [`Self::metadata`]. This
261    /// projects them back into a tool-keyed map for the runtime's agent-level
262    /// permission layer. Non-`tool_perm:` keys and non-string values are ignored.
263    pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
264        self.metadata
265            .iter()
266            .filter_map(|(k, v)| {
267                Some((
268                    k.strip_prefix("tool_perm:")?.to_string(),
269                    v.as_str()?.to_string(),
270                ))
271            })
272            .collect()
273    }
274
275    /// Add context transforms to this blueprint.
276    pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
277        self.transforms = transforms;
278        self
279    }
280
281    /// Set the version of this blueprint.
282    pub fn with_version(mut self, version: String) -> Self {
283        self.version = version;
284        self
285    }
286
287    /// Validate that the blueprint is well-formed.
288    pub fn validate(&self) -> std::result::Result<(), ValidationError> {
289        // Validate context layout
290        self.context_layout.validate()?;
291
292        // Check that all stages have valid configurations
293        for stage in &self.stages {
294            stage.validate()?;
295        }
296
297        // Validate transforms reference real regions
298        for transform in &self.transforms {
299            transform.validate(&self.context_layout)?;
300        }
301
302        // Graph validation
303        self.validate_graph()?;
304
305        self.validate_region_references()?;
306
307        Ok(())
308    }
309
310    /// Every region a stage can name, anywhere in this blueprint.
311    ///
312    /// The union of the global layout, every stage's own layout, and the three
313    /// the runtime adds if nobody declared them. It is a union rather than the
314    /// per-stage set on purpose: a stage that omits a region from its
315    /// `[context.regions]` hides it, it does not destroy it, so naming a region
316    /// another stage declared is legitimate. Only a name that exists nowhere is
317    /// a typo.
318    fn known_region_names(&self) -> std::collections::HashSet<&str> {
319        let mut names: std::collections::HashSet<&str> = self
320            .context_layout
321            .regions
322            .iter()
323            .map(|r| r.name.as_str())
324            .collect();
325        for stage in &self.stages {
326            if let Some(layout) = &stage.context_layout {
327                names.extend(layout.regions.iter().map(|r| r.name.as_str()));
328            }
329        }
330        // Added by `setup_context_window` when a blueprint does not declare
331        // them, so they are always addressable.
332        names.extend(ALWAYS_VISIBLE_REGIONS);
333        names
334    }
335
336    /// The regions `stage` can actually see while it runs.
337    ///
338    /// Its own `[context.regions]` when it declares one, the blueprint's
339    /// otherwise, plus the regions the runtime carries visible whatever a stage
340    /// says. Narrower than [`known_region_names`](Self::known_region_names),
341    /// which asks only whether a name exists somewhere - the difference is the
342    /// whole of #370: a region another stage declares exists, and is still not
343    /// readable from here.
344    fn regions_visible_to<'a>(&'a self, stage: &'a Stage) -> std::collections::HashSet<&'a str> {
345        let layout = stage
346            .context_layout
347            .as_ref()
348            .unwrap_or(&self.context_layout);
349        let mut names: std::collections::HashSet<&str> =
350            layout.regions.iter().map(|r| r.name.as_str()).collect();
351        names.extend(ALWAYS_VISIBLE_REGIONS);
352        // `validate_region_references` has already refused a hide list that
353        // names an always-visible region, so nothing here can remove one.
354        for hidden in &stage.context_hide {
355            names.remove(hidden.as_str());
356        }
357        names
358    }
359
360    /// Refuse a region name that exists nowhere in the blueprint.
361    ///
362    /// Routing and gates are addressed by name, and a name that matches nothing
363    /// used to be accepted in silence: the routed tool result went to the
364    /// default region and the gate held nothing back, both looking exactly like
365    /// a working config (#362). A gate that silently never fires is the
366    /// expensive case - it reads as the model behaving well.
367    fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
368        let known = self.known_region_names();
369        let checklists: std::collections::HashSet<&str> = self
370            .context_layout
371            .regions
372            .iter()
373            .chain(
374                self.stages
375                    .iter()
376                    .filter_map(|s| s.context_layout.as_ref())
377                    .flat_map(|l| l.regions.iter()),
378            )
379            .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
380            .map(|r| r.name.as_str())
381            .collect();
382
383        for stage in &self.stages {
384            let bad = |message: String| ValidationError::Stage {
385                stage: stage.name.clone(),
386                message,
387            };
388
389            // A hidden region has to be one the stage would otherwise carry:
390            // a name that matches nothing is a typo, and a typo here is the
391            // silent kind (the large region stays in every prompt and the
392            // bill says so a month later). The always-visible four cannot be
393            // hidden at all - the model's own turns live there.
394            for hidden in &stage.context_hide {
395                if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
396                    return Err(bad(format!(
397                        "context.hide names '{hidden}', which every stage carries and cannot hide"
398                    )));
399                }
400                if !known.contains(hidden.as_str()) {
401                    return Err(bad(format!(
402                        "context.hide names region '{hidden}', which no layout in this \
403                         blueprint declares"
404                    )));
405                }
406            }
407
408            if let Some(routing) = &stage.tool_result_routing {
409                // Routing is checked against what *this* stage can see, not
410                // against every name in the blueprint. A stage that omits a
411                // region from its own `[context.regions]` hides it, so a result
412                // routed there is written somewhere the stage cannot read - and
413                // the pointer left in `conversation` tells the model to go read
414                // it. There is no reading of a blueprint where that was
415                // intended (#370).
416                let visible = self.regions_visible_to(stage);
417                let dead_drop = |key: &str, region: &str| ValidationError::Stage {
418                    stage: stage.name.clone(),
419                    message: format!(
420                        "tool_routing.{key} sends results to region '{region}', \
421                             which this stage's context does not include, so it \
422                             could not read them back. Add '{region}' to \
423                             [stages.{}.context.regions], or route somewhere the \
424                             stage can see.",
425                        stage.name
426                    ),
427                };
428                if !visible.contains(routing.default_region.as_str()) {
429                    return Err(dead_drop("default_region", &routing.default_region));
430                }
431                for (tool, region) in &routing.tool_overrides {
432                    if !visible.contains(region.as_str()) {
433                        return Err(dead_drop(&format!("overrides.{tool}"), region));
434                    }
435                }
436            }
437
438            for edge in stage.transitions.iter().flat_map(|t| t.values()) {
439                let Some(gate) = &edge.gate else { continue };
440                for (key, region) in [
441                    ("region", gate.region.as_ref()),
442                    (
443                        "require_region_updated",
444                        gate.require_region_updated.as_ref(),
445                    ),
446                    ("require_no_open_items", gate.require_no_open_items.as_ref()),
447                ] {
448                    let Some(region) = region else { continue };
449                    if !known.contains(region.as_str()) {
450                        return Err(bad(format!(
451                            "transition to '{}': gate.{key} names region \
452                             '{region}', which no stage declares",
453                            edge.target
454                        )));
455                    }
456                }
457                // A checklist gate counts open items, which only a checklist
458                // region has. Pointed at any other kind it can only ever read
459                // zero, so it would pass on the first attempt every time.
460                if let Some(region) = &gate.require_no_open_items
461                    && !checklists.contains(region.as_str())
462                {
463                    return Err(bad(format!(
464                        "transition to '{}': gate.require_no_open_items names \
465                         region '{region}', which is not a checklist region \
466                         (set kind = \"checklist\" on it)",
467                        edge.target
468                    )));
469                }
470            }
471        }
472        Ok(())
473    }
474
475    /// Validate stage graph constraints.
476    fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
477        let stage_names: std::collections::HashSet<&str> =
478            self.stages.iter().map(|s| s.name.as_str()).collect();
479
480        // Entry stage must exist if set
481        if let Some(entry) = &self.entry_stage
482            && !stage_names.contains(entry.as_str())
483        {
484            return Err(ValidationError::Graph(format!(
485                "entry_stage '{}' does not match any defined stage",
486                entry
487            )));
488        }
489
490        // Fan-out stages reference a worker source + optional merge stage. These
491        // are checked even for otherwise-linear blueprints (before the early
492        // return below), since `worker_stage`/`merge_stage` name local stages.
493        // `worker_agent`/`worker_query` are environment-dependent (resolved
494        // against installed agents at run time), so they are not checked here.
495        for stage in &self.stages {
496            if let StageMode::FanOut { config } = &stage.mode {
497                let sources = [
498                    config.worker_agent.is_some(),
499                    config.worker_stage.is_some(),
500                    config.worker_query.is_some(),
501                ]
502                .iter()
503                .filter(|&&set| set)
504                .count();
505                if sources != 1 {
506                    return Err(ValidationError::Stage {
507                        stage: stage.name.clone(),
508                        message: "fan_out stage must set exactly one of worker_agent, \
509                                  worker_stage, or worker_query"
510                            .to_string(),
511                    });
512                }
513                if let Some(ws) = &config.worker_stage {
514                    match self.stages.iter().find(|s| &s.name == ws) {
515                        None => {
516                            return Err(ValidationError::Stage {
517                                stage: stage.name.clone(),
518                                message: format!("fan_out worker_stage '{}' does not exist", ws),
519                            });
520                        }
521                        Some(target) if !target.allow_as_worker => {
522                            return Err(ValidationError::Stage {
523                                stage: stage.name.clone(),
524                                message: format!(
525                                    "fan_out worker_stage '{}' must set allow_as_worker = true",
526                                    ws
527                                ),
528                            });
529                        }
530                        Some(_) => {}
531                    }
532                }
533                if let Some(ms) = &config.merge_stage
534                    && !stage_names.contains(ms.as_str())
535                {
536                    return Err(ValidationError::Stage {
537                        stage: stage.name.clone(),
538                        message: format!("fan_out merge_stage '{}' does not exist", ms),
539                    });
540                }
541            }
542        }
543
544        let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
545        if !has_any_transitions {
546            // Pure linear mode - no graph validation needed
547            return Ok(());
548        }
549
550        // All transition targets must exist
551        for stage in &self.stages {
552            if let Some(ref transitions) = stage.transitions {
553                for (target_name, edge) in transitions {
554                    if !stage_names.contains(target_name.as_str()) {
555                        return Err(ValidationError::Transition {
556                            from: stage.name.clone(),
557                            to: target_name.clone(),
558                            message: "target stage does not exist".to_string(),
559                        });
560                    }
561                    // A `stuck` edge with no threshold could never fire. Caught
562                    // here as well as in the manifest parser, so blueprints built
563                    // programmatically (API / `lev validate`) are held to it too.
564                    if edge.condition == TransitionCondition::Stuck
565                        && !edge.stuck.is_some_and(|c| c.is_armed())
566                    {
567                        return Err(ValidationError::Transition {
568                            from: stage.name.clone(),
569                            to: target_name.clone(),
570                            message: "condition = \"stuck\" requires at least one \
571                                      stuck_after_* threshold (the edge could never fire)"
572                                .to_string(),
573                        });
574                    }
575                }
576
577                // A `require_modifications` gate on a stage that advertises no
578                // file-modifying tool can never be satisfied - it would just
579                // burn the stage's re-run budget every time.
580                for (target_name, edge) in transitions {
581                    let Some(gate) = &edge.gate else { continue };
582                    if !gate.require_modifications {
583                        continue;
584                    }
585                    let can_modify = stage.available_tools.iter().any(|t| {
586                        MODIFYING_TOOLS.contains(&t.as_str())
587                            || gate.tools.iter().any(|extra| extra == t)
588                    });
589                    if !can_modify {
590                        return Err(ValidationError::Transition {
591                            from: stage.name.clone(),
592                            to: target_name.clone(),
593                            message: "gate requires modifications, but the stage has no \
594                                      file-modifying tool in available_tools"
595                                .to_string(),
596                        });
597                    }
598                }
599
600                // Self-loop safety: stages that transition to themselves need max_revisits
601                if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
602                    return Err(ValidationError::Stage {
603                        stage: stage.name.clone(),
604                        message: "self-loop transition requires max_revisits".to_string(),
605                    });
606                }
607            }
608        }
609
610        // At least one terminal path must exist (a stage with no outgoing transitions,
611        // or with only conditional transitions that may not fire)
612        let entry = self.resolve_entry_stage_name();
613        let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
614        if !has_terminal {
615            return Err(ValidationError::Graph(
616                "no terminal path exists from entry stage - agent would never complete".to_string(),
617            ));
618        }
619
620        Ok(())
621    }
622
623    /// Resolve the entry stage name.
624    pub fn resolve_entry_stage_name(&self) -> String {
625        self.entry_stage.clone().unwrap_or_else(|| {
626            self.stages
627                .first()
628                .map(|s| s.name.clone())
629                .unwrap_or_default()
630        })
631    }
632
633    /// Check if there is a terminal path reachable from `stage_name`.
634    fn has_terminal_path(
635        &self,
636        stage_name: &str,
637        visited: &mut std::collections::HashSet<String>,
638    ) -> bool {
639        if visited.contains(stage_name) {
640            return false;
641        }
642        visited.insert(stage_name.to_string());
643
644        let stage = self.stages.iter().find(|s| s.name == stage_name);
645        let stage = match stage {
646            Some(s) => s,
647            // Unreachable via this function's only call site (`validate_graph`,
648            // below): it rejects any transition target that doesn't match a
649            // real stage name *before* ever calling `has_terminal_path`, and
650            // `has_terminal_path` is private, so no other caller can pass in
651            // an unvalidated stage name.
652            None => return false,
653        };
654
655        // A fan-out stage with a merge stage hands off to it after workers
656        // complete, so its terminal path runs through the merge stage.
657        if let StageMode::FanOut {
658            config:
659                FanOutConfig {
660                    merge_stage: Some(ms),
661                    ..
662                },
663        } = &stage.mode
664        {
665            return self.has_terminal_path(ms, visited);
666        }
667
668        match &stage.transitions {
669            None => {
670                // Linear mode: check if there's a next stage by index
671                let idx = self
672                    .stages
673                    .iter()
674                    .position(|s| s.name == stage_name)
675                    .unwrap_or(0);
676                if idx + 1 >= self.stages.len() {
677                    return true; // terminal
678                }
679                self.has_terminal_path(&self.stages[idx + 1].name, visited)
680            }
681            Some(transitions) => {
682                if transitions.is_empty() {
683                    return true; // terminal stage
684                }
685                // Check if any transition leads to a terminal
686                for target in transitions.keys() {
687                    if self.has_terminal_path(target, visited) {
688                        return true;
689                    }
690                }
691                // No target reaches a terminal stage. This used to fall back to
692                // "all targets are exhaustible, so the stage will eventually
693                // have zero available edges" and call THAT a terminal path -
694                // but running out of edges mid-graph is now a run *error*
695                // (StageResolution::DeadEnd in the runtime), not a completion,
696                // so certifying it here validated blueprints that could never
697                // finish successfully.
698                false
699            }
700        }
701    }
702
703    /// Find a stage by name.
704    pub fn find_stage(&self, name: &str) -> Option<&Stage> {
705        self.stages.iter().find(|s| s.name == name)
706    }
707}
708
709// Sections of the former single-file blueprint, one per concept. Glob
710// re-exported so every existing `blueprint::Stage` path keeps working and the
711// split stays a pure move.
712mod model;
713pub use model::*;
714mod stage;
715pub use stage::*;
716mod transition;
717pub use transition::*;
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::layout::ContextLayout;
723    use crate::layout::RegionDefinition;
724    use crate::region::RegionKind;
725
726    /// Build a blueprint from a manifest, so these read as the TOML an author
727    /// would actually write rather than as hand-assembled structs.
728    fn bp_with_regions(regions_toml: &str) -> Blueprint {
729        crate::manifest::parse_manifest(&format!(
730            r#"
731[agent]
732name = "asked"
733
734[stages.main]
735mode = "autonomous"
736model = {{ provider = "anthropic", model = "m" }}
737
738[context.regions]
739{regions_toml}
740"#
741        ))
742        .expect("fixture parses")
743    }
744
745    #[test]
746    fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
747        // Both spellings: the explicit seed and the region named `task`, which
748        // gets the same seed implicitly.
749        assert!(
750            bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
751                .accepts_task()
752        );
753        assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
754    }
755
756    #[test]
757    fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
758        let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
759        assert!(!bp.accepts_task());
760        assert_eq!(bp.caller_inputs(), ["diff"]);
761    }
762
763    #[test]
764    fn the_refusal_names_what_the_agent_takes_instead() {
765        let bp = bp_with_regions(
766            r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
767criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
768        );
769        let msg = bp.task_refusal();
770        assert!(msg.contains("agent 'asked'"), "{msg}");
771        assert!(msg.contains("it takes: diff, criteria"), "{msg}");
772    }
773
774    #[test]
775    fn the_refusal_says_so_when_the_agent_takes_nothing() {
776        let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
777        assert!(bp.caller_inputs().is_empty());
778        // Bound rather than called inside the assert message: a message
779        // expression only runs when the assert fails, so it would be an
780        // uncovered region on every green run.
781        let msg = bp.task_refusal();
782        assert!(msg.contains("it takes no caller input at all"), "{msg}");
783    }
784
785    #[test]
786    fn resolve_nudge_defaults_when_nothing_is_configured() {
787        // No config anywhere: on for a normal stage, off for a reviewed one,
788        // with the built-in cap and text.
789        let normal = resolve_nudge(None, None, None, false);
790        assert!(normal.enabled);
791        assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
792        assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
793        let reviewed = resolve_nudge(None, None, None, true);
794        assert!(!reviewed.enabled);
795        // The other fields don't depend on review status.
796        assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
797        assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
798    }
799
800    #[test]
801    fn resolve_nudge_cascades_each_field_independently() {
802        let global = NudgeConfig {
803            enabled: Some(true),
804            max: Some(10),
805            text: Some("global".to_string()),
806        };
807        let agent = NudgeConfig {
808            max: Some(2),
809            ..Default::default()
810        };
811        let stage = NudgeConfig {
812            text: Some("stage".to_string()),
813            ..Default::default()
814        };
815        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
816        // enabled from global, max from agent, text from stage.
817        assert!(resolved.enabled);
818        assert_eq!(resolved.max, 2);
819        assert_eq!(resolved.text, "stage");
820        // The stage level wins over both when it sets a field.
821        let stage_all = NudgeConfig {
822            enabled: Some(false),
823            max: Some(0),
824            text: Some("s".to_string()),
825        };
826        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
827        assert_eq!(
828            resolved,
829            ResolvedNudge {
830                enabled: false,
831                max: 0,
832                text: "s".to_string()
833            }
834        );
835    }
836
837    #[test]
838    fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
839        // A reviewed stage is only *implicitly* exempt: any level that sets
840        // `enabled` speaks for itself, in either direction.
841        let on = NudgeConfig {
842            enabled: Some(true),
843            ..Default::default()
844        };
845        assert!(resolve_nudge(None, None, Some(&on), true).enabled);
846        assert!(resolve_nudge(None, Some(&on), None, true).enabled);
847        assert!(resolve_nudge(Some(&on), None, None, true).enabled);
848        let off = NudgeConfig {
849            enabled: Some(false),
850            ..Default::default()
851        };
852        assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
853    }
854
855    #[test]
856    fn test_blueprint_creation() {
857        let regions = vec![RegionDefinition::new(
858            "test".to_string(),
859            RegionKind::Pinned,
860            5000,
861        )];
862        let layout = ContextLayout::new(regions, 10000);
863
864        let stages = vec![Stage::new(
865            "analyze".to_string(),
866            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
867        )];
868
869        let blueprint = Blueprint::new(
870            "test-agent".to_string(),
871            "A test agent".to_string(),
872            stages,
873            layout,
874        );
875
876        assert_eq!(blueprint.name, "test-agent");
877        assert_eq!(blueprint.stages.len(), 1);
878    }
879
880    #[test]
881    fn test_blueprint_with_transforms_version() {
882        let stages = vec![Stage::new("plan".to_string(), make_model())];
883        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
884            .with_transforms(vec![ContextTransform {
885                from_blueprint: "a".to_string(),
886                to_blueprint: "b".to_string(),
887                mappings: vec![],
888            }])
889            .with_version("2.0.0".to_string());
890
891        assert_eq!(bp.transforms.len(), 1);
892        assert_eq!(bp.version, "2.0.0");
893    }
894
895    #[test]
896    fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
897        let stages = vec![Stage::new("plan".to_string(), make_model())];
898        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
899        // A well-formed tool_perm string entry - included.
900        bp.metadata.insert(
901            "tool_perm:bash".to_string(),
902            serde_json::Value::String("deny".to_string()),
903        );
904        // A non-`tool_perm:` key - skipped (strip_prefix returns None).
905        bp.metadata
906            .insert("title".to_string(), serde_json::Value::String("x".into()));
907        // A tool_perm key whose value isn't a string - skipped (as_str is None).
908        bp.metadata
909            .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
910
911        let perms = bp.agent_tool_permissions();
912        assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
913        assert!(!perms.contains_key("title"));
914        assert!(!perms.contains_key("weird"));
915        assert_eq!(perms.len(), 1);
916    }
917
918    #[test]
919    fn test_blueprint_validate_runs_transform_validation() {
920        // A transform whose mapping targets a real region - validate() must
921        // reach ContextTransform::validate() and succeed.
922        let stages = vec![Stage::new("plan".to_string(), make_model())];
923        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
924        bp.transforms.push(ContextTransform {
925            from_blueprint: "a".to_string(),
926            to_blueprint: "b".to_string(),
927            mappings: vec![RegionMapping {
928                from_region: "test".to_string(),
929                to_region: "test".to_string(),
930                transform: None,
931            }],
932        });
933        assert!(bp.validate().is_ok());
934    }
935
936    #[test]
937    fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
938        let stages = vec![Stage::new("plan".to_string(), make_model())];
939        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
940        bp.transforms.push(ContextTransform {
941            from_blueprint: "a".to_string(),
942            to_blueprint: "b".to_string(),
943            mappings: vec![RegionMapping {
944                from_region: "test".to_string(),
945                to_region: "nonexistent".to_string(),
946                transform: None,
947            }],
948        });
949        let err = bp.validate().unwrap_err();
950        assert_eq!(
951            err,
952            ValidationError::Region {
953                region: "nonexistent".to_string(),
954                message: "transform target region not found in layout".to_string(),
955            }
956        );
957    }
958
959    #[test]
960    fn test_mixed_linear_and_graph_mode_terminal_path() {
961        // "plan" has explicit transitions (triggers graph-mode validation),
962        // but "impl" and "review" have none - they must fall back to
963        // linear (next-by-index) terminal-path resolution.
964        let mut plan = Stage::new("plan".to_string(), make_model());
965        let impl_stage = Stage::new("impl".to_string(), make_model());
966        let review = Stage::new("review".to_string(), make_model());
967
968        let mut transitions = HashMap::new();
969        transitions.insert(
970            "impl".to_string(),
971            TransitionEdge {
972                target: "impl".to_string(),
973                condition: TransitionCondition::Always,
974                hint: None,
975                transform: EdgeTransform::Direct,
976                gate: None,
977                stuck: None,
978            },
979        );
980        plan.transitions = Some(transitions);
981
982        let bp = Blueprint::new(
983            "t".into(),
984            "".into(),
985            vec![plan, impl_stage, review],
986            make_layout(),
987        );
988        assert!(bp.validate().is_ok());
989    }
990
991    #[test]
992    fn test_stage_validation() {
993        let stage = Stage::new(
994            "test".to_string(),
995            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
996        );
997        assert!(stage.validate().is_ok());
998
999        let empty_stage = Stage::new(
1000            "".to_string(),
1001            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1002        );
1003        assert!(empty_stage.validate().is_err());
1004    }
1005
1006    #[test]
1007    fn test_stage_validate_with_valid_context_layout_is_ok() {
1008        let mut stage = Stage::new("test".to_string(), make_model());
1009        stage.context_layout = Some(make_layout());
1010        assert!(stage.validate().is_ok());
1011    }
1012
1013    #[test]
1014    fn test_stage_validate_with_invalid_context_layout_is_err() {
1015        // Duplicate region names make the layout itself invalid.
1016        let regions = vec![
1017            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1018            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1019        ];
1020        let mut stage = Stage::new("test".to_string(), make_model());
1021        stage.context_layout = Some(ContextLayout::new(regions, 200));
1022        assert!(stage.validate().is_err());
1023    }
1024
1025    #[test]
1026    fn test_stage_with_tools_context_layout_description() {
1027        let stage = Stage::new("test".to_string(), make_model())
1028            .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1029            .with_context_layout(make_layout())
1030            .with_description("does things".to_string());
1031
1032        assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1033        assert!(stage.context_layout.is_some());
1034        assert_eq!(stage.description.as_deref(), Some("does things"));
1035    }
1036
1037    #[test]
1038    fn test_stage_with_mode() {
1039        let stage = Stage::new("test".to_string(), make_model())
1040            .with_mode(StageMode::InteractivePoints { points: vec![] });
1041        assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1042    }
1043
1044    #[test]
1045    fn test_stage_allow_complete_defaults_false() {
1046        let stage = Stage::new("review".to_string(), make_model());
1047        assert!(!stage.allow_complete);
1048    }
1049
1050    #[test]
1051    fn test_stage_allow_complete_serde_default_when_missing() {
1052        // A serialized stage from before allow_complete existed must still
1053        // deserialize, defaulting to false.
1054        let json = r#"{
1055            "name": "review",
1056            "description": null,
1057            "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1058            "available_tools": [],
1059            "max_iterations": null,
1060            "context_layout": null,
1061            "config": {},
1062            "transitions": null,
1063            "max_revisits": null,
1064            "transition_prompt": null
1065        }"#;
1066        let stage: Stage = serde_json::from_str(json).unwrap();
1067        assert!(!stage.allow_complete);
1068        assert!(stage.accepts_messages);
1069    }
1070
1071    #[test]
1072    fn test_stage_allow_complete_roundtrip() {
1073        let mut stage = Stage::new("review".to_string(), make_model());
1074        stage.allow_complete = true;
1075        let json = serde_json::to_string(&stage).unwrap();
1076        let back: Stage = serde_json::from_str(&json).unwrap();
1077        assert!(back.allow_complete);
1078    }
1079
1080    #[test]
1081    fn test_interaction_point_directives_default_empty() {
1082        let point = InteractionPoint {
1083            name: "plan_approval".to_string(),
1084            prompt: "Approve?".to_string(),
1085            required: true,
1086            unattended: UnattendedPolicy::AutoApprove,
1087            style: InteractionStyle::MultipleChoice,
1088            options: vec!["Approve".to_string(), "Revise".to_string()],
1089            directives: HashMap::new(),
1090            abort_options: Vec::new(),
1091            edit_options: Vec::new(),
1092            document_region: None,
1093        };
1094        assert!(point.directives.is_empty());
1095        assert!(point.abort_options.is_empty());
1096        assert!(point.edit_options.is_empty());
1097    }
1098
1099    #[test]
1100    fn test_interaction_point_directives_roundtrip() {
1101        let mut directives = HashMap::new();
1102        directives.insert(
1103            "Revise".to_string(),
1104            "Ask what to change, then re-plan.".to_string(),
1105        );
1106        let point = InteractionPoint {
1107            name: "plan_approval".to_string(),
1108            prompt: "Approve?".to_string(),
1109            required: true,
1110            unattended: UnattendedPolicy::Ask,
1111            style: InteractionStyle::MultipleChoice,
1112            options: vec!["Approve".to_string(), "Revise".to_string()],
1113            directives,
1114            abort_options: vec!["Abort".to_string()],
1115            edit_options: vec!["Add detail".to_string()],
1116            document_region: Some("plan".to_string()),
1117        };
1118        let json = serde_json::to_string(&point).unwrap();
1119        let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1120        assert_eq!(
1121            back.directives.get("Revise").map(|s| s.as_str()),
1122            Some("Ask what to change, then re-plan.")
1123        );
1124        assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1125        assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1126        // A point that holds for a person under `--yolo` has to survive the
1127        // round trip: this is what a restored run re-arms from.
1128        assert_eq!(back.unattended, UnattendedPolicy::Ask);
1129    }
1130
1131    #[test]
1132    fn test_interaction_point_directives_serde_default_when_missing() {
1133        let json = r#"{
1134            "name": "plan_approval",
1135            "prompt": "Approve?",
1136            "required": true,
1137            "style": "multiple_choice",
1138            "options": ["Approve", "Revise"]
1139        }"#;
1140        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1141        assert!(point.directives.is_empty());
1142        assert!(point.abort_options.is_empty());
1143    }
1144
1145    #[test]
1146    fn test_interaction_point_followups_alias_still_deserializes() {
1147        // Backward compat: old serialized blueprints used "followups".
1148        let json = r#"{
1149            "name": "plan_approval",
1150            "prompt": "Approve?",
1151            "required": true,
1152            "style": "multiple_choice",
1153            "options": ["Approve", "Revise"],
1154            "followups": { "Revise": "What to change?" }
1155        }"#;
1156        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1157        assert_eq!(
1158            point.directives.get("Revise").map(|s| s.as_str()),
1159            Some("What to change?")
1160        );
1161    }
1162
1163    #[test]
1164    fn test_model_config_new_creates_single_entry() {
1165        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1166        assert_eq!(mc.models.len(), 1);
1167        assert_eq!(mc.models[0].provider, "anthropic");
1168        assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1169        assert!(mc.allow_user_default);
1170    }
1171
1172    #[test]
1173    fn test_model_config_with_multiple_models() {
1174        let mc = ModelConfig {
1175            models: vec![
1176                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1177                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1178                ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1179            ],
1180            allow_user_default: true,
1181            parameters: HashMap::new(),
1182            request_timeout_secs: None,
1183        };
1184        assert_eq!(mc.models.len(), 3);
1185        assert_eq!(mc.models[0].provider, "anthropic");
1186        assert_eq!(mc.models[1].provider, "openai");
1187        assert_eq!(mc.models[2].provider, "ollama");
1188    }
1189
1190    #[test]
1191    fn test_model_config_serde_roundtrip() {
1192        let mc = ModelConfig {
1193            models: vec![
1194                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1195                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1196            ],
1197            allow_user_default: false,
1198            parameters: HashMap::new(),
1199            request_timeout_secs: None,
1200        };
1201        let json = serde_json::to_string(&mc).unwrap();
1202        let back: ModelConfig = serde_json::from_str(&json).unwrap();
1203        assert_eq!(back.models.len(), 2);
1204        assert_eq!(back.models[0].provider, "anthropic");
1205        assert_eq!(back.models[1].provider, "openai");
1206        assert!(!back.allow_user_default);
1207    }
1208
1209    #[test]
1210    fn test_model_config_serde_defaults_when_fields_missing() {
1211        // Minimal JSON - models defaults to empty, allow_user_default defaults to true
1212        let json = r#"{"parameters": {}}"#;
1213        let mc: ModelConfig = serde_json::from_str(json).unwrap();
1214        assert!(mc.models.is_empty());
1215        assert!(mc.allow_user_default);
1216    }
1217
1218    #[test]
1219    fn test_model_config_convenience_accessors() {
1220        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1221        assert_eq!(mc.provider(), "anthropic");
1222        assert_eq!(mc.model(), "claude-sonnet-4-6");
1223    }
1224
1225    #[test]
1226    fn test_model_config_convenience_accessors_empty_models() {
1227        let mc = ModelConfig {
1228            models: vec![],
1229            allow_user_default: true,
1230            parameters: HashMap::new(),
1231            request_timeout_secs: None,
1232        };
1233        assert_eq!(mc.provider(), "anthropic");
1234        assert_eq!(mc.model(), "claude-sonnet-4-6");
1235    }
1236
1237    fn make_model() -> ModelConfig {
1238        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1239    }
1240
1241    fn make_layout() -> ContextLayout {
1242        let regions = vec![RegionDefinition::new(
1243            "test".to_string(),
1244            RegionKind::Pinned,
1245            5000,
1246        )];
1247        ContextLayout::new(regions, 10000)
1248    }
1249
1250    #[test]
1251    fn test_graph_validation_entry_stage_exists() {
1252        let stages = vec![Stage::new("plan".to_string(), make_model())];
1253        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1254        bp.entry_stage = Some("nonexistent".to_string());
1255        assert!(bp.validate().is_err());
1256    }
1257
1258    #[test]
1259    fn test_graph_validation_entry_stage_valid() {
1260        let stages = vec![Stage::new("plan".to_string(), make_model())];
1261        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1262        bp.entry_stage = Some("plan".to_string());
1263        assert!(bp.validate().is_ok());
1264    }
1265
1266    #[test]
1267    fn test_graph_validation_transition_target_missing() {
1268        let mut stage = Stage::new("plan".to_string(), make_model());
1269        let mut transitions = HashMap::new();
1270        transitions.insert(
1271            "nonexistent".to_string(),
1272            TransitionEdge {
1273                target: "nonexistent".to_string(),
1274                condition: TransitionCondition::Always,
1275                hint: None,
1276                transform: EdgeTransform::Direct,
1277                gate: None,
1278                stuck: None,
1279            },
1280        );
1281        stage.transitions = Some(transitions);
1282        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1283        assert!(bp.validate().is_err());
1284    }
1285
1286    /// A `require_modifications` gate on a stage that can't modify anything
1287    /// could never be satisfied - it would just burn the stage's re-run budget
1288    /// on every pass. Reject it at load time instead.
1289    #[test]
1290    fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1291        let gated = |tools: &[&str], extra: &[&str]| {
1292            let mut stage = Stage::new("impl".to_string(), make_model());
1293            stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1294            let mut transitions = HashMap::new();
1295            transitions.insert(
1296                "review".to_string(),
1297                TransitionEdge {
1298                    target: "review".to_string(),
1299                    condition: TransitionCondition::Always,
1300                    hint: None,
1301                    transform: EdgeTransform::Direct,
1302                    stuck: None,
1303                    gate: Some(TransitionGate {
1304                        require_modifications: true,
1305                        tools: extra.iter().map(|t| t.to_string()).collect(),
1306                        ..Default::default()
1307                    }),
1308                },
1309            );
1310            stage.transitions = Some(transitions);
1311            Blueprint::new(
1312                "t".into(),
1313                "".into(),
1314                vec![stage, Stage::new("review".to_string(), make_model())],
1315                make_layout(),
1316            )
1317        };
1318        let err = gated(&["read_file"], &[]).validate().unwrap_err();
1319        assert!(err.to_string().contains("no file-modifying tool"));
1320        // A built-in write tool satisfies it...
1321        assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1322        // ...as does one the gate itself declares (MCP / script toolchains).
1323        assert!(
1324            gated(&["read_file", "patch_file"], &["patch_file"])
1325                .validate()
1326                .is_ok()
1327        );
1328        // A gate that doesn't require modifications is never checked.
1329        let mut off = gated(&["read_file"], &[]);
1330        off.stages[0]
1331            .transitions
1332            .as_mut()
1333            .unwrap()
1334            .get_mut("review")
1335            .unwrap()
1336            .gate = Some(TransitionGate::default());
1337        assert!(off.validate().is_ok());
1338        // Neither is an edge with no gate at all.
1339        off.stages[0]
1340            .transitions
1341            .as_mut()
1342            .unwrap()
1343            .get_mut("review")
1344            .unwrap()
1345            .gate = None;
1346        assert!(off.validate().is_ok());
1347    }
1348
1349    #[test]
1350    fn test_graph_validation_self_loop_requires_max_revisits() {
1351        let mut stage = Stage::new("impl".to_string(), make_model());
1352        let mut transitions = HashMap::new();
1353        transitions.insert(
1354            "impl".to_string(),
1355            TransitionEdge {
1356                target: "impl".to_string(),
1357                condition: TransitionCondition::Always,
1358                hint: None,
1359                transform: EdgeTransform::Direct,
1360                gate: None,
1361                stuck: None,
1362            },
1363        );
1364        stage.transitions = Some(transitions);
1365        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1366        assert!(bp.validate().is_err());
1367    }
1368
1369    #[test]
1370    fn test_graph_validation_self_loop_with_max_revisits_ok() {
1371        let mut stage = Stage::new("impl".to_string(), make_model());
1372        stage.max_revisits = Some(3);
1373        let mut transitions = HashMap::new();
1374        transitions.insert(
1375            "impl".to_string(),
1376            TransitionEdge {
1377                target: "impl".to_string(),
1378                condition: TransitionCondition::Always,
1379                hint: None,
1380                transform: EdgeTransform::Direct,
1381                gate: None,
1382                stuck: None,
1383            },
1384        );
1385        stage.transitions = Some(transitions);
1386        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1387        // Must FAIL now: this used to pass on the theory that the self-loop
1388        // exhausts its max_revisits and "leaving zero edges" counts as
1389        // terminal - but running out of edges mid-graph is a run error
1390        // (StageResolution::DeadEnd), so a blueprint whose only ending is
1391        // exhaustion can never finish successfully.
1392        let err = bp
1393            .validate()
1394            .expect_err("an exhaustion-only graph is invalid");
1395        assert!(err.to_string().contains("no terminal path"), "{err}");
1396    }
1397
1398    #[test]
1399    fn test_graph_validation_terminal_path_exists() {
1400        let mut plan = Stage::new("plan".to_string(), make_model());
1401        let mut review = Stage::new("review".to_string(), make_model());
1402        review.transitions = Some(HashMap::new()); // terminal: no outgoing
1403
1404        let mut transitions = HashMap::new();
1405        transitions.insert(
1406            "review".to_string(),
1407            TransitionEdge {
1408                target: "review".to_string(),
1409                condition: TransitionCondition::Always,
1410                hint: None,
1411                transform: EdgeTransform::Direct,
1412                gate: None,
1413                stuck: None,
1414            },
1415        );
1416        plan.transitions = Some(transitions);
1417
1418        let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1419        assert!(bp.validate().is_ok());
1420    }
1421
1422    #[test]
1423    fn test_graph_no_terminal_path() {
1424        // Two stages that only transition to each other with no terminal
1425        let mut a = Stage::new("a".to_string(), make_model());
1426        let mut b = Stage::new("b".to_string(), make_model());
1427
1428        let mut a_transitions = HashMap::new();
1429        a_transitions.insert(
1430            "b".to_string(),
1431            TransitionEdge {
1432                target: "b".to_string(),
1433                condition: TransitionCondition::Always,
1434                hint: None,
1435                transform: EdgeTransform::Direct,
1436                gate: None,
1437                stuck: None,
1438            },
1439        );
1440        a.transitions = Some(a_transitions);
1441
1442        let mut b_transitions = HashMap::new();
1443        b_transitions.insert(
1444            "a".to_string(),
1445            TransitionEdge {
1446                target: "a".to_string(),
1447                condition: TransitionCondition::Always,
1448                hint: None,
1449                transform: EdgeTransform::Direct,
1450                gate: None,
1451                stuck: None,
1452            },
1453        );
1454        b.transitions = Some(b_transitions);
1455
1456        let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1457        assert!(bp.validate().is_err());
1458    }
1459
1460    #[test]
1461    fn test_linear_stages_still_validate() {
1462        // No transitions set at all - pure linear mode
1463        let stages = vec![
1464            Stage::new("plan".to_string(), make_model()),
1465            Stage::new("impl".to_string(), make_model()),
1466            Stage::new("review".to_string(), make_model()),
1467        ];
1468        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1469        assert!(bp.validate().is_ok());
1470    }
1471
1472    #[test]
1473    fn test_resolve_entry_stage_name() {
1474        let stages = vec![
1475            Stage::new("plan".to_string(), make_model()),
1476            Stage::new("impl".to_string(), make_model()),
1477        ];
1478        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1479        assert_eq!(bp.resolve_entry_stage_name(), "plan");
1480
1481        bp.entry_stage = Some("impl".to_string());
1482        assert_eq!(bp.resolve_entry_stage_name(), "impl");
1483    }
1484
1485    #[test]
1486    fn test_find_stage() {
1487        let stages = vec![
1488            Stage::new("plan".to_string(), make_model()),
1489            Stage::new("impl".to_string(), make_model()),
1490        ];
1491        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1492        assert!(bp.find_stage("plan").is_some());
1493        assert!(bp.find_stage("impl").is_some());
1494        assert!(bp.find_stage("nonexistent").is_none());
1495    }
1496
1497    #[test]
1498    fn test_transition_condition_default() {
1499        let cond = TransitionCondition::default();
1500        assert_eq!(cond, TransitionCondition::Always);
1501    }
1502
1503    #[test]
1504    fn test_edge_transform_default() {
1505        let t = EdgeTransform::default();
1506        assert_eq!(t, EdgeTransform::Direct);
1507    }
1508
1509    #[test]
1510    fn test_stage_mode_equality() {
1511        assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1512        assert_eq!(StageMode::Interactive, StageMode::Interactive);
1513        assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1514    }
1515
1516    #[test]
1517    fn test_interaction_style_equality() {
1518        assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1519        assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1520    }
1521
1522    // ─── stuck detection (#106) ─────────────────────────────────────────────
1523
1524    #[test]
1525    fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1526        assert!(!StuckConfig::default().is_armed());
1527        for cfg in [
1528            StuckConfig {
1529                after_iterations: Some(1),
1530                ..Default::default()
1531            },
1532            StuckConfig {
1533                after_minutes: Some(1),
1534                ..Default::default()
1535            },
1536            StuckConfig {
1537                after_same_file_edits: Some(1),
1538                ..Default::default()
1539            },
1540            StuckConfig {
1541                after_tool_calls: Some(1),
1542                ..Default::default()
1543            },
1544        ] {
1545            assert!(cfg.is_armed(), "{cfg:?} should be armed");
1546        }
1547    }
1548
1549    #[test]
1550    fn transition_condition_stuck_round_trips_as_snake_case() {
1551        let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1552        assert_eq!(json, "\"stuck\"");
1553        let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1554        assert_eq!(back, TransitionCondition::Stuck);
1555        assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1556    }
1557
1558    #[test]
1559    fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1560        let plain = TransitionEdge {
1561            target: "b".to_string(),
1562            condition: TransitionCondition::Always,
1563            hint: None,
1564            transform: EdgeTransform::Direct,
1565            gate: None,
1566            stuck: None,
1567        };
1568        let json = serde_json::to_string(&plain).unwrap();
1569        assert!(
1570            !json.contains("stuck"),
1571            "absent config must be skipped: {json}"
1572        );
1573
1574        let armed = TransitionEdge {
1575            condition: TransitionCondition::Stuck,
1576            stuck: Some(StuckConfig {
1577                after_iterations: Some(20),
1578                after_minutes: Some(10),
1579                after_same_file_edits: Some(3),
1580                after_tool_calls: Some(60),
1581            }),
1582            ..plain
1583        };
1584        let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1585            .expect("armed edge round-trips");
1586        assert_eq!(back.condition, TransitionCondition::Stuck);
1587        assert_eq!(back.stuck, armed.stuck);
1588    }
1589
1590    /// A blueprint built programmatically (API / `lev validate`) bypasses the
1591    /// manifest parser, so `validate` has to catch the dead-edge shape too.
1592    #[test]
1593    fn validate_rejects_a_stuck_edge_with_no_threshold() {
1594        let build = |stuck| {
1595            let mut a = Stage::new("a".to_string(), make_model());
1596            let b = Stage::new("b".to_string(), make_model());
1597            let mut transitions = std::collections::HashMap::new();
1598            transitions.insert(
1599                "b".to_string(),
1600                TransitionEdge {
1601                    target: "b".to_string(),
1602                    condition: TransitionCondition::Stuck,
1603                    hint: None,
1604                    transform: EdgeTransform::Direct,
1605                    gate: None,
1606                    stuck,
1607                },
1608            );
1609            a.transitions = Some(transitions);
1610            Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1611        };
1612
1613        for dead in [None, Some(StuckConfig::default())] {
1614            let err = build(dead)
1615                .validate()
1616                .expect_err("dead stuck edge rejected");
1617            assert!(
1618                format!("{err:?}").contains("stuck_after_"),
1619                "unexpected error: {err:?}"
1620            );
1621        }
1622
1623        // The same graph with a real threshold is fine.
1624        assert!(
1625            build(Some(StuckConfig {
1626                after_iterations: Some(5),
1627                ..Default::default()
1628            }))
1629            .validate()
1630            .is_ok()
1631        );
1632    }
1633
1634    /// `required_tools` keeps a blocking human tool through an unattended run.
1635    /// Naming one the stage can't call keeps nothing, so it is rejected rather
1636    /// than quietly ignored - the author meant something by writing it.
1637    #[test]
1638    fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1639        let mut stage = Stage::new("plan".to_string(), make_model());
1640        stage.available_tools = vec!["read_file".to_string()];
1641        stage.required_tools = vec!["ask_user_text".to_string()];
1642        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1643
1644        let err = bp.validate().expect_err("a tool it cannot call");
1645        let text = format!("{err:?}");
1646        assert!(text.contains("ask_user_text"), "names the tool: {text}");
1647        assert!(text.contains("available_tools"), "says why: {text}");
1648    }
1649
1650    #[test]
1651    fn validate_accepts_a_required_tool_the_stage_offers() {
1652        let mut stage = Stage::new("plan".to_string(), make_model());
1653        stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1654        stage.required_tools = vec!["ask_user_text".to_string()];
1655        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1656
1657        bp.validate().expect("the tool is on offer");
1658    }
1659
1660    /// A stage required to produce an output, without the tool that produces
1661    /// one, would spend its whole re-entry budget being nudged toward a tool it
1662    /// was never offered and then give up. Caught at load instead.
1663    #[test]
1664    fn validate_rejects_require_output_without_the_submit_tool() {
1665        let mut stage = Stage::new("summary".to_string(), make_model());
1666        stage.available_tools = vec!["read_file".to_string()];
1667        stage.require_output = true;
1668        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1669
1670        let err = bp.validate().expect_err("no way to submit");
1671        let text = format!("{err:?}");
1672        assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
1673        assert!(text.contains("require_output"), "says why: {text}");
1674    }
1675
1676    #[test]
1677    fn validate_accepts_require_output_when_the_stage_can_submit() {
1678        let mut stage = Stage::new("summary".to_string(), make_model());
1679        stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
1680        stage.require_output = true;
1681        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1682
1683        bp.validate().expect("the stage can submit");
1684    }
1685
1686    /// Declaring a shape is not the same as demanding one, so a stage carrying
1687    /// only an `output` block needs no tool grant.
1688    #[test]
1689    fn validate_accepts_a_declared_shape_without_require_output() {
1690        let mut stage = Stage::new("summary".to_string(), make_model());
1691        stage.available_tools = vec!["read_file".to_string()];
1692        stage.output = Some(crate::output::OutputSpec {
1693            format: Some("a2ui".to_string()),
1694            ..Default::default()
1695        });
1696        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1697
1698        bp.validate().expect("declaring a shape demands nothing");
1699    }
1700
1701    #[test]
1702    fn output_mode_compares_equal_only_to_itself() {
1703        assert_eq!(StageMode::Output, StageMode::Output);
1704        assert_ne!(StageMode::Output, StageMode::Autonomous);
1705        assert_ne!(StageMode::Autonomous, StageMode::Output);
1706    }
1707
1708    #[test]
1709    fn test_transition_condition_equality() {
1710        assert_eq!(
1711            TransitionCondition::LlmChoice,
1712            TransitionCondition::LlmChoice
1713        );
1714        assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1715    }
1716
1717    #[test]
1718    fn test_edge_transform_compact_and_custom_equality() {
1719        let a = EdgeTransform::Compact {
1720            prompt: Some("p".to_string()),
1721        };
1722        let b = EdgeTransform::Compact {
1723            prompt: Some("p".to_string()),
1724        };
1725        assert_eq!(a, b);
1726
1727        let c1 = EdgeTransform::Custom {
1728            carry: vec!["a".to_string()],
1729            compact: vec!["b".to_string()],
1730            clear: vec!["c".to_string()],
1731            compact_prompt: Some("p".to_string()),
1732        };
1733        let c2 = c1.clone();
1734        assert_eq!(c1, c2);
1735
1736        assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1737    }
1738
1739    #[test]
1740    fn test_stage_accepts_messages_default_true() {
1741        let stage = Stage::new(
1742            "test".to_string(),
1743            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1744        );
1745        assert!(stage.accepts_messages);
1746    }
1747
1748    #[test]
1749    fn test_stage_accepts_messages_serde_roundtrip() {
1750        // Serialize a stage with accepts_messages = false, then deserialize
1751        let mut stage = Stage::new(
1752            "report".to_string(),
1753            ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1754        );
1755        stage.accepts_messages = false;
1756
1757        let json = serde_json::to_string(&stage).expect("should serialize");
1758        let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1759        assert!(!deserialized.accepts_messages);
1760    }
1761
1762    #[test]
1763    fn test_stage_accepts_messages_json_default() {
1764        // When accepts_messages is missing from JSON, it should default to true
1765        let json = r#"{
1766            "name": "analyze",
1767            "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1768            "available_tools": [],
1769            "mode": "Autonomous",
1770            "config": {},
1771            "tool_permissions": {},
1772            "requires_children": false
1773        }"#;
1774        let stage: Stage = serde_json::from_str(json).expect("should parse");
1775        assert!(stage.accepts_messages);
1776    }
1777
1778    #[test]
1779    fn test_has_terminal_path_unknown_stage_returns_false() {
1780        // `has_terminal_path` is private; this test is in the same module.
1781        // Calling it with a stage name that doesn't exist in the Blueprint
1782        // exercises the `None => return false` arm (blueprint.rs line 203).
1783        let stages = vec![Stage::new("start".to_string(), make_model())];
1784        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1785        let mut visited = std::collections::HashSet::new();
1786        assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1787    }
1788
1789    #[test]
1790    fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1791        let regions = vec![
1792            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1793            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1794        ];
1795        let layout = ContextLayout::new(regions, 200);
1796        let stages = vec![Stage::new("start".to_string(), make_model())];
1797        let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
1798        assert_eq!(
1799            bp.validate().unwrap_err(),
1800            ValidationError::Region {
1801                region: "dup".to_string(),
1802                message: "duplicate region name".to_string(),
1803            }
1804        );
1805    }
1806
1807    #[test]
1808    fn test_blueprint_validate_fails_when_stage_has_empty_name() {
1809        let stages = vec![Stage::new("".to_string(), make_model())];
1810        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1811        assert_eq!(
1812            bp.validate().unwrap_err(),
1813            ValidationError::Stage {
1814                stage: "(empty)".to_string(),
1815                message: "stage name cannot be empty".to_string(),
1816            }
1817        );
1818    }
1819
1820    #[test]
1821    fn test_file_tracking_config_defaults() {
1822        let json = r#"{"region": "files"}"#;
1823        let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
1824        assert_eq!(config.region, "files");
1825        assert!(config.track_reads);
1826        assert!(config.track_writes);
1827        assert!(config.max_file_tokens.is_none());
1828    }
1829
1830    #[test]
1831    fn test_file_tracking_config_serde_roundtrip() {
1832        let config = FileTrackingConfig {
1833            region: "files".to_string(),
1834            track_reads: true,
1835            track_writes: false,
1836            max_file_tokens: Some(5000),
1837        };
1838        let json = serde_json::to_string(&config).unwrap();
1839        let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
1840        assert_eq!(back.region, "files");
1841        assert!(back.track_reads);
1842        assert!(!back.track_writes);
1843        assert_eq!(back.max_file_tokens, Some(5000));
1844    }
1845
1846    #[test]
1847    fn test_blueprint_file_tracking_default_none() {
1848        let stages = vec![Stage::new("plan".to_string(), make_model())];
1849        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1850        assert!(bp.file_tracking.is_none());
1851    }
1852
1853    #[test]
1854    fn test_blueprint_file_tracking_serde_roundtrip() {
1855        let stages = vec![Stage::new("plan".to_string(), make_model())];
1856        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1857        bp.file_tracking = Some(FileTrackingConfig {
1858            region: "files".to_string(),
1859            track_reads: true,
1860            track_writes: true,
1861            max_file_tokens: Some(3000),
1862        });
1863        let json = serde_json::to_string(&bp).unwrap();
1864        let back: Blueprint = serde_json::from_str(&json).unwrap();
1865        let ft = back.file_tracking.unwrap();
1866        assert_eq!(ft.region, "files");
1867        assert_eq!(ft.max_file_tokens, Some(3000));
1868    }
1869
1870    #[test]
1871    fn test_tool_result_routing_default() {
1872        let routing = ToolResultRouting::default();
1873        assert_eq!(routing.default_region, "tool_results");
1874        assert!(routing.persist);
1875        assert!(routing.tool_overrides.is_empty());
1876        assert!(routing.max_result_tokens.is_none());
1877    }
1878
1879    #[test]
1880    fn test_stage_new_has_no_tool_result_routing() {
1881        let stage = Stage::new("plan".to_string(), make_model());
1882        assert!(stage.tool_result_routing.is_none());
1883    }
1884
1885    #[test]
1886    fn test_tool_result_routing_serde_roundtrip() {
1887        let mut routing = ToolResultRouting {
1888            default_region: "custom_region".to_string(),
1889            persist: false,
1890            max_result_tokens: Some(4096),
1891            ..Default::default()
1892        };
1893        routing
1894            .tool_overrides
1895            .insert("read_file".to_string(), "file_reads".to_string());
1896
1897        let json = serde_json::to_string(&routing).unwrap();
1898        let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
1899
1900        assert_eq!(back.default_region, "custom_region");
1901        assert!(!back.persist);
1902        assert_eq!(back.max_result_tokens, Some(4096));
1903        assert_eq!(
1904            back.tool_overrides.get("read_file").map(String::as_str),
1905            Some("file_reads")
1906        );
1907    }
1908
1909    #[test]
1910    fn test_stage_with_tool_result_routing_serde_roundtrip() {
1911        let stages = vec![{
1912            let mut s = Stage::new("plan".to_string(), make_model());
1913            s.tool_result_routing = Some(ToolResultRouting {
1914                default_region: "results".to_string(),
1915                tool_overrides: HashMap::new(),
1916                persist: true,
1917                max_result_tokens: Some(2048),
1918                tool_max_result_tokens: HashMap::new(),
1919            });
1920            s
1921        }];
1922        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1923        let json = serde_json::to_string(&bp).unwrap();
1924        let back: Blueprint = serde_json::from_str(&json).unwrap();
1925
1926        let routing = back.stages[0]
1927            .tool_result_routing
1928            .as_ref()
1929            .expect("tool_result_routing should be Some");
1930        assert_eq!(routing.default_region, "results");
1931        assert!(routing.persist);
1932        assert_eq!(routing.max_result_tokens, Some(2048));
1933        assert!(routing.tool_overrides.is_empty());
1934    }
1935
1936    // ─── fan_out (StageMode::FanOut) ─────────────────────────────────────────
1937
1938    fn fanout_config() -> FanOutConfig {
1939        FanOutConfig {
1940            worker_agent: None,
1941            worker_stage: Some("fix_worker".to_string()),
1942            worker_query: None,
1943            merge_stage: Some("merge".to_string()),
1944            max_workers: 3,
1945            on_worker_failure: WorkerFailurePolicy::Continue,
1946            split_prompt: "split".to_string(),
1947            results_region: None,
1948            max_items: None,
1949            max_attempts: None,
1950        }
1951    }
1952
1953    /// Blueprint: fan_out stage (worker_stage=fix_worker) → merge → terminal.
1954    /// The merge stage carries an (empty) transitions table so the blueprint is
1955    /// in graph mode - this makes `validate_graph` run `has_terminal_path`,
1956    /// which walks the fan-out stage's merge hand-off.
1957    fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
1958        let mut fan = Stage::new("parallel".to_string(), make_model());
1959        fan.mode = StageMode::FanOut { config };
1960        let mut worker = Stage::new("fix_worker".to_string(), make_model());
1961        worker.allow_as_worker = worker_allowed;
1962        let mut merge = Stage::new("merge".to_string(), make_model());
1963        merge.transitions = Some(HashMap::new()); // terminal, graph mode
1964        Blueprint::new(
1965            "t".into(),
1966            "d".into(),
1967            vec![fan, worker, merge],
1968            make_layout(),
1969        )
1970    }
1971
1972    #[test]
1973    fn fanout_stagemode_partial_eq_and_default_policy() {
1974        let a = StageMode::FanOut {
1975            config: fanout_config(),
1976        };
1977        let b = StageMode::FanOut {
1978            config: fanout_config(),
1979        };
1980        assert_eq!(a, b);
1981        let mut other = fanout_config();
1982        other.max_workers = 99;
1983        assert_ne!(a, StageMode::FanOut { config: other });
1984        assert_ne!(a, StageMode::Autonomous);
1985        assert_eq!(
1986            WorkerFailurePolicy::default(),
1987            WorkerFailurePolicy::Continue
1988        );
1989    }
1990
1991    #[test]
1992    fn fanout_config_serde_roundtrip_and_max_workers_default() {
1993        let toml = r#"
1994worker_agent = "fixer"
1995split_prompt = "go"
1996on_worker_failure = "fail_all"
1997"#;
1998        let cfg: FanOutConfig = toml::from_str(toml).unwrap();
1999        assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2000        assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2001        assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2002        assert_eq!(
2003            FanOutConfig {
2004                max_workers: 0,
2005                ..fanout_config()
2006            }
2007            .worker_cap(),
2008            None
2009        );
2010        assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2011        // JSON round-trip preserves everything.
2012        let json = serde_json::to_string(&fanout_config()).unwrap();
2013        let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2014        assert_eq!(back, fanout_config());
2015    }
2016
2017    #[test]
2018    fn fanout_validate_ok_with_allowed_worker_stage() {
2019        assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2020    }
2021
2022    #[test]
2023    fn fanout_validate_rejects_worker_stage_not_opted_in() {
2024        let err = fanout_blueprint(false, fanout_config())
2025            .validate()
2026            .unwrap_err();
2027        assert!(err.to_string().contains("allow_as_worker"));
2028    }
2029
2030    #[test]
2031    fn fanout_validate_rejects_missing_worker_stage() {
2032        let mut cfg = fanout_config();
2033        cfg.worker_stage = Some("nope".to_string());
2034        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2035        assert!(err.to_string().contains("does not exist"));
2036    }
2037
2038    #[test]
2039    fn fanout_validate_rejects_missing_merge_stage() {
2040        let mut cfg = fanout_config();
2041        cfg.merge_stage = Some("nomerge".to_string());
2042        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2043        assert!(err.to_string().contains("merge_stage"));
2044    }
2045
2046    #[test]
2047    fn fanout_validate_rejects_wrong_worker_source_count() {
2048        // zero sources
2049        let mut cfg = fanout_config();
2050        cfg.worker_stage = None;
2051        assert!(fanout_blueprint(true, cfg).validate().is_err());
2052        // two sources
2053        let mut cfg2 = fanout_config();
2054        cfg2.worker_agent = Some("x".to_string()); // plus worker_stage
2055        assert!(fanout_blueprint(true, cfg2).validate().is_err());
2056    }
2057
2058    #[test]
2059    fn fanout_terminal_path_runs_through_merge_stage() {
2060        // worker_agent form (no local worker_stage), merge → terminal.
2061        let mut cfg = fanout_config();
2062        cfg.worker_stage = None;
2063        cfg.worker_agent = Some("external".to_string());
2064        assert!(fanout_blueprint(false, cfg).validate().is_ok());
2065    }
2066
2067    #[test]
2068    fn fanout_validate_ok_without_merge_stage() {
2069        // No merge stage: valid, and the fan-out stage falls through to the
2070        // linear next stage for its terminal path.
2071        let mut cfg = fanout_config();
2072        cfg.merge_stage = None;
2073        assert!(fanout_blueprint(true, cfg).validate().is_ok());
2074    }
2075}