1use crate::error::ValidationError;
9use crate::layout::{ContextLayout, RegionSeed};
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13
14pub const ALWAYS_VISIBLE_REGIONS: [&str; 4] = [
22 "conversation",
23 "tool_results",
24 "final_output",
25 crate::layout::STAGE_INSTRUCTIONS_REGION,
26];
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Blueprint {
35 pub name: String,
37
38 pub description: String,
40
41 pub stages: Vec<Stage>,
43
44 pub context_layout: ContextLayout,
46
47 pub transforms: Vec<ContextTransform>,
49
50 pub version: String,
52
53 pub compaction_config: Option<CompactionConfig>,
55
56 pub max_child_depth: Option<usize>,
58
59 pub entry_stage: Option<String>,
61
62 pub metadata: HashMap<String, serde_json::Value>,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub security: Option<crate::taint::SecurityConfig>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub batch_tool_hint: Option<bool>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub shell_hint: Option<bool>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub nudge: Option<NudgeConfig>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub repetition_detection: Option<RepetitionDetectionConfig>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub file_tracking: Option<FileTrackingConfig>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
100
101 #[serde(default)]
106 pub dynamic_tools: bool,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub read_paths: Option<ReadPathsConfig>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub safe_commands: Option<SafeCommandsConfig>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub output: Option<crate::output::OutputSpec>,
138
139 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
144 pub mime_types: toml::Table,
145
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub dependencies: Vec<Dependency>,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
165pub struct SafeCommandsConfig {
166 #[serde(default)]
168 pub tools: Vec<String>,
169 #[serde(default)]
172 pub shell: Vec<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ReadPathsConfig {
179 #[serde(default)]
188 pub allow: Vec<String>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct Dependency {
201 pub name: String,
203
204 #[serde(flatten)]
206 pub kind: DependencyKind,
207
208 #[serde(default = "default_dependency_required")]
211 pub required: bool,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub remedy: Option<String>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub description: Option<String>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub install: Option<DependencyInstall>,
227}
228
229fn default_dependency_required() -> bool {
232 true
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "kind", rename_all = "snake_case")]
238pub enum DependencyKind {
239 McpServer {
243 server: String,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 env: Vec<String>,
249 },
250 Env {
252 var: String,
254 },
255 Binary {
257 command: String,
259 },
260 Script {
264 check: String,
266 },
267}
268
269impl DependencyKind {
270 pub fn tag(&self) -> &'static str {
273 match self {
274 DependencyKind::McpServer { .. } => "mcp_server",
275 DependencyKind::Env { .. } => "env",
276 DependencyKind::Binary { .. } => "binary",
277 DependencyKind::Script { .. } => "script",
278 }
279 }
280}
281
282#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct DependencyInstall {
290 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub command: Option<String>,
294
295 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
298 pub commands: BTreeMap<String, String>,
299
300 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub script: Option<String>,
305
306 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub server: Option<McpServerTemplate>,
311}
312
313#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct McpServerTemplate {
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub transport: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub command: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub url: Option<String>,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub args: Vec<String>,
331 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
334 pub headers: BTreeMap<String, String>,
335 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
340 pub env: BTreeMap<String, String>,
341}
342
343impl Blueprint {
344 pub fn new(
346 name: String,
347 description: String,
348 stages: Vec<Stage>,
349 context_layout: ContextLayout,
350 ) -> Self {
351 Self {
352 name,
353 description,
354 stages,
355 context_layout,
356 transforms: Vec::new(),
357 version: "0.1.0".to_string(),
358 compaction_config: None,
359 max_child_depth: None,
360 entry_stage: None,
361 metadata: HashMap::new(),
362 security: None,
363 batch_tool_hint: None,
364 shell_hint: None,
365 nudge: None,
366 repetition_detection: None,
367 file_tracking: None,
368 sandbox: None,
369 dynamic_tools: false,
370 read_paths: None,
371 safe_commands: None,
372 output: None,
373 mime_types: toml::Table::new(),
374 dependencies: Vec::new(),
375 }
376 }
377
378 pub fn accepts_task(&self) -> bool {
386 self.context_layout
387 .regions
388 .iter()
389 .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
390 }
391
392 pub fn requires_task(&self) -> bool {
397 self.context_layout.regions.iter().any(|r| {
398 r.required
399 && matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task")
400 })
401 }
402
403 pub fn stage_inputs(&self, stage: &Stage) -> Vec<String> {
412 if !stage.input_accepts.is_empty() {
413 return stage.input_accepts.clone();
414 }
415 let layout = stage
416 .context_layout
417 .as_ref()
418 .unwrap_or(&self.context_layout);
419 let mut out: Vec<String> = Vec::new();
420 for region in &layout.regions {
421 if stage.context_hide.contains(®ion.name) {
422 continue;
423 }
424 let patterns: Vec<String> = match region.accepts.is_empty() {
425 true => vec!["*/*".to_string()],
426 false => region.accepts.clone(),
427 };
428 for p in patterns {
429 if !p.starts_with("text/") && !out.contains(&p) {
430 out.push(p);
431 }
432 }
433 }
434 out
435 }
436
437 pub fn caller_inputs(&self) -> Vec<&str> {
440 self.context_layout
441 .regions
442 .iter()
443 .filter_map(|r| match &r.seed {
444 Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
445 _ => None,
446 })
447 .collect()
448 }
449
450 pub fn task_refusal(&self) -> String {
456 let inputs = self.caller_inputs();
457 let takes = match inputs.is_empty() {
458 true => "it takes no caller input at all".to_string(),
459 false => format!("it takes: {}", inputs.join(", ")),
460 };
461 format!(
462 "agent '{}' was given a task but declares no region to put it in, so the task \
463 would be ignored - {takes}. Add a region seeded from the task, for example:\n\
464 [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
465 required = true, seed = \"task\" }}",
466 self.name,
467 )
468 }
469
470 pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
477 self.metadata
478 .iter()
479 .filter_map(|(k, v)| {
480 Some((
481 k.strip_prefix("tool_perm:")?.to_string(),
482 v.as_str()?.to_string(),
483 ))
484 })
485 .collect()
486 }
487
488 pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
490 self.transforms = transforms;
491 self
492 }
493
494 pub fn with_version(mut self, version: String) -> Self {
496 self.version = version;
497 self
498 }
499
500 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
502 self.context_layout.validate()?;
504
505 for stage in &self.stages {
507 stage.validate()?;
508 }
509
510 for transform in &self.transforms {
512 transform.validate(&self.context_layout)?;
513 }
514
515 self.validate_graph()?;
517
518 self.validate_region_references()?;
519
520 self.validate_dependencies()?;
521
522 Ok(())
523 }
524
525 fn validate_dependencies(&self) -> std::result::Result<(), ValidationError> {
532 let mut seen = std::collections::HashSet::new();
533 for dep in &self.dependencies {
534 let name = dep.name.trim();
535 if name.is_empty() {
536 return Err(ValidationError::Dependency {
537 name: dep.name.clone(),
538 message: "a dependency needs a non-empty name".to_string(),
539 });
540 }
541 if !seen.insert(name) {
542 return Err(ValidationError::Dependency {
543 name: name.to_string(),
544 message: "two dependencies share this name".to_string(),
545 });
546 }
547 let require = |field: &str, value: &str| -> std::result::Result<(), ValidationError> {
548 if value.trim().is_empty() {
549 return Err(ValidationError::Dependency {
550 name: name.to_string(),
551 message: format!(
552 "a '{}' dependency needs a non-empty '{field}'",
553 dep.kind.tag()
554 ),
555 });
556 }
557 Ok(())
558 };
559 match &dep.kind {
560 DependencyKind::McpServer { server, .. } => require("server", server)?,
561 DependencyKind::Env { var } => require("var", var)?,
562 DependencyKind::Binary { command } => require("command", command)?,
563 DependencyKind::Script { check } => require("check", check)?,
564 }
565 if let Some(install) = &dep.install {
566 if install.server.is_some() && !matches!(dep.kind, DependencyKind::McpServer { .. })
567 {
568 return Err(ValidationError::Dependency {
569 name: name.to_string(),
570 message: "install.server is only valid for a 'mcp_server' dependency"
571 .to_string(),
572 });
573 }
574 if let Some(transport) =
575 install.server.as_ref().and_then(|s| s.transport.as_deref())
576 && !matches!(transport, "stdio" | "http")
577 {
578 return Err(ValidationError::Dependency {
579 name: name.to_string(),
580 message: format!(
581 "install.server.transport must be \"stdio\" or \"http\", got \"{transport}\""
582 ),
583 });
584 }
585 for os in install.commands.keys() {
586 if !matches!(os.as_str(), "macos" | "linux" | "windows") {
587 return Err(ValidationError::Dependency {
588 name: name.to_string(),
589 message: format!(
590 "install.commands key '{os}' must be \"macos\", \"linux\" or \"windows\""
591 ),
592 });
593 }
594 }
595 }
596 }
597 Ok(())
598 }
599
600 fn known_region_names(&self) -> std::collections::HashSet<&str> {
609 let mut names: std::collections::HashSet<&str> = self
610 .context_layout
611 .regions
612 .iter()
613 .map(|r| r.name.as_str())
614 .collect();
615 for stage in &self.stages {
616 if let Some(layout) = &stage.context_layout {
617 names.extend(layout.regions.iter().map(|r| r.name.as_str()));
618 }
619 }
620 names.extend(ALWAYS_VISIBLE_REGIONS);
623 names
624 }
625
626 pub fn regions_visible_to<'a>(
639 &'a self,
640 stage: &'a Stage,
641 ) -> std::collections::HashSet<&'a str> {
642 let layout = stage
643 .context_layout
644 .as_ref()
645 .unwrap_or(&self.context_layout);
646 let mut names: std::collections::HashSet<&str> =
647 layout.regions.iter().map(|r| r.name.as_str()).collect();
648 names.extend(ALWAYS_VISIBLE_REGIONS);
649 for hidden in &stage.context_hide {
652 names.remove(hidden.as_str());
653 }
654 names
655 }
656
657 fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
665 let known = self.known_region_names();
666 let checklists: std::collections::HashSet<&str> = self
667 .context_layout
668 .regions
669 .iter()
670 .chain(
671 self.stages
672 .iter()
673 .filter_map(|s| s.context_layout.as_ref())
674 .flat_map(|l| l.regions.iter()),
675 )
676 .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
677 .map(|r| r.name.as_str())
678 .collect();
679
680 for stage in &self.stages {
681 let bad = |message: String| ValidationError::Stage {
682 stage: stage.name.clone(),
683 message,
684 };
685
686 for hidden in &stage.context_hide {
692 if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
693 return Err(bad(format!(
694 "context.hide names '{hidden}', which every stage carries and cannot hide"
695 )));
696 }
697 if !known.contains(hidden.as_str()) {
698 return Err(bad(format!(
699 "context.hide names region '{hidden}', which no layout in this \
700 blueprint declares"
701 )));
702 }
703 }
704
705 for name in &stage.context_reset {
710 if !known.contains(name.as_str()) {
711 return Err(bad(format!(
712 "context.reset names region '{name}', which no layout in this \
713 blueprint declares"
714 )));
715 }
716 }
717
718 if let Some(routing) = &stage.tool_result_routing {
719 let visible = self.regions_visible_to(stage);
727 let dead_drop = |key: &str, region: &str| ValidationError::Stage {
728 stage: stage.name.clone(),
729 message: format!(
730 "tool_routing.{key} sends results to region '{region}', \
731 which this stage's context does not include, so it \
732 could not read them back. Add '{region}' to \
733 [stages.{}.context.regions], or route somewhere the \
734 stage can see.",
735 stage.name
736 ),
737 };
738 if !visible.contains(routing.default_region.as_str()) {
739 return Err(dead_drop("default_region", &routing.default_region));
740 }
741 for (tool, region) in &routing.tool_overrides {
742 if !visible.contains(region.as_str()) {
743 return Err(dead_drop(&format!("overrides.{tool}"), region));
744 }
745 }
746 }
747
748 for (pattern, region) in &stage.output_routing {
754 if !known.contains(region.as_str()) {
755 return Err(ValidationError::Stage {
756 stage: stage.name.clone(),
757 message: format!(
758 "output_routing.\"{pattern}\" sends produced parts to region \
759 '{region}', which no layout in this blueprint declares. Add it to a \
760 [context.regions] table, or route to a region that exists."
761 ),
762 });
763 }
764 }
765
766 for edge in stage.transitions.iter().flat_map(|t| t.values()) {
767 let Some(gate) = &edge.gate else { continue };
768 for (key, region) in [
769 ("region", gate.region.as_ref()),
770 (
771 "require_region_updated",
772 gate.require_region_updated.as_ref(),
773 ),
774 ("require_no_open_items", gate.require_no_open_items.as_ref()),
775 (
776 "require_region_entries",
777 gate.require_region_entries.as_ref().map(|c| &c.region),
778 ),
779 ] {
780 let Some(region) = region else { continue };
781 if !known.contains(region.as_str()) {
782 return Err(bad(format!(
783 "transition to '{}': gate.{key} names region \
784 '{region}', which no stage declares",
785 edge.target
786 )));
787 }
788 }
789 if let Some(region) = &gate.require_no_open_items
793 && !checklists.contains(region.as_str())
794 {
795 return Err(bad(format!(
796 "transition to '{}': gate.require_no_open_items names \
797 region '{region}', which is not a checklist region \
798 (set kind = \"checklist\" on it)",
799 edge.target
800 )));
801 }
802 }
803 }
804 Ok(())
805 }
806
807 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
809 let stage_names: std::collections::HashSet<&str> =
810 self.stages.iter().map(|s| s.name.as_str()).collect();
811
812 if let Some(entry) = &self.entry_stage
814 && !stage_names.contains(entry.as_str())
815 {
816 return Err(ValidationError::Graph(format!(
817 "entry_stage '{}' does not match any defined stage",
818 entry
819 )));
820 }
821
822 for stage in &self.stages {
828 if let StageMode::FanOut { config } = &stage.mode {
829 let sources = [
830 config.worker_agent.is_some(),
831 config.worker_stage.is_some(),
832 config.worker_query.is_some(),
833 ]
834 .iter()
835 .filter(|&&set| set)
836 .count();
837 if sources != 1 {
838 return Err(ValidationError::Stage {
839 stage: stage.name.clone(),
840 message: "fan_out stage must set exactly one of worker_agent, \
841 worker_stage, or worker_query"
842 .to_string(),
843 });
844 }
845 if let Some(ws) = &config.worker_stage {
846 match self.stages.iter().find(|s| &s.name == ws) {
847 None => {
848 return Err(ValidationError::Stage {
849 stage: stage.name.clone(),
850 message: format!("fan_out worker_stage '{}' does not exist", ws),
851 });
852 }
853 Some(target) if !target.allow_as_worker => {
854 return Err(ValidationError::Stage {
855 stage: stage.name.clone(),
856 message: format!(
857 "fan_out worker_stage '{}' must set allow_as_worker = true",
858 ws
859 ),
860 });
861 }
862 Some(_) => {}
863 }
864 }
865 if let Some(ms) = &config.merge_stage
866 && !stage_names.contains(ms.as_str())
867 {
868 return Err(ValidationError::Stage {
869 stage: stage.name.clone(),
870 message: format!("fan_out merge_stage '{}' does not exist", ms),
871 });
872 }
873 }
874 }
875
876 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
877 if !has_any_transitions {
878 return Ok(());
880 }
881
882 for stage in &self.stages {
884 if let Some(ref transitions) = stage.transitions {
885 for (target_name, edge) in transitions {
886 if !stage_names.contains(target_name.as_str()) {
887 return Err(ValidationError::Transition {
888 from: stage.name.clone(),
889 to: target_name.clone(),
890 message: "target stage does not exist".to_string(),
891 });
892 }
893 if edge.condition == TransitionCondition::Stuck
897 && !edge.stuck.is_some_and(|c| c.is_armed())
898 {
899 return Err(ValidationError::Transition {
900 from: stage.name.clone(),
901 to: target_name.clone(),
902 message: "condition = \"stuck\" requires at least one \
903 stuck_after_* threshold (the edge could never fire)"
904 .to_string(),
905 });
906 }
907 }
908
909 for (target_name, edge) in transitions {
913 let Some(gate) = &edge.gate else { continue };
914 if !gate.require_modifications {
915 continue;
916 }
917 let can_modify = stage.grants_all_builtins()
918 || stage.available_tools.iter().any(|t| {
919 MODIFYING_TOOLS.contains(&t.as_str())
920 || gate.tools.iter().any(|extra| extra == t)
921 });
922 if !can_modify {
923 return Err(ValidationError::Transition {
924 from: stage.name.clone(),
925 to: target_name.clone(),
926 message: "gate requires modifications, but the stage has no \
927 file-modifying tool in available_tools"
928 .to_string(),
929 });
930 }
931 }
932
933 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
935 return Err(ValidationError::Stage {
936 stage: stage.name.clone(),
937 message: "self-loop transition requires max_revisits".to_string(),
938 });
939 }
940 }
941 }
942
943 let entry = self.resolve_entry_stage_name();
946 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
947 if !has_terminal {
948 return Err(ValidationError::Graph(
949 "no terminal path exists from entry stage - agent would never complete".to_string(),
950 ));
951 }
952
953 Ok(())
954 }
955
956 pub fn resolve_entry_stage_name(&self) -> String {
958 self.entry_stage.clone().unwrap_or_else(|| {
959 self.stages
960 .first()
961 .map(|s| s.name.clone())
962 .unwrap_or_default()
963 })
964 }
965
966 fn has_terminal_path(
968 &self,
969 stage_name: &str,
970 visited: &mut std::collections::HashSet<String>,
971 ) -> bool {
972 if visited.contains(stage_name) {
973 return false;
974 }
975 visited.insert(stage_name.to_string());
976
977 let stage = self.stages.iter().find(|s| s.name == stage_name);
978 let stage = match stage {
979 Some(s) => s,
980 None => return false,
986 };
987
988 if let StageMode::FanOut {
991 config:
992 FanOutConfig {
993 merge_stage: Some(ms),
994 ..
995 },
996 } = &stage.mode
997 {
998 return self.has_terminal_path(ms, visited);
999 }
1000
1001 match &stage.transitions {
1002 None => {
1003 let idx = self
1005 .stages
1006 .iter()
1007 .position(|s| s.name == stage_name)
1008 .unwrap_or(0);
1009 if idx + 1 >= self.stages.len() {
1010 return true; }
1012 self.has_terminal_path(&self.stages[idx + 1].name, visited)
1013 }
1014 Some(transitions) => {
1015 if transitions.is_empty() {
1016 return true; }
1018 for target in transitions.keys() {
1020 if self.has_terminal_path(target, visited) {
1021 return true;
1022 }
1023 }
1024 false
1030 }
1031 }
1032 }
1033
1034 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
1036 self.stages.iter().find(|s| s.name == name)
1037 }
1038}
1039
1040mod model;
1044pub use model::*;
1045mod stage;
1046pub use stage::*;
1047mod transition;
1048pub use transition::*;
1049mod tool_groups;
1050pub use tool_groups::*;
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use crate::layout::ContextLayout;
1056 use crate::layout::RegionDefinition;
1057 use crate::region::RegionKind;
1058
1059 fn bp_with_regions(regions_toml: &str) -> Blueprint {
1062 crate::manifest::parse_manifest(&format!(
1063 r#"
1064[agent]
1065name = "asked"
1066
1067[stages.main]
1068mode = "autonomous"
1069model = {{ provider = "anthropic", model = "m" }}
1070
1071[context.regions]
1072{regions_toml}
1073"#
1074 ))
1075 .expect("fixture parses")
1076 }
1077
1078 #[test]
1079 fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
1080 assert!(
1083 bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
1084 .accepts_task()
1085 );
1086 assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
1087 }
1088
1089 #[test]
1092 fn a_blueprint_requires_a_task_only_when_its_task_region_is_required() {
1093 assert!(
1094 bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10, required = true }"#)
1095 .requires_task()
1096 );
1097 let optional = bp_with_regions(
1098 r#"task = { kind = "pinned", max_tokens = 10 }
1099diff = { kind = "pinned", max_tokens = 10, seed = "diff", required = true }"#,
1100 );
1101 assert!(optional.accepts_task());
1102 assert!(!optional.requires_task());
1103 }
1104
1105 #[test]
1106 fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
1107 let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
1108 assert!(!bp.accepts_task());
1109 assert_eq!(bp.caller_inputs(), ["diff"]);
1110 assert!(!bp.requires_task());
1111 }
1112
1113 #[test]
1114 fn the_refusal_names_what_the_agent_takes_instead() {
1115 let bp = bp_with_regions(
1116 r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
1117criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
1118 );
1119 let msg = bp.task_refusal();
1120 assert!(msg.contains("agent 'asked'"), "{msg}");
1121 assert!(msg.contains("it takes: diff, criteria"), "{msg}");
1122 }
1123
1124 #[test]
1125 fn the_refusal_says_so_when_the_agent_takes_nothing() {
1126 let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
1127 assert!(bp.caller_inputs().is_empty());
1128 let msg = bp.task_refusal();
1132 assert!(msg.contains("it takes no caller input at all"), "{msg}");
1133 }
1134
1135 #[test]
1136 fn resolve_nudge_defaults_when_nothing_is_configured() {
1137 let normal = resolve_nudge(None, None, None, false);
1140 assert!(normal.enabled);
1141 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1142 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1143 let reviewed = resolve_nudge(None, None, None, true);
1144 assert!(!reviewed.enabled);
1145 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1147 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1148 }
1149
1150 #[test]
1151 fn resolve_nudge_cascades_each_field_independently() {
1152 let global = NudgeConfig {
1153 enabled: Some(true),
1154 max: Some(10),
1155 text: Some("global".to_string()),
1156 };
1157 let agent = NudgeConfig {
1158 max: Some(2),
1159 ..Default::default()
1160 };
1161 let stage = NudgeConfig {
1162 text: Some("stage".to_string()),
1163 ..Default::default()
1164 };
1165 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1166 assert!(resolved.enabled);
1168 assert_eq!(resolved.max, 2);
1169 assert_eq!(resolved.text, "stage");
1170 let stage_all = NudgeConfig {
1172 enabled: Some(false),
1173 max: Some(0),
1174 text: Some("s".to_string()),
1175 };
1176 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1177 assert_eq!(
1178 resolved,
1179 ResolvedNudge {
1180 enabled: false,
1181 max: 0,
1182 text: "s".to_string()
1183 }
1184 );
1185 }
1186
1187 #[test]
1188 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1189 let on = NudgeConfig {
1192 enabled: Some(true),
1193 ..Default::default()
1194 };
1195 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1196 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1197 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1198 let off = NudgeConfig {
1199 enabled: Some(false),
1200 ..Default::default()
1201 };
1202 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1203 }
1204
1205 #[test]
1206 fn test_blueprint_creation() {
1207 let regions = vec![RegionDefinition::new(
1208 "test".to_string(),
1209 RegionKind::Pinned,
1210 5000,
1211 )];
1212 let layout = ContextLayout::new(regions, 10000);
1213
1214 let stages = vec![Stage::new(
1215 "analyze".to_string(),
1216 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1217 )];
1218
1219 let blueprint = Blueprint::new(
1220 "test-agent".to_string(),
1221 "A test agent".to_string(),
1222 stages,
1223 layout,
1224 );
1225
1226 assert_eq!(blueprint.name, "test-agent");
1227 assert_eq!(blueprint.stages.len(), 1);
1228 }
1229
1230 #[test]
1231 fn test_blueprint_with_transforms_version() {
1232 let stages = vec![Stage::new("plan".to_string(), make_model())];
1233 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1234 .with_transforms(vec![ContextTransform {
1235 from_blueprint: "a".to_string(),
1236 to_blueprint: "b".to_string(),
1237 mappings: vec![],
1238 }])
1239 .with_version("2.0.0".to_string());
1240
1241 assert_eq!(bp.transforms.len(), 1);
1242 assert_eq!(bp.version, "2.0.0");
1243 }
1244
1245 #[test]
1246 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1247 let stages = vec![Stage::new("plan".to_string(), make_model())];
1248 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1249 bp.metadata.insert(
1251 "tool_perm:bash".to_string(),
1252 serde_json::Value::String("deny".to_string()),
1253 );
1254 bp.metadata
1256 .insert("title".to_string(), serde_json::Value::String("x".into()));
1257 bp.metadata
1259 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1260
1261 let perms = bp.agent_tool_permissions();
1262 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1263 assert!(!perms.contains_key("title"));
1264 assert!(!perms.contains_key("weird"));
1265 assert_eq!(perms.len(), 1);
1266 }
1267
1268 #[test]
1269 fn test_blueprint_validate_runs_transform_validation() {
1270 let stages = vec![Stage::new("plan".to_string(), make_model())];
1273 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1274 bp.transforms.push(ContextTransform {
1275 from_blueprint: "a".to_string(),
1276 to_blueprint: "b".to_string(),
1277 mappings: vec![RegionMapping {
1278 from_region: "test".to_string(),
1279 to_region: "test".to_string(),
1280 transform: None,
1281 }],
1282 });
1283 assert!(bp.validate().is_ok());
1284 }
1285
1286 #[test]
1287 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1288 let stages = vec![Stage::new("plan".to_string(), make_model())];
1289 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1290 bp.transforms.push(ContextTransform {
1291 from_blueprint: "a".to_string(),
1292 to_blueprint: "b".to_string(),
1293 mappings: vec![RegionMapping {
1294 from_region: "test".to_string(),
1295 to_region: "nonexistent".to_string(),
1296 transform: None,
1297 }],
1298 });
1299 let err = bp.validate().unwrap_err();
1300 assert_eq!(
1301 err,
1302 ValidationError::Region {
1303 region: "nonexistent".to_string(),
1304 message: "transform target region not found in layout".to_string(),
1305 }
1306 );
1307 }
1308
1309 #[test]
1310 fn test_mixed_linear_and_graph_mode_terminal_path() {
1311 let mut plan = Stage::new("plan".to_string(), make_model());
1315 let impl_stage = Stage::new("impl".to_string(), make_model());
1316 let review = Stage::new("review".to_string(), make_model());
1317
1318 let mut transitions = HashMap::new();
1319 transitions.insert(
1320 "impl".to_string(),
1321 TransitionEdge {
1322 target: "impl".to_string(),
1323 condition: TransitionCondition::Always,
1324 hint: None,
1325 transform: EdgeTransform::Direct,
1326 gate: None,
1327 stuck: None,
1328 },
1329 );
1330 plan.transitions = Some(transitions);
1331
1332 let bp = Blueprint::new(
1333 "t".into(),
1334 "".into(),
1335 vec![plan, impl_stage, review],
1336 make_layout(),
1337 );
1338 assert!(bp.validate().is_ok());
1339 }
1340
1341 #[test]
1342 fn test_stage_validation() {
1343 let stage = Stage::new(
1344 "test".to_string(),
1345 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1346 );
1347 assert!(stage.validate().is_ok());
1348
1349 let empty_stage = Stage::new(
1350 "".to_string(),
1351 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1352 );
1353 assert!(empty_stage.validate().is_err());
1354 }
1355
1356 #[test]
1357 fn test_stage_validate_with_valid_context_layout_is_ok() {
1358 let mut stage = Stage::new("test".to_string(), make_model());
1359 stage.context_layout = Some(make_layout());
1360 assert!(stage.validate().is_ok());
1361 }
1362
1363 #[test]
1364 fn test_stage_validate_with_invalid_context_layout_is_err() {
1365 let regions = vec![
1367 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1368 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1369 ];
1370 let mut stage = Stage::new("test".to_string(), make_model());
1371 stage.context_layout = Some(ContextLayout::new(regions, 200));
1372 assert!(stage.validate().is_err());
1373 }
1374
1375 #[test]
1376 fn test_stage_with_tools_context_layout_description() {
1377 let stage = Stage::new("test".to_string(), make_model())
1378 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1379 .with_context_layout(make_layout())
1380 .with_description("does things".to_string());
1381
1382 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1383 assert!(stage.context_layout.is_some());
1384 assert_eq!(stage.description.as_deref(), Some("does things"));
1385 }
1386
1387 #[test]
1388 fn test_stage_with_mode() {
1389 let stage = Stage::new("test".to_string(), make_model())
1390 .with_mode(StageMode::InteractivePoints { points: vec![] });
1391 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1392 }
1393
1394 #[test]
1395 fn test_stage_allow_complete_defaults_false() {
1396 let stage = Stage::new("review".to_string(), make_model());
1397 assert!(!stage.allow_complete);
1398 }
1399
1400 #[test]
1401 fn test_stage_allow_complete_serde_default_when_missing() {
1402 let json = r#"{
1405 "name": "review",
1406 "description": null,
1407 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1408 "available_tools": [],
1409 "max_iterations": null,
1410 "context_layout": null,
1411 "config": {},
1412 "transitions": null,
1413 "max_revisits": null,
1414 "transition_prompt": null
1415 }"#;
1416 let stage: Stage = serde_json::from_str(json).unwrap();
1417 assert!(!stage.allow_complete);
1418 assert!(stage.accepts_messages);
1419 }
1420
1421 #[test]
1422 fn test_stage_allow_complete_roundtrip() {
1423 let mut stage = Stage::new("review".to_string(), make_model());
1424 stage.allow_complete = true;
1425 let json = serde_json::to_string(&stage).unwrap();
1426 let back: Stage = serde_json::from_str(&json).unwrap();
1427 assert!(back.allow_complete);
1428 }
1429
1430 #[test]
1431 fn test_interaction_point_directives_default_empty() {
1432 let point = InteractionPoint {
1433 name: "plan_approval".to_string(),
1434 prompt: "Approve?".to_string(),
1435 required: true,
1436 unattended: UnattendedPolicy::AutoApprove,
1437 style: InteractionStyle::MultipleChoice,
1438 options: vec!["Approve".to_string(), "Revise".to_string()],
1439 directives: HashMap::new(),
1440 abort_options: Vec::new(),
1441 edit_options: Vec::new(),
1442 document_region: None,
1443 };
1444 assert!(point.directives.is_empty());
1445 assert!(point.abort_options.is_empty());
1446 assert!(point.edit_options.is_empty());
1447 }
1448
1449 #[test]
1450 fn test_interaction_point_directives_roundtrip() {
1451 let mut directives = HashMap::new();
1452 directives.insert(
1453 "Revise".to_string(),
1454 "Ask what to change, then re-plan.".to_string(),
1455 );
1456 let point = InteractionPoint {
1457 name: "plan_approval".to_string(),
1458 prompt: "Approve?".to_string(),
1459 required: true,
1460 unattended: UnattendedPolicy::Ask,
1461 style: InteractionStyle::MultipleChoice,
1462 options: vec!["Approve".to_string(), "Revise".to_string()],
1463 directives,
1464 abort_options: vec!["Abort".to_string()],
1465 edit_options: vec!["Add detail".to_string()],
1466 document_region: Some("plan".to_string()),
1467 };
1468 let json = serde_json::to_string(&point).unwrap();
1469 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1470 assert_eq!(
1471 back.directives.get("Revise").map(|s| s.as_str()),
1472 Some("Ask what to change, then re-plan.")
1473 );
1474 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1475 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1476 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1479 }
1480
1481 #[test]
1482 fn test_interaction_point_directives_serde_default_when_missing() {
1483 let json = r#"{
1484 "name": "plan_approval",
1485 "prompt": "Approve?",
1486 "required": true,
1487 "style": "multiple_choice",
1488 "options": ["Approve", "Revise"]
1489 }"#;
1490 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1491 assert!(point.directives.is_empty());
1492 assert!(point.abort_options.is_empty());
1493 }
1494
1495 #[test]
1496 fn test_interaction_point_followups_alias_still_deserializes() {
1497 let json = r#"{
1499 "name": "plan_approval",
1500 "prompt": "Approve?",
1501 "required": true,
1502 "style": "multiple_choice",
1503 "options": ["Approve", "Revise"],
1504 "followups": { "Revise": "What to change?" }
1505 }"#;
1506 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1507 assert_eq!(
1508 point.directives.get("Revise").map(|s| s.as_str()),
1509 Some("What to change?")
1510 );
1511 }
1512
1513 #[test]
1514 fn test_model_config_new_creates_single_entry() {
1515 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1516 assert_eq!(mc.models.len(), 1);
1517 assert_eq!(mc.models[0].provider, "anthropic");
1518 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1519 assert!(mc.allow_user_default);
1520 }
1521
1522 #[test]
1523 fn test_model_config_with_multiple_models() {
1524 let mc = ModelConfig {
1525 models: vec![
1526 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1527 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1528 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1529 ],
1530 allow_user_default: true,
1531 parameters: HashMap::new(),
1532 request_timeout_secs: None,
1533 };
1534 assert_eq!(mc.models.len(), 3);
1535 assert_eq!(mc.models[0].provider, "anthropic");
1536 assert_eq!(mc.models[1].provider, "openai");
1537 assert_eq!(mc.models[2].provider, "ollama");
1538 }
1539
1540 #[test]
1541 fn test_model_config_serde_roundtrip() {
1542 let mc = ModelConfig {
1543 models: vec![
1544 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1545 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1546 ],
1547 allow_user_default: false,
1548 parameters: HashMap::new(),
1549 request_timeout_secs: None,
1550 };
1551 let json = serde_json::to_string(&mc).unwrap();
1552 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1553 assert_eq!(back.models.len(), 2);
1554 assert_eq!(back.models[0].provider, "anthropic");
1555 assert_eq!(back.models[1].provider, "openai");
1556 assert!(!back.allow_user_default);
1557 }
1558
1559 #[test]
1560 fn test_model_config_serde_defaults_when_fields_missing() {
1561 let json = r#"{"parameters": {}}"#;
1563 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1564 assert!(mc.models.is_empty());
1565 assert!(mc.allow_user_default);
1566 }
1567
1568 #[test]
1569 fn test_model_config_convenience_accessors() {
1570 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1571 assert_eq!(mc.provider(), "anthropic");
1572 assert_eq!(mc.model(), "claude-sonnet-4-6");
1573 }
1574
1575 #[test]
1576 fn test_model_config_convenience_accessors_empty_models() {
1577 let mc = ModelConfig {
1578 models: vec![],
1579 allow_user_default: true,
1580 parameters: HashMap::new(),
1581 request_timeout_secs: None,
1582 };
1583 assert_eq!(mc.provider(), "anthropic");
1584 assert_eq!(mc.model(), "claude-sonnet-4-6");
1585 }
1586
1587 fn make_model() -> ModelConfig {
1588 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1589 }
1590
1591 fn make_layout() -> ContextLayout {
1592 let regions = vec![RegionDefinition::new(
1593 "test".to_string(),
1594 RegionKind::Pinned,
1595 5000,
1596 )];
1597 ContextLayout::new(regions, 10000)
1598 }
1599
1600 #[test]
1601 fn test_graph_validation_entry_stage_exists() {
1602 let stages = vec![Stage::new("plan".to_string(), make_model())];
1603 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1604 bp.entry_stage = Some("nonexistent".to_string());
1605 assert!(bp.validate().is_err());
1606 }
1607
1608 #[test]
1609 fn test_graph_validation_entry_stage_valid() {
1610 let stages = vec![Stage::new("plan".to_string(), make_model())];
1611 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1612 bp.entry_stage = Some("plan".to_string());
1613 assert!(bp.validate().is_ok());
1614 }
1615
1616 #[test]
1617 fn test_graph_validation_transition_target_missing() {
1618 let mut stage = Stage::new("plan".to_string(), make_model());
1619 let mut transitions = HashMap::new();
1620 transitions.insert(
1621 "nonexistent".to_string(),
1622 TransitionEdge {
1623 target: "nonexistent".to_string(),
1624 condition: TransitionCondition::Always,
1625 hint: None,
1626 transform: EdgeTransform::Direct,
1627 gate: None,
1628 stuck: None,
1629 },
1630 );
1631 stage.transitions = Some(transitions);
1632 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1633 assert!(bp.validate().is_err());
1634 }
1635
1636 #[test]
1640 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1641 let gated = |tools: &[&str], extra: &[&str]| {
1642 let mut stage = Stage::new("impl".to_string(), make_model());
1643 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1644 let mut transitions = HashMap::new();
1645 transitions.insert(
1646 "review".to_string(),
1647 TransitionEdge {
1648 target: "review".to_string(),
1649 condition: TransitionCondition::Always,
1650 hint: None,
1651 transform: EdgeTransform::Direct,
1652 stuck: None,
1653 gate: Some(TransitionGate {
1654 require_modifications: true,
1655 tools: extra.iter().map(|t| t.to_string()).collect(),
1656 ..Default::default()
1657 }),
1658 },
1659 );
1660 stage.transitions = Some(transitions);
1661 Blueprint::new(
1662 "t".into(),
1663 "".into(),
1664 vec![stage, Stage::new("review".to_string(), make_model())],
1665 make_layout(),
1666 )
1667 };
1668 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1669 assert!(err.to_string().contains("no file-modifying tool"));
1670 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1672 assert!(gated(&["@builtin"], &[]).validate().is_ok());
1674 assert!(gated(&["@all"], &[]).validate().is_ok());
1675 assert!(gated(&["@scripts"], &[]).validate().is_err());
1677 assert!(
1679 gated(&["read_file", "patch_file"], &["patch_file"])
1680 .validate()
1681 .is_ok()
1682 );
1683 let mut off = gated(&["read_file"], &[]);
1685 off.stages[0]
1686 .transitions
1687 .as_mut()
1688 .unwrap()
1689 .get_mut("review")
1690 .unwrap()
1691 .gate = Some(TransitionGate::default());
1692 assert!(off.validate().is_ok());
1693 off.stages[0]
1695 .transitions
1696 .as_mut()
1697 .unwrap()
1698 .get_mut("review")
1699 .unwrap()
1700 .gate = None;
1701 assert!(off.validate().is_ok());
1702 }
1703
1704 #[test]
1705 fn test_graph_validation_self_loop_requires_max_revisits() {
1706 let mut stage = Stage::new("impl".to_string(), make_model());
1707 let mut transitions = HashMap::new();
1708 transitions.insert(
1709 "impl".to_string(),
1710 TransitionEdge {
1711 target: "impl".to_string(),
1712 condition: TransitionCondition::Always,
1713 hint: None,
1714 transform: EdgeTransform::Direct,
1715 gate: None,
1716 stuck: None,
1717 },
1718 );
1719 stage.transitions = Some(transitions);
1720 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1721 assert!(bp.validate().is_err());
1722 }
1723
1724 #[test]
1725 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1726 let mut stage = Stage::new("impl".to_string(), make_model());
1727 stage.max_revisits = Some(3);
1728 let mut transitions = HashMap::new();
1729 transitions.insert(
1730 "impl".to_string(),
1731 TransitionEdge {
1732 target: "impl".to_string(),
1733 condition: TransitionCondition::Always,
1734 hint: None,
1735 transform: EdgeTransform::Direct,
1736 gate: None,
1737 stuck: None,
1738 },
1739 );
1740 stage.transitions = Some(transitions);
1741 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1742 let err = bp
1747 .validate()
1748 .expect_err("an exhaustion-only graph is invalid");
1749 assert!(err.to_string().contains("no terminal path"), "{err}");
1750 }
1751
1752 #[test]
1753 fn test_graph_validation_terminal_path_exists() {
1754 let mut plan = Stage::new("plan".to_string(), make_model());
1755 let mut review = Stage::new("review".to_string(), make_model());
1756 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1759 transitions.insert(
1760 "review".to_string(),
1761 TransitionEdge {
1762 target: "review".to_string(),
1763 condition: TransitionCondition::Always,
1764 hint: None,
1765 transform: EdgeTransform::Direct,
1766 gate: None,
1767 stuck: None,
1768 },
1769 );
1770 plan.transitions = Some(transitions);
1771
1772 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1773 assert!(bp.validate().is_ok());
1774 }
1775
1776 #[test]
1777 fn test_graph_no_terminal_path() {
1778 let mut a = Stage::new("a".to_string(), make_model());
1780 let mut b = Stage::new("b".to_string(), make_model());
1781
1782 let mut a_transitions = HashMap::new();
1783 a_transitions.insert(
1784 "b".to_string(),
1785 TransitionEdge {
1786 target: "b".to_string(),
1787 condition: TransitionCondition::Always,
1788 hint: None,
1789 transform: EdgeTransform::Direct,
1790 gate: None,
1791 stuck: None,
1792 },
1793 );
1794 a.transitions = Some(a_transitions);
1795
1796 let mut b_transitions = HashMap::new();
1797 b_transitions.insert(
1798 "a".to_string(),
1799 TransitionEdge {
1800 target: "a".to_string(),
1801 condition: TransitionCondition::Always,
1802 hint: None,
1803 transform: EdgeTransform::Direct,
1804 gate: None,
1805 stuck: None,
1806 },
1807 );
1808 b.transitions = Some(b_transitions);
1809
1810 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1811 assert!(bp.validate().is_err());
1812 }
1813
1814 #[test]
1815 fn test_linear_stages_still_validate() {
1816 let stages = vec![
1818 Stage::new("plan".to_string(), make_model()),
1819 Stage::new("impl".to_string(), make_model()),
1820 Stage::new("review".to_string(), make_model()),
1821 ];
1822 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1823 assert!(bp.validate().is_ok());
1824 }
1825
1826 #[test]
1827 fn test_resolve_entry_stage_name() {
1828 let stages = vec![
1829 Stage::new("plan".to_string(), make_model()),
1830 Stage::new("impl".to_string(), make_model()),
1831 ];
1832 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1833 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1834
1835 bp.entry_stage = Some("impl".to_string());
1836 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1837 }
1838
1839 #[test]
1840 fn test_find_stage() {
1841 let stages = vec![
1842 Stage::new("plan".to_string(), make_model()),
1843 Stage::new("impl".to_string(), make_model()),
1844 ];
1845 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1846 assert!(bp.find_stage("plan").is_some());
1847 assert!(bp.find_stage("impl").is_some());
1848 assert!(bp.find_stage("nonexistent").is_none());
1849 }
1850
1851 #[test]
1852 fn test_transition_condition_default() {
1853 let cond = TransitionCondition::default();
1854 assert_eq!(cond, TransitionCondition::Always);
1855 }
1856
1857 #[test]
1858 fn test_edge_transform_default() {
1859 let t = EdgeTransform::default();
1860 assert_eq!(t, EdgeTransform::Direct);
1861 }
1862
1863 #[test]
1864 fn test_stage_mode_equality() {
1865 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1866 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1867 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1868 }
1869
1870 #[test]
1871 fn test_interaction_style_equality() {
1872 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1873 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1874 }
1875
1876 #[test]
1879 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1880 assert!(!StuckConfig::default().is_armed());
1881 for cfg in [
1882 StuckConfig {
1883 after_iterations: Some(1),
1884 ..Default::default()
1885 },
1886 StuckConfig {
1887 after_minutes: Some(1),
1888 ..Default::default()
1889 },
1890 StuckConfig {
1891 after_same_file_edits: Some(1),
1892 ..Default::default()
1893 },
1894 StuckConfig {
1895 after_tool_calls: Some(1),
1896 ..Default::default()
1897 },
1898 ] {
1899 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1900 }
1901 }
1902
1903 #[test]
1904 fn transition_condition_stuck_round_trips_as_snake_case() {
1905 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1906 assert_eq!(json, "\"stuck\"");
1907 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1908 assert_eq!(back, TransitionCondition::Stuck);
1909 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1910 }
1911
1912 #[test]
1913 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1914 let plain = TransitionEdge {
1915 target: "b".to_string(),
1916 condition: TransitionCondition::Always,
1917 hint: None,
1918 transform: EdgeTransform::Direct,
1919 gate: None,
1920 stuck: None,
1921 };
1922 let json = serde_json::to_string(&plain).unwrap();
1923 assert!(
1924 !json.contains("stuck"),
1925 "absent config must be skipped: {json}"
1926 );
1927
1928 let armed = TransitionEdge {
1929 condition: TransitionCondition::Stuck,
1930 stuck: Some(StuckConfig {
1931 after_iterations: Some(20),
1932 after_minutes: Some(10),
1933 after_same_file_edits: Some(3),
1934 after_tool_calls: Some(60),
1935 }),
1936 ..plain
1937 };
1938 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1939 .expect("armed edge round-trips");
1940 assert_eq!(back.condition, TransitionCondition::Stuck);
1941 assert_eq!(back.stuck, armed.stuck);
1942 }
1943
1944 #[test]
1947 fn validate_rejects_a_stuck_edge_with_no_threshold() {
1948 let build = |stuck| {
1949 let mut a = Stage::new("a".to_string(), make_model());
1950 let b = Stage::new("b".to_string(), make_model());
1951 let mut transitions = std::collections::HashMap::new();
1952 transitions.insert(
1953 "b".to_string(),
1954 TransitionEdge {
1955 target: "b".to_string(),
1956 condition: TransitionCondition::Stuck,
1957 hint: None,
1958 transform: EdgeTransform::Direct,
1959 gate: None,
1960 stuck,
1961 },
1962 );
1963 a.transitions = Some(transitions);
1964 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1965 };
1966
1967 for dead in [None, Some(StuckConfig::default())] {
1968 let err = build(dead)
1969 .validate()
1970 .expect_err("dead stuck edge rejected");
1971 assert!(
1972 format!("{err:?}").contains("stuck_after_"),
1973 "unexpected error: {err:?}"
1974 );
1975 }
1976
1977 assert!(
1979 build(Some(StuckConfig {
1980 after_iterations: Some(5),
1981 ..Default::default()
1982 }))
1983 .validate()
1984 .is_ok()
1985 );
1986 }
1987
1988 #[test]
1992 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1993 let mut stage = Stage::new("plan".to_string(), make_model());
1994 stage.available_tools = vec!["read_file".to_string()];
1995 stage.required_tools = vec!["ask_user_text".to_string()];
1996 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1997
1998 let err = bp.validate().expect_err("a tool it cannot call");
1999 let text = format!("{err:?}");
2000 assert!(text.contains("ask_user_text"), "names the tool: {text}");
2001 assert!(text.contains("available_tools"), "says why: {text}");
2002 }
2003
2004 #[test]
2005 fn validate_accepts_a_required_tool_the_stage_offers() {
2006 let mut stage = Stage::new("plan".to_string(), make_model());
2007 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2008 stage.required_tools = vec!["ask_user_text".to_string()];
2009 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2010
2011 bp.validate().expect("the tool is on offer");
2012 }
2013
2014 #[test]
2017 fn validate_accepts_a_required_tool_a_group_could_cover() {
2018 let mut stage = Stage::new("plan".to_string(), make_model());
2019 stage.available_tools = vec!["@builtin".to_string()];
2020 stage.required_tools = vec!["ask_user_text".to_string()];
2021 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2022
2023 bp.validate().expect("the group may cover it");
2024 }
2025
2026 #[test]
2027 fn validate_rejects_a_group_shaped_entry_that_names_no_group() {
2028 let mut stage = Stage::new("plan".to_string(), make_model());
2029 stage.available_tools = vec!["read_file".to_string(), "@builtins".to_string()];
2030 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2031
2032 let err = bp.validate().expect_err("not a group");
2033 let text = format!("{err:?}");
2034 assert!(text.contains("@builtins"), "names the entry: {text}");
2035 assert!(text.contains("@builtin,"), "lists the groups: {text}");
2036 }
2037
2038 #[test]
2039 fn stage_reports_its_groups_and_named_tools_separately() {
2040 let mut stage = Stage::new("plan".to_string(), make_model());
2041 stage.available_tools = vec![
2042 "read_file".to_string(),
2043 "@scripts".to_string(),
2044 "github__create_issue".to_string(),
2045 ];
2046 assert_eq!(stage.tool_groups(), vec![ToolGroup::Scripts]);
2047 assert!(stage.grants_group(ToolGroup::Scripts));
2048 assert!(!stage.grants_group(ToolGroup::Mcp));
2049 assert!(!stage.grants_all_builtins());
2050 let named: Vec<&String> = stage.named_tools().collect();
2051 assert_eq!(named, vec!["read_file", "github__create_issue"]);
2052
2053 stage.available_tools = vec!["@all".to_string()];
2054 assert!(stage.grants_all_builtins());
2055 assert!(stage.grants_group(ToolGroup::Mcp));
2056 assert_eq!(stage.named_tools().count(), 0);
2057 }
2058
2059 #[test]
2063 fn validate_rejects_require_output_without_the_submit_tool() {
2064 let mut stage = Stage::new("summary".to_string(), make_model());
2065 stage.available_tools = vec!["read_file".to_string()];
2066 stage.require_output = true;
2067 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2068
2069 let err = bp.validate().expect_err("no way to submit");
2070 let text = format!("{err:?}");
2071 assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
2072 assert!(text.contains("require_output"), "says why: {text}");
2073 }
2074
2075 #[test]
2076 fn validate_accepts_require_output_when_the_stage_can_submit() {
2077 let mut stage = Stage::new("summary".to_string(), make_model());
2078 stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
2079 stage.require_output = true;
2080 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2081
2082 bp.validate().expect("the stage can submit");
2083 }
2084
2085 #[test]
2088 fn validate_accepts_a_declared_shape_without_require_output() {
2089 let mut stage = Stage::new("summary".to_string(), make_model());
2090 stage.available_tools = vec!["read_file".to_string()];
2091 stage.output = Some(crate::output::OutputSpec {
2092 format: Some("a2ui".to_string()),
2093 ..Default::default()
2094 });
2095 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2096
2097 bp.validate().expect("declaring a shape demands nothing");
2098 }
2099
2100 #[test]
2101 fn output_mode_compares_equal_only_to_itself() {
2102 assert_eq!(StageMode::Output, StageMode::Output);
2103 assert_ne!(StageMode::Output, StageMode::Autonomous);
2104 assert_ne!(StageMode::Autonomous, StageMode::Output);
2105 }
2106
2107 #[test]
2108 fn test_transition_condition_equality() {
2109 assert_eq!(
2110 TransitionCondition::LlmChoice,
2111 TransitionCondition::LlmChoice
2112 );
2113 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2114 }
2115
2116 #[test]
2117 fn test_edge_transform_compact_and_custom_equality() {
2118 let a = EdgeTransform::Compact {
2119 prompt: Some("p".to_string()),
2120 };
2121 let b = EdgeTransform::Compact {
2122 prompt: Some("p".to_string()),
2123 };
2124 assert_eq!(a, b);
2125
2126 let c1 = EdgeTransform::Custom {
2127 carry: vec!["a".to_string()],
2128 compact: vec!["b".to_string()],
2129 clear: vec!["c".to_string()],
2130 compact_prompt: Some("p".to_string()),
2131 };
2132 let c2 = c1.clone();
2133 assert_eq!(c1, c2);
2134
2135 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2136 }
2137
2138 #[test]
2139 fn test_stage_accepts_messages_default_true() {
2140 let stage = Stage::new(
2141 "test".to_string(),
2142 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2143 );
2144 assert!(stage.accepts_messages);
2145 }
2146
2147 #[test]
2148 fn test_stage_accepts_messages_serde_roundtrip() {
2149 let mut stage = Stage::new(
2151 "report".to_string(),
2152 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2153 );
2154 stage.accepts_messages = false;
2155
2156 let json = serde_json::to_string(&stage).expect("should serialize");
2157 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2158 assert!(!deserialized.accepts_messages);
2159 }
2160
2161 #[test]
2162 fn test_stage_accepts_messages_json_default() {
2163 let json = r#"{
2165 "name": "analyze",
2166 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2167 "available_tools": [],
2168 "mode": "Autonomous",
2169 "config": {},
2170 "tool_permissions": {},
2171 "requires_children": false
2172 }"#;
2173 let stage: Stage = serde_json::from_str(json).expect("should parse");
2174 assert!(stage.accepts_messages);
2175 }
2176
2177 #[test]
2178 fn test_has_terminal_path_unknown_stage_returns_false() {
2179 let stages = vec![Stage::new("start".to_string(), make_model())];
2183 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2184 let mut visited = std::collections::HashSet::new();
2185 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2186 }
2187
2188 #[test]
2189 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2190 let regions = vec![
2191 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2192 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2193 ];
2194 let layout = ContextLayout::new(regions, 200);
2195 let stages = vec![Stage::new("start".to_string(), make_model())];
2196 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2197 assert_eq!(
2198 bp.validate().unwrap_err(),
2199 ValidationError::Region {
2200 region: "dup".to_string(),
2201 message: "duplicate region name".to_string(),
2202 }
2203 );
2204 }
2205
2206 #[test]
2207 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2208 let stages = vec![Stage::new("".to_string(), make_model())];
2209 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2210 assert_eq!(
2211 bp.validate().unwrap_err(),
2212 ValidationError::Stage {
2213 stage: "(empty)".to_string(),
2214 message: "stage name cannot be empty".to_string(),
2215 }
2216 );
2217 }
2218
2219 #[test]
2220 fn test_file_tracking_config_defaults() {
2221 let json = r#"{"region": "files"}"#;
2222 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2223 assert_eq!(config.region, "files");
2224 assert!(config.track_reads);
2225 assert!(config.track_writes);
2226 assert!(config.max_file_tokens.is_none());
2227 }
2228
2229 #[test]
2230 fn test_file_tracking_config_serde_roundtrip() {
2231 let config = FileTrackingConfig {
2232 region: "files".to_string(),
2233 track_reads: true,
2234 track_writes: false,
2235 max_file_tokens: Some(5000),
2236 };
2237 let json = serde_json::to_string(&config).unwrap();
2238 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2239 assert_eq!(back.region, "files");
2240 assert!(back.track_reads);
2241 assert!(!back.track_writes);
2242 assert_eq!(back.max_file_tokens, Some(5000));
2243 }
2244
2245 #[test]
2246 fn test_blueprint_file_tracking_default_none() {
2247 let stages = vec![Stage::new("plan".to_string(), make_model())];
2248 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2249 assert!(bp.file_tracking.is_none());
2250 }
2251
2252 #[test]
2253 fn test_blueprint_file_tracking_serde_roundtrip() {
2254 let stages = vec![Stage::new("plan".to_string(), make_model())];
2255 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2256 bp.file_tracking = Some(FileTrackingConfig {
2257 region: "files".to_string(),
2258 track_reads: true,
2259 track_writes: true,
2260 max_file_tokens: Some(3000),
2261 });
2262 let json = serde_json::to_string(&bp).unwrap();
2263 let back: Blueprint = serde_json::from_str(&json).unwrap();
2264 let ft = back.file_tracking.unwrap();
2265 assert_eq!(ft.region, "files");
2266 assert_eq!(ft.max_file_tokens, Some(3000));
2267 }
2268
2269 #[test]
2270 fn test_tool_result_routing_default() {
2271 let routing = ToolResultRouting::default();
2272 assert_eq!(routing.default_region, "tool_results");
2273 assert!(routing.persist);
2274 assert!(routing.tool_overrides.is_empty());
2275 assert!(routing.max_result_tokens.is_none());
2276 }
2277
2278 #[test]
2279 fn test_stage_new_has_no_tool_result_routing() {
2280 let stage = Stage::new("plan".to_string(), make_model());
2281 assert!(stage.tool_result_routing.is_none());
2282 }
2283
2284 #[test]
2285 fn test_tool_result_routing_serde_roundtrip() {
2286 let mut routing = ToolResultRouting {
2287 default_region: "custom_region".to_string(),
2288 persist: false,
2289 max_result_tokens: Some(4096),
2290 ..Default::default()
2291 };
2292 routing
2293 .tool_overrides
2294 .insert("read_file".to_string(), "file_reads".to_string());
2295
2296 let json = serde_json::to_string(&routing).unwrap();
2297 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2298
2299 assert_eq!(back.default_region, "custom_region");
2300 assert!(!back.persist);
2301 assert_eq!(back.max_result_tokens, Some(4096));
2302 assert_eq!(
2303 back.tool_overrides.get("read_file").map(String::as_str),
2304 Some("file_reads")
2305 );
2306 }
2307
2308 #[test]
2309 fn test_stage_with_tool_result_routing_serde_roundtrip() {
2310 let stages = vec![{
2311 let mut s = Stage::new("plan".to_string(), make_model());
2312 s.tool_result_routing = Some(ToolResultRouting {
2313 default_region: "results".to_string(),
2314 tool_overrides: HashMap::new(),
2315 persist: true,
2316 max_result_tokens: Some(2048),
2317 tool_max_result_tokens: HashMap::new(),
2318 });
2319 s
2320 }];
2321 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2322 let json = serde_json::to_string(&bp).unwrap();
2323 let back: Blueprint = serde_json::from_str(&json).unwrap();
2324
2325 let routing = back.stages[0]
2326 .tool_result_routing
2327 .as_ref()
2328 .expect("tool_result_routing should be Some");
2329 assert_eq!(routing.default_region, "results");
2330 assert!(routing.persist);
2331 assert_eq!(routing.max_result_tokens, Some(2048));
2332 assert!(routing.tool_overrides.is_empty());
2333 }
2334
2335 fn fanout_config() -> FanOutConfig {
2338 FanOutConfig {
2339 worker_agent: None,
2340 worker_stage: Some("fix_worker".to_string()),
2341 worker_query: None,
2342 merge_stage: Some("merge".to_string()),
2343 max_workers: 3,
2344 on_worker_failure: WorkerFailurePolicy::Continue,
2345 split_prompt: "split".to_string(),
2346 results_region: None,
2347 max_items: None,
2348 max_attempts: None,
2349 }
2350 }
2351
2352 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2357 let mut fan = Stage::new("parallel".to_string(), make_model());
2358 fan.mode = StageMode::FanOut { config };
2359 let mut worker = Stage::new("fix_worker".to_string(), make_model());
2360 worker.allow_as_worker = worker_allowed;
2361 let mut merge = Stage::new("merge".to_string(), make_model());
2362 merge.transitions = Some(HashMap::new()); Blueprint::new(
2364 "t".into(),
2365 "d".into(),
2366 vec![fan, worker, merge],
2367 make_layout(),
2368 )
2369 }
2370
2371 #[test]
2372 fn fanout_stagemode_partial_eq_and_default_policy() {
2373 let a = StageMode::FanOut {
2374 config: fanout_config(),
2375 };
2376 let b = StageMode::FanOut {
2377 config: fanout_config(),
2378 };
2379 assert_eq!(a, b);
2380 let mut other = fanout_config();
2381 other.max_workers = 99;
2382 assert_ne!(a, StageMode::FanOut { config: other });
2383 assert_ne!(a, StageMode::Autonomous);
2384 assert_eq!(
2385 WorkerFailurePolicy::default(),
2386 WorkerFailurePolicy::Continue
2387 );
2388 }
2389
2390 #[test]
2391 fn fanout_config_serde_roundtrip_and_max_workers_default() {
2392 let toml = r#"
2393worker_agent = "fixer"
2394split_prompt = "go"
2395on_worker_failure = "fail_all"
2396"#;
2397 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2398 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2399 assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2400 assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2401 assert_eq!(
2402 FanOutConfig {
2403 max_workers: 0,
2404 ..fanout_config()
2405 }
2406 .worker_cap(),
2407 None
2408 );
2409 assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2410 let json = serde_json::to_string(&fanout_config()).unwrap();
2412 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2413 assert_eq!(back, fanout_config());
2414 }
2415
2416 #[test]
2417 fn fanout_validate_ok_with_allowed_worker_stage() {
2418 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2419 }
2420
2421 #[test]
2422 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2423 let err = fanout_blueprint(false, fanout_config())
2424 .validate()
2425 .unwrap_err();
2426 assert!(err.to_string().contains("allow_as_worker"));
2427 }
2428
2429 #[test]
2430 fn fanout_validate_rejects_missing_worker_stage() {
2431 let mut cfg = fanout_config();
2432 cfg.worker_stage = Some("nope".to_string());
2433 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2434 assert!(err.to_string().contains("does not exist"));
2435 }
2436
2437 #[test]
2438 fn fanout_validate_rejects_missing_merge_stage() {
2439 let mut cfg = fanout_config();
2440 cfg.merge_stage = Some("nomerge".to_string());
2441 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2442 assert!(err.to_string().contains("merge_stage"));
2443 }
2444
2445 #[test]
2446 fn fanout_validate_rejects_wrong_worker_source_count() {
2447 let mut cfg = fanout_config();
2449 cfg.worker_stage = None;
2450 assert!(fanout_blueprint(true, cfg).validate().is_err());
2451 let mut cfg2 = fanout_config();
2453 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2455 }
2456
2457 #[test]
2458 fn fanout_terminal_path_runs_through_merge_stage() {
2459 let mut cfg = fanout_config();
2461 cfg.worker_stage = None;
2462 cfg.worker_agent = Some("external".to_string());
2463 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2464 }
2465
2466 #[test]
2467 fn fanout_validate_ok_without_merge_stage() {
2468 let mut cfg = fanout_config();
2471 cfg.merge_stage = None;
2472 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2473 }
2474}