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 being
342    /// that a region another stage declares exists, and is still not readable
343    /// 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
363    /// nothing, accepted in silence, sends the routed tool result to the
364    /// default region and leaves the gate holding nothing back - both looking
365    /// exactly like a working config. 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 is
415                // intended.
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. Exhausting a stage's
692                // edges is not a terminal path: running out of edges mid-graph
693                // is a run *error* (StageResolution::DeadEnd in the runtime),
694                // not a completion, so certifying it here would validate
695                // blueprints that can never finish successfully.
696                false
697            }
698        }
699    }
700
701    /// Find a stage by name.
702    pub fn find_stage(&self, name: &str) -> Option<&Stage> {
703        self.stages.iter().find(|s| s.name == name)
704    }
705}
706
707// Sections of the former single-file blueprint, one per concept. Glob
708// re-exported so every existing `blueprint::Stage` path keeps working and the
709// split stays a pure move.
710mod model;
711pub use model::*;
712mod stage;
713pub use stage::*;
714mod transition;
715pub use transition::*;
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use crate::layout::ContextLayout;
721    use crate::layout::RegionDefinition;
722    use crate::region::RegionKind;
723
724    /// Build a blueprint from a manifest, so these read as the TOML an author
725    /// would actually write rather than as hand-assembled structs.
726    fn bp_with_regions(regions_toml: &str) -> Blueprint {
727        crate::manifest::parse_manifest(&format!(
728            r#"
729[agent]
730name = "asked"
731
732[stages.main]
733mode = "autonomous"
734model = {{ provider = "anthropic", model = "m" }}
735
736[context.regions]
737{regions_toml}
738"#
739        ))
740        .expect("fixture parses")
741    }
742
743    #[test]
744    fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
745        // Both spellings: the explicit seed and the region named `task`, which
746        // gets the same seed implicitly.
747        assert!(
748            bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
749                .accepts_task()
750        );
751        assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
752    }
753
754    #[test]
755    fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
756        let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
757        assert!(!bp.accepts_task());
758        assert_eq!(bp.caller_inputs(), ["diff"]);
759    }
760
761    #[test]
762    fn the_refusal_names_what_the_agent_takes_instead() {
763        let bp = bp_with_regions(
764            r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
765criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
766        );
767        let msg = bp.task_refusal();
768        assert!(msg.contains("agent 'asked'"), "{msg}");
769        assert!(msg.contains("it takes: diff, criteria"), "{msg}");
770    }
771
772    #[test]
773    fn the_refusal_says_so_when_the_agent_takes_nothing() {
774        let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
775        assert!(bp.caller_inputs().is_empty());
776        // Bound rather than called inside the assert message: a message
777        // expression only runs when the assert fails, so it would be an
778        // uncovered region on every green run.
779        let msg = bp.task_refusal();
780        assert!(msg.contains("it takes no caller input at all"), "{msg}");
781    }
782
783    #[test]
784    fn resolve_nudge_defaults_when_nothing_is_configured() {
785        // No config anywhere: on for a normal stage, off for a reviewed one,
786        // with the built-in cap and text.
787        let normal = resolve_nudge(None, None, None, false);
788        assert!(normal.enabled);
789        assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
790        assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
791        let reviewed = resolve_nudge(None, None, None, true);
792        assert!(!reviewed.enabled);
793        // The other fields don't depend on review status.
794        assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
795        assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
796    }
797
798    #[test]
799    fn resolve_nudge_cascades_each_field_independently() {
800        let global = NudgeConfig {
801            enabled: Some(true),
802            max: Some(10),
803            text: Some("global".to_string()),
804        };
805        let agent = NudgeConfig {
806            max: Some(2),
807            ..Default::default()
808        };
809        let stage = NudgeConfig {
810            text: Some("stage".to_string()),
811            ..Default::default()
812        };
813        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
814        // enabled from global, max from agent, text from stage.
815        assert!(resolved.enabled);
816        assert_eq!(resolved.max, 2);
817        assert_eq!(resolved.text, "stage");
818        // The stage level wins over both when it sets a field.
819        let stage_all = NudgeConfig {
820            enabled: Some(false),
821            max: Some(0),
822            text: Some("s".to_string()),
823        };
824        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
825        assert_eq!(
826            resolved,
827            ResolvedNudge {
828                enabled: false,
829                max: 0,
830                text: "s".to_string()
831            }
832        );
833    }
834
835    #[test]
836    fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
837        // A reviewed stage is only *implicitly* exempt: any level that sets
838        // `enabled` speaks for itself, in either direction.
839        let on = NudgeConfig {
840            enabled: Some(true),
841            ..Default::default()
842        };
843        assert!(resolve_nudge(None, None, Some(&on), true).enabled);
844        assert!(resolve_nudge(None, Some(&on), None, true).enabled);
845        assert!(resolve_nudge(Some(&on), None, None, true).enabled);
846        let off = NudgeConfig {
847            enabled: Some(false),
848            ..Default::default()
849        };
850        assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
851    }
852
853    #[test]
854    fn test_blueprint_creation() {
855        let regions = vec![RegionDefinition::new(
856            "test".to_string(),
857            RegionKind::Pinned,
858            5000,
859        )];
860        let layout = ContextLayout::new(regions, 10000);
861
862        let stages = vec![Stage::new(
863            "analyze".to_string(),
864            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
865        )];
866
867        let blueprint = Blueprint::new(
868            "test-agent".to_string(),
869            "A test agent".to_string(),
870            stages,
871            layout,
872        );
873
874        assert_eq!(blueprint.name, "test-agent");
875        assert_eq!(blueprint.stages.len(), 1);
876    }
877
878    #[test]
879    fn test_blueprint_with_transforms_version() {
880        let stages = vec![Stage::new("plan".to_string(), make_model())];
881        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
882            .with_transforms(vec![ContextTransform {
883                from_blueprint: "a".to_string(),
884                to_blueprint: "b".to_string(),
885                mappings: vec![],
886            }])
887            .with_version("2.0.0".to_string());
888
889        assert_eq!(bp.transforms.len(), 1);
890        assert_eq!(bp.version, "2.0.0");
891    }
892
893    #[test]
894    fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
895        let stages = vec![Stage::new("plan".to_string(), make_model())];
896        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
897        // A well-formed tool_perm string entry - included.
898        bp.metadata.insert(
899            "tool_perm:bash".to_string(),
900            serde_json::Value::String("deny".to_string()),
901        );
902        // A non-`tool_perm:` key - skipped (strip_prefix returns None).
903        bp.metadata
904            .insert("title".to_string(), serde_json::Value::String("x".into()));
905        // A tool_perm key whose value isn't a string - skipped (as_str is None).
906        bp.metadata
907            .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
908
909        let perms = bp.agent_tool_permissions();
910        assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
911        assert!(!perms.contains_key("title"));
912        assert!(!perms.contains_key("weird"));
913        assert_eq!(perms.len(), 1);
914    }
915
916    #[test]
917    fn test_blueprint_validate_runs_transform_validation() {
918        // A transform whose mapping targets a real region - validate() must
919        // reach ContextTransform::validate() and succeed.
920        let stages = vec![Stage::new("plan".to_string(), make_model())];
921        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
922        bp.transforms.push(ContextTransform {
923            from_blueprint: "a".to_string(),
924            to_blueprint: "b".to_string(),
925            mappings: vec![RegionMapping {
926                from_region: "test".to_string(),
927                to_region: "test".to_string(),
928                transform: None,
929            }],
930        });
931        assert!(bp.validate().is_ok());
932    }
933
934    #[test]
935    fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
936        let stages = vec![Stage::new("plan".to_string(), make_model())];
937        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
938        bp.transforms.push(ContextTransform {
939            from_blueprint: "a".to_string(),
940            to_blueprint: "b".to_string(),
941            mappings: vec![RegionMapping {
942                from_region: "test".to_string(),
943                to_region: "nonexistent".to_string(),
944                transform: None,
945            }],
946        });
947        let err = bp.validate().unwrap_err();
948        assert_eq!(
949            err,
950            ValidationError::Region {
951                region: "nonexistent".to_string(),
952                message: "transform target region not found in layout".to_string(),
953            }
954        );
955    }
956
957    #[test]
958    fn test_mixed_linear_and_graph_mode_terminal_path() {
959        // "plan" has explicit transitions (triggers graph-mode validation),
960        // but "impl" and "review" have none - they must fall back to
961        // linear (next-by-index) terminal-path resolution.
962        let mut plan = Stage::new("plan".to_string(), make_model());
963        let impl_stage = Stage::new("impl".to_string(), make_model());
964        let review = Stage::new("review".to_string(), make_model());
965
966        let mut transitions = HashMap::new();
967        transitions.insert(
968            "impl".to_string(),
969            TransitionEdge {
970                target: "impl".to_string(),
971                condition: TransitionCondition::Always,
972                hint: None,
973                transform: EdgeTransform::Direct,
974                gate: None,
975                stuck: None,
976            },
977        );
978        plan.transitions = Some(transitions);
979
980        let bp = Blueprint::new(
981            "t".into(),
982            "".into(),
983            vec![plan, impl_stage, review],
984            make_layout(),
985        );
986        assert!(bp.validate().is_ok());
987    }
988
989    #[test]
990    fn test_stage_validation() {
991        let stage = Stage::new(
992            "test".to_string(),
993            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
994        );
995        assert!(stage.validate().is_ok());
996
997        let empty_stage = Stage::new(
998            "".to_string(),
999            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1000        );
1001        assert!(empty_stage.validate().is_err());
1002    }
1003
1004    #[test]
1005    fn test_stage_validate_with_valid_context_layout_is_ok() {
1006        let mut stage = Stage::new("test".to_string(), make_model());
1007        stage.context_layout = Some(make_layout());
1008        assert!(stage.validate().is_ok());
1009    }
1010
1011    #[test]
1012    fn test_stage_validate_with_invalid_context_layout_is_err() {
1013        // Duplicate region names make the layout itself invalid.
1014        let regions = vec![
1015            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1016            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1017        ];
1018        let mut stage = Stage::new("test".to_string(), make_model());
1019        stage.context_layout = Some(ContextLayout::new(regions, 200));
1020        assert!(stage.validate().is_err());
1021    }
1022
1023    #[test]
1024    fn test_stage_with_tools_context_layout_description() {
1025        let stage = Stage::new("test".to_string(), make_model())
1026            .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1027            .with_context_layout(make_layout())
1028            .with_description("does things".to_string());
1029
1030        assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1031        assert!(stage.context_layout.is_some());
1032        assert_eq!(stage.description.as_deref(), Some("does things"));
1033    }
1034
1035    #[test]
1036    fn test_stage_with_mode() {
1037        let stage = Stage::new("test".to_string(), make_model())
1038            .with_mode(StageMode::InteractivePoints { points: vec![] });
1039        assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1040    }
1041
1042    #[test]
1043    fn test_stage_allow_complete_defaults_false() {
1044        let stage = Stage::new("review".to_string(), make_model());
1045        assert!(!stage.allow_complete);
1046    }
1047
1048    #[test]
1049    fn test_stage_allow_complete_serde_default_when_missing() {
1050        // A serialized stage from before allow_complete existed must still
1051        // deserialize, defaulting to false.
1052        let json = r#"{
1053            "name": "review",
1054            "description": null,
1055            "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1056            "available_tools": [],
1057            "max_iterations": null,
1058            "context_layout": null,
1059            "config": {},
1060            "transitions": null,
1061            "max_revisits": null,
1062            "transition_prompt": null
1063        }"#;
1064        let stage: Stage = serde_json::from_str(json).unwrap();
1065        assert!(!stage.allow_complete);
1066        assert!(stage.accepts_messages);
1067    }
1068
1069    #[test]
1070    fn test_stage_allow_complete_roundtrip() {
1071        let mut stage = Stage::new("review".to_string(), make_model());
1072        stage.allow_complete = true;
1073        let json = serde_json::to_string(&stage).unwrap();
1074        let back: Stage = serde_json::from_str(&json).unwrap();
1075        assert!(back.allow_complete);
1076    }
1077
1078    #[test]
1079    fn test_interaction_point_directives_default_empty() {
1080        let point = InteractionPoint {
1081            name: "plan_approval".to_string(),
1082            prompt: "Approve?".to_string(),
1083            required: true,
1084            unattended: UnattendedPolicy::AutoApprove,
1085            style: InteractionStyle::MultipleChoice,
1086            options: vec!["Approve".to_string(), "Revise".to_string()],
1087            directives: HashMap::new(),
1088            abort_options: Vec::new(),
1089            edit_options: Vec::new(),
1090            document_region: None,
1091        };
1092        assert!(point.directives.is_empty());
1093        assert!(point.abort_options.is_empty());
1094        assert!(point.edit_options.is_empty());
1095    }
1096
1097    #[test]
1098    fn test_interaction_point_directives_roundtrip() {
1099        let mut directives = HashMap::new();
1100        directives.insert(
1101            "Revise".to_string(),
1102            "Ask what to change, then re-plan.".to_string(),
1103        );
1104        let point = InteractionPoint {
1105            name: "plan_approval".to_string(),
1106            prompt: "Approve?".to_string(),
1107            required: true,
1108            unattended: UnattendedPolicy::Ask,
1109            style: InteractionStyle::MultipleChoice,
1110            options: vec!["Approve".to_string(), "Revise".to_string()],
1111            directives,
1112            abort_options: vec!["Abort".to_string()],
1113            edit_options: vec!["Add detail".to_string()],
1114            document_region: Some("plan".to_string()),
1115        };
1116        let json = serde_json::to_string(&point).unwrap();
1117        let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1118        assert_eq!(
1119            back.directives.get("Revise").map(|s| s.as_str()),
1120            Some("Ask what to change, then re-plan.")
1121        );
1122        assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1123        assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1124        // A point that holds for a person under `--yolo` has to survive the
1125        // round trip: this is what a restored run re-arms from.
1126        assert_eq!(back.unattended, UnattendedPolicy::Ask);
1127    }
1128
1129    #[test]
1130    fn test_interaction_point_directives_serde_default_when_missing() {
1131        let json = r#"{
1132            "name": "plan_approval",
1133            "prompt": "Approve?",
1134            "required": true,
1135            "style": "multiple_choice",
1136            "options": ["Approve", "Revise"]
1137        }"#;
1138        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1139        assert!(point.directives.is_empty());
1140        assert!(point.abort_options.is_empty());
1141    }
1142
1143    #[test]
1144    fn test_interaction_point_followups_alias_still_deserializes() {
1145        // Backward compat: old serialized blueprints used "followups".
1146        let json = r#"{
1147            "name": "plan_approval",
1148            "prompt": "Approve?",
1149            "required": true,
1150            "style": "multiple_choice",
1151            "options": ["Approve", "Revise"],
1152            "followups": { "Revise": "What to change?" }
1153        }"#;
1154        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1155        assert_eq!(
1156            point.directives.get("Revise").map(|s| s.as_str()),
1157            Some("What to change?")
1158        );
1159    }
1160
1161    #[test]
1162    fn test_model_config_new_creates_single_entry() {
1163        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1164        assert_eq!(mc.models.len(), 1);
1165        assert_eq!(mc.models[0].provider, "anthropic");
1166        assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1167        assert!(mc.allow_user_default);
1168    }
1169
1170    #[test]
1171    fn test_model_config_with_multiple_models() {
1172        let mc = ModelConfig {
1173            models: vec![
1174                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1175                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1176                ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1177            ],
1178            allow_user_default: true,
1179            parameters: HashMap::new(),
1180            request_timeout_secs: None,
1181        };
1182        assert_eq!(mc.models.len(), 3);
1183        assert_eq!(mc.models[0].provider, "anthropic");
1184        assert_eq!(mc.models[1].provider, "openai");
1185        assert_eq!(mc.models[2].provider, "ollama");
1186    }
1187
1188    #[test]
1189    fn test_model_config_serde_roundtrip() {
1190        let mc = ModelConfig {
1191            models: vec![
1192                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1193                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1194            ],
1195            allow_user_default: false,
1196            parameters: HashMap::new(),
1197            request_timeout_secs: None,
1198        };
1199        let json = serde_json::to_string(&mc).unwrap();
1200        let back: ModelConfig = serde_json::from_str(&json).unwrap();
1201        assert_eq!(back.models.len(), 2);
1202        assert_eq!(back.models[0].provider, "anthropic");
1203        assert_eq!(back.models[1].provider, "openai");
1204        assert!(!back.allow_user_default);
1205    }
1206
1207    #[test]
1208    fn test_model_config_serde_defaults_when_fields_missing() {
1209        // Minimal JSON - models defaults to empty, allow_user_default defaults to true
1210        let json = r#"{"parameters": {}}"#;
1211        let mc: ModelConfig = serde_json::from_str(json).unwrap();
1212        assert!(mc.models.is_empty());
1213        assert!(mc.allow_user_default);
1214    }
1215
1216    #[test]
1217    fn test_model_config_convenience_accessors() {
1218        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1219        assert_eq!(mc.provider(), "anthropic");
1220        assert_eq!(mc.model(), "claude-sonnet-4-6");
1221    }
1222
1223    #[test]
1224    fn test_model_config_convenience_accessors_empty_models() {
1225        let mc = ModelConfig {
1226            models: vec![],
1227            allow_user_default: true,
1228            parameters: HashMap::new(),
1229            request_timeout_secs: None,
1230        };
1231        assert_eq!(mc.provider(), "anthropic");
1232        assert_eq!(mc.model(), "claude-sonnet-4-6");
1233    }
1234
1235    fn make_model() -> ModelConfig {
1236        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1237    }
1238
1239    fn make_layout() -> ContextLayout {
1240        let regions = vec![RegionDefinition::new(
1241            "test".to_string(),
1242            RegionKind::Pinned,
1243            5000,
1244        )];
1245        ContextLayout::new(regions, 10000)
1246    }
1247
1248    #[test]
1249    fn test_graph_validation_entry_stage_exists() {
1250        let stages = vec![Stage::new("plan".to_string(), make_model())];
1251        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1252        bp.entry_stage = Some("nonexistent".to_string());
1253        assert!(bp.validate().is_err());
1254    }
1255
1256    #[test]
1257    fn test_graph_validation_entry_stage_valid() {
1258        let stages = vec![Stage::new("plan".to_string(), make_model())];
1259        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1260        bp.entry_stage = Some("plan".to_string());
1261        assert!(bp.validate().is_ok());
1262    }
1263
1264    #[test]
1265    fn test_graph_validation_transition_target_missing() {
1266        let mut stage = Stage::new("plan".to_string(), make_model());
1267        let mut transitions = HashMap::new();
1268        transitions.insert(
1269            "nonexistent".to_string(),
1270            TransitionEdge {
1271                target: "nonexistent".to_string(),
1272                condition: TransitionCondition::Always,
1273                hint: None,
1274                transform: EdgeTransform::Direct,
1275                gate: None,
1276                stuck: None,
1277            },
1278        );
1279        stage.transitions = Some(transitions);
1280        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1281        assert!(bp.validate().is_err());
1282    }
1283
1284    /// A `require_modifications` gate on a stage that can't modify anything
1285    /// could never be satisfied - it would just burn the stage's re-run budget
1286    /// on every pass. Reject it at load time instead.
1287    #[test]
1288    fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1289        let gated = |tools: &[&str], extra: &[&str]| {
1290            let mut stage = Stage::new("impl".to_string(), make_model());
1291            stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1292            let mut transitions = HashMap::new();
1293            transitions.insert(
1294                "review".to_string(),
1295                TransitionEdge {
1296                    target: "review".to_string(),
1297                    condition: TransitionCondition::Always,
1298                    hint: None,
1299                    transform: EdgeTransform::Direct,
1300                    stuck: None,
1301                    gate: Some(TransitionGate {
1302                        require_modifications: true,
1303                        tools: extra.iter().map(|t| t.to_string()).collect(),
1304                        ..Default::default()
1305                    }),
1306                },
1307            );
1308            stage.transitions = Some(transitions);
1309            Blueprint::new(
1310                "t".into(),
1311                "".into(),
1312                vec![stage, Stage::new("review".to_string(), make_model())],
1313                make_layout(),
1314            )
1315        };
1316        let err = gated(&["read_file"], &[]).validate().unwrap_err();
1317        assert!(err.to_string().contains("no file-modifying tool"));
1318        // A built-in write tool satisfies it...
1319        assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1320        // ...as does one the gate itself declares (MCP / script toolchains).
1321        assert!(
1322            gated(&["read_file", "patch_file"], &["patch_file"])
1323                .validate()
1324                .is_ok()
1325        );
1326        // A gate that doesn't require modifications is never checked.
1327        let mut off = gated(&["read_file"], &[]);
1328        off.stages[0]
1329            .transitions
1330            .as_mut()
1331            .unwrap()
1332            .get_mut("review")
1333            .unwrap()
1334            .gate = Some(TransitionGate::default());
1335        assert!(off.validate().is_ok());
1336        // Neither is an edge with no gate at all.
1337        off.stages[0]
1338            .transitions
1339            .as_mut()
1340            .unwrap()
1341            .get_mut("review")
1342            .unwrap()
1343            .gate = None;
1344        assert!(off.validate().is_ok());
1345    }
1346
1347    #[test]
1348    fn test_graph_validation_self_loop_requires_max_revisits() {
1349        let mut stage = Stage::new("impl".to_string(), make_model());
1350        let mut transitions = HashMap::new();
1351        transitions.insert(
1352            "impl".to_string(),
1353            TransitionEdge {
1354                target: "impl".to_string(),
1355                condition: TransitionCondition::Always,
1356                hint: None,
1357                transform: EdgeTransform::Direct,
1358                gate: None,
1359                stuck: None,
1360            },
1361        );
1362        stage.transitions = Some(transitions);
1363        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1364        assert!(bp.validate().is_err());
1365    }
1366
1367    #[test]
1368    fn test_graph_validation_self_loop_with_max_revisits_ok() {
1369        let mut stage = Stage::new("impl".to_string(), make_model());
1370        stage.max_revisits = Some(3);
1371        let mut transitions = HashMap::new();
1372        transitions.insert(
1373            "impl".to_string(),
1374            TransitionEdge {
1375                target: "impl".to_string(),
1376                condition: TransitionCondition::Always,
1377                hint: None,
1378                transform: EdgeTransform::Direct,
1379                gate: None,
1380                stuck: None,
1381            },
1382        );
1383        stage.transitions = Some(transitions);
1384        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1385        // Must fail: a self-loop exhausting its max_revisits leaves zero
1386        // edges, which is a run error (StageResolution::DeadEnd) and not a
1387        // terminal path, so a blueprint whose only ending is exhaustion can
1388        // never finish successfully.
1389        let err = bp
1390            .validate()
1391            .expect_err("an exhaustion-only graph is invalid");
1392        assert!(err.to_string().contains("no terminal path"), "{err}");
1393    }
1394
1395    #[test]
1396    fn test_graph_validation_terminal_path_exists() {
1397        let mut plan = Stage::new("plan".to_string(), make_model());
1398        let mut review = Stage::new("review".to_string(), make_model());
1399        review.transitions = Some(HashMap::new()); // terminal: no outgoing
1400
1401        let mut transitions = HashMap::new();
1402        transitions.insert(
1403            "review".to_string(),
1404            TransitionEdge {
1405                target: "review".to_string(),
1406                condition: TransitionCondition::Always,
1407                hint: None,
1408                transform: EdgeTransform::Direct,
1409                gate: None,
1410                stuck: None,
1411            },
1412        );
1413        plan.transitions = Some(transitions);
1414
1415        let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1416        assert!(bp.validate().is_ok());
1417    }
1418
1419    #[test]
1420    fn test_graph_no_terminal_path() {
1421        // Two stages that only transition to each other with no terminal
1422        let mut a = Stage::new("a".to_string(), make_model());
1423        let mut b = Stage::new("b".to_string(), make_model());
1424
1425        let mut a_transitions = HashMap::new();
1426        a_transitions.insert(
1427            "b".to_string(),
1428            TransitionEdge {
1429                target: "b".to_string(),
1430                condition: TransitionCondition::Always,
1431                hint: None,
1432                transform: EdgeTransform::Direct,
1433                gate: None,
1434                stuck: None,
1435            },
1436        );
1437        a.transitions = Some(a_transitions);
1438
1439        let mut b_transitions = HashMap::new();
1440        b_transitions.insert(
1441            "a".to_string(),
1442            TransitionEdge {
1443                target: "a".to_string(),
1444                condition: TransitionCondition::Always,
1445                hint: None,
1446                transform: EdgeTransform::Direct,
1447                gate: None,
1448                stuck: None,
1449            },
1450        );
1451        b.transitions = Some(b_transitions);
1452
1453        let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1454        assert!(bp.validate().is_err());
1455    }
1456
1457    #[test]
1458    fn test_linear_stages_still_validate() {
1459        // No transitions set at all - pure linear mode
1460        let stages = vec![
1461            Stage::new("plan".to_string(), make_model()),
1462            Stage::new("impl".to_string(), make_model()),
1463            Stage::new("review".to_string(), make_model()),
1464        ];
1465        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1466        assert!(bp.validate().is_ok());
1467    }
1468
1469    #[test]
1470    fn test_resolve_entry_stage_name() {
1471        let stages = vec![
1472            Stage::new("plan".to_string(), make_model()),
1473            Stage::new("impl".to_string(), make_model()),
1474        ];
1475        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1476        assert_eq!(bp.resolve_entry_stage_name(), "plan");
1477
1478        bp.entry_stage = Some("impl".to_string());
1479        assert_eq!(bp.resolve_entry_stage_name(), "impl");
1480    }
1481
1482    #[test]
1483    fn test_find_stage() {
1484        let stages = vec![
1485            Stage::new("plan".to_string(), make_model()),
1486            Stage::new("impl".to_string(), make_model()),
1487        ];
1488        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1489        assert!(bp.find_stage("plan").is_some());
1490        assert!(bp.find_stage("impl").is_some());
1491        assert!(bp.find_stage("nonexistent").is_none());
1492    }
1493
1494    #[test]
1495    fn test_transition_condition_default() {
1496        let cond = TransitionCondition::default();
1497        assert_eq!(cond, TransitionCondition::Always);
1498    }
1499
1500    #[test]
1501    fn test_edge_transform_default() {
1502        let t = EdgeTransform::default();
1503        assert_eq!(t, EdgeTransform::Direct);
1504    }
1505
1506    #[test]
1507    fn test_stage_mode_equality() {
1508        assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1509        assert_eq!(StageMode::Interactive, StageMode::Interactive);
1510        assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1511    }
1512
1513    #[test]
1514    fn test_interaction_style_equality() {
1515        assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1516        assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1517    }
1518
1519    // ─── stuck detection ────────────────────────────────────────────────────
1520
1521    #[test]
1522    fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1523        assert!(!StuckConfig::default().is_armed());
1524        for cfg in [
1525            StuckConfig {
1526                after_iterations: Some(1),
1527                ..Default::default()
1528            },
1529            StuckConfig {
1530                after_minutes: Some(1),
1531                ..Default::default()
1532            },
1533            StuckConfig {
1534                after_same_file_edits: Some(1),
1535                ..Default::default()
1536            },
1537            StuckConfig {
1538                after_tool_calls: Some(1),
1539                ..Default::default()
1540            },
1541        ] {
1542            assert!(cfg.is_armed(), "{cfg:?} should be armed");
1543        }
1544    }
1545
1546    #[test]
1547    fn transition_condition_stuck_round_trips_as_snake_case() {
1548        let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1549        assert_eq!(json, "\"stuck\"");
1550        let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1551        assert_eq!(back, TransitionCondition::Stuck);
1552        assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1553    }
1554
1555    #[test]
1556    fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1557        let plain = TransitionEdge {
1558            target: "b".to_string(),
1559            condition: TransitionCondition::Always,
1560            hint: None,
1561            transform: EdgeTransform::Direct,
1562            gate: None,
1563            stuck: None,
1564        };
1565        let json = serde_json::to_string(&plain).unwrap();
1566        assert!(
1567            !json.contains("stuck"),
1568            "absent config must be skipped: {json}"
1569        );
1570
1571        let armed = TransitionEdge {
1572            condition: TransitionCondition::Stuck,
1573            stuck: Some(StuckConfig {
1574                after_iterations: Some(20),
1575                after_minutes: Some(10),
1576                after_same_file_edits: Some(3),
1577                after_tool_calls: Some(60),
1578            }),
1579            ..plain
1580        };
1581        let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1582            .expect("armed edge round-trips");
1583        assert_eq!(back.condition, TransitionCondition::Stuck);
1584        assert_eq!(back.stuck, armed.stuck);
1585    }
1586
1587    /// A blueprint built programmatically (API / `lev validate`) bypasses the
1588    /// manifest parser, so `validate` has to catch the dead-edge shape too.
1589    #[test]
1590    fn validate_rejects_a_stuck_edge_with_no_threshold() {
1591        let build = |stuck| {
1592            let mut a = Stage::new("a".to_string(), make_model());
1593            let b = Stage::new("b".to_string(), make_model());
1594            let mut transitions = std::collections::HashMap::new();
1595            transitions.insert(
1596                "b".to_string(),
1597                TransitionEdge {
1598                    target: "b".to_string(),
1599                    condition: TransitionCondition::Stuck,
1600                    hint: None,
1601                    transform: EdgeTransform::Direct,
1602                    gate: None,
1603                    stuck,
1604                },
1605            );
1606            a.transitions = Some(transitions);
1607            Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1608        };
1609
1610        for dead in [None, Some(StuckConfig::default())] {
1611            let err = build(dead)
1612                .validate()
1613                .expect_err("dead stuck edge rejected");
1614            assert!(
1615                format!("{err:?}").contains("stuck_after_"),
1616                "unexpected error: {err:?}"
1617            );
1618        }
1619
1620        // The same graph with a real threshold is fine.
1621        assert!(
1622            build(Some(StuckConfig {
1623                after_iterations: Some(5),
1624                ..Default::default()
1625            }))
1626            .validate()
1627            .is_ok()
1628        );
1629    }
1630
1631    /// `required_tools` keeps a blocking human tool through an unattended run.
1632    /// Naming one the stage can't call keeps nothing, so it is rejected rather
1633    /// than quietly ignored - the author meant something by writing it.
1634    #[test]
1635    fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1636        let mut stage = Stage::new("plan".to_string(), make_model());
1637        stage.available_tools = vec!["read_file".to_string()];
1638        stage.required_tools = vec!["ask_user_text".to_string()];
1639        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1640
1641        let err = bp.validate().expect_err("a tool it cannot call");
1642        let text = format!("{err:?}");
1643        assert!(text.contains("ask_user_text"), "names the tool: {text}");
1644        assert!(text.contains("available_tools"), "says why: {text}");
1645    }
1646
1647    #[test]
1648    fn validate_accepts_a_required_tool_the_stage_offers() {
1649        let mut stage = Stage::new("plan".to_string(), make_model());
1650        stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1651        stage.required_tools = vec!["ask_user_text".to_string()];
1652        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1653
1654        bp.validate().expect("the tool is on offer");
1655    }
1656
1657    /// A stage required to produce an output, without the tool that produces
1658    /// one, would spend its whole re-entry budget being nudged toward a tool it
1659    /// was never offered and then give up. Caught at load instead.
1660    #[test]
1661    fn validate_rejects_require_output_without_the_submit_tool() {
1662        let mut stage = Stage::new("summary".to_string(), make_model());
1663        stage.available_tools = vec!["read_file".to_string()];
1664        stage.require_output = true;
1665        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1666
1667        let err = bp.validate().expect_err("no way to submit");
1668        let text = format!("{err:?}");
1669        assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
1670        assert!(text.contains("require_output"), "says why: {text}");
1671    }
1672
1673    #[test]
1674    fn validate_accepts_require_output_when_the_stage_can_submit() {
1675        let mut stage = Stage::new("summary".to_string(), make_model());
1676        stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
1677        stage.require_output = true;
1678        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1679
1680        bp.validate().expect("the stage can submit");
1681    }
1682
1683    /// Declaring a shape is not the same as demanding one, so a stage carrying
1684    /// only an `output` block needs no tool grant.
1685    #[test]
1686    fn validate_accepts_a_declared_shape_without_require_output() {
1687        let mut stage = Stage::new("summary".to_string(), make_model());
1688        stage.available_tools = vec!["read_file".to_string()];
1689        stage.output = Some(crate::output::OutputSpec {
1690            format: Some("a2ui".to_string()),
1691            ..Default::default()
1692        });
1693        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1694
1695        bp.validate().expect("declaring a shape demands nothing");
1696    }
1697
1698    #[test]
1699    fn output_mode_compares_equal_only_to_itself() {
1700        assert_eq!(StageMode::Output, StageMode::Output);
1701        assert_ne!(StageMode::Output, StageMode::Autonomous);
1702        assert_ne!(StageMode::Autonomous, StageMode::Output);
1703    }
1704
1705    #[test]
1706    fn test_transition_condition_equality() {
1707        assert_eq!(
1708            TransitionCondition::LlmChoice,
1709            TransitionCondition::LlmChoice
1710        );
1711        assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1712    }
1713
1714    #[test]
1715    fn test_edge_transform_compact_and_custom_equality() {
1716        let a = EdgeTransform::Compact {
1717            prompt: Some("p".to_string()),
1718        };
1719        let b = EdgeTransform::Compact {
1720            prompt: Some("p".to_string()),
1721        };
1722        assert_eq!(a, b);
1723
1724        let c1 = EdgeTransform::Custom {
1725            carry: vec!["a".to_string()],
1726            compact: vec!["b".to_string()],
1727            clear: vec!["c".to_string()],
1728            compact_prompt: Some("p".to_string()),
1729        };
1730        let c2 = c1.clone();
1731        assert_eq!(c1, c2);
1732
1733        assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1734    }
1735
1736    #[test]
1737    fn test_stage_accepts_messages_default_true() {
1738        let stage = Stage::new(
1739            "test".to_string(),
1740            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1741        );
1742        assert!(stage.accepts_messages);
1743    }
1744
1745    #[test]
1746    fn test_stage_accepts_messages_serde_roundtrip() {
1747        // Serialize a stage with accepts_messages = false, then deserialize
1748        let mut stage = Stage::new(
1749            "report".to_string(),
1750            ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1751        );
1752        stage.accepts_messages = false;
1753
1754        let json = serde_json::to_string(&stage).expect("should serialize");
1755        let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1756        assert!(!deserialized.accepts_messages);
1757    }
1758
1759    #[test]
1760    fn test_stage_accepts_messages_json_default() {
1761        // When accepts_messages is missing from JSON, it should default to true
1762        let json = r#"{
1763            "name": "analyze",
1764            "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1765            "available_tools": [],
1766            "mode": "Autonomous",
1767            "config": {},
1768            "tool_permissions": {},
1769            "requires_children": false
1770        }"#;
1771        let stage: Stage = serde_json::from_str(json).expect("should parse");
1772        assert!(stage.accepts_messages);
1773    }
1774
1775    #[test]
1776    fn test_has_terminal_path_unknown_stage_returns_false() {
1777        // `has_terminal_path` is private; this test is in the same module.
1778        // Calling it with a stage name that doesn't exist in the Blueprint
1779        // exercises the `None => return false` arm (blueprint.rs line 203).
1780        let stages = vec![Stage::new("start".to_string(), make_model())];
1781        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1782        let mut visited = std::collections::HashSet::new();
1783        assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1784    }
1785
1786    #[test]
1787    fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1788        let regions = vec![
1789            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1790            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1791        ];
1792        let layout = ContextLayout::new(regions, 200);
1793        let stages = vec![Stage::new("start".to_string(), make_model())];
1794        let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
1795        assert_eq!(
1796            bp.validate().unwrap_err(),
1797            ValidationError::Region {
1798                region: "dup".to_string(),
1799                message: "duplicate region name".to_string(),
1800            }
1801        );
1802    }
1803
1804    #[test]
1805    fn test_blueprint_validate_fails_when_stage_has_empty_name() {
1806        let stages = vec![Stage::new("".to_string(), make_model())];
1807        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1808        assert_eq!(
1809            bp.validate().unwrap_err(),
1810            ValidationError::Stage {
1811                stage: "(empty)".to_string(),
1812                message: "stage name cannot be empty".to_string(),
1813            }
1814        );
1815    }
1816
1817    #[test]
1818    fn test_file_tracking_config_defaults() {
1819        let json = r#"{"region": "files"}"#;
1820        let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
1821        assert_eq!(config.region, "files");
1822        assert!(config.track_reads);
1823        assert!(config.track_writes);
1824        assert!(config.max_file_tokens.is_none());
1825    }
1826
1827    #[test]
1828    fn test_file_tracking_config_serde_roundtrip() {
1829        let config = FileTrackingConfig {
1830            region: "files".to_string(),
1831            track_reads: true,
1832            track_writes: false,
1833            max_file_tokens: Some(5000),
1834        };
1835        let json = serde_json::to_string(&config).unwrap();
1836        let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
1837        assert_eq!(back.region, "files");
1838        assert!(back.track_reads);
1839        assert!(!back.track_writes);
1840        assert_eq!(back.max_file_tokens, Some(5000));
1841    }
1842
1843    #[test]
1844    fn test_blueprint_file_tracking_default_none() {
1845        let stages = vec![Stage::new("plan".to_string(), make_model())];
1846        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1847        assert!(bp.file_tracking.is_none());
1848    }
1849
1850    #[test]
1851    fn test_blueprint_file_tracking_serde_roundtrip() {
1852        let stages = vec![Stage::new("plan".to_string(), make_model())];
1853        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1854        bp.file_tracking = Some(FileTrackingConfig {
1855            region: "files".to_string(),
1856            track_reads: true,
1857            track_writes: true,
1858            max_file_tokens: Some(3000),
1859        });
1860        let json = serde_json::to_string(&bp).unwrap();
1861        let back: Blueprint = serde_json::from_str(&json).unwrap();
1862        let ft = back.file_tracking.unwrap();
1863        assert_eq!(ft.region, "files");
1864        assert_eq!(ft.max_file_tokens, Some(3000));
1865    }
1866
1867    #[test]
1868    fn test_tool_result_routing_default() {
1869        let routing = ToolResultRouting::default();
1870        assert_eq!(routing.default_region, "tool_results");
1871        assert!(routing.persist);
1872        assert!(routing.tool_overrides.is_empty());
1873        assert!(routing.max_result_tokens.is_none());
1874    }
1875
1876    #[test]
1877    fn test_stage_new_has_no_tool_result_routing() {
1878        let stage = Stage::new("plan".to_string(), make_model());
1879        assert!(stage.tool_result_routing.is_none());
1880    }
1881
1882    #[test]
1883    fn test_tool_result_routing_serde_roundtrip() {
1884        let mut routing = ToolResultRouting {
1885            default_region: "custom_region".to_string(),
1886            persist: false,
1887            max_result_tokens: Some(4096),
1888            ..Default::default()
1889        };
1890        routing
1891            .tool_overrides
1892            .insert("read_file".to_string(), "file_reads".to_string());
1893
1894        let json = serde_json::to_string(&routing).unwrap();
1895        let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
1896
1897        assert_eq!(back.default_region, "custom_region");
1898        assert!(!back.persist);
1899        assert_eq!(back.max_result_tokens, Some(4096));
1900        assert_eq!(
1901            back.tool_overrides.get("read_file").map(String::as_str),
1902            Some("file_reads")
1903        );
1904    }
1905
1906    #[test]
1907    fn test_stage_with_tool_result_routing_serde_roundtrip() {
1908        let stages = vec![{
1909            let mut s = Stage::new("plan".to_string(), make_model());
1910            s.tool_result_routing = Some(ToolResultRouting {
1911                default_region: "results".to_string(),
1912                tool_overrides: HashMap::new(),
1913                persist: true,
1914                max_result_tokens: Some(2048),
1915                tool_max_result_tokens: HashMap::new(),
1916            });
1917            s
1918        }];
1919        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1920        let json = serde_json::to_string(&bp).unwrap();
1921        let back: Blueprint = serde_json::from_str(&json).unwrap();
1922
1923        let routing = back.stages[0]
1924            .tool_result_routing
1925            .as_ref()
1926            .expect("tool_result_routing should be Some");
1927        assert_eq!(routing.default_region, "results");
1928        assert!(routing.persist);
1929        assert_eq!(routing.max_result_tokens, Some(2048));
1930        assert!(routing.tool_overrides.is_empty());
1931    }
1932
1933    // ─── fan_out (StageMode::FanOut) ─────────────────────────────────────────
1934
1935    fn fanout_config() -> FanOutConfig {
1936        FanOutConfig {
1937            worker_agent: None,
1938            worker_stage: Some("fix_worker".to_string()),
1939            worker_query: None,
1940            merge_stage: Some("merge".to_string()),
1941            max_workers: 3,
1942            on_worker_failure: WorkerFailurePolicy::Continue,
1943            split_prompt: "split".to_string(),
1944            results_region: None,
1945            max_items: None,
1946            max_attempts: None,
1947        }
1948    }
1949
1950    /// Blueprint: fan_out stage (worker_stage=fix_worker) → merge → terminal.
1951    /// The merge stage carries an (empty) transitions table so the blueprint is
1952    /// in graph mode - this makes `validate_graph` run `has_terminal_path`,
1953    /// which walks the fan-out stage's merge hand-off.
1954    fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
1955        let mut fan = Stage::new("parallel".to_string(), make_model());
1956        fan.mode = StageMode::FanOut { config };
1957        let mut worker = Stage::new("fix_worker".to_string(), make_model());
1958        worker.allow_as_worker = worker_allowed;
1959        let mut merge = Stage::new("merge".to_string(), make_model());
1960        merge.transitions = Some(HashMap::new()); // terminal, graph mode
1961        Blueprint::new(
1962            "t".into(),
1963            "d".into(),
1964            vec![fan, worker, merge],
1965            make_layout(),
1966        )
1967    }
1968
1969    #[test]
1970    fn fanout_stagemode_partial_eq_and_default_policy() {
1971        let a = StageMode::FanOut {
1972            config: fanout_config(),
1973        };
1974        let b = StageMode::FanOut {
1975            config: fanout_config(),
1976        };
1977        assert_eq!(a, b);
1978        let mut other = fanout_config();
1979        other.max_workers = 99;
1980        assert_ne!(a, StageMode::FanOut { config: other });
1981        assert_ne!(a, StageMode::Autonomous);
1982        assert_eq!(
1983            WorkerFailurePolicy::default(),
1984            WorkerFailurePolicy::Continue
1985        );
1986    }
1987
1988    #[test]
1989    fn fanout_config_serde_roundtrip_and_max_workers_default() {
1990        let toml = r#"
1991worker_agent = "fixer"
1992split_prompt = "go"
1993on_worker_failure = "fail_all"
1994"#;
1995        let cfg: FanOutConfig = toml::from_str(toml).unwrap();
1996        assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
1997        assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
1998        assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
1999        assert_eq!(
2000            FanOutConfig {
2001                max_workers: 0,
2002                ..fanout_config()
2003            }
2004            .worker_cap(),
2005            None
2006        );
2007        assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2008        // JSON round-trip preserves everything.
2009        let json = serde_json::to_string(&fanout_config()).unwrap();
2010        let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2011        assert_eq!(back, fanout_config());
2012    }
2013
2014    #[test]
2015    fn fanout_validate_ok_with_allowed_worker_stage() {
2016        assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2017    }
2018
2019    #[test]
2020    fn fanout_validate_rejects_worker_stage_not_opted_in() {
2021        let err = fanout_blueprint(false, fanout_config())
2022            .validate()
2023            .unwrap_err();
2024        assert!(err.to_string().contains("allow_as_worker"));
2025    }
2026
2027    #[test]
2028    fn fanout_validate_rejects_missing_worker_stage() {
2029        let mut cfg = fanout_config();
2030        cfg.worker_stage = Some("nope".to_string());
2031        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2032        assert!(err.to_string().contains("does not exist"));
2033    }
2034
2035    #[test]
2036    fn fanout_validate_rejects_missing_merge_stage() {
2037        let mut cfg = fanout_config();
2038        cfg.merge_stage = Some("nomerge".to_string());
2039        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2040        assert!(err.to_string().contains("merge_stage"));
2041    }
2042
2043    #[test]
2044    fn fanout_validate_rejects_wrong_worker_source_count() {
2045        // zero sources
2046        let mut cfg = fanout_config();
2047        cfg.worker_stage = None;
2048        assert!(fanout_blueprint(true, cfg).validate().is_err());
2049        // two sources
2050        let mut cfg2 = fanout_config();
2051        cfg2.worker_agent = Some("x".to_string()); // plus worker_stage
2052        assert!(fanout_blueprint(true, cfg2).validate().is_err());
2053    }
2054
2055    #[test]
2056    fn fanout_terminal_path_runs_through_merge_stage() {
2057        // worker_agent form (no local worker_stage), merge → terminal.
2058        let mut cfg = fanout_config();
2059        cfg.worker_stage = None;
2060        cfg.worker_agent = Some("external".to_string());
2061        assert!(fanout_blueprint(false, cfg).validate().is_ok());
2062    }
2063
2064    #[test]
2065    fn fanout_validate_ok_without_merge_stage() {
2066        // No merge stage: valid, and the fan-out stage falls through to the
2067        // linear next stage for its terminal path.
2068        let mut cfg = fanout_config();
2069        cfg.merge_stage = None;
2070        assert!(fanout_blueprint(true, cfg).validate().is_ok());
2071    }
2072}