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