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, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ToolRescan {
36 #[default]
39 AtSpawn,
40 AfterWrites,
43 BeforeDispatch,
57}
58
59impl ToolRescan {
60 pub fn rescans(self) -> bool {
65 !matches!(self, Self::AtSpawn)
66 }
67
68 pub fn before_dispatch(self) -> bool {
70 matches!(self, Self::BeforeDispatch)
71 }
72
73 pub fn wire(self) -> &'static str {
75 match self {
76 Self::AtSpawn => "at_spawn",
77 Self::AfterWrites => "after_writes",
78 Self::BeforeDispatch => "before_dispatch",
79 }
80 }
81
82 pub fn parse(word: &str) -> Option<Self> {
84 Some(match word {
85 "at_spawn" => Self::AtSpawn,
86 "after_writes" => Self::AfterWrites,
87 "before_dispatch" => Self::BeforeDispatch,
88 _ => return None,
89 })
90 }
91
92 pub const ALL: [Self; 3] = [Self::AtSpawn, Self::AfterWrites, Self::BeforeDispatch];
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Blueprint {
103 pub name: String,
105
106 pub description: String,
108
109 pub stages: Vec<Stage>,
111
112 pub context_layout: ContextLayout,
114
115 pub transforms: Vec<ContextTransform>,
117
118 pub version: String,
120
121 pub compaction_config: Option<CompactionConfig>,
123
124 pub max_child_depth: Option<usize>,
126
127 pub entry_stage: Option<String>,
129
130 pub metadata: HashMap<String, serde_json::Value>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub security: Option<crate::taint::SecurityConfig>,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub batch_tool_hint: Option<bool>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub shell_hint: Option<bool>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub nudge: Option<NudgeConfig>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub repetition_detection: Option<RepetitionDetectionConfig>,
158
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub file_tracking: Option<FileTrackingConfig>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
168
169 #[serde(default)]
180 pub tool_rescan: ToolRescan,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub read_paths: Option<ReadPathsConfig>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub safe_commands: Option<SafeCommandsConfig>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub output: Option<crate::output::OutputSpec>,
212
213 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
218 pub mime_types: toml::Table,
219
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub dependencies: Vec<Dependency>,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct SafeCommandsConfig {
240 #[serde(default)]
242 pub tools: Vec<String>,
243 #[serde(default)]
246 pub shell: Vec<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ReadPathsConfig {
253 #[serde(default)]
262 pub allow: Vec<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct Dependency {
275 pub name: String,
277
278 #[serde(flatten)]
280 pub kind: DependencyKind,
281
282 #[serde(default = "default_dependency_required")]
285 pub required: bool,
286
287 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub remedy: Option<String>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub description: Option<String>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub install: Option<DependencyInstall>,
301}
302
303fn default_dependency_required() -> bool {
306 true
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(tag = "kind", rename_all = "snake_case")]
312pub enum DependencyKind {
313 McpServer {
317 server: String,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 env: Vec<String>,
323 },
324 Env {
326 var: String,
328 },
329 Binary {
331 command: String,
333 },
334 Script {
338 check: String,
340 },
341}
342
343impl DependencyKind {
344 pub fn tag(&self) -> &'static str {
347 match self {
348 DependencyKind::McpServer { .. } => "mcp_server",
349 DependencyKind::Env { .. } => "env",
350 DependencyKind::Binary { .. } => "binary",
351 DependencyKind::Script { .. } => "script",
352 }
353 }
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DependencyInstall {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub command: Option<String>,
368
369 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
372 pub commands: BTreeMap<String, String>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub script: Option<String>,
379
380 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub server: Option<McpServerTemplate>,
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392pub struct McpServerTemplate {
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub transport: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub command: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub url: Option<String>,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
404 pub args: Vec<String>,
405 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
408 pub headers: BTreeMap<String, String>,
409 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
414 pub env: BTreeMap<String, String>,
415}
416
417impl Blueprint {
418 pub fn new(
420 name: String,
421 description: String,
422 stages: Vec<Stage>,
423 context_layout: ContextLayout,
424 ) -> Self {
425 Self {
426 name,
427 description,
428 stages,
429 context_layout,
430 transforms: Vec::new(),
431 version: "0.1.0".to_string(),
432 compaction_config: None,
433 max_child_depth: None,
434 entry_stage: None,
435 metadata: HashMap::new(),
436 security: None,
437 batch_tool_hint: None,
438 shell_hint: None,
439 nudge: None,
440 repetition_detection: None,
441 file_tracking: None,
442 sandbox: None,
443 tool_rescan: ToolRescan::AtSpawn,
444 read_paths: None,
445 safe_commands: None,
446 output: None,
447 mime_types: toml::Table::new(),
448 dependencies: Vec::new(),
449 }
450 }
451
452 pub fn accepts_task(&self) -> bool {
460 self.context_layout
461 .regions
462 .iter()
463 .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
464 }
465
466 pub fn requires_task(&self) -> bool {
471 self.context_layout.regions.iter().any(|r| {
472 r.required
473 && matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task")
474 })
475 }
476
477 pub fn stage_inputs(&self, stage: &Stage) -> Vec<String> {
486 if !stage.input_accepts.is_empty() {
487 return stage.input_accepts.clone();
488 }
489 let layout = stage
490 .context_layout
491 .as_ref()
492 .unwrap_or(&self.context_layout);
493 let mut out: Vec<String> = Vec::new();
494 for region in &layout.regions {
495 if stage.context_hide.contains(®ion.name) {
496 continue;
497 }
498 let patterns: Vec<String> = match region.accepts.is_empty() {
499 true => vec!["*/*".to_string()],
500 false => region.accepts.clone(),
501 };
502 for p in patterns {
503 if !p.starts_with("text/") && !out.contains(&p) {
504 out.push(p);
505 }
506 }
507 }
508 out
509 }
510
511 pub fn caller_inputs(&self) -> Vec<&str> {
514 self.context_layout
515 .regions
516 .iter()
517 .filter_map(|r| match &r.seed {
518 Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
519 _ => None,
520 })
521 .collect()
522 }
523
524 pub fn task_refusal(&self) -> String {
530 let inputs = self.caller_inputs();
531 let takes = match inputs.is_empty() {
532 true => "it takes no caller input at all".to_string(),
533 false => format!("it takes: {}", inputs.join(", ")),
534 };
535 format!(
536 "agent '{}' was given a task but declares no region to put it in, so the task \
537 would be ignored - {takes}. Add a region seeded from the task, for example:\n\
538 [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
539 required = true, seed = \"task\" }}",
540 self.name,
541 )
542 }
543
544 pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
551 self.metadata
552 .iter()
553 .filter_map(|(k, v)| {
554 Some((
555 k.strip_prefix("tool_perm:")?.to_string(),
556 v.as_str()?.to_string(),
557 ))
558 })
559 .collect()
560 }
561
562 pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
564 self.transforms = transforms;
565 self
566 }
567
568 pub fn with_version(mut self, version: String) -> Self {
570 self.version = version;
571 self
572 }
573
574 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
576 self.context_layout.validate()?;
578
579 for stage in &self.stages {
581 stage.validate()?;
582 }
583
584 for transform in &self.transforms {
586 transform.validate(&self.context_layout)?;
587 }
588
589 self.validate_graph()?;
591
592 self.validate_region_references()?;
593
594 self.validate_dependencies()?;
595
596 Ok(())
597 }
598
599 fn validate_dependencies(&self) -> std::result::Result<(), ValidationError> {
606 let mut seen = std::collections::HashSet::new();
607 for dep in &self.dependencies {
608 let name = dep.name.trim();
609 if name.is_empty() {
610 return Err(ValidationError::Dependency {
611 name: dep.name.clone(),
612 message: "a dependency needs a non-empty name".to_string(),
613 });
614 }
615 if !seen.insert(name) {
616 return Err(ValidationError::Dependency {
617 name: name.to_string(),
618 message: "two dependencies share this name".to_string(),
619 });
620 }
621 let require = |field: &str, value: &str| -> std::result::Result<(), ValidationError> {
622 if value.trim().is_empty() {
623 return Err(ValidationError::Dependency {
624 name: name.to_string(),
625 message: format!(
626 "a '{}' dependency needs a non-empty '{field}'",
627 dep.kind.tag()
628 ),
629 });
630 }
631 Ok(())
632 };
633 match &dep.kind {
634 DependencyKind::McpServer { server, .. } => require("server", server)?,
635 DependencyKind::Env { var } => require("var", var)?,
636 DependencyKind::Binary { command } => require("command", command)?,
637 DependencyKind::Script { check } => require("check", check)?,
638 }
639 if let Some(install) = &dep.install {
640 if install.server.is_some() && !matches!(dep.kind, DependencyKind::McpServer { .. })
641 {
642 return Err(ValidationError::Dependency {
643 name: name.to_string(),
644 message: "install.server is only valid for a 'mcp_server' dependency"
645 .to_string(),
646 });
647 }
648 if let Some(transport) =
649 install.server.as_ref().and_then(|s| s.transport.as_deref())
650 && !matches!(transport, "stdio" | "http")
651 {
652 return Err(ValidationError::Dependency {
653 name: name.to_string(),
654 message: format!(
655 "install.server.transport must be \"stdio\" or \"http\", got \"{transport}\""
656 ),
657 });
658 }
659 for os in install.commands.keys() {
660 if !matches!(os.as_str(), "macos" | "linux" | "windows") {
661 return Err(ValidationError::Dependency {
662 name: name.to_string(),
663 message: format!(
664 "install.commands key '{os}' must be \"macos\", \"linux\" or \"windows\""
665 ),
666 });
667 }
668 }
669 }
670 }
671 Ok(())
672 }
673
674 fn known_region_names(&self) -> std::collections::HashSet<&str> {
683 let mut names: std::collections::HashSet<&str> = self
684 .context_layout
685 .regions
686 .iter()
687 .map(|r| r.name.as_str())
688 .collect();
689 for stage in &self.stages {
690 if let Some(layout) = &stage.context_layout {
691 names.extend(layout.regions.iter().map(|r| r.name.as_str()));
692 }
693 }
694 names.extend(ALWAYS_VISIBLE_REGIONS);
697 names
698 }
699
700 pub fn regions_visible_to<'a>(
713 &'a self,
714 stage: &'a Stage,
715 ) -> std::collections::HashSet<&'a str> {
716 let layout = stage
717 .context_layout
718 .as_ref()
719 .unwrap_or(&self.context_layout);
720 let mut names: std::collections::HashSet<&str> =
721 layout.regions.iter().map(|r| r.name.as_str()).collect();
722 names.extend(ALWAYS_VISIBLE_REGIONS);
723 for hidden in &stage.context_hide {
726 names.remove(hidden.as_str());
727 }
728 names
729 }
730
731 fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
739 let known = self.known_region_names();
740 let checklists: std::collections::HashSet<&str> = self
741 .context_layout
742 .regions
743 .iter()
744 .chain(
745 self.stages
746 .iter()
747 .filter_map(|s| s.context_layout.as_ref())
748 .flat_map(|l| l.regions.iter()),
749 )
750 .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
751 .map(|r| r.name.as_str())
752 .collect();
753
754 for stage in &self.stages {
755 let bad = |message: String| ValidationError::Stage {
756 stage: stage.name.clone(),
757 message,
758 };
759
760 for hidden in &stage.context_hide {
766 if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
767 return Err(bad(format!(
768 "context.hide names '{hidden}', which every stage carries and cannot hide"
769 )));
770 }
771 if !known.contains(hidden.as_str()) {
772 return Err(bad(format!(
773 "context.hide names region '{hidden}', which no layout in this \
774 blueprint declares"
775 )));
776 }
777 }
778
779 for name in &stage.context_reset {
784 if !known.contains(name.as_str()) {
785 return Err(bad(format!(
786 "context.reset names region '{name}', which no layout in this \
787 blueprint declares"
788 )));
789 }
790 }
791
792 if let Some(routing) = &stage.tool_result_routing {
793 let visible = self.regions_visible_to(stage);
801 let dead_drop = |key: &str, region: &str| ValidationError::Stage {
802 stage: stage.name.clone(),
803 message: format!(
804 "tool_routing.{key} sends results to region '{region}', \
805 which this stage's context does not include, so it \
806 could not read them back. Add '{region}' to \
807 [stages.{}.context.regions], or route somewhere the \
808 stage can see.",
809 stage.name
810 ),
811 };
812 if !visible.contains(routing.default_region.as_str()) {
813 return Err(dead_drop("default_region", &routing.default_region));
814 }
815 for (tool, region) in &routing.tool_overrides {
816 if !visible.contains(region.as_str()) {
817 return Err(dead_drop(&format!("overrides.{tool}"), region));
818 }
819 }
820 }
821
822 for (pattern, region) in &stage.output_routing {
828 if !known.contains(region.as_str()) {
829 return Err(ValidationError::Stage {
830 stage: stage.name.clone(),
831 message: format!(
832 "output_routing.\"{pattern}\" sends produced parts to region \
833 '{region}', which no layout in this blueprint declares. Add it to a \
834 [context.regions] table, or route to a region that exists."
835 ),
836 });
837 }
838 }
839
840 for edge in stage.transitions.iter().flat_map(|t| t.values()) {
841 let Some(gate) = &edge.gate else { continue };
842 for (key, region) in [
843 ("region", gate.region.as_ref()),
844 (
845 "require_region_updated",
846 gate.require_region_updated.as_ref(),
847 ),
848 ("require_no_open_items", gate.require_no_open_items.as_ref()),
849 (
850 "require_region_entries",
851 gate.require_region_entries.as_ref().map(|c| &c.region),
852 ),
853 ] {
854 let Some(region) = region else { continue };
855 if !known.contains(region.as_str()) {
856 return Err(bad(format!(
857 "transition to '{}': gate.{key} names region \
858 '{region}', which no stage declares",
859 edge.target
860 )));
861 }
862 }
863 if let Some(region) = &gate.require_no_open_items
867 && !checklists.contains(region.as_str())
868 {
869 return Err(bad(format!(
870 "transition to '{}': gate.require_no_open_items names \
871 region '{region}', which is not a checklist region \
872 (set kind = \"checklist\" on it)",
873 edge.target
874 )));
875 }
876 }
877 }
878 Ok(())
879 }
880
881 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
883 let stage_names: std::collections::HashSet<&str> =
884 self.stages.iter().map(|s| s.name.as_str()).collect();
885
886 if let Some(entry) = &self.entry_stage
888 && !stage_names.contains(entry.as_str())
889 {
890 return Err(ValidationError::Graph(format!(
891 "entry_stage '{}' does not match any defined stage",
892 entry
893 )));
894 }
895
896 for stage in &self.stages {
902 if let StageMode::FanOut { config } = &stage.mode {
903 let sources = [
904 config.worker_agent.is_some(),
905 config.worker_stage.is_some(),
906 config.worker_query.is_some(),
907 ]
908 .iter()
909 .filter(|&&set| set)
910 .count();
911 if sources != 1 {
912 return Err(ValidationError::Stage {
913 stage: stage.name.clone(),
914 message: "fan_out stage must set exactly one of worker_agent, \
915 worker_stage, or worker_query"
916 .to_string(),
917 });
918 }
919 if let Some(ws) = &config.worker_stage {
920 match self.stages.iter().find(|s| &s.name == ws) {
921 None => {
922 return Err(ValidationError::Stage {
923 stage: stage.name.clone(),
924 message: format!("fan_out worker_stage '{}' does not exist", ws),
925 });
926 }
927 Some(target) if !target.allow_as_worker => {
928 return Err(ValidationError::Stage {
929 stage: stage.name.clone(),
930 message: format!(
931 "fan_out worker_stage '{}' must set allow_as_worker = true",
932 ws
933 ),
934 });
935 }
936 Some(_) => {}
937 }
938 }
939 if let Some(ms) = &config.merge_stage
940 && !stage_names.contains(ms.as_str())
941 {
942 return Err(ValidationError::Stage {
943 stage: stage.name.clone(),
944 message: format!("fan_out merge_stage '{}' does not exist", ms),
945 });
946 }
947 }
948 }
949
950 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
951 if !has_any_transitions {
952 return Ok(());
954 }
955
956 for stage in &self.stages {
958 if let Some(ref transitions) = stage.transitions {
959 for (target_name, edge) in transitions {
960 if !stage_names.contains(target_name.as_str()) {
961 return Err(ValidationError::Transition {
962 from: stage.name.clone(),
963 to: target_name.clone(),
964 message: "target stage does not exist".to_string(),
965 });
966 }
967 if edge.condition == TransitionCondition::Stuck
971 && !edge.stuck.is_some_and(|c| c.is_armed())
972 {
973 return Err(ValidationError::Transition {
974 from: stage.name.clone(),
975 to: target_name.clone(),
976 message: "condition = \"stuck\" requires at least one \
977 stuck_after_* threshold (the edge could never fire)"
978 .to_string(),
979 });
980 }
981 }
982
983 for (target_name, edge) in transitions {
987 let Some(gate) = &edge.gate else { continue };
988 if !gate.require_modifications {
989 continue;
990 }
991 let can_modify = stage.grants_all_builtins()
992 || stage.available_tools.iter().any(|t| {
993 MODIFYING_TOOLS.contains(&t.as_str())
994 || gate.tools.iter().any(|extra| extra == t)
995 });
996 if !can_modify {
997 return Err(ValidationError::Transition {
998 from: stage.name.clone(),
999 to: target_name.clone(),
1000 message: "gate requires modifications, but the stage has no \
1001 file-modifying tool in available_tools"
1002 .to_string(),
1003 });
1004 }
1005 }
1006
1007 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
1009 return Err(ValidationError::Stage {
1010 stage: stage.name.clone(),
1011 message: "self-loop transition requires max_revisits".to_string(),
1012 });
1013 }
1014 }
1015 }
1016
1017 let entry = self.resolve_entry_stage_name();
1020 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
1021 if !has_terminal {
1022 return Err(ValidationError::Graph(
1023 "no terminal path exists from entry stage - agent would never complete".to_string(),
1024 ));
1025 }
1026
1027 Ok(())
1028 }
1029
1030 pub fn resolve_entry_stage_name(&self) -> String {
1032 self.entry_stage.clone().unwrap_or_else(|| {
1033 self.stages
1034 .first()
1035 .map(|s| s.name.clone())
1036 .unwrap_or_default()
1037 })
1038 }
1039
1040 fn has_terminal_path(
1042 &self,
1043 stage_name: &str,
1044 visited: &mut std::collections::HashSet<String>,
1045 ) -> bool {
1046 if visited.contains(stage_name) {
1047 return false;
1048 }
1049 visited.insert(stage_name.to_string());
1050
1051 let stage = self.stages.iter().find(|s| s.name == stage_name);
1052 let stage = match stage {
1053 Some(s) => s,
1054 None => return false,
1060 };
1061
1062 if let StageMode::FanOut {
1065 config:
1066 FanOutConfig {
1067 merge_stage: Some(ms),
1068 ..
1069 },
1070 } = &stage.mode
1071 {
1072 return self.has_terminal_path(ms, visited);
1073 }
1074
1075 match &stage.transitions {
1076 None => {
1077 let idx = self
1079 .stages
1080 .iter()
1081 .position(|s| s.name == stage_name)
1082 .unwrap_or(0);
1083 if idx + 1 >= self.stages.len() {
1084 return true; }
1086 self.has_terminal_path(&self.stages[idx + 1].name, visited)
1087 }
1088 Some(transitions) => {
1089 if transitions.is_empty() {
1090 return true; }
1092 for target in transitions.keys() {
1094 if self.has_terminal_path(target, visited) {
1095 return true;
1096 }
1097 }
1098 false
1104 }
1105 }
1106 }
1107
1108 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
1110 self.stages.iter().find(|s| s.name == name)
1111 }
1112}
1113
1114mod model;
1118pub use model::*;
1119mod stage;
1120pub use stage::*;
1121mod transition;
1122pub use transition::*;
1123mod tool_groups;
1124pub use tool_groups::*;
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use crate::layout::ContextLayout;
1130 use crate::layout::RegionDefinition;
1131 use crate::region::RegionKind;
1132
1133 fn bp_with_regions(regions_toml: &str) -> Blueprint {
1136 crate::manifest::parse_manifest(&format!(
1137 r#"
1138[agent]
1139name = "asked"
1140
1141[stages.main]
1142mode = "autonomous"
1143model = {{ provider = "anthropic", model = "m" }}
1144
1145[context.regions]
1146{regions_toml}
1147"#
1148 ))
1149 .expect("fixture parses")
1150 }
1151
1152 #[test]
1153 fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
1154 assert!(
1157 bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
1158 .accepts_task()
1159 );
1160 assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
1161 }
1162
1163 #[test]
1166 fn a_blueprint_requires_a_task_only_when_its_task_region_is_required() {
1167 assert!(
1168 bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10, required = true }"#)
1169 .requires_task()
1170 );
1171 let optional = bp_with_regions(
1172 r#"task = { kind = "pinned", max_tokens = 10 }
1173diff = { kind = "pinned", max_tokens = 10, seed = "diff", required = true }"#,
1174 );
1175 assert!(optional.accepts_task());
1176 assert!(!optional.requires_task());
1177 }
1178
1179 #[test]
1180 fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
1181 let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
1182 assert!(!bp.accepts_task());
1183 assert_eq!(bp.caller_inputs(), ["diff"]);
1184 assert!(!bp.requires_task());
1185 }
1186
1187 #[test]
1188 fn the_refusal_names_what_the_agent_takes_instead() {
1189 let bp = bp_with_regions(
1190 r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
1191criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
1192 );
1193 let msg = bp.task_refusal();
1194 assert!(msg.contains("agent 'asked'"), "{msg}");
1195 assert!(msg.contains("it takes: diff, criteria"), "{msg}");
1196 }
1197
1198 #[test]
1199 fn the_refusal_says_so_when_the_agent_takes_nothing() {
1200 let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
1201 assert!(bp.caller_inputs().is_empty());
1202 let msg = bp.task_refusal();
1206 assert!(msg.contains("it takes no caller input at all"), "{msg}");
1207 }
1208
1209 #[test]
1210 fn resolve_nudge_defaults_when_nothing_is_configured() {
1211 let normal = resolve_nudge(None, None, None, false);
1214 assert!(normal.enabled);
1215 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1216 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1217 let reviewed = resolve_nudge(None, None, None, true);
1218 assert!(!reviewed.enabled);
1219 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1221 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1222 }
1223
1224 #[test]
1225 fn resolve_nudge_cascades_each_field_independently() {
1226 let global = NudgeConfig {
1227 enabled: Some(true),
1228 max: Some(10),
1229 text: Some("global".to_string()),
1230 };
1231 let agent = NudgeConfig {
1232 max: Some(2),
1233 ..Default::default()
1234 };
1235 let stage = NudgeConfig {
1236 text: Some("stage".to_string()),
1237 ..Default::default()
1238 };
1239 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1240 assert!(resolved.enabled);
1242 assert_eq!(resolved.max, 2);
1243 assert_eq!(resolved.text, "stage");
1244 let stage_all = NudgeConfig {
1246 enabled: Some(false),
1247 max: Some(0),
1248 text: Some("s".to_string()),
1249 };
1250 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1251 assert_eq!(
1252 resolved,
1253 ResolvedNudge {
1254 enabled: false,
1255 max: 0,
1256 text: "s".to_string()
1257 }
1258 );
1259 }
1260
1261 #[test]
1262 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1263 let on = NudgeConfig {
1266 enabled: Some(true),
1267 ..Default::default()
1268 };
1269 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1270 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1271 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1272 let off = NudgeConfig {
1273 enabled: Some(false),
1274 ..Default::default()
1275 };
1276 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1277 }
1278
1279 #[test]
1280 fn test_blueprint_creation() {
1281 let regions = vec![RegionDefinition::new(
1282 "test".to_string(),
1283 RegionKind::Pinned,
1284 5000,
1285 )];
1286 let layout = ContextLayout::new(regions, 10000);
1287
1288 let stages = vec![Stage::new(
1289 "analyze".to_string(),
1290 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1291 )];
1292
1293 let blueprint = Blueprint::new(
1294 "test-agent".to_string(),
1295 "A test agent".to_string(),
1296 stages,
1297 layout,
1298 );
1299
1300 assert_eq!(blueprint.name, "test-agent");
1301 assert_eq!(blueprint.stages.len(), 1);
1302 }
1303
1304 #[test]
1305 fn test_blueprint_with_transforms_version() {
1306 let stages = vec![Stage::new("plan".to_string(), make_model())];
1307 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1308 .with_transforms(vec![ContextTransform {
1309 from_blueprint: "a".to_string(),
1310 to_blueprint: "b".to_string(),
1311 mappings: vec![],
1312 }])
1313 .with_version("2.0.0".to_string());
1314
1315 assert_eq!(bp.transforms.len(), 1);
1316 assert_eq!(bp.version, "2.0.0");
1317 }
1318
1319 #[test]
1320 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1321 let stages = vec![Stage::new("plan".to_string(), make_model())];
1322 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1323 bp.metadata.insert(
1325 "tool_perm:bash".to_string(),
1326 serde_json::Value::String("deny".to_string()),
1327 );
1328 bp.metadata
1330 .insert("title".to_string(), serde_json::Value::String("x".into()));
1331 bp.metadata
1333 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1334
1335 let perms = bp.agent_tool_permissions();
1336 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1337 assert!(!perms.contains_key("title"));
1338 assert!(!perms.contains_key("weird"));
1339 assert_eq!(perms.len(), 1);
1340 }
1341
1342 #[test]
1343 fn test_blueprint_validate_runs_transform_validation() {
1344 let stages = vec![Stage::new("plan".to_string(), make_model())];
1347 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1348 bp.transforms.push(ContextTransform {
1349 from_blueprint: "a".to_string(),
1350 to_blueprint: "b".to_string(),
1351 mappings: vec![RegionMapping {
1352 from_region: "test".to_string(),
1353 to_region: "test".to_string(),
1354 transform: None,
1355 }],
1356 });
1357 assert!(bp.validate().is_ok());
1358 }
1359
1360 #[test]
1361 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1362 let stages = vec![Stage::new("plan".to_string(), make_model())];
1363 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1364 bp.transforms.push(ContextTransform {
1365 from_blueprint: "a".to_string(),
1366 to_blueprint: "b".to_string(),
1367 mappings: vec![RegionMapping {
1368 from_region: "test".to_string(),
1369 to_region: "nonexistent".to_string(),
1370 transform: None,
1371 }],
1372 });
1373 let err = bp.validate().unwrap_err();
1374 assert_eq!(
1375 err,
1376 ValidationError::Region {
1377 region: "nonexistent".to_string(),
1378 message: "transform target region not found in layout".to_string(),
1379 }
1380 );
1381 }
1382
1383 #[test]
1384 fn test_mixed_linear_and_graph_mode_terminal_path() {
1385 let mut plan = Stage::new("plan".to_string(), make_model());
1389 let impl_stage = Stage::new("impl".to_string(), make_model());
1390 let review = Stage::new("review".to_string(), make_model());
1391
1392 let mut transitions = HashMap::new();
1393 transitions.insert(
1394 "impl".to_string(),
1395 TransitionEdge {
1396 target: "impl".to_string(),
1397 condition: TransitionCondition::Always,
1398 hint: None,
1399 transform: EdgeTransform::Direct,
1400 gate: None,
1401 stuck: None,
1402 },
1403 );
1404 plan.transitions = Some(transitions);
1405
1406 let bp = Blueprint::new(
1407 "t".into(),
1408 "".into(),
1409 vec![plan, impl_stage, review],
1410 make_layout(),
1411 );
1412 assert!(bp.validate().is_ok());
1413 }
1414
1415 #[test]
1416 fn test_stage_validation() {
1417 let stage = Stage::new(
1418 "test".to_string(),
1419 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1420 );
1421 assert!(stage.validate().is_ok());
1422
1423 let empty_stage = Stage::new(
1424 "".to_string(),
1425 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1426 );
1427 assert!(empty_stage.validate().is_err());
1428 }
1429
1430 #[test]
1431 fn test_stage_validate_with_valid_context_layout_is_ok() {
1432 let mut stage = Stage::new("test".to_string(), make_model());
1433 stage.context_layout = Some(make_layout());
1434 assert!(stage.validate().is_ok());
1435 }
1436
1437 #[test]
1438 fn test_stage_validate_with_invalid_context_layout_is_err() {
1439 let regions = vec![
1441 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1442 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1443 ];
1444 let mut stage = Stage::new("test".to_string(), make_model());
1445 stage.context_layout = Some(ContextLayout::new(regions, 200));
1446 assert!(stage.validate().is_err());
1447 }
1448
1449 #[test]
1450 fn test_stage_with_tools_context_layout_description() {
1451 let stage = Stage::new("test".to_string(), make_model())
1452 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1453 .with_context_layout(make_layout())
1454 .with_description("does things".to_string());
1455
1456 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1457 assert!(stage.context_layout.is_some());
1458 assert_eq!(stage.description.as_deref(), Some("does things"));
1459 }
1460
1461 #[test]
1462 fn test_stage_with_mode() {
1463 let stage = Stage::new("test".to_string(), make_model())
1464 .with_mode(StageMode::InteractivePoints { points: vec![] });
1465 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1466 }
1467
1468 #[test]
1469 fn test_stage_allow_complete_defaults_false() {
1470 let stage = Stage::new("review".to_string(), make_model());
1471 assert!(!stage.allow_complete);
1472 }
1473
1474 #[test]
1475 fn test_stage_allow_complete_serde_default_when_missing() {
1476 let json = r#"{
1479 "name": "review",
1480 "description": null,
1481 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1482 "available_tools": [],
1483 "max_iterations": null,
1484 "context_layout": null,
1485 "config": {},
1486 "transitions": null,
1487 "max_revisits": null,
1488 "transition_prompt": null
1489 }"#;
1490 let stage: Stage = serde_json::from_str(json).unwrap();
1491 assert!(!stage.allow_complete);
1492 assert!(stage.accepts_messages);
1493 }
1494
1495 #[test]
1496 fn test_stage_allow_complete_roundtrip() {
1497 let mut stage = Stage::new("review".to_string(), make_model());
1498 stage.allow_complete = true;
1499 let json = serde_json::to_string(&stage).unwrap();
1500 let back: Stage = serde_json::from_str(&json).unwrap();
1501 assert!(back.allow_complete);
1502 }
1503
1504 #[test]
1505 fn test_interaction_point_directives_default_empty() {
1506 let point = InteractionPoint {
1507 name: "plan_approval".to_string(),
1508 prompt: "Approve?".to_string(),
1509 required: true,
1510 unattended: UnattendedPolicy::AutoApprove,
1511 style: InteractionStyle::MultipleChoice,
1512 options: vec!["Approve".to_string(), "Revise".to_string()],
1513 directives: HashMap::new(),
1514 abort_options: Vec::new(),
1515 edit_options: Vec::new(),
1516 document_region: None,
1517 };
1518 assert!(point.directives.is_empty());
1519 assert!(point.abort_options.is_empty());
1520 assert!(point.edit_options.is_empty());
1521 }
1522
1523 #[test]
1524 fn test_interaction_point_directives_roundtrip() {
1525 let mut directives = HashMap::new();
1526 directives.insert(
1527 "Revise".to_string(),
1528 "Ask what to change, then re-plan.".to_string(),
1529 );
1530 let point = InteractionPoint {
1531 name: "plan_approval".to_string(),
1532 prompt: "Approve?".to_string(),
1533 required: true,
1534 unattended: UnattendedPolicy::Ask,
1535 style: InteractionStyle::MultipleChoice,
1536 options: vec!["Approve".to_string(), "Revise".to_string()],
1537 directives,
1538 abort_options: vec!["Abort".to_string()],
1539 edit_options: vec!["Add detail".to_string()],
1540 document_region: Some("plan".to_string()),
1541 };
1542 let json = serde_json::to_string(&point).unwrap();
1543 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1544 assert_eq!(
1545 back.directives.get("Revise").map(|s| s.as_str()),
1546 Some("Ask what to change, then re-plan.")
1547 );
1548 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1549 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1550 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1553 }
1554
1555 #[test]
1556 fn test_interaction_point_directives_serde_default_when_missing() {
1557 let json = r#"{
1558 "name": "plan_approval",
1559 "prompt": "Approve?",
1560 "required": true,
1561 "style": "multiple_choice",
1562 "options": ["Approve", "Revise"]
1563 }"#;
1564 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1565 assert!(point.directives.is_empty());
1566 assert!(point.abort_options.is_empty());
1567 }
1568
1569 #[test]
1570 fn test_interaction_point_followups_alias_still_deserializes() {
1571 let json = r#"{
1573 "name": "plan_approval",
1574 "prompt": "Approve?",
1575 "required": true,
1576 "style": "multiple_choice",
1577 "options": ["Approve", "Revise"],
1578 "followups": { "Revise": "What to change?" }
1579 }"#;
1580 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1581 assert_eq!(
1582 point.directives.get("Revise").map(|s| s.as_str()),
1583 Some("What to change?")
1584 );
1585 }
1586
1587 #[test]
1588 fn test_model_config_new_creates_single_entry() {
1589 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1590 assert_eq!(mc.models.len(), 1);
1591 assert_eq!(mc.models[0].provider, "anthropic");
1592 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1593 assert!(mc.allow_user_default);
1594 }
1595
1596 #[test]
1597 fn test_model_config_with_multiple_models() {
1598 let mc = ModelConfig {
1599 models: vec![
1600 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1601 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1602 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1603 ],
1604 allow_user_default: true,
1605 parameters: HashMap::new(),
1606 request_timeout_secs: None,
1607 };
1608 assert_eq!(mc.models.len(), 3);
1609 assert_eq!(mc.models[0].provider, "anthropic");
1610 assert_eq!(mc.models[1].provider, "openai");
1611 assert_eq!(mc.models[2].provider, "ollama");
1612 }
1613
1614 #[test]
1615 fn test_model_config_serde_roundtrip() {
1616 let mc = ModelConfig {
1617 models: vec![
1618 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1619 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1620 ],
1621 allow_user_default: false,
1622 parameters: HashMap::new(),
1623 request_timeout_secs: None,
1624 };
1625 let json = serde_json::to_string(&mc).unwrap();
1626 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1627 assert_eq!(back.models.len(), 2);
1628 assert_eq!(back.models[0].provider, "anthropic");
1629 assert_eq!(back.models[1].provider, "openai");
1630 assert!(!back.allow_user_default);
1631 }
1632
1633 #[test]
1634 fn test_model_config_serde_defaults_when_fields_missing() {
1635 let json = r#"{"parameters": {}}"#;
1637 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1638 assert!(mc.models.is_empty());
1639 assert!(mc.allow_user_default);
1640 }
1641
1642 #[test]
1643 fn test_model_config_convenience_accessors() {
1644 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1645 assert_eq!(mc.provider(), "anthropic");
1646 assert_eq!(mc.model(), "claude-sonnet-4-6");
1647 }
1648
1649 #[test]
1650 fn test_model_config_convenience_accessors_empty_models() {
1651 let mc = ModelConfig {
1652 models: vec![],
1653 allow_user_default: true,
1654 parameters: HashMap::new(),
1655 request_timeout_secs: None,
1656 };
1657 assert_eq!(mc.provider(), "anthropic");
1658 assert_eq!(mc.model(), "claude-sonnet-4-6");
1659 }
1660
1661 fn make_model() -> ModelConfig {
1662 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1663 }
1664
1665 fn make_layout() -> ContextLayout {
1666 let regions = vec![RegionDefinition::new(
1667 "test".to_string(),
1668 RegionKind::Pinned,
1669 5000,
1670 )];
1671 ContextLayout::new(regions, 10000)
1672 }
1673
1674 #[test]
1675 fn test_graph_validation_entry_stage_exists() {
1676 let stages = vec![Stage::new("plan".to_string(), make_model())];
1677 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1678 bp.entry_stage = Some("nonexistent".to_string());
1679 assert!(bp.validate().is_err());
1680 }
1681
1682 #[test]
1683 fn test_graph_validation_entry_stage_valid() {
1684 let stages = vec![Stage::new("plan".to_string(), make_model())];
1685 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1686 bp.entry_stage = Some("plan".to_string());
1687 assert!(bp.validate().is_ok());
1688 }
1689
1690 #[test]
1691 fn test_graph_validation_transition_target_missing() {
1692 let mut stage = Stage::new("plan".to_string(), make_model());
1693 let mut transitions = HashMap::new();
1694 transitions.insert(
1695 "nonexistent".to_string(),
1696 TransitionEdge {
1697 target: "nonexistent".to_string(),
1698 condition: TransitionCondition::Always,
1699 hint: None,
1700 transform: EdgeTransform::Direct,
1701 gate: None,
1702 stuck: None,
1703 },
1704 );
1705 stage.transitions = Some(transitions);
1706 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1707 assert!(bp.validate().is_err());
1708 }
1709
1710 #[test]
1714 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1715 let gated = |tools: &[&str], extra: &[&str]| {
1716 let mut stage = Stage::new("impl".to_string(), make_model());
1717 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1718 let mut transitions = HashMap::new();
1719 transitions.insert(
1720 "review".to_string(),
1721 TransitionEdge {
1722 target: "review".to_string(),
1723 condition: TransitionCondition::Always,
1724 hint: None,
1725 transform: EdgeTransform::Direct,
1726 stuck: None,
1727 gate: Some(TransitionGate {
1728 require_modifications: true,
1729 tools: extra.iter().map(|t| t.to_string()).collect(),
1730 ..Default::default()
1731 }),
1732 },
1733 );
1734 stage.transitions = Some(transitions);
1735 Blueprint::new(
1736 "t".into(),
1737 "".into(),
1738 vec![stage, Stage::new("review".to_string(), make_model())],
1739 make_layout(),
1740 )
1741 };
1742 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1743 assert!(err.to_string().contains("no file-modifying tool"));
1744 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1746 assert!(gated(&["@builtin"], &[]).validate().is_ok());
1748 assert!(gated(&["@all"], &[]).validate().is_ok());
1749 assert!(gated(&["@scripts"], &[]).validate().is_err());
1751 assert!(
1753 gated(&["read_file", "patch_file"], &["patch_file"])
1754 .validate()
1755 .is_ok()
1756 );
1757 let mut off = gated(&["read_file"], &[]);
1759 off.stages[0]
1760 .transitions
1761 .as_mut()
1762 .unwrap()
1763 .get_mut("review")
1764 .unwrap()
1765 .gate = Some(TransitionGate::default());
1766 assert!(off.validate().is_ok());
1767 off.stages[0]
1769 .transitions
1770 .as_mut()
1771 .unwrap()
1772 .get_mut("review")
1773 .unwrap()
1774 .gate = None;
1775 assert!(off.validate().is_ok());
1776 }
1777
1778 #[test]
1779 fn test_graph_validation_self_loop_requires_max_revisits() {
1780 let mut stage = Stage::new("impl".to_string(), make_model());
1781 let mut transitions = HashMap::new();
1782 transitions.insert(
1783 "impl".to_string(),
1784 TransitionEdge {
1785 target: "impl".to_string(),
1786 condition: TransitionCondition::Always,
1787 hint: None,
1788 transform: EdgeTransform::Direct,
1789 gate: None,
1790 stuck: None,
1791 },
1792 );
1793 stage.transitions = Some(transitions);
1794 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1795 assert!(bp.validate().is_err());
1796 }
1797
1798 #[test]
1799 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1800 let mut stage = Stage::new("impl".to_string(), make_model());
1801 stage.max_revisits = Some(3);
1802 let mut transitions = HashMap::new();
1803 transitions.insert(
1804 "impl".to_string(),
1805 TransitionEdge {
1806 target: "impl".to_string(),
1807 condition: TransitionCondition::Always,
1808 hint: None,
1809 transform: EdgeTransform::Direct,
1810 gate: None,
1811 stuck: None,
1812 },
1813 );
1814 stage.transitions = Some(transitions);
1815 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1816 let err = bp
1821 .validate()
1822 .expect_err("an exhaustion-only graph is invalid");
1823 assert!(err.to_string().contains("no terminal path"), "{err}");
1824 }
1825
1826 #[test]
1827 fn test_graph_validation_terminal_path_exists() {
1828 let mut plan = Stage::new("plan".to_string(), make_model());
1829 let mut review = Stage::new("review".to_string(), make_model());
1830 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1833 transitions.insert(
1834 "review".to_string(),
1835 TransitionEdge {
1836 target: "review".to_string(),
1837 condition: TransitionCondition::Always,
1838 hint: None,
1839 transform: EdgeTransform::Direct,
1840 gate: None,
1841 stuck: None,
1842 },
1843 );
1844 plan.transitions = Some(transitions);
1845
1846 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1847 assert!(bp.validate().is_ok());
1848 }
1849
1850 #[test]
1851 fn test_graph_no_terminal_path() {
1852 let mut a = Stage::new("a".to_string(), make_model());
1854 let mut b = Stage::new("b".to_string(), make_model());
1855
1856 let mut a_transitions = HashMap::new();
1857 a_transitions.insert(
1858 "b".to_string(),
1859 TransitionEdge {
1860 target: "b".to_string(),
1861 condition: TransitionCondition::Always,
1862 hint: None,
1863 transform: EdgeTransform::Direct,
1864 gate: None,
1865 stuck: None,
1866 },
1867 );
1868 a.transitions = Some(a_transitions);
1869
1870 let mut b_transitions = HashMap::new();
1871 b_transitions.insert(
1872 "a".to_string(),
1873 TransitionEdge {
1874 target: "a".to_string(),
1875 condition: TransitionCondition::Always,
1876 hint: None,
1877 transform: EdgeTransform::Direct,
1878 gate: None,
1879 stuck: None,
1880 },
1881 );
1882 b.transitions = Some(b_transitions);
1883
1884 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1885 assert!(bp.validate().is_err());
1886 }
1887
1888 #[test]
1889 fn test_linear_stages_still_validate() {
1890 let stages = vec![
1892 Stage::new("plan".to_string(), make_model()),
1893 Stage::new("impl".to_string(), make_model()),
1894 Stage::new("review".to_string(), make_model()),
1895 ];
1896 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1897 assert!(bp.validate().is_ok());
1898 }
1899
1900 #[test]
1901 fn test_resolve_entry_stage_name() {
1902 let stages = vec![
1903 Stage::new("plan".to_string(), make_model()),
1904 Stage::new("impl".to_string(), make_model()),
1905 ];
1906 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1907 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1908
1909 bp.entry_stage = Some("impl".to_string());
1910 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1911 }
1912
1913 #[test]
1914 fn test_find_stage() {
1915 let stages = vec![
1916 Stage::new("plan".to_string(), make_model()),
1917 Stage::new("impl".to_string(), make_model()),
1918 ];
1919 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1920 assert!(bp.find_stage("plan").is_some());
1921 assert!(bp.find_stage("impl").is_some());
1922 assert!(bp.find_stage("nonexistent").is_none());
1923 }
1924
1925 #[test]
1926 fn test_transition_condition_default() {
1927 let cond = TransitionCondition::default();
1928 assert_eq!(cond, TransitionCondition::Always);
1929 }
1930
1931 #[test]
1932 fn test_edge_transform_default() {
1933 let t = EdgeTransform::default();
1934 assert_eq!(t, EdgeTransform::Direct);
1935 }
1936
1937 #[test]
1938 fn test_stage_mode_equality() {
1939 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1940 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1941 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1942 }
1943
1944 #[test]
1945 fn test_interaction_style_equality() {
1946 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1947 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1948 }
1949
1950 #[test]
1953 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1954 assert!(!StuckConfig::default().is_armed());
1955 for cfg in [
1956 StuckConfig {
1957 after_iterations: Some(1),
1958 ..Default::default()
1959 },
1960 StuckConfig {
1961 after_minutes: Some(1),
1962 ..Default::default()
1963 },
1964 StuckConfig {
1965 after_same_file_edits: Some(1),
1966 ..Default::default()
1967 },
1968 StuckConfig {
1969 after_tool_calls: Some(1),
1970 ..Default::default()
1971 },
1972 ] {
1973 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1974 }
1975 }
1976
1977 #[test]
1978 fn transition_condition_stuck_round_trips_as_snake_case() {
1979 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1980 assert_eq!(json, "\"stuck\"");
1981 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1982 assert_eq!(back, TransitionCondition::Stuck);
1983 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1984 }
1985
1986 #[test]
1987 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1988 let plain = TransitionEdge {
1989 target: "b".to_string(),
1990 condition: TransitionCondition::Always,
1991 hint: None,
1992 transform: EdgeTransform::Direct,
1993 gate: None,
1994 stuck: None,
1995 };
1996 let json = serde_json::to_string(&plain).unwrap();
1997 assert!(
1998 !json.contains("stuck"),
1999 "absent config must be skipped: {json}"
2000 );
2001
2002 let armed = TransitionEdge {
2003 condition: TransitionCondition::Stuck,
2004 stuck: Some(StuckConfig {
2005 after_iterations: Some(20),
2006 after_minutes: Some(10),
2007 after_same_file_edits: Some(3),
2008 after_tool_calls: Some(60),
2009 }),
2010 ..plain
2011 };
2012 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
2013 .expect("armed edge round-trips");
2014 assert_eq!(back.condition, TransitionCondition::Stuck);
2015 assert_eq!(back.stuck, armed.stuck);
2016 }
2017
2018 #[test]
2021 fn validate_rejects_a_stuck_edge_with_no_threshold() {
2022 let build = |stuck| {
2023 let mut a = Stage::new("a".to_string(), make_model());
2024 let b = Stage::new("b".to_string(), make_model());
2025 let mut transitions = std::collections::HashMap::new();
2026 transitions.insert(
2027 "b".to_string(),
2028 TransitionEdge {
2029 target: "b".to_string(),
2030 condition: TransitionCondition::Stuck,
2031 hint: None,
2032 transform: EdgeTransform::Direct,
2033 gate: None,
2034 stuck,
2035 },
2036 );
2037 a.transitions = Some(transitions);
2038 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
2039 };
2040
2041 for dead in [None, Some(StuckConfig::default())] {
2042 let err = build(dead)
2043 .validate()
2044 .expect_err("dead stuck edge rejected");
2045 assert!(
2046 format!("{err:?}").contains("stuck_after_"),
2047 "unexpected error: {err:?}"
2048 );
2049 }
2050
2051 assert!(
2053 build(Some(StuckConfig {
2054 after_iterations: Some(5),
2055 ..Default::default()
2056 }))
2057 .validate()
2058 .is_ok()
2059 );
2060 }
2061
2062 #[test]
2066 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
2067 let mut stage = Stage::new("plan".to_string(), make_model());
2068 stage.available_tools = vec!["read_file".to_string()];
2069 stage.required_tools = vec!["ask_user_text".to_string()];
2070 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2071
2072 let err = bp.validate().expect_err("a tool it cannot call");
2073 let text = format!("{err:?}");
2074 assert!(text.contains("ask_user_text"), "names the tool: {text}");
2075 assert!(text.contains("available_tools"), "says why: {text}");
2076 }
2077
2078 #[test]
2079 fn validate_accepts_a_required_tool_the_stage_offers() {
2080 let mut stage = Stage::new("plan".to_string(), make_model());
2081 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2082 stage.required_tools = vec!["ask_user_text".to_string()];
2083 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2084
2085 bp.validate().expect("the tool is on offer");
2086 }
2087
2088 #[test]
2091 fn validate_accepts_a_required_tool_a_group_could_cover() {
2092 let mut stage = Stage::new("plan".to_string(), make_model());
2093 stage.available_tools = vec!["@builtin".to_string()];
2094 stage.required_tools = vec!["ask_user_text".to_string()];
2095 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2096
2097 bp.validate().expect("the group may cover it");
2098 }
2099
2100 #[test]
2101 fn validate_rejects_a_group_shaped_entry_that_names_no_group() {
2102 let mut stage = Stage::new("plan".to_string(), make_model());
2103 stage.available_tools = vec!["read_file".to_string(), "@builtins".to_string()];
2104 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2105
2106 let err = bp.validate().expect_err("not a group");
2107 let text = format!("{err:?}");
2108 assert!(text.contains("@builtins"), "names the entry: {text}");
2109 assert!(text.contains("@builtin,"), "lists the groups: {text}");
2110 }
2111
2112 #[test]
2113 fn stage_reports_its_groups_and_named_tools_separately() {
2114 let mut stage = Stage::new("plan".to_string(), make_model());
2115 stage.available_tools = vec![
2116 "read_file".to_string(),
2117 "@scripts".to_string(),
2118 "github__create_issue".to_string(),
2119 ];
2120 assert_eq!(stage.tool_groups(), vec![ToolGroup::Scripts]);
2121 assert!(stage.grants_group(ToolGroup::Scripts));
2122 assert!(!stage.grants_group(ToolGroup::Mcp));
2123 assert!(!stage.grants_all_builtins());
2124 let named: Vec<&String> = stage.named_tools().collect();
2125 assert_eq!(named, vec!["read_file", "github__create_issue"]);
2126
2127 stage.available_tools = vec!["@all".to_string()];
2128 assert!(stage.grants_all_builtins());
2129 assert!(stage.grants_group(ToolGroup::Mcp));
2130 assert_eq!(stage.named_tools().count(), 0);
2131 }
2132
2133 #[test]
2137 fn validate_rejects_require_output_without_the_submit_tool() {
2138 let mut stage = Stage::new("summary".to_string(), make_model());
2139 stage.available_tools = vec!["read_file".to_string()];
2140 stage.require_output = true;
2141 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2142
2143 let err = bp.validate().expect_err("no way to submit");
2144 let text = format!("{err:?}");
2145 assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
2146 assert!(text.contains("require_output"), "says why: {text}");
2147 }
2148
2149 #[test]
2150 fn validate_accepts_require_output_when_the_stage_can_submit() {
2151 let mut stage = Stage::new("summary".to_string(), make_model());
2152 stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
2153 stage.require_output = true;
2154 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2155
2156 bp.validate().expect("the stage can submit");
2157 }
2158
2159 #[test]
2162 fn validate_accepts_a_declared_shape_without_require_output() {
2163 let mut stage = Stage::new("summary".to_string(), make_model());
2164 stage.available_tools = vec!["read_file".to_string()];
2165 stage.output = Some(crate::output::OutputSpec {
2166 format: Some("a2ui".to_string()),
2167 ..Default::default()
2168 });
2169 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2170
2171 bp.validate().expect("declaring a shape demands nothing");
2172 }
2173
2174 #[test]
2175 fn output_mode_compares_equal_only_to_itself() {
2176 assert_eq!(StageMode::Output, StageMode::Output);
2177 assert_ne!(StageMode::Output, StageMode::Autonomous);
2178 assert_ne!(StageMode::Autonomous, StageMode::Output);
2179 }
2180
2181 #[test]
2182 fn test_transition_condition_equality() {
2183 assert_eq!(
2184 TransitionCondition::LlmChoice,
2185 TransitionCondition::LlmChoice
2186 );
2187 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2188 }
2189
2190 #[test]
2191 fn test_edge_transform_compact_and_custom_equality() {
2192 let a = EdgeTransform::Compact {
2193 prompt: Some("p".to_string()),
2194 };
2195 let b = EdgeTransform::Compact {
2196 prompt: Some("p".to_string()),
2197 };
2198 assert_eq!(a, b);
2199
2200 let c1 = EdgeTransform::Custom {
2201 carry: vec!["a".to_string()],
2202 compact: vec!["b".to_string()],
2203 clear: vec!["c".to_string()],
2204 compact_prompt: Some("p".to_string()),
2205 };
2206 let c2 = c1.clone();
2207 assert_eq!(c1, c2);
2208
2209 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2210 }
2211
2212 #[test]
2213 fn test_stage_accepts_messages_default_true() {
2214 let stage = Stage::new(
2215 "test".to_string(),
2216 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2217 );
2218 assert!(stage.accepts_messages);
2219 }
2220
2221 #[test]
2222 fn test_stage_accepts_messages_serde_roundtrip() {
2223 let mut stage = Stage::new(
2225 "report".to_string(),
2226 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2227 );
2228 stage.accepts_messages = false;
2229
2230 let json = serde_json::to_string(&stage).expect("should serialize");
2231 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2232 assert!(!deserialized.accepts_messages);
2233 }
2234
2235 #[test]
2236 fn test_stage_accepts_messages_json_default() {
2237 let json = r#"{
2239 "name": "analyze",
2240 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2241 "available_tools": [],
2242 "mode": "Autonomous",
2243 "config": {},
2244 "tool_permissions": {},
2245 "requires_children": false
2246 }"#;
2247 let stage: Stage = serde_json::from_str(json).expect("should parse");
2248 assert!(stage.accepts_messages);
2249 }
2250
2251 #[test]
2252 fn test_has_terminal_path_unknown_stage_returns_false() {
2253 let stages = vec![Stage::new("start".to_string(), make_model())];
2257 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2258 let mut visited = std::collections::HashSet::new();
2259 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2260 }
2261
2262 #[test]
2263 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2264 let regions = vec![
2265 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2266 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2267 ];
2268 let layout = ContextLayout::new(regions, 200);
2269 let stages = vec![Stage::new("start".to_string(), make_model())];
2270 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2271 assert_eq!(
2272 bp.validate().unwrap_err(),
2273 ValidationError::Region {
2274 region: "dup".to_string(),
2275 message: "duplicate region name".to_string(),
2276 }
2277 );
2278 }
2279
2280 #[test]
2281 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2282 let stages = vec![Stage::new("".to_string(), make_model())];
2283 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2284 assert_eq!(
2285 bp.validate().unwrap_err(),
2286 ValidationError::Stage {
2287 stage: "(empty)".to_string(),
2288 message: "stage name cannot be empty".to_string(),
2289 }
2290 );
2291 }
2292
2293 #[test]
2294 fn test_file_tracking_config_defaults() {
2295 let json = r#"{"region": "files"}"#;
2296 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2297 assert_eq!(config.region, "files");
2298 assert!(config.track_reads);
2299 assert!(config.track_writes);
2300 assert!(config.max_file_tokens.is_none());
2301 }
2302
2303 #[test]
2304 fn test_file_tracking_config_serde_roundtrip() {
2305 let config = FileTrackingConfig {
2306 region: "files".to_string(),
2307 track_reads: true,
2308 track_writes: false,
2309 max_file_tokens: Some(5000),
2310 };
2311 let json = serde_json::to_string(&config).unwrap();
2312 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2313 assert_eq!(back.region, "files");
2314 assert!(back.track_reads);
2315 assert!(!back.track_writes);
2316 assert_eq!(back.max_file_tokens, Some(5000));
2317 }
2318
2319 #[test]
2320 fn test_blueprint_file_tracking_default_none() {
2321 let stages = vec![Stage::new("plan".to_string(), make_model())];
2322 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2323 assert!(bp.file_tracking.is_none());
2324 }
2325
2326 #[test]
2327 fn test_blueprint_file_tracking_serde_roundtrip() {
2328 let stages = vec![Stage::new("plan".to_string(), make_model())];
2329 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2330 bp.file_tracking = Some(FileTrackingConfig {
2331 region: "files".to_string(),
2332 track_reads: true,
2333 track_writes: true,
2334 max_file_tokens: Some(3000),
2335 });
2336 let json = serde_json::to_string(&bp).unwrap();
2337 let back: Blueprint = serde_json::from_str(&json).unwrap();
2338 let ft = back.file_tracking.unwrap();
2339 assert_eq!(ft.region, "files");
2340 assert_eq!(ft.max_file_tokens, Some(3000));
2341 }
2342
2343 #[test]
2344 fn test_tool_result_routing_default() {
2345 let routing = ToolResultRouting::default();
2346 assert_eq!(routing.default_region, "tool_results");
2347 assert!(routing.keep_results);
2348 assert!(routing.tool_overrides.is_empty());
2349 assert!(routing.max_result_tokens.is_none());
2350 }
2351
2352 #[test]
2353 fn test_stage_new_has_no_tool_result_routing() {
2354 let stage = Stage::new("plan".to_string(), make_model());
2355 assert!(stage.tool_result_routing.is_none());
2356 }
2357
2358 #[test]
2359 fn test_tool_result_routing_serde_roundtrip() {
2360 let mut routing = ToolResultRouting {
2361 default_region: "custom_region".to_string(),
2362 keep_results: false,
2363 max_result_tokens: Some(4096),
2364 ..Default::default()
2365 };
2366 routing
2367 .tool_overrides
2368 .insert("read_file".to_string(), "file_reads".to_string());
2369
2370 let json = serde_json::to_string(&routing).unwrap();
2371 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2372
2373 assert_eq!(back.default_region, "custom_region");
2374 assert!(!back.keep_results);
2375 assert_eq!(back.max_result_tokens, Some(4096));
2376 assert_eq!(
2377 back.tool_overrides.get("read_file").map(String::as_str),
2378 Some("file_reads")
2379 );
2380 }
2381
2382 #[test]
2383 fn test_stage_with_tool_result_routing_serde_roundtrip() {
2384 let stages = vec![{
2385 let mut s = Stage::new("plan".to_string(), make_model());
2386 s.tool_result_routing = Some(ToolResultRouting {
2387 default_region: "results".to_string(),
2388 tool_overrides: HashMap::new(),
2389 keep_results: true,
2390 max_result_tokens: Some(2048),
2391 tool_max_result_tokens: HashMap::new(),
2392 });
2393 s
2394 }];
2395 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2396 let json = serde_json::to_string(&bp).unwrap();
2397 let back: Blueprint = serde_json::from_str(&json).unwrap();
2398
2399 let routing = back.stages[0]
2400 .tool_result_routing
2401 .as_ref()
2402 .expect("tool_result_routing should be Some");
2403 assert_eq!(routing.default_region, "results");
2404 assert!(routing.keep_results);
2405 assert_eq!(routing.max_result_tokens, Some(2048));
2406 assert!(routing.tool_overrides.is_empty());
2407 }
2408
2409 fn fanout_config() -> FanOutConfig {
2412 FanOutConfig {
2413 worker_agent: None,
2414 worker_stage: Some("fix_worker".to_string()),
2415 worker_query: None,
2416 merge_stage: Some("merge".to_string()),
2417 max_workers: 3,
2418 on_worker_failure: WorkerFailurePolicy::Continue,
2419 split_prompt: "split".to_string(),
2420 results_region: None,
2421 max_items: None,
2422 max_attempts: None,
2423 }
2424 }
2425
2426 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2431 let mut fan = Stage::new("parallel".to_string(), make_model());
2432 fan.mode = StageMode::FanOut { config };
2433 let mut worker = Stage::new("fix_worker".to_string(), make_model());
2434 worker.allow_as_worker = worker_allowed;
2435 let mut merge = Stage::new("merge".to_string(), make_model());
2436 merge.transitions = Some(HashMap::new()); Blueprint::new(
2438 "t".into(),
2439 "d".into(),
2440 vec![fan, worker, merge],
2441 make_layout(),
2442 )
2443 }
2444
2445 #[test]
2446 fn fanout_stagemode_partial_eq_and_default_policy() {
2447 let a = StageMode::FanOut {
2448 config: fanout_config(),
2449 };
2450 let b = StageMode::FanOut {
2451 config: fanout_config(),
2452 };
2453 assert_eq!(a, b);
2454 let mut other = fanout_config();
2455 other.max_workers = 99;
2456 assert_ne!(a, StageMode::FanOut { config: other });
2457 assert_ne!(a, StageMode::Autonomous);
2458 assert_eq!(
2459 WorkerFailurePolicy::default(),
2460 WorkerFailurePolicy::Continue
2461 );
2462 }
2463
2464 #[test]
2465 fn fanout_config_serde_roundtrip_and_max_workers_default() {
2466 let toml = r#"
2467worker_agent = "fixer"
2468split_prompt = "go"
2469on_worker_failure = "fail_all"
2470"#;
2471 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2472 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2473 assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2474 assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2475 assert_eq!(
2476 FanOutConfig {
2477 max_workers: 0,
2478 ..fanout_config()
2479 }
2480 .worker_cap(),
2481 None
2482 );
2483 assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2484 let json = serde_json::to_string(&fanout_config()).unwrap();
2486 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2487 assert_eq!(back, fanout_config());
2488 }
2489
2490 #[test]
2491 fn fanout_validate_ok_with_allowed_worker_stage() {
2492 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2493 }
2494
2495 #[test]
2496 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2497 let err = fanout_blueprint(false, fanout_config())
2498 .validate()
2499 .unwrap_err();
2500 assert!(err.to_string().contains("allow_as_worker"));
2501 }
2502
2503 #[test]
2504 fn fanout_validate_rejects_missing_worker_stage() {
2505 let mut cfg = fanout_config();
2506 cfg.worker_stage = Some("nope".to_string());
2507 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2508 assert!(err.to_string().contains("does not exist"));
2509 }
2510
2511 #[test]
2512 fn fanout_validate_rejects_missing_merge_stage() {
2513 let mut cfg = fanout_config();
2514 cfg.merge_stage = Some("nomerge".to_string());
2515 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2516 assert!(err.to_string().contains("merge_stage"));
2517 }
2518
2519 #[test]
2520 fn fanout_validate_rejects_wrong_worker_source_count() {
2521 let mut cfg = fanout_config();
2523 cfg.worker_stage = None;
2524 assert!(fanout_blueprint(true, cfg).validate().is_err());
2525 let mut cfg2 = fanout_config();
2527 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2529 }
2530
2531 #[test]
2532 fn fanout_terminal_path_runs_through_merge_stage() {
2533 let mut cfg = fanout_config();
2535 cfg.worker_stage = None;
2536 cfg.worker_agent = Some("external".to_string());
2537 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2538 }
2539
2540 #[test]
2541 fn fanout_validate_ok_without_merge_stage() {
2542 let mut cfg = fanout_config();
2545 cfg.merge_stage = None;
2546 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2547 }
2548}