1use crate::error::ValidationError;
9use crate::layout::{ContextLayout, RegionSeed};
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14pub const ALWAYS_VISIBLE_REGIONS: [&str; 4] = [
22 "conversation",
23 "tool_results",
24 "final_output",
25 crate::layout::STAGE_INSTRUCTIONS_REGION,
26];
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Blueprint {
35 pub name: String,
37
38 pub description: String,
40
41 pub stages: Vec<Stage>,
43
44 pub context_layout: ContextLayout,
46
47 pub transforms: Vec<ContextTransform>,
49
50 pub version: String,
52
53 pub compaction_config: Option<CompactionConfig>,
55
56 pub max_child_depth: Option<usize>,
58
59 pub entry_stage: Option<String>,
61
62 pub metadata: HashMap<String, serde_json::Value>,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub security: Option<crate::taint::SecurityConfig>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub batch_tool_hint: Option<bool>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub shell_hint: Option<bool>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub nudge: Option<NudgeConfig>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub repetition_detection: Option<RepetitionDetectionConfig>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub file_tracking: Option<FileTrackingConfig>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
100
101 #[serde(default)]
106 pub dynamic_tools: bool,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub read_paths: Option<ReadPathsConfig>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub safe_commands: Option<SafeCommandsConfig>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub output: Option<crate::output::OutputSpec>,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SafeCommandsConfig {
150 #[serde(default)]
152 pub tools: Vec<String>,
153 #[serde(default)]
156 pub shell: Vec<String>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ReadPathsConfig {
163 #[serde(default)]
172 pub allow: Vec<String>,
173}
174
175impl Blueprint {
176 pub fn new(
178 name: String,
179 description: String,
180 stages: Vec<Stage>,
181 context_layout: ContextLayout,
182 ) -> Self {
183 Self {
184 name,
185 description,
186 stages,
187 context_layout,
188 transforms: Vec::new(),
189 version: "0.1.0".to_string(),
190 compaction_config: None,
191 max_child_depth: None,
192 entry_stage: None,
193 metadata: HashMap::new(),
194 security: None,
195 batch_tool_hint: None,
196 shell_hint: None,
197 nudge: None,
198 repetition_detection: None,
199 file_tracking: None,
200 sandbox: None,
201 dynamic_tools: false,
202 read_paths: None,
203 safe_commands: None,
204 output: None,
205 }
206 }
207
208 pub fn accepts_task(&self) -> bool {
216 self.context_layout
217 .regions
218 .iter()
219 .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
220 }
221
222 pub fn caller_inputs(&self) -> Vec<&str> {
227 self.context_layout
228 .regions
229 .iter()
230 .filter_map(|r| match &r.seed {
231 Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
232 _ => None,
233 })
234 .collect()
235 }
236
237 pub fn task_refusal(&self) -> String {
243 let inputs = self.caller_inputs();
244 let takes = match inputs.is_empty() {
245 true => "it takes no caller input at all".to_string(),
246 false => format!("it takes: {}", inputs.join(", ")),
247 };
248 format!(
249 "agent '{}' was given a task but declares no region to put it in, so the task \
250 would be ignored - {takes}. Add a region seeded from the task, for example:\n\
251 [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
252 required = true, seed = \"task\" }}",
253 self.name,
254 )
255 }
256
257 pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
264 self.metadata
265 .iter()
266 .filter_map(|(k, v)| {
267 Some((
268 k.strip_prefix("tool_perm:")?.to_string(),
269 v.as_str()?.to_string(),
270 ))
271 })
272 .collect()
273 }
274
275 pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
277 self.transforms = transforms;
278 self
279 }
280
281 pub fn with_version(mut self, version: String) -> Self {
283 self.version = version;
284 self
285 }
286
287 pub fn validate(&self) -> std::result::Result<(), ValidationError> {
289 self.context_layout.validate()?;
291
292 for stage in &self.stages {
294 stage.validate()?;
295 }
296
297 for transform in &self.transforms {
299 transform.validate(&self.context_layout)?;
300 }
301
302 self.validate_graph()?;
304
305 self.validate_region_references()?;
306
307 Ok(())
308 }
309
310 fn known_region_names(&self) -> std::collections::HashSet<&str> {
319 let mut names: std::collections::HashSet<&str> = self
320 .context_layout
321 .regions
322 .iter()
323 .map(|r| r.name.as_str())
324 .collect();
325 for stage in &self.stages {
326 if let Some(layout) = &stage.context_layout {
327 names.extend(layout.regions.iter().map(|r| r.name.as_str()));
328 }
329 }
330 names.extend(ALWAYS_VISIBLE_REGIONS);
333 names
334 }
335
336 fn regions_visible_to<'a>(&'a self, stage: &'a Stage) -> std::collections::HashSet<&'a str> {
345 let layout = stage
346 .context_layout
347 .as_ref()
348 .unwrap_or(&self.context_layout);
349 let mut names: std::collections::HashSet<&str> =
350 layout.regions.iter().map(|r| r.name.as_str()).collect();
351 names.extend(ALWAYS_VISIBLE_REGIONS);
352 for hidden in &stage.context_hide {
355 names.remove(hidden.as_str());
356 }
357 names
358 }
359
360 fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
368 let known = self.known_region_names();
369 let checklists: std::collections::HashSet<&str> = self
370 .context_layout
371 .regions
372 .iter()
373 .chain(
374 self.stages
375 .iter()
376 .filter_map(|s| s.context_layout.as_ref())
377 .flat_map(|l| l.regions.iter()),
378 )
379 .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
380 .map(|r| r.name.as_str())
381 .collect();
382
383 for stage in &self.stages {
384 let bad = |message: String| ValidationError::Stage {
385 stage: stage.name.clone(),
386 message,
387 };
388
389 for hidden in &stage.context_hide {
395 if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
396 return Err(bad(format!(
397 "context.hide names '{hidden}', which every stage carries and cannot hide"
398 )));
399 }
400 if !known.contains(hidden.as_str()) {
401 return Err(bad(format!(
402 "context.hide names region '{hidden}', which no layout in this \
403 blueprint declares"
404 )));
405 }
406 }
407
408 if let Some(routing) = &stage.tool_result_routing {
409 let visible = self.regions_visible_to(stage);
417 let dead_drop = |key: &str, region: &str| ValidationError::Stage {
418 stage: stage.name.clone(),
419 message: format!(
420 "tool_routing.{key} sends results to region '{region}', \
421 which this stage's context does not include, so it \
422 could not read them back. Add '{region}' to \
423 [stages.{}.context.regions], or route somewhere the \
424 stage can see.",
425 stage.name
426 ),
427 };
428 if !visible.contains(routing.default_region.as_str()) {
429 return Err(dead_drop("default_region", &routing.default_region));
430 }
431 for (tool, region) in &routing.tool_overrides {
432 if !visible.contains(region.as_str()) {
433 return Err(dead_drop(&format!("overrides.{tool}"), region));
434 }
435 }
436 }
437
438 for edge in stage.transitions.iter().flat_map(|t| t.values()) {
439 let Some(gate) = &edge.gate else { continue };
440 for (key, region) in [
441 ("region", gate.region.as_ref()),
442 (
443 "require_region_updated",
444 gate.require_region_updated.as_ref(),
445 ),
446 ("require_no_open_items", gate.require_no_open_items.as_ref()),
447 ] {
448 let Some(region) = region else { continue };
449 if !known.contains(region.as_str()) {
450 return Err(bad(format!(
451 "transition to '{}': gate.{key} names region \
452 '{region}', which no stage declares",
453 edge.target
454 )));
455 }
456 }
457 if let Some(region) = &gate.require_no_open_items
461 && !checklists.contains(region.as_str())
462 {
463 return Err(bad(format!(
464 "transition to '{}': gate.require_no_open_items names \
465 region '{region}', which is not a checklist region \
466 (set kind = \"checklist\" on it)",
467 edge.target
468 )));
469 }
470 }
471 }
472 Ok(())
473 }
474
475 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
477 let stage_names: std::collections::HashSet<&str> =
478 self.stages.iter().map(|s| s.name.as_str()).collect();
479
480 if let Some(entry) = &self.entry_stage
482 && !stage_names.contains(entry.as_str())
483 {
484 return Err(ValidationError::Graph(format!(
485 "entry_stage '{}' does not match any defined stage",
486 entry
487 )));
488 }
489
490 for stage in &self.stages {
496 if let StageMode::FanOut { config } = &stage.mode {
497 let sources = [
498 config.worker_agent.is_some(),
499 config.worker_stage.is_some(),
500 config.worker_query.is_some(),
501 ]
502 .iter()
503 .filter(|&&set| set)
504 .count();
505 if sources != 1 {
506 return Err(ValidationError::Stage {
507 stage: stage.name.clone(),
508 message: "fan_out stage must set exactly one of worker_agent, \
509 worker_stage, or worker_query"
510 .to_string(),
511 });
512 }
513 if let Some(ws) = &config.worker_stage {
514 match self.stages.iter().find(|s| &s.name == ws) {
515 None => {
516 return Err(ValidationError::Stage {
517 stage: stage.name.clone(),
518 message: format!("fan_out worker_stage '{}' does not exist", ws),
519 });
520 }
521 Some(target) if !target.allow_as_worker => {
522 return Err(ValidationError::Stage {
523 stage: stage.name.clone(),
524 message: format!(
525 "fan_out worker_stage '{}' must set allow_as_worker = true",
526 ws
527 ),
528 });
529 }
530 Some(_) => {}
531 }
532 }
533 if let Some(ms) = &config.merge_stage
534 && !stage_names.contains(ms.as_str())
535 {
536 return Err(ValidationError::Stage {
537 stage: stage.name.clone(),
538 message: format!("fan_out merge_stage '{}' does not exist", ms),
539 });
540 }
541 }
542 }
543
544 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
545 if !has_any_transitions {
546 return Ok(());
548 }
549
550 for stage in &self.stages {
552 if let Some(ref transitions) = stage.transitions {
553 for (target_name, edge) in transitions {
554 if !stage_names.contains(target_name.as_str()) {
555 return Err(ValidationError::Transition {
556 from: stage.name.clone(),
557 to: target_name.clone(),
558 message: "target stage does not exist".to_string(),
559 });
560 }
561 if edge.condition == TransitionCondition::Stuck
565 && !edge.stuck.is_some_and(|c| c.is_armed())
566 {
567 return Err(ValidationError::Transition {
568 from: stage.name.clone(),
569 to: target_name.clone(),
570 message: "condition = \"stuck\" requires at least one \
571 stuck_after_* threshold (the edge could never fire)"
572 .to_string(),
573 });
574 }
575 }
576
577 for (target_name, edge) in transitions {
581 let Some(gate) = &edge.gate else { continue };
582 if !gate.require_modifications {
583 continue;
584 }
585 let can_modify = stage.available_tools.iter().any(|t| {
586 MODIFYING_TOOLS.contains(&t.as_str())
587 || gate.tools.iter().any(|extra| extra == t)
588 });
589 if !can_modify {
590 return Err(ValidationError::Transition {
591 from: stage.name.clone(),
592 to: target_name.clone(),
593 message: "gate requires modifications, but the stage has no \
594 file-modifying tool in available_tools"
595 .to_string(),
596 });
597 }
598 }
599
600 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
602 return Err(ValidationError::Stage {
603 stage: stage.name.clone(),
604 message: "self-loop transition requires max_revisits".to_string(),
605 });
606 }
607 }
608 }
609
610 let entry = self.resolve_entry_stage_name();
613 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
614 if !has_terminal {
615 return Err(ValidationError::Graph(
616 "no terminal path exists from entry stage - agent would never complete".to_string(),
617 ));
618 }
619
620 Ok(())
621 }
622
623 pub fn resolve_entry_stage_name(&self) -> String {
625 self.entry_stage.clone().unwrap_or_else(|| {
626 self.stages
627 .first()
628 .map(|s| s.name.clone())
629 .unwrap_or_default()
630 })
631 }
632
633 fn has_terminal_path(
635 &self,
636 stage_name: &str,
637 visited: &mut std::collections::HashSet<String>,
638 ) -> bool {
639 if visited.contains(stage_name) {
640 return false;
641 }
642 visited.insert(stage_name.to_string());
643
644 let stage = self.stages.iter().find(|s| s.name == stage_name);
645 let stage = match stage {
646 Some(s) => s,
647 None => return false,
653 };
654
655 if let StageMode::FanOut {
658 config:
659 FanOutConfig {
660 merge_stage: Some(ms),
661 ..
662 },
663 } = &stage.mode
664 {
665 return self.has_terminal_path(ms, visited);
666 }
667
668 match &stage.transitions {
669 None => {
670 let idx = self
672 .stages
673 .iter()
674 .position(|s| s.name == stage_name)
675 .unwrap_or(0);
676 if idx + 1 >= self.stages.len() {
677 return true; }
679 self.has_terminal_path(&self.stages[idx + 1].name, visited)
680 }
681 Some(transitions) => {
682 if transitions.is_empty() {
683 return true; }
685 for target in transitions.keys() {
687 if self.has_terminal_path(target, visited) {
688 return true;
689 }
690 }
691 false
699 }
700 }
701 }
702
703 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
705 self.stages.iter().find(|s| s.name == name)
706 }
707}
708
709mod model;
713pub use model::*;
714mod stage;
715pub use stage::*;
716mod transition;
717pub use transition::*;
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use crate::layout::ContextLayout;
723 use crate::layout::RegionDefinition;
724 use crate::region::RegionKind;
725
726 fn bp_with_regions(regions_toml: &str) -> Blueprint {
729 crate::manifest::parse_manifest(&format!(
730 r#"
731[agent]
732name = "asked"
733
734[stages.main]
735mode = "autonomous"
736model = {{ provider = "anthropic", model = "m" }}
737
738[context.regions]
739{regions_toml}
740"#
741 ))
742 .expect("fixture parses")
743 }
744
745 #[test]
746 fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
747 assert!(
750 bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
751 .accepts_task()
752 );
753 assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
754 }
755
756 #[test]
757 fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
758 let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
759 assert!(!bp.accepts_task());
760 assert_eq!(bp.caller_inputs(), ["diff"]);
761 }
762
763 #[test]
764 fn the_refusal_names_what_the_agent_takes_instead() {
765 let bp = bp_with_regions(
766 r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
767criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
768 );
769 let msg = bp.task_refusal();
770 assert!(msg.contains("agent 'asked'"), "{msg}");
771 assert!(msg.contains("it takes: diff, criteria"), "{msg}");
772 }
773
774 #[test]
775 fn the_refusal_says_so_when_the_agent_takes_nothing() {
776 let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
777 assert!(bp.caller_inputs().is_empty());
778 let msg = bp.task_refusal();
782 assert!(msg.contains("it takes no caller input at all"), "{msg}");
783 }
784
785 #[test]
786 fn resolve_nudge_defaults_when_nothing_is_configured() {
787 let normal = resolve_nudge(None, None, None, false);
790 assert!(normal.enabled);
791 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
792 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
793 let reviewed = resolve_nudge(None, None, None, true);
794 assert!(!reviewed.enabled);
795 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
797 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
798 }
799
800 #[test]
801 fn resolve_nudge_cascades_each_field_independently() {
802 let global = NudgeConfig {
803 enabled: Some(true),
804 max: Some(10),
805 text: Some("global".to_string()),
806 };
807 let agent = NudgeConfig {
808 max: Some(2),
809 ..Default::default()
810 };
811 let stage = NudgeConfig {
812 text: Some("stage".to_string()),
813 ..Default::default()
814 };
815 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
816 assert!(resolved.enabled);
818 assert_eq!(resolved.max, 2);
819 assert_eq!(resolved.text, "stage");
820 let stage_all = NudgeConfig {
822 enabled: Some(false),
823 max: Some(0),
824 text: Some("s".to_string()),
825 };
826 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
827 assert_eq!(
828 resolved,
829 ResolvedNudge {
830 enabled: false,
831 max: 0,
832 text: "s".to_string()
833 }
834 );
835 }
836
837 #[test]
838 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
839 let on = NudgeConfig {
842 enabled: Some(true),
843 ..Default::default()
844 };
845 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
846 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
847 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
848 let off = NudgeConfig {
849 enabled: Some(false),
850 ..Default::default()
851 };
852 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
853 }
854
855 #[test]
856 fn test_blueprint_creation() {
857 let regions = vec![RegionDefinition::new(
858 "test".to_string(),
859 RegionKind::Pinned,
860 5000,
861 )];
862 let layout = ContextLayout::new(regions, 10000);
863
864 let stages = vec![Stage::new(
865 "analyze".to_string(),
866 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
867 )];
868
869 let blueprint = Blueprint::new(
870 "test-agent".to_string(),
871 "A test agent".to_string(),
872 stages,
873 layout,
874 );
875
876 assert_eq!(blueprint.name, "test-agent");
877 assert_eq!(blueprint.stages.len(), 1);
878 }
879
880 #[test]
881 fn test_blueprint_with_transforms_version() {
882 let stages = vec![Stage::new("plan".to_string(), make_model())];
883 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
884 .with_transforms(vec![ContextTransform {
885 from_blueprint: "a".to_string(),
886 to_blueprint: "b".to_string(),
887 mappings: vec![],
888 }])
889 .with_version("2.0.0".to_string());
890
891 assert_eq!(bp.transforms.len(), 1);
892 assert_eq!(bp.version, "2.0.0");
893 }
894
895 #[test]
896 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
897 let stages = vec![Stage::new("plan".to_string(), make_model())];
898 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
899 bp.metadata.insert(
901 "tool_perm:bash".to_string(),
902 serde_json::Value::String("deny".to_string()),
903 );
904 bp.metadata
906 .insert("title".to_string(), serde_json::Value::String("x".into()));
907 bp.metadata
909 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
910
911 let perms = bp.agent_tool_permissions();
912 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
913 assert!(!perms.contains_key("title"));
914 assert!(!perms.contains_key("weird"));
915 assert_eq!(perms.len(), 1);
916 }
917
918 #[test]
919 fn test_blueprint_validate_runs_transform_validation() {
920 let stages = vec![Stage::new("plan".to_string(), make_model())];
923 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
924 bp.transforms.push(ContextTransform {
925 from_blueprint: "a".to_string(),
926 to_blueprint: "b".to_string(),
927 mappings: vec![RegionMapping {
928 from_region: "test".to_string(),
929 to_region: "test".to_string(),
930 transform: None,
931 }],
932 });
933 assert!(bp.validate().is_ok());
934 }
935
936 #[test]
937 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
938 let stages = vec![Stage::new("plan".to_string(), make_model())];
939 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
940 bp.transforms.push(ContextTransform {
941 from_blueprint: "a".to_string(),
942 to_blueprint: "b".to_string(),
943 mappings: vec![RegionMapping {
944 from_region: "test".to_string(),
945 to_region: "nonexistent".to_string(),
946 transform: None,
947 }],
948 });
949 let err = bp.validate().unwrap_err();
950 assert_eq!(
951 err,
952 ValidationError::Region {
953 region: "nonexistent".to_string(),
954 message: "transform target region not found in layout".to_string(),
955 }
956 );
957 }
958
959 #[test]
960 fn test_mixed_linear_and_graph_mode_terminal_path() {
961 let mut plan = Stage::new("plan".to_string(), make_model());
965 let impl_stage = Stage::new("impl".to_string(), make_model());
966 let review = Stage::new("review".to_string(), make_model());
967
968 let mut transitions = HashMap::new();
969 transitions.insert(
970 "impl".to_string(),
971 TransitionEdge {
972 target: "impl".to_string(),
973 condition: TransitionCondition::Always,
974 hint: None,
975 transform: EdgeTransform::Direct,
976 gate: None,
977 stuck: None,
978 },
979 );
980 plan.transitions = Some(transitions);
981
982 let bp = Blueprint::new(
983 "t".into(),
984 "".into(),
985 vec![plan, impl_stage, review],
986 make_layout(),
987 );
988 assert!(bp.validate().is_ok());
989 }
990
991 #[test]
992 fn test_stage_validation() {
993 let stage = Stage::new(
994 "test".to_string(),
995 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
996 );
997 assert!(stage.validate().is_ok());
998
999 let empty_stage = Stage::new(
1000 "".to_string(),
1001 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1002 );
1003 assert!(empty_stage.validate().is_err());
1004 }
1005
1006 #[test]
1007 fn test_stage_validate_with_valid_context_layout_is_ok() {
1008 let mut stage = Stage::new("test".to_string(), make_model());
1009 stage.context_layout = Some(make_layout());
1010 assert!(stage.validate().is_ok());
1011 }
1012
1013 #[test]
1014 fn test_stage_validate_with_invalid_context_layout_is_err() {
1015 let regions = vec![
1017 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1018 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1019 ];
1020 let mut stage = Stage::new("test".to_string(), make_model());
1021 stage.context_layout = Some(ContextLayout::new(regions, 200));
1022 assert!(stage.validate().is_err());
1023 }
1024
1025 #[test]
1026 fn test_stage_with_tools_context_layout_description() {
1027 let stage = Stage::new("test".to_string(), make_model())
1028 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1029 .with_context_layout(make_layout())
1030 .with_description("does things".to_string());
1031
1032 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1033 assert!(stage.context_layout.is_some());
1034 assert_eq!(stage.description.as_deref(), Some("does things"));
1035 }
1036
1037 #[test]
1038 fn test_stage_with_mode() {
1039 let stage = Stage::new("test".to_string(), make_model())
1040 .with_mode(StageMode::InteractivePoints { points: vec![] });
1041 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1042 }
1043
1044 #[test]
1045 fn test_stage_allow_complete_defaults_false() {
1046 let stage = Stage::new("review".to_string(), make_model());
1047 assert!(!stage.allow_complete);
1048 }
1049
1050 #[test]
1051 fn test_stage_allow_complete_serde_default_when_missing() {
1052 let json = r#"{
1055 "name": "review",
1056 "description": null,
1057 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1058 "available_tools": [],
1059 "max_iterations": null,
1060 "context_layout": null,
1061 "config": {},
1062 "transitions": null,
1063 "max_revisits": null,
1064 "transition_prompt": null
1065 }"#;
1066 let stage: Stage = serde_json::from_str(json).unwrap();
1067 assert!(!stage.allow_complete);
1068 assert!(stage.accepts_messages);
1069 }
1070
1071 #[test]
1072 fn test_stage_allow_complete_roundtrip() {
1073 let mut stage = Stage::new("review".to_string(), make_model());
1074 stage.allow_complete = true;
1075 let json = serde_json::to_string(&stage).unwrap();
1076 let back: Stage = serde_json::from_str(&json).unwrap();
1077 assert!(back.allow_complete);
1078 }
1079
1080 #[test]
1081 fn test_interaction_point_directives_default_empty() {
1082 let point = InteractionPoint {
1083 name: "plan_approval".to_string(),
1084 prompt: "Approve?".to_string(),
1085 required: true,
1086 unattended: UnattendedPolicy::AutoApprove,
1087 style: InteractionStyle::MultipleChoice,
1088 options: vec!["Approve".to_string(), "Revise".to_string()],
1089 directives: HashMap::new(),
1090 abort_options: Vec::new(),
1091 edit_options: Vec::new(),
1092 document_region: None,
1093 };
1094 assert!(point.directives.is_empty());
1095 assert!(point.abort_options.is_empty());
1096 assert!(point.edit_options.is_empty());
1097 }
1098
1099 #[test]
1100 fn test_interaction_point_directives_roundtrip() {
1101 let mut directives = HashMap::new();
1102 directives.insert(
1103 "Revise".to_string(),
1104 "Ask what to change, then re-plan.".to_string(),
1105 );
1106 let point = InteractionPoint {
1107 name: "plan_approval".to_string(),
1108 prompt: "Approve?".to_string(),
1109 required: true,
1110 unattended: UnattendedPolicy::Ask,
1111 style: InteractionStyle::MultipleChoice,
1112 options: vec!["Approve".to_string(), "Revise".to_string()],
1113 directives,
1114 abort_options: vec!["Abort".to_string()],
1115 edit_options: vec!["Add detail".to_string()],
1116 document_region: Some("plan".to_string()),
1117 };
1118 let json = serde_json::to_string(&point).unwrap();
1119 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1120 assert_eq!(
1121 back.directives.get("Revise").map(|s| s.as_str()),
1122 Some("Ask what to change, then re-plan.")
1123 );
1124 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1125 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1126 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1129 }
1130
1131 #[test]
1132 fn test_interaction_point_directives_serde_default_when_missing() {
1133 let json = r#"{
1134 "name": "plan_approval",
1135 "prompt": "Approve?",
1136 "required": true,
1137 "style": "multiple_choice",
1138 "options": ["Approve", "Revise"]
1139 }"#;
1140 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1141 assert!(point.directives.is_empty());
1142 assert!(point.abort_options.is_empty());
1143 }
1144
1145 #[test]
1146 fn test_interaction_point_followups_alias_still_deserializes() {
1147 let json = r#"{
1149 "name": "plan_approval",
1150 "prompt": "Approve?",
1151 "required": true,
1152 "style": "multiple_choice",
1153 "options": ["Approve", "Revise"],
1154 "followups": { "Revise": "What to change?" }
1155 }"#;
1156 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1157 assert_eq!(
1158 point.directives.get("Revise").map(|s| s.as_str()),
1159 Some("What to change?")
1160 );
1161 }
1162
1163 #[test]
1164 fn test_model_config_new_creates_single_entry() {
1165 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1166 assert_eq!(mc.models.len(), 1);
1167 assert_eq!(mc.models[0].provider, "anthropic");
1168 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1169 assert!(mc.allow_user_default);
1170 }
1171
1172 #[test]
1173 fn test_model_config_with_multiple_models() {
1174 let mc = ModelConfig {
1175 models: vec![
1176 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1177 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1178 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1179 ],
1180 allow_user_default: true,
1181 parameters: HashMap::new(),
1182 request_timeout_secs: None,
1183 };
1184 assert_eq!(mc.models.len(), 3);
1185 assert_eq!(mc.models[0].provider, "anthropic");
1186 assert_eq!(mc.models[1].provider, "openai");
1187 assert_eq!(mc.models[2].provider, "ollama");
1188 }
1189
1190 #[test]
1191 fn test_model_config_serde_roundtrip() {
1192 let mc = ModelConfig {
1193 models: vec![
1194 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1195 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1196 ],
1197 allow_user_default: false,
1198 parameters: HashMap::new(),
1199 request_timeout_secs: None,
1200 };
1201 let json = serde_json::to_string(&mc).unwrap();
1202 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1203 assert_eq!(back.models.len(), 2);
1204 assert_eq!(back.models[0].provider, "anthropic");
1205 assert_eq!(back.models[1].provider, "openai");
1206 assert!(!back.allow_user_default);
1207 }
1208
1209 #[test]
1210 fn test_model_config_serde_defaults_when_fields_missing() {
1211 let json = r#"{"parameters": {}}"#;
1213 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1214 assert!(mc.models.is_empty());
1215 assert!(mc.allow_user_default);
1216 }
1217
1218 #[test]
1219 fn test_model_config_convenience_accessors() {
1220 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1221 assert_eq!(mc.provider(), "anthropic");
1222 assert_eq!(mc.model(), "claude-sonnet-4-6");
1223 }
1224
1225 #[test]
1226 fn test_model_config_convenience_accessors_empty_models() {
1227 let mc = ModelConfig {
1228 models: vec![],
1229 allow_user_default: true,
1230 parameters: HashMap::new(),
1231 request_timeout_secs: None,
1232 };
1233 assert_eq!(mc.provider(), "anthropic");
1234 assert_eq!(mc.model(), "claude-sonnet-4-6");
1235 }
1236
1237 fn make_model() -> ModelConfig {
1238 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1239 }
1240
1241 fn make_layout() -> ContextLayout {
1242 let regions = vec![RegionDefinition::new(
1243 "test".to_string(),
1244 RegionKind::Pinned,
1245 5000,
1246 )];
1247 ContextLayout::new(regions, 10000)
1248 }
1249
1250 #[test]
1251 fn test_graph_validation_entry_stage_exists() {
1252 let stages = vec![Stage::new("plan".to_string(), make_model())];
1253 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1254 bp.entry_stage = Some("nonexistent".to_string());
1255 assert!(bp.validate().is_err());
1256 }
1257
1258 #[test]
1259 fn test_graph_validation_entry_stage_valid() {
1260 let stages = vec![Stage::new("plan".to_string(), make_model())];
1261 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1262 bp.entry_stage = Some("plan".to_string());
1263 assert!(bp.validate().is_ok());
1264 }
1265
1266 #[test]
1267 fn test_graph_validation_transition_target_missing() {
1268 let mut stage = Stage::new("plan".to_string(), make_model());
1269 let mut transitions = HashMap::new();
1270 transitions.insert(
1271 "nonexistent".to_string(),
1272 TransitionEdge {
1273 target: "nonexistent".to_string(),
1274 condition: TransitionCondition::Always,
1275 hint: None,
1276 transform: EdgeTransform::Direct,
1277 gate: None,
1278 stuck: None,
1279 },
1280 );
1281 stage.transitions = Some(transitions);
1282 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1283 assert!(bp.validate().is_err());
1284 }
1285
1286 #[test]
1290 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1291 let gated = |tools: &[&str], extra: &[&str]| {
1292 let mut stage = Stage::new("impl".to_string(), make_model());
1293 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1294 let mut transitions = HashMap::new();
1295 transitions.insert(
1296 "review".to_string(),
1297 TransitionEdge {
1298 target: "review".to_string(),
1299 condition: TransitionCondition::Always,
1300 hint: None,
1301 transform: EdgeTransform::Direct,
1302 stuck: None,
1303 gate: Some(TransitionGate {
1304 require_modifications: true,
1305 tools: extra.iter().map(|t| t.to_string()).collect(),
1306 ..Default::default()
1307 }),
1308 },
1309 );
1310 stage.transitions = Some(transitions);
1311 Blueprint::new(
1312 "t".into(),
1313 "".into(),
1314 vec![stage, Stage::new("review".to_string(), make_model())],
1315 make_layout(),
1316 )
1317 };
1318 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1319 assert!(err.to_string().contains("no file-modifying tool"));
1320 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1322 assert!(
1324 gated(&["read_file", "patch_file"], &["patch_file"])
1325 .validate()
1326 .is_ok()
1327 );
1328 let mut off = gated(&["read_file"], &[]);
1330 off.stages[0]
1331 .transitions
1332 .as_mut()
1333 .unwrap()
1334 .get_mut("review")
1335 .unwrap()
1336 .gate = Some(TransitionGate::default());
1337 assert!(off.validate().is_ok());
1338 off.stages[0]
1340 .transitions
1341 .as_mut()
1342 .unwrap()
1343 .get_mut("review")
1344 .unwrap()
1345 .gate = None;
1346 assert!(off.validate().is_ok());
1347 }
1348
1349 #[test]
1350 fn test_graph_validation_self_loop_requires_max_revisits() {
1351 let mut stage = Stage::new("impl".to_string(), make_model());
1352 let mut transitions = HashMap::new();
1353 transitions.insert(
1354 "impl".to_string(),
1355 TransitionEdge {
1356 target: "impl".to_string(),
1357 condition: TransitionCondition::Always,
1358 hint: None,
1359 transform: EdgeTransform::Direct,
1360 gate: None,
1361 stuck: None,
1362 },
1363 );
1364 stage.transitions = Some(transitions);
1365 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1366 assert!(bp.validate().is_err());
1367 }
1368
1369 #[test]
1370 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1371 let mut stage = Stage::new("impl".to_string(), make_model());
1372 stage.max_revisits = Some(3);
1373 let mut transitions = HashMap::new();
1374 transitions.insert(
1375 "impl".to_string(),
1376 TransitionEdge {
1377 target: "impl".to_string(),
1378 condition: TransitionCondition::Always,
1379 hint: None,
1380 transform: EdgeTransform::Direct,
1381 gate: None,
1382 stuck: None,
1383 },
1384 );
1385 stage.transitions = Some(transitions);
1386 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1387 let err = bp
1393 .validate()
1394 .expect_err("an exhaustion-only graph is invalid");
1395 assert!(err.to_string().contains("no terminal path"), "{err}");
1396 }
1397
1398 #[test]
1399 fn test_graph_validation_terminal_path_exists() {
1400 let mut plan = Stage::new("plan".to_string(), make_model());
1401 let mut review = Stage::new("review".to_string(), make_model());
1402 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1405 transitions.insert(
1406 "review".to_string(),
1407 TransitionEdge {
1408 target: "review".to_string(),
1409 condition: TransitionCondition::Always,
1410 hint: None,
1411 transform: EdgeTransform::Direct,
1412 gate: None,
1413 stuck: None,
1414 },
1415 );
1416 plan.transitions = Some(transitions);
1417
1418 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1419 assert!(bp.validate().is_ok());
1420 }
1421
1422 #[test]
1423 fn test_graph_no_terminal_path() {
1424 let mut a = Stage::new("a".to_string(), make_model());
1426 let mut b = Stage::new("b".to_string(), make_model());
1427
1428 let mut a_transitions = HashMap::new();
1429 a_transitions.insert(
1430 "b".to_string(),
1431 TransitionEdge {
1432 target: "b".to_string(),
1433 condition: TransitionCondition::Always,
1434 hint: None,
1435 transform: EdgeTransform::Direct,
1436 gate: None,
1437 stuck: None,
1438 },
1439 );
1440 a.transitions = Some(a_transitions);
1441
1442 let mut b_transitions = HashMap::new();
1443 b_transitions.insert(
1444 "a".to_string(),
1445 TransitionEdge {
1446 target: "a".to_string(),
1447 condition: TransitionCondition::Always,
1448 hint: None,
1449 transform: EdgeTransform::Direct,
1450 gate: None,
1451 stuck: None,
1452 },
1453 );
1454 b.transitions = Some(b_transitions);
1455
1456 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1457 assert!(bp.validate().is_err());
1458 }
1459
1460 #[test]
1461 fn test_linear_stages_still_validate() {
1462 let stages = vec![
1464 Stage::new("plan".to_string(), make_model()),
1465 Stage::new("impl".to_string(), make_model()),
1466 Stage::new("review".to_string(), make_model()),
1467 ];
1468 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1469 assert!(bp.validate().is_ok());
1470 }
1471
1472 #[test]
1473 fn test_resolve_entry_stage_name() {
1474 let stages = vec![
1475 Stage::new("plan".to_string(), make_model()),
1476 Stage::new("impl".to_string(), make_model()),
1477 ];
1478 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1479 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1480
1481 bp.entry_stage = Some("impl".to_string());
1482 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1483 }
1484
1485 #[test]
1486 fn test_find_stage() {
1487 let stages = vec![
1488 Stage::new("plan".to_string(), make_model()),
1489 Stage::new("impl".to_string(), make_model()),
1490 ];
1491 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1492 assert!(bp.find_stage("plan").is_some());
1493 assert!(bp.find_stage("impl").is_some());
1494 assert!(bp.find_stage("nonexistent").is_none());
1495 }
1496
1497 #[test]
1498 fn test_transition_condition_default() {
1499 let cond = TransitionCondition::default();
1500 assert_eq!(cond, TransitionCondition::Always);
1501 }
1502
1503 #[test]
1504 fn test_edge_transform_default() {
1505 let t = EdgeTransform::default();
1506 assert_eq!(t, EdgeTransform::Direct);
1507 }
1508
1509 #[test]
1510 fn test_stage_mode_equality() {
1511 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1512 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1513 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1514 }
1515
1516 #[test]
1517 fn test_interaction_style_equality() {
1518 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1519 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1520 }
1521
1522 #[test]
1525 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1526 assert!(!StuckConfig::default().is_armed());
1527 for cfg in [
1528 StuckConfig {
1529 after_iterations: Some(1),
1530 ..Default::default()
1531 },
1532 StuckConfig {
1533 after_minutes: Some(1),
1534 ..Default::default()
1535 },
1536 StuckConfig {
1537 after_same_file_edits: Some(1),
1538 ..Default::default()
1539 },
1540 StuckConfig {
1541 after_tool_calls: Some(1),
1542 ..Default::default()
1543 },
1544 ] {
1545 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1546 }
1547 }
1548
1549 #[test]
1550 fn transition_condition_stuck_round_trips_as_snake_case() {
1551 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1552 assert_eq!(json, "\"stuck\"");
1553 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1554 assert_eq!(back, TransitionCondition::Stuck);
1555 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1556 }
1557
1558 #[test]
1559 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1560 let plain = TransitionEdge {
1561 target: "b".to_string(),
1562 condition: TransitionCondition::Always,
1563 hint: None,
1564 transform: EdgeTransform::Direct,
1565 gate: None,
1566 stuck: None,
1567 };
1568 let json = serde_json::to_string(&plain).unwrap();
1569 assert!(
1570 !json.contains("stuck"),
1571 "absent config must be skipped: {json}"
1572 );
1573
1574 let armed = TransitionEdge {
1575 condition: TransitionCondition::Stuck,
1576 stuck: Some(StuckConfig {
1577 after_iterations: Some(20),
1578 after_minutes: Some(10),
1579 after_same_file_edits: Some(3),
1580 after_tool_calls: Some(60),
1581 }),
1582 ..plain
1583 };
1584 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1585 .expect("armed edge round-trips");
1586 assert_eq!(back.condition, TransitionCondition::Stuck);
1587 assert_eq!(back.stuck, armed.stuck);
1588 }
1589
1590 #[test]
1593 fn validate_rejects_a_stuck_edge_with_no_threshold() {
1594 let build = |stuck| {
1595 let mut a = Stage::new("a".to_string(), make_model());
1596 let b = Stage::new("b".to_string(), make_model());
1597 let mut transitions = std::collections::HashMap::new();
1598 transitions.insert(
1599 "b".to_string(),
1600 TransitionEdge {
1601 target: "b".to_string(),
1602 condition: TransitionCondition::Stuck,
1603 hint: None,
1604 transform: EdgeTransform::Direct,
1605 gate: None,
1606 stuck,
1607 },
1608 );
1609 a.transitions = Some(transitions);
1610 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1611 };
1612
1613 for dead in [None, Some(StuckConfig::default())] {
1614 let err = build(dead)
1615 .validate()
1616 .expect_err("dead stuck edge rejected");
1617 assert!(
1618 format!("{err:?}").contains("stuck_after_"),
1619 "unexpected error: {err:?}"
1620 );
1621 }
1622
1623 assert!(
1625 build(Some(StuckConfig {
1626 after_iterations: Some(5),
1627 ..Default::default()
1628 }))
1629 .validate()
1630 .is_ok()
1631 );
1632 }
1633
1634 #[test]
1638 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1639 let mut stage = Stage::new("plan".to_string(), make_model());
1640 stage.available_tools = vec!["read_file".to_string()];
1641 stage.required_tools = vec!["ask_user_text".to_string()];
1642 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1643
1644 let err = bp.validate().expect_err("a tool it cannot call");
1645 let text = format!("{err:?}");
1646 assert!(text.contains("ask_user_text"), "names the tool: {text}");
1647 assert!(text.contains("available_tools"), "says why: {text}");
1648 }
1649
1650 #[test]
1651 fn validate_accepts_a_required_tool_the_stage_offers() {
1652 let mut stage = Stage::new("plan".to_string(), make_model());
1653 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1654 stage.required_tools = vec!["ask_user_text".to_string()];
1655 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1656
1657 bp.validate().expect("the tool is on offer");
1658 }
1659
1660 #[test]
1664 fn validate_rejects_require_output_without_the_submit_tool() {
1665 let mut stage = Stage::new("summary".to_string(), make_model());
1666 stage.available_tools = vec!["read_file".to_string()];
1667 stage.require_output = true;
1668 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1669
1670 let err = bp.validate().expect_err("no way to submit");
1671 let text = format!("{err:?}");
1672 assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
1673 assert!(text.contains("require_output"), "says why: {text}");
1674 }
1675
1676 #[test]
1677 fn validate_accepts_require_output_when_the_stage_can_submit() {
1678 let mut stage = Stage::new("summary".to_string(), make_model());
1679 stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
1680 stage.require_output = true;
1681 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1682
1683 bp.validate().expect("the stage can submit");
1684 }
1685
1686 #[test]
1689 fn validate_accepts_a_declared_shape_without_require_output() {
1690 let mut stage = Stage::new("summary".to_string(), make_model());
1691 stage.available_tools = vec!["read_file".to_string()];
1692 stage.output = Some(crate::output::OutputSpec {
1693 format: Some("a2ui".to_string()),
1694 ..Default::default()
1695 });
1696 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1697
1698 bp.validate().expect("declaring a shape demands nothing");
1699 }
1700
1701 #[test]
1702 fn output_mode_compares_equal_only_to_itself() {
1703 assert_eq!(StageMode::Output, StageMode::Output);
1704 assert_ne!(StageMode::Output, StageMode::Autonomous);
1705 assert_ne!(StageMode::Autonomous, StageMode::Output);
1706 }
1707
1708 #[test]
1709 fn test_transition_condition_equality() {
1710 assert_eq!(
1711 TransitionCondition::LlmChoice,
1712 TransitionCondition::LlmChoice
1713 );
1714 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1715 }
1716
1717 #[test]
1718 fn test_edge_transform_compact_and_custom_equality() {
1719 let a = EdgeTransform::Compact {
1720 prompt: Some("p".to_string()),
1721 };
1722 let b = EdgeTransform::Compact {
1723 prompt: Some("p".to_string()),
1724 };
1725 assert_eq!(a, b);
1726
1727 let c1 = EdgeTransform::Custom {
1728 carry: vec!["a".to_string()],
1729 compact: vec!["b".to_string()],
1730 clear: vec!["c".to_string()],
1731 compact_prompt: Some("p".to_string()),
1732 };
1733 let c2 = c1.clone();
1734 assert_eq!(c1, c2);
1735
1736 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1737 }
1738
1739 #[test]
1740 fn test_stage_accepts_messages_default_true() {
1741 let stage = Stage::new(
1742 "test".to_string(),
1743 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1744 );
1745 assert!(stage.accepts_messages);
1746 }
1747
1748 #[test]
1749 fn test_stage_accepts_messages_serde_roundtrip() {
1750 let mut stage = Stage::new(
1752 "report".to_string(),
1753 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1754 );
1755 stage.accepts_messages = false;
1756
1757 let json = serde_json::to_string(&stage).expect("should serialize");
1758 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1759 assert!(!deserialized.accepts_messages);
1760 }
1761
1762 #[test]
1763 fn test_stage_accepts_messages_json_default() {
1764 let json = r#"{
1766 "name": "analyze",
1767 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1768 "available_tools": [],
1769 "mode": "Autonomous",
1770 "config": {},
1771 "tool_permissions": {},
1772 "requires_children": false
1773 }"#;
1774 let stage: Stage = serde_json::from_str(json).expect("should parse");
1775 assert!(stage.accepts_messages);
1776 }
1777
1778 #[test]
1779 fn test_has_terminal_path_unknown_stage_returns_false() {
1780 let stages = vec![Stage::new("start".to_string(), make_model())];
1784 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1785 let mut visited = std::collections::HashSet::new();
1786 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1787 }
1788
1789 #[test]
1790 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1791 let regions = vec![
1792 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1793 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1794 ];
1795 let layout = ContextLayout::new(regions, 200);
1796 let stages = vec![Stage::new("start".to_string(), make_model())];
1797 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
1798 assert_eq!(
1799 bp.validate().unwrap_err(),
1800 ValidationError::Region {
1801 region: "dup".to_string(),
1802 message: "duplicate region name".to_string(),
1803 }
1804 );
1805 }
1806
1807 #[test]
1808 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
1809 let stages = vec![Stage::new("".to_string(), make_model())];
1810 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1811 assert_eq!(
1812 bp.validate().unwrap_err(),
1813 ValidationError::Stage {
1814 stage: "(empty)".to_string(),
1815 message: "stage name cannot be empty".to_string(),
1816 }
1817 );
1818 }
1819
1820 #[test]
1821 fn test_file_tracking_config_defaults() {
1822 let json = r#"{"region": "files"}"#;
1823 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
1824 assert_eq!(config.region, "files");
1825 assert!(config.track_reads);
1826 assert!(config.track_writes);
1827 assert!(config.max_file_tokens.is_none());
1828 }
1829
1830 #[test]
1831 fn test_file_tracking_config_serde_roundtrip() {
1832 let config = FileTrackingConfig {
1833 region: "files".to_string(),
1834 track_reads: true,
1835 track_writes: false,
1836 max_file_tokens: Some(5000),
1837 };
1838 let json = serde_json::to_string(&config).unwrap();
1839 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
1840 assert_eq!(back.region, "files");
1841 assert!(back.track_reads);
1842 assert!(!back.track_writes);
1843 assert_eq!(back.max_file_tokens, Some(5000));
1844 }
1845
1846 #[test]
1847 fn test_blueprint_file_tracking_default_none() {
1848 let stages = vec![Stage::new("plan".to_string(), make_model())];
1849 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1850 assert!(bp.file_tracking.is_none());
1851 }
1852
1853 #[test]
1854 fn test_blueprint_file_tracking_serde_roundtrip() {
1855 let stages = vec![Stage::new("plan".to_string(), make_model())];
1856 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1857 bp.file_tracking = Some(FileTrackingConfig {
1858 region: "files".to_string(),
1859 track_reads: true,
1860 track_writes: true,
1861 max_file_tokens: Some(3000),
1862 });
1863 let json = serde_json::to_string(&bp).unwrap();
1864 let back: Blueprint = serde_json::from_str(&json).unwrap();
1865 let ft = back.file_tracking.unwrap();
1866 assert_eq!(ft.region, "files");
1867 assert_eq!(ft.max_file_tokens, Some(3000));
1868 }
1869
1870 #[test]
1871 fn test_tool_result_routing_default() {
1872 let routing = ToolResultRouting::default();
1873 assert_eq!(routing.default_region, "tool_results");
1874 assert!(routing.persist);
1875 assert!(routing.tool_overrides.is_empty());
1876 assert!(routing.max_result_tokens.is_none());
1877 }
1878
1879 #[test]
1880 fn test_stage_new_has_no_tool_result_routing() {
1881 let stage = Stage::new("plan".to_string(), make_model());
1882 assert!(stage.tool_result_routing.is_none());
1883 }
1884
1885 #[test]
1886 fn test_tool_result_routing_serde_roundtrip() {
1887 let mut routing = ToolResultRouting {
1888 default_region: "custom_region".to_string(),
1889 persist: false,
1890 max_result_tokens: Some(4096),
1891 ..Default::default()
1892 };
1893 routing
1894 .tool_overrides
1895 .insert("read_file".to_string(), "file_reads".to_string());
1896
1897 let json = serde_json::to_string(&routing).unwrap();
1898 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
1899
1900 assert_eq!(back.default_region, "custom_region");
1901 assert!(!back.persist);
1902 assert_eq!(back.max_result_tokens, Some(4096));
1903 assert_eq!(
1904 back.tool_overrides.get("read_file").map(String::as_str),
1905 Some("file_reads")
1906 );
1907 }
1908
1909 #[test]
1910 fn test_stage_with_tool_result_routing_serde_roundtrip() {
1911 let stages = vec![{
1912 let mut s = Stage::new("plan".to_string(), make_model());
1913 s.tool_result_routing = Some(ToolResultRouting {
1914 default_region: "results".to_string(),
1915 tool_overrides: HashMap::new(),
1916 persist: true,
1917 max_result_tokens: Some(2048),
1918 tool_max_result_tokens: HashMap::new(),
1919 });
1920 s
1921 }];
1922 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1923 let json = serde_json::to_string(&bp).unwrap();
1924 let back: Blueprint = serde_json::from_str(&json).unwrap();
1925
1926 let routing = back.stages[0]
1927 .tool_result_routing
1928 .as_ref()
1929 .expect("tool_result_routing should be Some");
1930 assert_eq!(routing.default_region, "results");
1931 assert!(routing.persist);
1932 assert_eq!(routing.max_result_tokens, Some(2048));
1933 assert!(routing.tool_overrides.is_empty());
1934 }
1935
1936 fn fanout_config() -> FanOutConfig {
1939 FanOutConfig {
1940 worker_agent: None,
1941 worker_stage: Some("fix_worker".to_string()),
1942 worker_query: None,
1943 merge_stage: Some("merge".to_string()),
1944 max_workers: 3,
1945 on_worker_failure: WorkerFailurePolicy::Continue,
1946 split_prompt: "split".to_string(),
1947 results_region: None,
1948 max_items: None,
1949 max_attempts: None,
1950 }
1951 }
1952
1953 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
1958 let mut fan = Stage::new("parallel".to_string(), make_model());
1959 fan.mode = StageMode::FanOut { config };
1960 let mut worker = Stage::new("fix_worker".to_string(), make_model());
1961 worker.allow_as_worker = worker_allowed;
1962 let mut merge = Stage::new("merge".to_string(), make_model());
1963 merge.transitions = Some(HashMap::new()); Blueprint::new(
1965 "t".into(),
1966 "d".into(),
1967 vec![fan, worker, merge],
1968 make_layout(),
1969 )
1970 }
1971
1972 #[test]
1973 fn fanout_stagemode_partial_eq_and_default_policy() {
1974 let a = StageMode::FanOut {
1975 config: fanout_config(),
1976 };
1977 let b = StageMode::FanOut {
1978 config: fanout_config(),
1979 };
1980 assert_eq!(a, b);
1981 let mut other = fanout_config();
1982 other.max_workers = 99;
1983 assert_ne!(a, StageMode::FanOut { config: other });
1984 assert_ne!(a, StageMode::Autonomous);
1985 assert_eq!(
1986 WorkerFailurePolicy::default(),
1987 WorkerFailurePolicy::Continue
1988 );
1989 }
1990
1991 #[test]
1992 fn fanout_config_serde_roundtrip_and_max_workers_default() {
1993 let toml = r#"
1994worker_agent = "fixer"
1995split_prompt = "go"
1996on_worker_failure = "fail_all"
1997"#;
1998 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
1999 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2000 assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2001 assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2002 assert_eq!(
2003 FanOutConfig {
2004 max_workers: 0,
2005 ..fanout_config()
2006 }
2007 .worker_cap(),
2008 None
2009 );
2010 assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2011 let json = serde_json::to_string(&fanout_config()).unwrap();
2013 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2014 assert_eq!(back, fanout_config());
2015 }
2016
2017 #[test]
2018 fn fanout_validate_ok_with_allowed_worker_stage() {
2019 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2020 }
2021
2022 #[test]
2023 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2024 let err = fanout_blueprint(false, fanout_config())
2025 .validate()
2026 .unwrap_err();
2027 assert!(err.to_string().contains("allow_as_worker"));
2028 }
2029
2030 #[test]
2031 fn fanout_validate_rejects_missing_worker_stage() {
2032 let mut cfg = fanout_config();
2033 cfg.worker_stage = Some("nope".to_string());
2034 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2035 assert!(err.to_string().contains("does not exist"));
2036 }
2037
2038 #[test]
2039 fn fanout_validate_rejects_missing_merge_stage() {
2040 let mut cfg = fanout_config();
2041 cfg.merge_stage = Some("nomerge".to_string());
2042 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2043 assert!(err.to_string().contains("merge_stage"));
2044 }
2045
2046 #[test]
2047 fn fanout_validate_rejects_wrong_worker_source_count() {
2048 let mut cfg = fanout_config();
2050 cfg.worker_stage = None;
2051 assert!(fanout_blueprint(true, cfg).validate().is_err());
2052 let mut cfg2 = fanout_config();
2054 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2056 }
2057
2058 #[test]
2059 fn fanout_terminal_path_runs_through_merge_stage() {
2060 let mut cfg = fanout_config();
2062 cfg.worker_stage = None;
2063 cfg.worker_agent = Some("external".to_string());
2064 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2065 }
2066
2067 #[test]
2068 fn fanout_validate_ok_without_merge_stage() {
2069 let mut cfg = fanout_config();
2072 cfg.merge_stage = None;
2073 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2074 }
2075}