1use crate::error::ValidationError;
9use crate::layout::ContextLayout;
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Blueprint {
21 pub name: String,
23
24 pub description: String,
26
27 pub stages: Vec<Stage>,
29
30 pub context_layout: ContextLayout,
32
33 pub transforms: Vec<ContextTransform>,
35
36 pub version: String,
38
39 pub compaction_config: Option<CompactionConfig>,
41
42 pub max_child_depth: Option<usize>,
44
45 pub entry_stage: Option<String>,
47
48 pub metadata: HashMap<String, serde_json::Value>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub security: Option<crate::taint::SecurityConfig>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub batch_tool_hint: Option<bool>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub repetition_detection: Option<RepetitionDetectionConfig>,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub file_tracking: Option<FileTrackingConfig>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
74
75 #[serde(default)]
80 pub dynamic_tools: bool,
81}
82
83impl Blueprint {
84 pub fn new(
86 name: String,
87 description: String,
88 stages: Vec<Stage>,
89 context_layout: ContextLayout,
90 ) -> Self {
91 Self {
92 name,
93 description,
94 stages,
95 context_layout,
96 transforms: Vec::new(),
97 version: "0.1.0".to_string(),
98 compaction_config: None,
99 max_child_depth: None,
100 entry_stage: None,
101 metadata: HashMap::new(),
102 security: None,
103 batch_tool_hint: None,
104 repetition_detection: None,
105 file_tracking: None,
106 sandbox: None,
107 dynamic_tools: false,
108 }
109 }
110
111 pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
118 self.metadata
119 .iter()
120 .filter_map(|(k, v)| {
121 Some((
122 k.strip_prefix("tool_perm:")?.to_string(),
123 v.as_str()?.to_string(),
124 ))
125 })
126 .collect()
127 }
128
129 pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
131 self.transforms = transforms;
132 self
133 }
134
135 pub fn with_version(mut self, version: String) -> Self {
137 self.version = version;
138 self
139 }
140
141 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
143 self.context_layout.validate()?;
145
146 for stage in &self.stages {
148 stage.validate()?;
149 }
150
151 for transform in &self.transforms {
153 transform.validate(&self.context_layout)?;
154 }
155
156 self.validate_graph()?;
158
159 Ok(())
160 }
161
162 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
164 let stage_names: std::collections::HashSet<&str> =
165 self.stages.iter().map(|s| s.name.as_str()).collect();
166
167 if let Some(entry) = &self.entry_stage
169 && !stage_names.contains(entry.as_str())
170 {
171 return Err(ValidationError::Graph(format!(
172 "entry_stage '{}' does not match any defined stage",
173 entry
174 )));
175 }
176
177 for stage in &self.stages {
183 if let StageMode::FanOut { config } = &stage.mode {
184 let sources = [
185 config.worker_agent.is_some(),
186 config.worker_stage.is_some(),
187 config.worker_query.is_some(),
188 ]
189 .iter()
190 .filter(|&&set| set)
191 .count();
192 if sources != 1 {
193 return Err(ValidationError::Stage {
194 stage: stage.name.clone(),
195 message: "fan_out stage must set exactly one of worker_agent, \
196 worker_stage, or worker_query"
197 .to_string(),
198 });
199 }
200 if let Some(ws) = &config.worker_stage {
201 match self.stages.iter().find(|s| &s.name == ws) {
202 None => {
203 return Err(ValidationError::Stage {
204 stage: stage.name.clone(),
205 message: format!("fan_out worker_stage '{}' does not exist", ws),
206 });
207 }
208 Some(target) if !target.allow_as_worker => {
209 return Err(ValidationError::Stage {
210 stage: stage.name.clone(),
211 message: format!(
212 "fan_out worker_stage '{}' must set allow_as_worker = true",
213 ws
214 ),
215 });
216 }
217 Some(_) => {}
218 }
219 }
220 if let Some(ms) = &config.merge_stage
221 && !stage_names.contains(ms.as_str())
222 {
223 return Err(ValidationError::Stage {
224 stage: stage.name.clone(),
225 message: format!("fan_out merge_stage '{}' does not exist", ms),
226 });
227 }
228 }
229 }
230
231 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
232 if !has_any_transitions {
233 return Ok(());
235 }
236
237 for stage in &self.stages {
239 if let Some(ref transitions) = stage.transitions {
240 for (target_name, edge) in transitions {
241 if !stage_names.contains(target_name.as_str()) {
242 return Err(ValidationError::Transition {
243 from: stage.name.clone(),
244 to: target_name.clone(),
245 message: "target stage does not exist".to_string(),
246 });
247 }
248 if edge.condition == TransitionCondition::Stuck
252 && !edge.stuck.is_some_and(|c| c.is_armed())
253 {
254 return Err(ValidationError::Transition {
255 from: stage.name.clone(),
256 to: target_name.clone(),
257 message: "condition = \"stuck\" requires at least one \
258 stuck_after_* threshold (the edge could never fire)"
259 .to_string(),
260 });
261 }
262 }
263
264 for (target_name, edge) in transitions {
268 let Some(gate) = &edge.gate else { continue };
269 if !gate.require_modifications {
270 continue;
271 }
272 let can_modify = stage.available_tools.iter().any(|t| {
273 MODIFYING_TOOLS.contains(&t.as_str())
274 || gate.tools.iter().any(|extra| extra == t)
275 });
276 if !can_modify {
277 return Err(ValidationError::Transition {
278 from: stage.name.clone(),
279 to: target_name.clone(),
280 message: "gate requires modifications, but the stage has no \
281 file-modifying tool in available_tools"
282 .to_string(),
283 });
284 }
285 }
286
287 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
289 return Err(ValidationError::Stage {
290 stage: stage.name.clone(),
291 message: "self-loop transition requires max_revisits".to_string(),
292 });
293 }
294 }
295 }
296
297 let entry = self.resolve_entry_stage_name();
300 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
301 if !has_terminal {
302 return Err(ValidationError::Graph(
303 "no terminal path exists from entry stage - agent would never complete".to_string(),
304 ));
305 }
306
307 Ok(())
308 }
309
310 pub fn resolve_entry_stage_name(&self) -> String {
312 self.entry_stage.clone().unwrap_or_else(|| {
313 self.stages
314 .first()
315 .map(|s| s.name.clone())
316 .unwrap_or_default()
317 })
318 }
319
320 fn has_terminal_path(
322 &self,
323 stage_name: &str,
324 visited: &mut std::collections::HashSet<String>,
325 ) -> bool {
326 if visited.contains(stage_name) {
327 return false;
328 }
329 visited.insert(stage_name.to_string());
330
331 let stage = self.stages.iter().find(|s| s.name == stage_name);
332 let stage = match stage {
333 Some(s) => s,
334 None => return false,
340 };
341
342 if let StageMode::FanOut {
345 config:
346 FanOutConfig {
347 merge_stage: Some(ms),
348 ..
349 },
350 } = &stage.mode
351 {
352 return self.has_terminal_path(ms, visited);
353 }
354
355 match &stage.transitions {
356 None => {
357 let idx = self
359 .stages
360 .iter()
361 .position(|s| s.name == stage_name)
362 .unwrap_or(0);
363 if idx + 1 >= self.stages.len() {
364 return true; }
366 self.has_terminal_path(&self.stages[idx + 1].name, visited)
367 }
368 Some(transitions) => {
369 if transitions.is_empty() {
370 return true; }
372 for target in transitions.keys() {
374 if self.has_terminal_path(target, visited) {
375 return true;
376 }
377 }
378 transitions.keys().all(|target| {
381 self.stages
382 .iter()
383 .find(|s| s.name == *target)
384 .map(|s| s.max_revisits.is_some())
385 .unwrap_or(false)
386 })
387 }
388 }
389 }
390
391 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
393 self.stages.iter().find(|s| s.name == name)
394 }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct FileTrackingConfig {
404 pub region: String,
406 #[serde(default = "default_true_val")]
408 pub track_reads: bool,
409 #[serde(default = "default_true_val")]
413 pub track_writes: bool,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub max_file_tokens: Option<usize>,
417}
418
419fn default_true_val() -> bool {
420 true
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RepetitionDetectionConfig {
429 pub max_repeat_calls: Option<usize>,
431 pub max_readonly_streak: Option<usize>,
433 pub enabled: Option<bool>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct ToolResultRouting {
440 pub default_region: String,
442 pub tool_overrides: HashMap<String, String>,
444 pub persist: bool,
446 pub max_result_tokens: Option<usize>,
448}
449
450impl Default for ToolResultRouting {
451 fn default() -> Self {
452 Self {
453 default_region: "tool_results".to_string(),
454 tool_overrides: HashMap::new(),
455 persist: true,
456 max_result_tokens: None,
457 }
458 }
459}
460
461#[derive(Debug, Clone, Default, Serialize, Deserialize)]
463pub enum StageMode {
464 #[default]
466 Autonomous,
467
468 Interactive,
470
471 InteractivePoints {
473 points: Vec<InteractionPoint>,
475 },
476
477 FanOut {
480 config: FanOutConfig,
482 },
483}
484
485impl PartialEq for StageMode {
486 #[inline(never)]
487 fn eq(&self, other: &Self) -> bool {
488 match (self, other) {
489 (Self::Autonomous, Self::Autonomous) | (Self::Interactive, Self::Interactive) => true,
490 (Self::InteractivePoints { points: a }, Self::InteractivePoints { points: b }) => {
491 a == b
492 }
493 (Self::FanOut { config: a }, Self::FanOut { config: b }) => a == b,
494 _ => false,
495 }
496 }
497}
498impl Eq for StageMode {}
499
500#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
502#[serde(rename_all = "snake_case")]
503pub enum WorkerFailurePolicy {
504 #[default]
507 Continue,
508 FailAll,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
517pub struct FanOutConfig {
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub worker_agent: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub worker_stage: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub worker_query: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub merge_stage: Option<String>,
531 #[serde(default = "default_max_workers")]
533 pub max_workers: usize,
534 #[serde(default)]
536 pub on_worker_failure: WorkerFailurePolicy,
537 #[serde(default)]
539 pub split_prompt: String,
540}
541
542fn default_max_workers() -> usize {
544 4
545}
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum InteractionStyle {
551 #[default]
553 FreeText,
554 MultipleChoice,
556 Confirm,
558}
559
560impl PartialEq for InteractionStyle {
561 #[inline(never)]
562 fn eq(&self, other: &Self) -> bool {
563 std::mem::discriminant(self) == std::mem::discriminant(other)
564 }
565}
566impl Eq for InteractionStyle {}
567
568#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
570pub struct InteractionPoint {
571 pub name: String,
573
574 pub prompt: String,
576
577 pub required: bool,
579
580 #[serde(default)]
582 pub style: InteractionStyle,
583
584 #[serde(default)]
586 pub options: Vec<String>,
587
588 #[serde(default, alias = "followups")]
599 pub directives: HashMap<String, String>,
600
601 #[serde(default)]
606 pub abort_options: Vec<String>,
607
608 #[serde(default)]
614 pub edit_options: Vec<String>,
615
616 #[serde(default)]
623 pub document_region: Option<String>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct Stage {
639 pub name: String,
641
642 pub description: Option<String>,
644
645 pub model: ModelConfig,
647
648 pub available_tools: Vec<String>,
650
651 pub max_iterations: Option<usize>,
653
654 #[serde(default)]
656 pub mode: StageMode,
657
658 pub context_layout: Option<ContextLayout>,
661
662 pub config: HashMap<String, serde_json::Value>,
664
665 #[serde(default)]
669 pub tool_permissions: HashMap<String, String>,
670
671 #[serde(default)]
674 pub requires_children: bool,
675
676 pub transitions: Option<HashMap<String, TransitionEdge>>,
678
679 pub max_revisits: Option<usize>,
681
682 pub transition_prompt: Option<String>,
684
685 #[serde(default = "default_true")]
689 pub accepts_messages: bool,
690
691 #[serde(default)]
698 pub allow_complete: bool,
699
700 #[serde(default)]
705 pub allow_as_worker: bool,
706
707 #[serde(default)]
712 pub security: Option<crate::taint::SecurityConfig>,
713
714 #[serde(default)]
719 pub batch_tool_hint: Option<bool>,
720
721 #[serde(default)]
726 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
727
728 #[serde(default)]
732 pub tool_result_routing: Option<ToolResultRouting>,
733}
734
735fn default_true() -> bool {
737 true
738}
739
740impl Stage {
741 pub fn new(name: String, model: ModelConfig) -> Self {
743 Self {
744 name,
745 description: None,
746 model,
747 available_tools: Vec::new(),
748 max_iterations: None,
749 mode: StageMode::Autonomous,
750 context_layout: None,
751 config: HashMap::new(),
752 tool_permissions: HashMap::new(),
753 requires_children: false,
754 transitions: None,
755 max_revisits: None,
756 transition_prompt: None,
757 accepts_messages: true,
758 allow_complete: false,
759 allow_as_worker: false,
760 security: None,
761 batch_tool_hint: None,
762 sandbox: None,
763 tool_result_routing: None,
764 }
765 }
766
767 pub fn with_tools(mut self, tools: Vec<String>) -> Self {
769 self.available_tools = tools;
770 self
771 }
772
773 pub fn with_mode(mut self, mode: StageMode) -> Self {
775 self.mode = mode;
776 self
777 }
778
779 pub fn with_context_layout(mut self, layout: ContextLayout) -> Self {
781 self.context_layout = Some(layout);
782 self
783 }
784
785 pub fn with_description(mut self, description: String) -> Self {
787 self.description = Some(description);
788 self
789 }
790
791 fn validate(&self) -> std::result::Result<(), ValidationError> {
793 if self.name.is_empty() {
794 return Err(ValidationError::Stage {
795 stage: "(empty)".to_string(),
796 message: "stage name cannot be empty".to_string(),
797 });
798 }
799
800 if let Some(layout) = &self.context_layout {
802 layout.validate()?;
803 }
804
805 Ok(())
806 }
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
811pub struct ModelEntry {
812 pub provider: String,
814
815 pub model: String,
817}
818
819impl ModelEntry {
820 pub fn new(provider: String, model: String) -> Self {
821 Self { provider, model }
822 }
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct ModelConfig {
834 #[serde(default)]
836 pub models: Vec<ModelEntry>,
837
838 #[serde(default = "default_allow_user_default")]
841 pub allow_user_default: bool,
842
843 #[serde(default)]
845 pub parameters: HashMap<String, serde_json::Value>,
846
847 #[serde(default)]
855 pub request_timeout_secs: Option<u64>,
856}
857
858fn default_allow_user_default() -> bool {
859 true
860}
861
862impl ModelConfig {
863 pub fn new(provider: String, model: String) -> Self {
865 Self {
866 models: vec![ModelEntry::new(provider, model)],
867 allow_user_default: true,
868 parameters: HashMap::new(),
869 request_timeout_secs: None,
870 }
871 }
872
873 pub fn provider(&self) -> &str {
875 self.models
876 .first()
877 .map(|e| e.provider.as_str())
878 .unwrap_or("anthropic")
879 }
880
881 pub fn model(&self) -> &str {
883 self.models
884 .first()
885 .map(|e| e.model.as_str())
886 .unwrap_or("claude-sonnet-4-6")
887 }
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
897pub struct ContextTransform {
898 pub from_blueprint: String,
900
901 pub to_blueprint: String,
903
904 pub mappings: Vec<RegionMapping>,
906}
907
908impl ContextTransform {
909 fn validate(&self, layout: &ContextLayout) -> std::result::Result<(), ValidationError> {
911 for mapping in &self.mappings {
912 if layout.get_region(&mapping.to_region).is_none() {
915 return Err(ValidationError::Region {
916 region: mapping.to_region.clone(),
917 message: "transform target region not found in layout".to_string(),
918 });
919 }
920 }
921 Ok(())
922 }
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
927pub struct RegionMapping {
928 pub from_region: String,
930
931 pub to_region: String,
933
934 pub transform: Option<ContentTransform>,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct TransitionEdge {
941 pub target: String,
943
944 #[serde(default)]
946 pub condition: TransitionCondition,
947
948 pub hint: Option<String>,
950
951 #[serde(default)]
953 pub transform: EdgeTransform,
954
955 #[serde(default)]
958 pub gate: Option<TransitionGate>,
959
960 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub stuck: Option<StuckConfig>,
965}
966
967#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
975pub struct StuckConfig {
976 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub after_iterations: Option<usize>,
979
980 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub after_minutes: Option<usize>,
983
984 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub after_same_file_edits: Option<usize>,
988
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub after_tool_calls: Option<usize>,
992}
993
994impl StuckConfig {
995 pub fn is_armed(&self) -> bool {
997 self.after_iterations.is_some()
998 || self.after_minutes.is_some()
999 || self.after_same_file_edits.is_some()
1000 || self.after_tool_calls.is_some()
1001 }
1002}
1003
1004#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1012pub struct TransitionGate {
1013 #[serde(default)]
1016 pub require_modifications: bool,
1017
1018 #[serde(default)]
1021 pub message: Option<String>,
1022
1023 #[serde(default)]
1028 pub region: Option<String>,
1029
1030 #[serde(default)]
1033 pub tools: Vec<String>,
1034
1035 #[serde(default)]
1039 pub max_attempts: Option<usize>,
1040}
1041
1042pub const DEFAULT_GATE_ATTEMPTS: usize = 3;
1044
1045pub const MODIFYING_TOOLS: &[&str] = &["write_file", "edit_file"];
1049
1050#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1052#[serde(rename_all = "snake_case")]
1053pub enum TransitionCondition {
1054 #[default]
1056 Always,
1057 Error,
1059 MaxIterations,
1061 LlmChoice,
1063 Stuck,
1069}
1070
1071#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1073#[serde(rename_all = "snake_case")]
1074pub enum EdgeTransform {
1075 #[default]
1077 Direct,
1078
1079 Clear,
1081
1082 Compact {
1084 #[serde(default)]
1085 prompt: Option<String>,
1086 },
1087
1088 Custom {
1090 carry: Vec<String>,
1091 compact: Vec<String>,
1092 clear: Vec<String>,
1093 compact_prompt: Option<String>,
1094 },
1095}
1096
1097impl PartialEq for EdgeTransform {
1098 #[inline(never)]
1099 fn eq(&self, other: &Self) -> bool {
1100 match (self, other) {
1101 (Self::Direct, Self::Direct) | (Self::Clear, Self::Clear) => true,
1102 (Self::Compact { prompt: a }, Self::Compact { prompt: b }) => a == b,
1103 (
1104 Self::Custom {
1105 carry: ca,
1106 compact: coa,
1107 clear: cla,
1108 compact_prompt: cpa,
1109 },
1110 Self::Custom {
1111 carry: cb,
1112 compact: cob,
1113 clear: clb,
1114 compact_prompt: cpb,
1115 },
1116 ) => ca == cb && coa == cob && cla == clb && cpa == cpb,
1117 _ => false,
1118 }
1119 }
1120}
1121impl Eq for EdgeTransform {}
1122
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1125pub enum ContentTransform {
1126 Direct,
1128
1129 Summarize,
1131
1132 Extract { fields: Vec<String> },
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use crate::layout::ContextLayout;
1140 use crate::layout::RegionDefinition;
1141 use crate::region::RegionKind;
1142
1143 #[test]
1144 fn test_blueprint_creation() {
1145 let regions = vec![RegionDefinition::new(
1146 "test".to_string(),
1147 RegionKind::Pinned,
1148 5000,
1149 )];
1150 let layout = ContextLayout::new(regions, 10000);
1151
1152 let stages = vec![Stage::new(
1153 "analyze".to_string(),
1154 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1155 )];
1156
1157 let blueprint = Blueprint::new(
1158 "test-agent".to_string(),
1159 "A test agent".to_string(),
1160 stages,
1161 layout,
1162 );
1163
1164 assert_eq!(blueprint.name, "test-agent");
1165 assert_eq!(blueprint.stages.len(), 1);
1166 }
1167
1168 #[test]
1169 fn test_blueprint_with_transforms_version() {
1170 let stages = vec![Stage::new("plan".to_string(), make_model())];
1171 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1172 .with_transforms(vec![ContextTransform {
1173 from_blueprint: "a".to_string(),
1174 to_blueprint: "b".to_string(),
1175 mappings: vec![],
1176 }])
1177 .with_version("2.0.0".to_string());
1178
1179 assert_eq!(bp.transforms.len(), 1);
1180 assert_eq!(bp.version, "2.0.0");
1181 }
1182
1183 #[test]
1184 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1185 let stages = vec![Stage::new("plan".to_string(), make_model())];
1186 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1187 bp.metadata.insert(
1189 "tool_perm:bash".to_string(),
1190 serde_json::Value::String("deny".to_string()),
1191 );
1192 bp.metadata
1194 .insert("title".to_string(), serde_json::Value::String("x".into()));
1195 bp.metadata
1197 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1198
1199 let perms = bp.agent_tool_permissions();
1200 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1201 assert!(!perms.contains_key("title"));
1202 assert!(!perms.contains_key("weird"));
1203 assert_eq!(perms.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_blueprint_validate_runs_transform_validation() {
1208 let stages = vec![Stage::new("plan".to_string(), make_model())];
1211 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1212 bp.transforms.push(ContextTransform {
1213 from_blueprint: "a".to_string(),
1214 to_blueprint: "b".to_string(),
1215 mappings: vec![RegionMapping {
1216 from_region: "test".to_string(),
1217 to_region: "test".to_string(),
1218 transform: None,
1219 }],
1220 });
1221 assert!(bp.validate().is_ok());
1222 }
1223
1224 #[test]
1225 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1226 let stages = vec![Stage::new("plan".to_string(), make_model())];
1227 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1228 bp.transforms.push(ContextTransform {
1229 from_blueprint: "a".to_string(),
1230 to_blueprint: "b".to_string(),
1231 mappings: vec![RegionMapping {
1232 from_region: "test".to_string(),
1233 to_region: "nonexistent".to_string(),
1234 transform: None,
1235 }],
1236 });
1237 let err = bp.validate().unwrap_err();
1238 assert_eq!(
1239 err,
1240 ValidationError::Region {
1241 region: "nonexistent".to_string(),
1242 message: "transform target region not found in layout".to_string(),
1243 }
1244 );
1245 }
1246
1247 #[test]
1248 fn test_mixed_linear_and_graph_mode_terminal_path() {
1249 let mut plan = Stage::new("plan".to_string(), make_model());
1253 let impl_stage = Stage::new("impl".to_string(), make_model());
1254 let review = Stage::new("review".to_string(), make_model());
1255
1256 let mut transitions = HashMap::new();
1257 transitions.insert(
1258 "impl".to_string(),
1259 TransitionEdge {
1260 target: "impl".to_string(),
1261 condition: TransitionCondition::Always,
1262 hint: None,
1263 transform: EdgeTransform::Direct,
1264 gate: None,
1265 stuck: None,
1266 },
1267 );
1268 plan.transitions = Some(transitions);
1269
1270 let bp = Blueprint::new(
1271 "t".into(),
1272 "".into(),
1273 vec![plan, impl_stage, review],
1274 make_layout(),
1275 );
1276 assert!(bp.validate().is_ok());
1277 }
1278
1279 #[test]
1280 fn test_stage_validation() {
1281 let stage = Stage::new(
1282 "test".to_string(),
1283 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1284 );
1285 assert!(stage.validate().is_ok());
1286
1287 let empty_stage = Stage::new(
1288 "".to_string(),
1289 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1290 );
1291 assert!(empty_stage.validate().is_err());
1292 }
1293
1294 #[test]
1295 fn test_stage_validate_with_valid_context_layout_is_ok() {
1296 let mut stage = Stage::new("test".to_string(), make_model());
1297 stage.context_layout = Some(make_layout());
1298 assert!(stage.validate().is_ok());
1299 }
1300
1301 #[test]
1302 fn test_stage_validate_with_invalid_context_layout_is_err() {
1303 let regions = vec![
1305 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1306 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1307 ];
1308 let mut stage = Stage::new("test".to_string(), make_model());
1309 stage.context_layout = Some(ContextLayout::new(regions, 200));
1310 assert!(stage.validate().is_err());
1311 }
1312
1313 #[test]
1314 fn test_stage_with_tools_context_layout_description() {
1315 let stage = Stage::new("test".to_string(), make_model())
1316 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1317 .with_context_layout(make_layout())
1318 .with_description("does things".to_string());
1319
1320 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1321 assert!(stage.context_layout.is_some());
1322 assert_eq!(stage.description.as_deref(), Some("does things"));
1323 }
1324
1325 #[test]
1326 fn test_stage_with_mode() {
1327 let stage = Stage::new("test".to_string(), make_model())
1328 .with_mode(StageMode::InteractivePoints { points: vec![] });
1329 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1330 }
1331
1332 #[test]
1333 fn test_stage_allow_complete_defaults_false() {
1334 let stage = Stage::new("review".to_string(), make_model());
1335 assert!(!stage.allow_complete);
1336 }
1337
1338 #[test]
1339 fn test_stage_allow_complete_serde_default_when_missing() {
1340 let json = r#"{
1343 "name": "review",
1344 "description": null,
1345 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1346 "available_tools": [],
1347 "max_iterations": null,
1348 "context_layout": null,
1349 "config": {},
1350 "transitions": null,
1351 "max_revisits": null,
1352 "transition_prompt": null
1353 }"#;
1354 let stage: Stage = serde_json::from_str(json).unwrap();
1355 assert!(!stage.allow_complete);
1356 assert!(stage.accepts_messages);
1357 }
1358
1359 #[test]
1360 fn test_stage_allow_complete_roundtrip() {
1361 let mut stage = Stage::new("review".to_string(), make_model());
1362 stage.allow_complete = true;
1363 let json = serde_json::to_string(&stage).unwrap();
1364 let back: Stage = serde_json::from_str(&json).unwrap();
1365 assert!(back.allow_complete);
1366 }
1367
1368 #[test]
1369 fn test_interaction_point_directives_default_empty() {
1370 let point = InteractionPoint {
1371 name: "plan_approval".to_string(),
1372 prompt: "Approve?".to_string(),
1373 required: true,
1374 style: InteractionStyle::MultipleChoice,
1375 options: vec!["Approve".to_string(), "Revise".to_string()],
1376 directives: HashMap::new(),
1377 abort_options: Vec::new(),
1378 edit_options: Vec::new(),
1379 document_region: None,
1380 };
1381 assert!(point.directives.is_empty());
1382 assert!(point.abort_options.is_empty());
1383 assert!(point.edit_options.is_empty());
1384 }
1385
1386 #[test]
1387 fn test_interaction_point_directives_roundtrip() {
1388 let mut directives = HashMap::new();
1389 directives.insert(
1390 "Revise".to_string(),
1391 "Ask what to change, then re-plan.".to_string(),
1392 );
1393 let point = InteractionPoint {
1394 name: "plan_approval".to_string(),
1395 prompt: "Approve?".to_string(),
1396 required: true,
1397 style: InteractionStyle::MultipleChoice,
1398 options: vec!["Approve".to_string(), "Revise".to_string()],
1399 directives,
1400 abort_options: vec!["Abort".to_string()],
1401 edit_options: vec!["Add detail".to_string()],
1402 document_region: Some("plan".to_string()),
1403 };
1404 let json = serde_json::to_string(&point).unwrap();
1405 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1406 assert_eq!(
1407 back.directives.get("Revise").map(|s| s.as_str()),
1408 Some("Ask what to change, then re-plan.")
1409 );
1410 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1411 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1412 }
1413
1414 #[test]
1415 fn test_interaction_point_directives_serde_default_when_missing() {
1416 let json = r#"{
1417 "name": "plan_approval",
1418 "prompt": "Approve?",
1419 "required": true,
1420 "style": "multiple_choice",
1421 "options": ["Approve", "Revise"]
1422 }"#;
1423 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1424 assert!(point.directives.is_empty());
1425 assert!(point.abort_options.is_empty());
1426 }
1427
1428 #[test]
1429 fn test_interaction_point_followups_alias_still_deserializes() {
1430 let json = r#"{
1432 "name": "plan_approval",
1433 "prompt": "Approve?",
1434 "required": true,
1435 "style": "multiple_choice",
1436 "options": ["Approve", "Revise"],
1437 "followups": { "Revise": "What to change?" }
1438 }"#;
1439 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1440 assert_eq!(
1441 point.directives.get("Revise").map(|s| s.as_str()),
1442 Some("What to change?")
1443 );
1444 }
1445
1446 #[test]
1447 fn test_model_config_new_creates_single_entry() {
1448 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1449 assert_eq!(mc.models.len(), 1);
1450 assert_eq!(mc.models[0].provider, "anthropic");
1451 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1452 assert!(mc.allow_user_default);
1453 }
1454
1455 #[test]
1456 fn test_model_config_with_multiple_models() {
1457 let mc = ModelConfig {
1458 models: vec![
1459 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1460 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1461 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1462 ],
1463 allow_user_default: true,
1464 parameters: HashMap::new(),
1465 request_timeout_secs: None,
1466 };
1467 assert_eq!(mc.models.len(), 3);
1468 assert_eq!(mc.models[0].provider, "anthropic");
1469 assert_eq!(mc.models[1].provider, "openai");
1470 assert_eq!(mc.models[2].provider, "ollama");
1471 }
1472
1473 #[test]
1474 fn test_model_config_serde_roundtrip() {
1475 let mc = ModelConfig {
1476 models: vec![
1477 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1478 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1479 ],
1480 allow_user_default: false,
1481 parameters: HashMap::new(),
1482 request_timeout_secs: None,
1483 };
1484 let json = serde_json::to_string(&mc).unwrap();
1485 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1486 assert_eq!(back.models.len(), 2);
1487 assert_eq!(back.models[0].provider, "anthropic");
1488 assert_eq!(back.models[1].provider, "openai");
1489 assert!(!back.allow_user_default);
1490 }
1491
1492 #[test]
1493 fn test_model_config_serde_defaults_when_fields_missing() {
1494 let json = r#"{"parameters": {}}"#;
1496 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1497 assert!(mc.models.is_empty());
1498 assert!(mc.allow_user_default);
1499 }
1500
1501 #[test]
1502 fn test_model_config_convenience_accessors() {
1503 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1504 assert_eq!(mc.provider(), "anthropic");
1505 assert_eq!(mc.model(), "claude-sonnet-4-6");
1506 }
1507
1508 #[test]
1509 fn test_model_config_convenience_accessors_empty_models() {
1510 let mc = ModelConfig {
1511 models: vec![],
1512 allow_user_default: true,
1513 parameters: HashMap::new(),
1514 request_timeout_secs: None,
1515 };
1516 assert_eq!(mc.provider(), "anthropic");
1517 assert_eq!(mc.model(), "claude-sonnet-4-6");
1518 }
1519
1520 fn make_model() -> ModelConfig {
1521 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1522 }
1523
1524 fn make_layout() -> ContextLayout {
1525 let regions = vec![RegionDefinition::new(
1526 "test".to_string(),
1527 RegionKind::Pinned,
1528 5000,
1529 )];
1530 ContextLayout::new(regions, 10000)
1531 }
1532
1533 #[test]
1534 fn test_graph_validation_entry_stage_exists() {
1535 let stages = vec![Stage::new("plan".to_string(), make_model())];
1536 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1537 bp.entry_stage = Some("nonexistent".to_string());
1538 assert!(bp.validate().is_err());
1539 }
1540
1541 #[test]
1542 fn test_graph_validation_entry_stage_valid() {
1543 let stages = vec![Stage::new("plan".to_string(), make_model())];
1544 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1545 bp.entry_stage = Some("plan".to_string());
1546 assert!(bp.validate().is_ok());
1547 }
1548
1549 #[test]
1550 fn test_graph_validation_transition_target_missing() {
1551 let mut stage = Stage::new("plan".to_string(), make_model());
1552 let mut transitions = HashMap::new();
1553 transitions.insert(
1554 "nonexistent".to_string(),
1555 TransitionEdge {
1556 target: "nonexistent".to_string(),
1557 condition: TransitionCondition::Always,
1558 hint: None,
1559 transform: EdgeTransform::Direct,
1560 gate: None,
1561 stuck: None,
1562 },
1563 );
1564 stage.transitions = Some(transitions);
1565 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1566 assert!(bp.validate().is_err());
1567 }
1568
1569 #[test]
1573 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1574 let gated = |tools: &[&str], extra: &[&str]| {
1575 let mut stage = Stage::new("impl".to_string(), make_model());
1576 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1577 let mut transitions = HashMap::new();
1578 transitions.insert(
1579 "review".to_string(),
1580 TransitionEdge {
1581 target: "review".to_string(),
1582 condition: TransitionCondition::Always,
1583 hint: None,
1584 transform: EdgeTransform::Direct,
1585 stuck: None,
1586 gate: Some(TransitionGate {
1587 require_modifications: true,
1588 tools: extra.iter().map(|t| t.to_string()).collect(),
1589 ..Default::default()
1590 }),
1591 },
1592 );
1593 stage.transitions = Some(transitions);
1594 Blueprint::new(
1595 "t".into(),
1596 "".into(),
1597 vec![stage, Stage::new("review".to_string(), make_model())],
1598 make_layout(),
1599 )
1600 };
1601 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1602 assert!(err.to_string().contains("no file-modifying tool"));
1603 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1605 assert!(
1607 gated(&["read_file", "patch_file"], &["patch_file"])
1608 .validate()
1609 .is_ok()
1610 );
1611 let mut off = gated(&["read_file"], &[]);
1613 off.stages[0]
1614 .transitions
1615 .as_mut()
1616 .unwrap()
1617 .get_mut("review")
1618 .unwrap()
1619 .gate = Some(TransitionGate::default());
1620 assert!(off.validate().is_ok());
1621 off.stages[0]
1623 .transitions
1624 .as_mut()
1625 .unwrap()
1626 .get_mut("review")
1627 .unwrap()
1628 .gate = None;
1629 assert!(off.validate().is_ok());
1630 }
1631
1632 #[test]
1633 fn test_graph_validation_self_loop_requires_max_revisits() {
1634 let mut stage = Stage::new("impl".to_string(), make_model());
1635 let mut transitions = HashMap::new();
1636 transitions.insert(
1637 "impl".to_string(),
1638 TransitionEdge {
1639 target: "impl".to_string(),
1640 condition: TransitionCondition::Always,
1641 hint: None,
1642 transform: EdgeTransform::Direct,
1643 gate: None,
1644 stuck: None,
1645 },
1646 );
1647 stage.transitions = Some(transitions);
1648 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1649 assert!(bp.validate().is_err());
1650 }
1651
1652 #[test]
1653 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1654 let mut stage = Stage::new("impl".to_string(), make_model());
1655 stage.max_revisits = Some(3);
1656 let mut transitions = HashMap::new();
1657 transitions.insert(
1658 "impl".to_string(),
1659 TransitionEdge {
1660 target: "impl".to_string(),
1661 condition: TransitionCondition::Always,
1662 hint: None,
1663 transform: EdgeTransform::Direct,
1664 gate: None,
1665 stuck: None,
1666 },
1667 );
1668 stage.transitions = Some(transitions);
1669 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1670 assert!(bp.validate().is_ok());
1673 }
1674
1675 #[test]
1676 fn test_graph_validation_terminal_path_exists() {
1677 let mut plan = Stage::new("plan".to_string(), make_model());
1678 let mut review = Stage::new("review".to_string(), make_model());
1679 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1682 transitions.insert(
1683 "review".to_string(),
1684 TransitionEdge {
1685 target: "review".to_string(),
1686 condition: TransitionCondition::Always,
1687 hint: None,
1688 transform: EdgeTransform::Direct,
1689 gate: None,
1690 stuck: None,
1691 },
1692 );
1693 plan.transitions = Some(transitions);
1694
1695 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1696 assert!(bp.validate().is_ok());
1697 }
1698
1699 #[test]
1700 fn test_graph_no_terminal_path() {
1701 let mut a = Stage::new("a".to_string(), make_model());
1703 let mut b = Stage::new("b".to_string(), make_model());
1704
1705 let mut a_transitions = HashMap::new();
1706 a_transitions.insert(
1707 "b".to_string(),
1708 TransitionEdge {
1709 target: "b".to_string(),
1710 condition: TransitionCondition::Always,
1711 hint: None,
1712 transform: EdgeTransform::Direct,
1713 gate: None,
1714 stuck: None,
1715 },
1716 );
1717 a.transitions = Some(a_transitions);
1718
1719 let mut b_transitions = HashMap::new();
1720 b_transitions.insert(
1721 "a".to_string(),
1722 TransitionEdge {
1723 target: "a".to_string(),
1724 condition: TransitionCondition::Always,
1725 hint: None,
1726 transform: EdgeTransform::Direct,
1727 gate: None,
1728 stuck: None,
1729 },
1730 );
1731 b.transitions = Some(b_transitions);
1732
1733 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1734 assert!(bp.validate().is_err());
1735 }
1736
1737 #[test]
1738 fn test_linear_stages_still_validate() {
1739 let stages = vec![
1741 Stage::new("plan".to_string(), make_model()),
1742 Stage::new("impl".to_string(), make_model()),
1743 Stage::new("review".to_string(), make_model()),
1744 ];
1745 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1746 assert!(bp.validate().is_ok());
1747 }
1748
1749 #[test]
1750 fn test_resolve_entry_stage_name() {
1751 let stages = vec![
1752 Stage::new("plan".to_string(), make_model()),
1753 Stage::new("impl".to_string(), make_model()),
1754 ];
1755 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1756 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1757
1758 bp.entry_stage = Some("impl".to_string());
1759 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1760 }
1761
1762 #[test]
1763 fn test_find_stage() {
1764 let stages = vec![
1765 Stage::new("plan".to_string(), make_model()),
1766 Stage::new("impl".to_string(), make_model()),
1767 ];
1768 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1769 assert!(bp.find_stage("plan").is_some());
1770 assert!(bp.find_stage("impl").is_some());
1771 assert!(bp.find_stage("nonexistent").is_none());
1772 }
1773
1774 #[test]
1775 fn test_transition_condition_default() {
1776 let cond = TransitionCondition::default();
1777 assert_eq!(cond, TransitionCondition::Always);
1778 }
1779
1780 #[test]
1781 fn test_edge_transform_default() {
1782 let t = EdgeTransform::default();
1783 assert_eq!(t, EdgeTransform::Direct);
1784 }
1785
1786 #[test]
1787 fn test_stage_mode_equality() {
1788 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1789 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1790 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1791 }
1792
1793 #[test]
1794 fn test_interaction_style_equality() {
1795 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1796 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1797 }
1798
1799 #[test]
1802 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1803 assert!(!StuckConfig::default().is_armed());
1804 for cfg in [
1805 StuckConfig {
1806 after_iterations: Some(1),
1807 ..Default::default()
1808 },
1809 StuckConfig {
1810 after_minutes: Some(1),
1811 ..Default::default()
1812 },
1813 StuckConfig {
1814 after_same_file_edits: Some(1),
1815 ..Default::default()
1816 },
1817 StuckConfig {
1818 after_tool_calls: Some(1),
1819 ..Default::default()
1820 },
1821 ] {
1822 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1823 }
1824 }
1825
1826 #[test]
1827 fn transition_condition_stuck_round_trips_as_snake_case() {
1828 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1829 assert_eq!(json, "\"stuck\"");
1830 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1831 assert_eq!(back, TransitionCondition::Stuck);
1832 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1833 }
1834
1835 #[test]
1836 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1837 let plain = TransitionEdge {
1838 target: "b".to_string(),
1839 condition: TransitionCondition::Always,
1840 hint: None,
1841 transform: EdgeTransform::Direct,
1842 gate: None,
1843 stuck: None,
1844 };
1845 let json = serde_json::to_string(&plain).unwrap();
1846 assert!(
1847 !json.contains("stuck"),
1848 "absent config must be skipped: {json}"
1849 );
1850
1851 let armed = TransitionEdge {
1852 condition: TransitionCondition::Stuck,
1853 stuck: Some(StuckConfig {
1854 after_iterations: Some(20),
1855 after_minutes: Some(10),
1856 after_same_file_edits: Some(3),
1857 after_tool_calls: Some(60),
1858 }),
1859 ..plain
1860 };
1861 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1862 .expect("armed edge round-trips");
1863 assert_eq!(back.condition, TransitionCondition::Stuck);
1864 assert_eq!(back.stuck, armed.stuck);
1865 }
1866
1867 #[test]
1870 fn validate_rejects_a_stuck_edge_with_no_threshold() {
1871 let build = |stuck| {
1872 let mut a = Stage::new("a".to_string(), make_model());
1873 let b = Stage::new("b".to_string(), make_model());
1874 let mut transitions = std::collections::HashMap::new();
1875 transitions.insert(
1876 "b".to_string(),
1877 TransitionEdge {
1878 target: "b".to_string(),
1879 condition: TransitionCondition::Stuck,
1880 hint: None,
1881 transform: EdgeTransform::Direct,
1882 gate: None,
1883 stuck,
1884 },
1885 );
1886 a.transitions = Some(transitions);
1887 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1888 };
1889
1890 for dead in [None, Some(StuckConfig::default())] {
1891 let err = build(dead)
1892 .validate()
1893 .expect_err("dead stuck edge rejected");
1894 assert!(
1895 format!("{err:?}").contains("stuck_after_"),
1896 "unexpected error: {err:?}"
1897 );
1898 }
1899
1900 assert!(
1902 build(Some(StuckConfig {
1903 after_iterations: Some(5),
1904 ..Default::default()
1905 }))
1906 .validate()
1907 .is_ok()
1908 );
1909 }
1910
1911 #[test]
1912 fn test_transition_condition_equality() {
1913 assert_eq!(
1914 TransitionCondition::LlmChoice,
1915 TransitionCondition::LlmChoice
1916 );
1917 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1918 }
1919
1920 #[test]
1921 fn test_edge_transform_compact_and_custom_equality() {
1922 let a = EdgeTransform::Compact {
1923 prompt: Some("p".to_string()),
1924 };
1925 let b = EdgeTransform::Compact {
1926 prompt: Some("p".to_string()),
1927 };
1928 assert_eq!(a, b);
1929
1930 let c1 = EdgeTransform::Custom {
1931 carry: vec!["a".to_string()],
1932 compact: vec!["b".to_string()],
1933 clear: vec!["c".to_string()],
1934 compact_prompt: Some("p".to_string()),
1935 };
1936 let c2 = c1.clone();
1937 assert_eq!(c1, c2);
1938
1939 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1940 }
1941
1942 #[test]
1943 fn test_stage_accepts_messages_default_true() {
1944 let stage = Stage::new(
1945 "test".to_string(),
1946 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1947 );
1948 assert!(stage.accepts_messages);
1949 }
1950
1951 #[test]
1952 fn test_stage_accepts_messages_serde_roundtrip() {
1953 let mut stage = Stage::new(
1955 "report".to_string(),
1956 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1957 );
1958 stage.accepts_messages = false;
1959
1960 let json = serde_json::to_string(&stage).expect("should serialize");
1961 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1962 assert!(!deserialized.accepts_messages);
1963 }
1964
1965 #[test]
1966 fn test_stage_accepts_messages_json_default() {
1967 let json = r#"{
1969 "name": "analyze",
1970 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1971 "available_tools": [],
1972 "mode": "Autonomous",
1973 "config": {},
1974 "tool_permissions": {},
1975 "requires_children": false
1976 }"#;
1977 let stage: Stage = serde_json::from_str(json).expect("should parse");
1978 assert!(stage.accepts_messages);
1979 }
1980
1981 #[test]
1982 fn test_has_terminal_path_unknown_stage_returns_false() {
1983 let stages = vec![Stage::new("start".to_string(), make_model())];
1987 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1988 let mut visited = std::collections::HashSet::new();
1989 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1990 }
1991
1992 #[test]
1993 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1994 let regions = vec![
1995 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1996 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1997 ];
1998 let layout = ContextLayout::new(regions, 200);
1999 let stages = vec![Stage::new("start".to_string(), make_model())];
2000 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2001 assert_eq!(
2002 bp.validate().unwrap_err(),
2003 ValidationError::Region {
2004 region: "dup".to_string(),
2005 message: "duplicate region name".to_string(),
2006 }
2007 );
2008 }
2009
2010 #[test]
2011 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2012 let stages = vec![Stage::new("".to_string(), make_model())];
2013 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2014 assert_eq!(
2015 bp.validate().unwrap_err(),
2016 ValidationError::Stage {
2017 stage: "(empty)".to_string(),
2018 message: "stage name cannot be empty".to_string(),
2019 }
2020 );
2021 }
2022
2023 #[test]
2024 fn test_file_tracking_config_defaults() {
2025 let json = r#"{"region": "files"}"#;
2026 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2027 assert_eq!(config.region, "files");
2028 assert!(config.track_reads);
2029 assert!(config.track_writes);
2030 assert!(config.max_file_tokens.is_none());
2031 }
2032
2033 #[test]
2034 fn test_file_tracking_config_serde_roundtrip() {
2035 let config = FileTrackingConfig {
2036 region: "files".to_string(),
2037 track_reads: true,
2038 track_writes: false,
2039 max_file_tokens: Some(5000),
2040 };
2041 let json = serde_json::to_string(&config).unwrap();
2042 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2043 assert_eq!(back.region, "files");
2044 assert!(back.track_reads);
2045 assert!(!back.track_writes);
2046 assert_eq!(back.max_file_tokens, Some(5000));
2047 }
2048
2049 #[test]
2050 fn test_blueprint_file_tracking_default_none() {
2051 let stages = vec![Stage::new("plan".to_string(), make_model())];
2052 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2053 assert!(bp.file_tracking.is_none());
2054 }
2055
2056 #[test]
2057 fn test_blueprint_file_tracking_serde_roundtrip() {
2058 let stages = vec![Stage::new("plan".to_string(), make_model())];
2059 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2060 bp.file_tracking = Some(FileTrackingConfig {
2061 region: "files".to_string(),
2062 track_reads: true,
2063 track_writes: true,
2064 max_file_tokens: Some(3000),
2065 });
2066 let json = serde_json::to_string(&bp).unwrap();
2067 let back: Blueprint = serde_json::from_str(&json).unwrap();
2068 let ft = back.file_tracking.unwrap();
2069 assert_eq!(ft.region, "files");
2070 assert_eq!(ft.max_file_tokens, Some(3000));
2071 }
2072
2073 #[test]
2074 fn test_tool_result_routing_default() {
2075 let routing = ToolResultRouting::default();
2076 assert_eq!(routing.default_region, "tool_results");
2077 assert!(routing.persist);
2078 assert!(routing.tool_overrides.is_empty());
2079 assert!(routing.max_result_tokens.is_none());
2080 }
2081
2082 #[test]
2083 fn test_stage_new_has_no_tool_result_routing() {
2084 let stage = Stage::new("plan".to_string(), make_model());
2085 assert!(stage.tool_result_routing.is_none());
2086 }
2087
2088 #[test]
2089 fn test_tool_result_routing_serde_roundtrip() {
2090 let mut routing = ToolResultRouting {
2091 default_region: "custom_region".to_string(),
2092 persist: false,
2093 max_result_tokens: Some(4096),
2094 ..Default::default()
2095 };
2096 routing
2097 .tool_overrides
2098 .insert("read_file".to_string(), "file_reads".to_string());
2099
2100 let json = serde_json::to_string(&routing).unwrap();
2101 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2102
2103 assert_eq!(back.default_region, "custom_region");
2104 assert!(!back.persist);
2105 assert_eq!(back.max_result_tokens, Some(4096));
2106 assert_eq!(
2107 back.tool_overrides.get("read_file").map(String::as_str),
2108 Some("file_reads")
2109 );
2110 }
2111
2112 #[test]
2113 fn test_stage_with_tool_result_routing_serde_roundtrip() {
2114 let stages = vec![{
2115 let mut s = Stage::new("plan".to_string(), make_model());
2116 s.tool_result_routing = Some(ToolResultRouting {
2117 default_region: "results".to_string(),
2118 tool_overrides: HashMap::new(),
2119 persist: true,
2120 max_result_tokens: Some(2048),
2121 });
2122 s
2123 }];
2124 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2125 let json = serde_json::to_string(&bp).unwrap();
2126 let back: Blueprint = serde_json::from_str(&json).unwrap();
2127
2128 let routing = back.stages[0]
2129 .tool_result_routing
2130 .as_ref()
2131 .expect("tool_result_routing should be Some");
2132 assert_eq!(routing.default_region, "results");
2133 assert!(routing.persist);
2134 assert_eq!(routing.max_result_tokens, Some(2048));
2135 assert!(routing.tool_overrides.is_empty());
2136 }
2137
2138 fn fanout_config() -> FanOutConfig {
2141 FanOutConfig {
2142 worker_agent: None,
2143 worker_stage: Some("fix_worker".to_string()),
2144 worker_query: None,
2145 merge_stage: Some("merge".to_string()),
2146 max_workers: 3,
2147 on_worker_failure: WorkerFailurePolicy::Continue,
2148 split_prompt: "split".to_string(),
2149 }
2150 }
2151
2152 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2157 let mut fan = Stage::new("parallel".to_string(), make_model());
2158 fan.mode = StageMode::FanOut { config };
2159 let mut worker = Stage::new("fix_worker".to_string(), make_model());
2160 worker.allow_as_worker = worker_allowed;
2161 let mut merge = Stage::new("merge".to_string(), make_model());
2162 merge.transitions = Some(HashMap::new()); Blueprint::new(
2164 "t".into(),
2165 "d".into(),
2166 vec![fan, worker, merge],
2167 make_layout(),
2168 )
2169 }
2170
2171 #[test]
2172 fn fanout_stagemode_partial_eq_and_default_policy() {
2173 let a = StageMode::FanOut {
2174 config: fanout_config(),
2175 };
2176 let b = StageMode::FanOut {
2177 config: fanout_config(),
2178 };
2179 assert_eq!(a, b);
2180 let mut other = fanout_config();
2181 other.max_workers = 99;
2182 assert_ne!(a, StageMode::FanOut { config: other });
2183 assert_ne!(a, StageMode::Autonomous);
2184 assert_eq!(
2185 WorkerFailurePolicy::default(),
2186 WorkerFailurePolicy::Continue
2187 );
2188 }
2189
2190 #[test]
2191 fn fanout_config_serde_roundtrip_and_max_workers_default() {
2192 let toml = r#"
2193worker_agent = "fixer"
2194split_prompt = "go"
2195on_worker_failure = "fail_all"
2196"#;
2197 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2198 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2199 assert_eq!(cfg.max_workers, 4); assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2201 let json = serde_json::to_string(&fanout_config()).unwrap();
2203 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2204 assert_eq!(back, fanout_config());
2205 }
2206
2207 #[test]
2208 fn fanout_validate_ok_with_allowed_worker_stage() {
2209 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2210 }
2211
2212 #[test]
2213 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2214 let err = fanout_blueprint(false, fanout_config())
2215 .validate()
2216 .unwrap_err();
2217 assert!(err.to_string().contains("allow_as_worker"));
2218 }
2219
2220 #[test]
2221 fn fanout_validate_rejects_missing_worker_stage() {
2222 let mut cfg = fanout_config();
2223 cfg.worker_stage = Some("nope".to_string());
2224 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2225 assert!(err.to_string().contains("does not exist"));
2226 }
2227
2228 #[test]
2229 fn fanout_validate_rejects_missing_merge_stage() {
2230 let mut cfg = fanout_config();
2231 cfg.merge_stage = Some("nomerge".to_string());
2232 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2233 assert!(err.to_string().contains("merge_stage"));
2234 }
2235
2236 #[test]
2237 fn fanout_validate_rejects_wrong_worker_source_count() {
2238 let mut cfg = fanout_config();
2240 cfg.worker_stage = None;
2241 assert!(fanout_blueprint(true, cfg).validate().is_err());
2242 let mut cfg2 = fanout_config();
2244 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2246 }
2247
2248 #[test]
2249 fn fanout_terminal_path_runs_through_merge_stage() {
2250 let mut cfg = fanout_config();
2252 cfg.worker_stage = None;
2253 cfg.worker_agent = Some("external".to_string());
2254 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2255 }
2256
2257 #[test]
2258 fn fanout_validate_ok_without_merge_stage() {
2259 let mut cfg = fanout_config();
2262 cfg.merge_stage = None;
2263 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2264 }
2265}