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
697 }
698 }
699 }
700
701 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
703 self.stages.iter().find(|s| s.name == name)
704 }
705}
706
707mod model;
711pub use model::*;
712mod stage;
713pub use stage::*;
714mod transition;
715pub use transition::*;
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::layout::ContextLayout;
721 use crate::layout::RegionDefinition;
722 use crate::region::RegionKind;
723
724 fn bp_with_regions(regions_toml: &str) -> Blueprint {
727 crate::manifest::parse_manifest(&format!(
728 r#"
729[agent]
730name = "asked"
731
732[stages.main]
733mode = "autonomous"
734model = {{ provider = "anthropic", model = "m" }}
735
736[context.regions]
737{regions_toml}
738"#
739 ))
740 .expect("fixture parses")
741 }
742
743 #[test]
744 fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
745 assert!(
748 bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
749 .accepts_task()
750 );
751 assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
752 }
753
754 #[test]
755 fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
756 let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
757 assert!(!bp.accepts_task());
758 assert_eq!(bp.caller_inputs(), ["diff"]);
759 }
760
761 #[test]
762 fn the_refusal_names_what_the_agent_takes_instead() {
763 let bp = bp_with_regions(
764 r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
765criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
766 );
767 let msg = bp.task_refusal();
768 assert!(msg.contains("agent 'asked'"), "{msg}");
769 assert!(msg.contains("it takes: diff, criteria"), "{msg}");
770 }
771
772 #[test]
773 fn the_refusal_says_so_when_the_agent_takes_nothing() {
774 let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
775 assert!(bp.caller_inputs().is_empty());
776 let msg = bp.task_refusal();
780 assert!(msg.contains("it takes no caller input at all"), "{msg}");
781 }
782
783 #[test]
784 fn resolve_nudge_defaults_when_nothing_is_configured() {
785 let normal = resolve_nudge(None, None, None, false);
788 assert!(normal.enabled);
789 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
790 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
791 let reviewed = resolve_nudge(None, None, None, true);
792 assert!(!reviewed.enabled);
793 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
795 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
796 }
797
798 #[test]
799 fn resolve_nudge_cascades_each_field_independently() {
800 let global = NudgeConfig {
801 enabled: Some(true),
802 max: Some(10),
803 text: Some("global".to_string()),
804 };
805 let agent = NudgeConfig {
806 max: Some(2),
807 ..Default::default()
808 };
809 let stage = NudgeConfig {
810 text: Some("stage".to_string()),
811 ..Default::default()
812 };
813 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
814 assert!(resolved.enabled);
816 assert_eq!(resolved.max, 2);
817 assert_eq!(resolved.text, "stage");
818 let stage_all = NudgeConfig {
820 enabled: Some(false),
821 max: Some(0),
822 text: Some("s".to_string()),
823 };
824 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
825 assert_eq!(
826 resolved,
827 ResolvedNudge {
828 enabled: false,
829 max: 0,
830 text: "s".to_string()
831 }
832 );
833 }
834
835 #[test]
836 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
837 let on = NudgeConfig {
840 enabled: Some(true),
841 ..Default::default()
842 };
843 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
844 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
845 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
846 let off = NudgeConfig {
847 enabled: Some(false),
848 ..Default::default()
849 };
850 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
851 }
852
853 #[test]
854 fn test_blueprint_creation() {
855 let regions = vec![RegionDefinition::new(
856 "test".to_string(),
857 RegionKind::Pinned,
858 5000,
859 )];
860 let layout = ContextLayout::new(regions, 10000);
861
862 let stages = vec![Stage::new(
863 "analyze".to_string(),
864 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
865 )];
866
867 let blueprint = Blueprint::new(
868 "test-agent".to_string(),
869 "A test agent".to_string(),
870 stages,
871 layout,
872 );
873
874 assert_eq!(blueprint.name, "test-agent");
875 assert_eq!(blueprint.stages.len(), 1);
876 }
877
878 #[test]
879 fn test_blueprint_with_transforms_version() {
880 let stages = vec![Stage::new("plan".to_string(), make_model())];
881 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
882 .with_transforms(vec![ContextTransform {
883 from_blueprint: "a".to_string(),
884 to_blueprint: "b".to_string(),
885 mappings: vec![],
886 }])
887 .with_version("2.0.0".to_string());
888
889 assert_eq!(bp.transforms.len(), 1);
890 assert_eq!(bp.version, "2.0.0");
891 }
892
893 #[test]
894 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
895 let stages = vec![Stage::new("plan".to_string(), make_model())];
896 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
897 bp.metadata.insert(
899 "tool_perm:bash".to_string(),
900 serde_json::Value::String("deny".to_string()),
901 );
902 bp.metadata
904 .insert("title".to_string(), serde_json::Value::String("x".into()));
905 bp.metadata
907 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
908
909 let perms = bp.agent_tool_permissions();
910 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
911 assert!(!perms.contains_key("title"));
912 assert!(!perms.contains_key("weird"));
913 assert_eq!(perms.len(), 1);
914 }
915
916 #[test]
917 fn test_blueprint_validate_runs_transform_validation() {
918 let stages = vec![Stage::new("plan".to_string(), make_model())];
921 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
922 bp.transforms.push(ContextTransform {
923 from_blueprint: "a".to_string(),
924 to_blueprint: "b".to_string(),
925 mappings: vec![RegionMapping {
926 from_region: "test".to_string(),
927 to_region: "test".to_string(),
928 transform: None,
929 }],
930 });
931 assert!(bp.validate().is_ok());
932 }
933
934 #[test]
935 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
936 let stages = vec![Stage::new("plan".to_string(), make_model())];
937 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
938 bp.transforms.push(ContextTransform {
939 from_blueprint: "a".to_string(),
940 to_blueprint: "b".to_string(),
941 mappings: vec![RegionMapping {
942 from_region: "test".to_string(),
943 to_region: "nonexistent".to_string(),
944 transform: None,
945 }],
946 });
947 let err = bp.validate().unwrap_err();
948 assert_eq!(
949 err,
950 ValidationError::Region {
951 region: "nonexistent".to_string(),
952 message: "transform target region not found in layout".to_string(),
953 }
954 );
955 }
956
957 #[test]
958 fn test_mixed_linear_and_graph_mode_terminal_path() {
959 let mut plan = Stage::new("plan".to_string(), make_model());
963 let impl_stage = Stage::new("impl".to_string(), make_model());
964 let review = Stage::new("review".to_string(), make_model());
965
966 let mut transitions = HashMap::new();
967 transitions.insert(
968 "impl".to_string(),
969 TransitionEdge {
970 target: "impl".to_string(),
971 condition: TransitionCondition::Always,
972 hint: None,
973 transform: EdgeTransform::Direct,
974 gate: None,
975 stuck: None,
976 },
977 );
978 plan.transitions = Some(transitions);
979
980 let bp = Blueprint::new(
981 "t".into(),
982 "".into(),
983 vec![plan, impl_stage, review],
984 make_layout(),
985 );
986 assert!(bp.validate().is_ok());
987 }
988
989 #[test]
990 fn test_stage_validation() {
991 let stage = Stage::new(
992 "test".to_string(),
993 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
994 );
995 assert!(stage.validate().is_ok());
996
997 let empty_stage = Stage::new(
998 "".to_string(),
999 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1000 );
1001 assert!(empty_stage.validate().is_err());
1002 }
1003
1004 #[test]
1005 fn test_stage_validate_with_valid_context_layout_is_ok() {
1006 let mut stage = Stage::new("test".to_string(), make_model());
1007 stage.context_layout = Some(make_layout());
1008 assert!(stage.validate().is_ok());
1009 }
1010
1011 #[test]
1012 fn test_stage_validate_with_invalid_context_layout_is_err() {
1013 let regions = vec![
1015 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1016 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1017 ];
1018 let mut stage = Stage::new("test".to_string(), make_model());
1019 stage.context_layout = Some(ContextLayout::new(regions, 200));
1020 assert!(stage.validate().is_err());
1021 }
1022
1023 #[test]
1024 fn test_stage_with_tools_context_layout_description() {
1025 let stage = Stage::new("test".to_string(), make_model())
1026 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1027 .with_context_layout(make_layout())
1028 .with_description("does things".to_string());
1029
1030 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1031 assert!(stage.context_layout.is_some());
1032 assert_eq!(stage.description.as_deref(), Some("does things"));
1033 }
1034
1035 #[test]
1036 fn test_stage_with_mode() {
1037 let stage = Stage::new("test".to_string(), make_model())
1038 .with_mode(StageMode::InteractivePoints { points: vec![] });
1039 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1040 }
1041
1042 #[test]
1043 fn test_stage_allow_complete_defaults_false() {
1044 let stage = Stage::new("review".to_string(), make_model());
1045 assert!(!stage.allow_complete);
1046 }
1047
1048 #[test]
1049 fn test_stage_allow_complete_serde_default_when_missing() {
1050 let json = r#"{
1053 "name": "review",
1054 "description": null,
1055 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1056 "available_tools": [],
1057 "max_iterations": null,
1058 "context_layout": null,
1059 "config": {},
1060 "transitions": null,
1061 "max_revisits": null,
1062 "transition_prompt": null
1063 }"#;
1064 let stage: Stage = serde_json::from_str(json).unwrap();
1065 assert!(!stage.allow_complete);
1066 assert!(stage.accepts_messages);
1067 }
1068
1069 #[test]
1070 fn test_stage_allow_complete_roundtrip() {
1071 let mut stage = Stage::new("review".to_string(), make_model());
1072 stage.allow_complete = true;
1073 let json = serde_json::to_string(&stage).unwrap();
1074 let back: Stage = serde_json::from_str(&json).unwrap();
1075 assert!(back.allow_complete);
1076 }
1077
1078 #[test]
1079 fn test_interaction_point_directives_default_empty() {
1080 let point = InteractionPoint {
1081 name: "plan_approval".to_string(),
1082 prompt: "Approve?".to_string(),
1083 required: true,
1084 unattended: UnattendedPolicy::AutoApprove,
1085 style: InteractionStyle::MultipleChoice,
1086 options: vec!["Approve".to_string(), "Revise".to_string()],
1087 directives: HashMap::new(),
1088 abort_options: Vec::new(),
1089 edit_options: Vec::new(),
1090 document_region: None,
1091 };
1092 assert!(point.directives.is_empty());
1093 assert!(point.abort_options.is_empty());
1094 assert!(point.edit_options.is_empty());
1095 }
1096
1097 #[test]
1098 fn test_interaction_point_directives_roundtrip() {
1099 let mut directives = HashMap::new();
1100 directives.insert(
1101 "Revise".to_string(),
1102 "Ask what to change, then re-plan.".to_string(),
1103 );
1104 let point = InteractionPoint {
1105 name: "plan_approval".to_string(),
1106 prompt: "Approve?".to_string(),
1107 required: true,
1108 unattended: UnattendedPolicy::Ask,
1109 style: InteractionStyle::MultipleChoice,
1110 options: vec!["Approve".to_string(), "Revise".to_string()],
1111 directives,
1112 abort_options: vec!["Abort".to_string()],
1113 edit_options: vec!["Add detail".to_string()],
1114 document_region: Some("plan".to_string()),
1115 };
1116 let json = serde_json::to_string(&point).unwrap();
1117 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1118 assert_eq!(
1119 back.directives.get("Revise").map(|s| s.as_str()),
1120 Some("Ask what to change, then re-plan.")
1121 );
1122 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1123 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1124 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1127 }
1128
1129 #[test]
1130 fn test_interaction_point_directives_serde_default_when_missing() {
1131 let json = r#"{
1132 "name": "plan_approval",
1133 "prompt": "Approve?",
1134 "required": true,
1135 "style": "multiple_choice",
1136 "options": ["Approve", "Revise"]
1137 }"#;
1138 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1139 assert!(point.directives.is_empty());
1140 assert!(point.abort_options.is_empty());
1141 }
1142
1143 #[test]
1144 fn test_interaction_point_followups_alias_still_deserializes() {
1145 let json = r#"{
1147 "name": "plan_approval",
1148 "prompt": "Approve?",
1149 "required": true,
1150 "style": "multiple_choice",
1151 "options": ["Approve", "Revise"],
1152 "followups": { "Revise": "What to change?" }
1153 }"#;
1154 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1155 assert_eq!(
1156 point.directives.get("Revise").map(|s| s.as_str()),
1157 Some("What to change?")
1158 );
1159 }
1160
1161 #[test]
1162 fn test_model_config_new_creates_single_entry() {
1163 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1164 assert_eq!(mc.models.len(), 1);
1165 assert_eq!(mc.models[0].provider, "anthropic");
1166 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1167 assert!(mc.allow_user_default);
1168 }
1169
1170 #[test]
1171 fn test_model_config_with_multiple_models() {
1172 let mc = ModelConfig {
1173 models: vec![
1174 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1175 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1176 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1177 ],
1178 allow_user_default: true,
1179 parameters: HashMap::new(),
1180 request_timeout_secs: None,
1181 };
1182 assert_eq!(mc.models.len(), 3);
1183 assert_eq!(mc.models[0].provider, "anthropic");
1184 assert_eq!(mc.models[1].provider, "openai");
1185 assert_eq!(mc.models[2].provider, "ollama");
1186 }
1187
1188 #[test]
1189 fn test_model_config_serde_roundtrip() {
1190 let mc = ModelConfig {
1191 models: vec![
1192 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1193 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1194 ],
1195 allow_user_default: false,
1196 parameters: HashMap::new(),
1197 request_timeout_secs: None,
1198 };
1199 let json = serde_json::to_string(&mc).unwrap();
1200 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1201 assert_eq!(back.models.len(), 2);
1202 assert_eq!(back.models[0].provider, "anthropic");
1203 assert_eq!(back.models[1].provider, "openai");
1204 assert!(!back.allow_user_default);
1205 }
1206
1207 #[test]
1208 fn test_model_config_serde_defaults_when_fields_missing() {
1209 let json = r#"{"parameters": {}}"#;
1211 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1212 assert!(mc.models.is_empty());
1213 assert!(mc.allow_user_default);
1214 }
1215
1216 #[test]
1217 fn test_model_config_convenience_accessors() {
1218 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1219 assert_eq!(mc.provider(), "anthropic");
1220 assert_eq!(mc.model(), "claude-sonnet-4-6");
1221 }
1222
1223 #[test]
1224 fn test_model_config_convenience_accessors_empty_models() {
1225 let mc = ModelConfig {
1226 models: vec![],
1227 allow_user_default: true,
1228 parameters: HashMap::new(),
1229 request_timeout_secs: None,
1230 };
1231 assert_eq!(mc.provider(), "anthropic");
1232 assert_eq!(mc.model(), "claude-sonnet-4-6");
1233 }
1234
1235 fn make_model() -> ModelConfig {
1236 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1237 }
1238
1239 fn make_layout() -> ContextLayout {
1240 let regions = vec![RegionDefinition::new(
1241 "test".to_string(),
1242 RegionKind::Pinned,
1243 5000,
1244 )];
1245 ContextLayout::new(regions, 10000)
1246 }
1247
1248 #[test]
1249 fn test_graph_validation_entry_stage_exists() {
1250 let stages = vec![Stage::new("plan".to_string(), make_model())];
1251 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1252 bp.entry_stage = Some("nonexistent".to_string());
1253 assert!(bp.validate().is_err());
1254 }
1255
1256 #[test]
1257 fn test_graph_validation_entry_stage_valid() {
1258 let stages = vec![Stage::new("plan".to_string(), make_model())];
1259 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1260 bp.entry_stage = Some("plan".to_string());
1261 assert!(bp.validate().is_ok());
1262 }
1263
1264 #[test]
1265 fn test_graph_validation_transition_target_missing() {
1266 let mut stage = Stage::new("plan".to_string(), make_model());
1267 let mut transitions = HashMap::new();
1268 transitions.insert(
1269 "nonexistent".to_string(),
1270 TransitionEdge {
1271 target: "nonexistent".to_string(),
1272 condition: TransitionCondition::Always,
1273 hint: None,
1274 transform: EdgeTransform::Direct,
1275 gate: None,
1276 stuck: None,
1277 },
1278 );
1279 stage.transitions = Some(transitions);
1280 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1281 assert!(bp.validate().is_err());
1282 }
1283
1284 #[test]
1288 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1289 let gated = |tools: &[&str], extra: &[&str]| {
1290 let mut stage = Stage::new("impl".to_string(), make_model());
1291 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1292 let mut transitions = HashMap::new();
1293 transitions.insert(
1294 "review".to_string(),
1295 TransitionEdge {
1296 target: "review".to_string(),
1297 condition: TransitionCondition::Always,
1298 hint: None,
1299 transform: EdgeTransform::Direct,
1300 stuck: None,
1301 gate: Some(TransitionGate {
1302 require_modifications: true,
1303 tools: extra.iter().map(|t| t.to_string()).collect(),
1304 ..Default::default()
1305 }),
1306 },
1307 );
1308 stage.transitions = Some(transitions);
1309 Blueprint::new(
1310 "t".into(),
1311 "".into(),
1312 vec![stage, Stage::new("review".to_string(), make_model())],
1313 make_layout(),
1314 )
1315 };
1316 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1317 assert!(err.to_string().contains("no file-modifying tool"));
1318 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1320 assert!(
1322 gated(&["read_file", "patch_file"], &["patch_file"])
1323 .validate()
1324 .is_ok()
1325 );
1326 let mut off = gated(&["read_file"], &[]);
1328 off.stages[0]
1329 .transitions
1330 .as_mut()
1331 .unwrap()
1332 .get_mut("review")
1333 .unwrap()
1334 .gate = Some(TransitionGate::default());
1335 assert!(off.validate().is_ok());
1336 off.stages[0]
1338 .transitions
1339 .as_mut()
1340 .unwrap()
1341 .get_mut("review")
1342 .unwrap()
1343 .gate = None;
1344 assert!(off.validate().is_ok());
1345 }
1346
1347 #[test]
1348 fn test_graph_validation_self_loop_requires_max_revisits() {
1349 let mut stage = Stage::new("impl".to_string(), make_model());
1350 let mut transitions = HashMap::new();
1351 transitions.insert(
1352 "impl".to_string(),
1353 TransitionEdge {
1354 target: "impl".to_string(),
1355 condition: TransitionCondition::Always,
1356 hint: None,
1357 transform: EdgeTransform::Direct,
1358 gate: None,
1359 stuck: None,
1360 },
1361 );
1362 stage.transitions = Some(transitions);
1363 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1364 assert!(bp.validate().is_err());
1365 }
1366
1367 #[test]
1368 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1369 let mut stage = Stage::new("impl".to_string(), make_model());
1370 stage.max_revisits = Some(3);
1371 let mut transitions = HashMap::new();
1372 transitions.insert(
1373 "impl".to_string(),
1374 TransitionEdge {
1375 target: "impl".to_string(),
1376 condition: TransitionCondition::Always,
1377 hint: None,
1378 transform: EdgeTransform::Direct,
1379 gate: None,
1380 stuck: None,
1381 },
1382 );
1383 stage.transitions = Some(transitions);
1384 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1385 let err = bp
1390 .validate()
1391 .expect_err("an exhaustion-only graph is invalid");
1392 assert!(err.to_string().contains("no terminal path"), "{err}");
1393 }
1394
1395 #[test]
1396 fn test_graph_validation_terminal_path_exists() {
1397 let mut plan = Stage::new("plan".to_string(), make_model());
1398 let mut review = Stage::new("review".to_string(), make_model());
1399 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1402 transitions.insert(
1403 "review".to_string(),
1404 TransitionEdge {
1405 target: "review".to_string(),
1406 condition: TransitionCondition::Always,
1407 hint: None,
1408 transform: EdgeTransform::Direct,
1409 gate: None,
1410 stuck: None,
1411 },
1412 );
1413 plan.transitions = Some(transitions);
1414
1415 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1416 assert!(bp.validate().is_ok());
1417 }
1418
1419 #[test]
1420 fn test_graph_no_terminal_path() {
1421 let mut a = Stage::new("a".to_string(), make_model());
1423 let mut b = Stage::new("b".to_string(), make_model());
1424
1425 let mut a_transitions = HashMap::new();
1426 a_transitions.insert(
1427 "b".to_string(),
1428 TransitionEdge {
1429 target: "b".to_string(),
1430 condition: TransitionCondition::Always,
1431 hint: None,
1432 transform: EdgeTransform::Direct,
1433 gate: None,
1434 stuck: None,
1435 },
1436 );
1437 a.transitions = Some(a_transitions);
1438
1439 let mut b_transitions = HashMap::new();
1440 b_transitions.insert(
1441 "a".to_string(),
1442 TransitionEdge {
1443 target: "a".to_string(),
1444 condition: TransitionCondition::Always,
1445 hint: None,
1446 transform: EdgeTransform::Direct,
1447 gate: None,
1448 stuck: None,
1449 },
1450 );
1451 b.transitions = Some(b_transitions);
1452
1453 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1454 assert!(bp.validate().is_err());
1455 }
1456
1457 #[test]
1458 fn test_linear_stages_still_validate() {
1459 let stages = vec![
1461 Stage::new("plan".to_string(), make_model()),
1462 Stage::new("impl".to_string(), make_model()),
1463 Stage::new("review".to_string(), make_model()),
1464 ];
1465 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1466 assert!(bp.validate().is_ok());
1467 }
1468
1469 #[test]
1470 fn test_resolve_entry_stage_name() {
1471 let stages = vec![
1472 Stage::new("plan".to_string(), make_model()),
1473 Stage::new("impl".to_string(), make_model()),
1474 ];
1475 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1476 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1477
1478 bp.entry_stage = Some("impl".to_string());
1479 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1480 }
1481
1482 #[test]
1483 fn test_find_stage() {
1484 let stages = vec![
1485 Stage::new("plan".to_string(), make_model()),
1486 Stage::new("impl".to_string(), make_model()),
1487 ];
1488 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1489 assert!(bp.find_stage("plan").is_some());
1490 assert!(bp.find_stage("impl").is_some());
1491 assert!(bp.find_stage("nonexistent").is_none());
1492 }
1493
1494 #[test]
1495 fn test_transition_condition_default() {
1496 let cond = TransitionCondition::default();
1497 assert_eq!(cond, TransitionCondition::Always);
1498 }
1499
1500 #[test]
1501 fn test_edge_transform_default() {
1502 let t = EdgeTransform::default();
1503 assert_eq!(t, EdgeTransform::Direct);
1504 }
1505
1506 #[test]
1507 fn test_stage_mode_equality() {
1508 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1509 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1510 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1511 }
1512
1513 #[test]
1514 fn test_interaction_style_equality() {
1515 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1516 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1517 }
1518
1519 #[test]
1522 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1523 assert!(!StuckConfig::default().is_armed());
1524 for cfg in [
1525 StuckConfig {
1526 after_iterations: Some(1),
1527 ..Default::default()
1528 },
1529 StuckConfig {
1530 after_minutes: Some(1),
1531 ..Default::default()
1532 },
1533 StuckConfig {
1534 after_same_file_edits: Some(1),
1535 ..Default::default()
1536 },
1537 StuckConfig {
1538 after_tool_calls: Some(1),
1539 ..Default::default()
1540 },
1541 ] {
1542 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1543 }
1544 }
1545
1546 #[test]
1547 fn transition_condition_stuck_round_trips_as_snake_case() {
1548 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1549 assert_eq!(json, "\"stuck\"");
1550 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1551 assert_eq!(back, TransitionCondition::Stuck);
1552 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1553 }
1554
1555 #[test]
1556 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1557 let plain = TransitionEdge {
1558 target: "b".to_string(),
1559 condition: TransitionCondition::Always,
1560 hint: None,
1561 transform: EdgeTransform::Direct,
1562 gate: None,
1563 stuck: None,
1564 };
1565 let json = serde_json::to_string(&plain).unwrap();
1566 assert!(
1567 !json.contains("stuck"),
1568 "absent config must be skipped: {json}"
1569 );
1570
1571 let armed = TransitionEdge {
1572 condition: TransitionCondition::Stuck,
1573 stuck: Some(StuckConfig {
1574 after_iterations: Some(20),
1575 after_minutes: Some(10),
1576 after_same_file_edits: Some(3),
1577 after_tool_calls: Some(60),
1578 }),
1579 ..plain
1580 };
1581 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1582 .expect("armed edge round-trips");
1583 assert_eq!(back.condition, TransitionCondition::Stuck);
1584 assert_eq!(back.stuck, armed.stuck);
1585 }
1586
1587 #[test]
1590 fn validate_rejects_a_stuck_edge_with_no_threshold() {
1591 let build = |stuck| {
1592 let mut a = Stage::new("a".to_string(), make_model());
1593 let b = Stage::new("b".to_string(), make_model());
1594 let mut transitions = std::collections::HashMap::new();
1595 transitions.insert(
1596 "b".to_string(),
1597 TransitionEdge {
1598 target: "b".to_string(),
1599 condition: TransitionCondition::Stuck,
1600 hint: None,
1601 transform: EdgeTransform::Direct,
1602 gate: None,
1603 stuck,
1604 },
1605 );
1606 a.transitions = Some(transitions);
1607 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1608 };
1609
1610 for dead in [None, Some(StuckConfig::default())] {
1611 let err = build(dead)
1612 .validate()
1613 .expect_err("dead stuck edge rejected");
1614 assert!(
1615 format!("{err:?}").contains("stuck_after_"),
1616 "unexpected error: {err:?}"
1617 );
1618 }
1619
1620 assert!(
1622 build(Some(StuckConfig {
1623 after_iterations: Some(5),
1624 ..Default::default()
1625 }))
1626 .validate()
1627 .is_ok()
1628 );
1629 }
1630
1631 #[test]
1635 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1636 let mut stage = Stage::new("plan".to_string(), make_model());
1637 stage.available_tools = vec!["read_file".to_string()];
1638 stage.required_tools = vec!["ask_user_text".to_string()];
1639 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1640
1641 let err = bp.validate().expect_err("a tool it cannot call");
1642 let text = format!("{err:?}");
1643 assert!(text.contains("ask_user_text"), "names the tool: {text}");
1644 assert!(text.contains("available_tools"), "says why: {text}");
1645 }
1646
1647 #[test]
1648 fn validate_accepts_a_required_tool_the_stage_offers() {
1649 let mut stage = Stage::new("plan".to_string(), make_model());
1650 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1651 stage.required_tools = vec!["ask_user_text".to_string()];
1652 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1653
1654 bp.validate().expect("the tool is on offer");
1655 }
1656
1657 #[test]
1661 fn validate_rejects_require_output_without_the_submit_tool() {
1662 let mut stage = Stage::new("summary".to_string(), make_model());
1663 stage.available_tools = vec!["read_file".to_string()];
1664 stage.require_output = true;
1665 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1666
1667 let err = bp.validate().expect_err("no way to submit");
1668 let text = format!("{err:?}");
1669 assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
1670 assert!(text.contains("require_output"), "says why: {text}");
1671 }
1672
1673 #[test]
1674 fn validate_accepts_require_output_when_the_stage_can_submit() {
1675 let mut stage = Stage::new("summary".to_string(), make_model());
1676 stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
1677 stage.require_output = true;
1678 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1679
1680 bp.validate().expect("the stage can submit");
1681 }
1682
1683 #[test]
1686 fn validate_accepts_a_declared_shape_without_require_output() {
1687 let mut stage = Stage::new("summary".to_string(), make_model());
1688 stage.available_tools = vec!["read_file".to_string()];
1689 stage.output = Some(crate::output::OutputSpec {
1690 format: Some("a2ui".to_string()),
1691 ..Default::default()
1692 });
1693 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1694
1695 bp.validate().expect("declaring a shape demands nothing");
1696 }
1697
1698 #[test]
1699 fn output_mode_compares_equal_only_to_itself() {
1700 assert_eq!(StageMode::Output, StageMode::Output);
1701 assert_ne!(StageMode::Output, StageMode::Autonomous);
1702 assert_ne!(StageMode::Autonomous, StageMode::Output);
1703 }
1704
1705 #[test]
1706 fn test_transition_condition_equality() {
1707 assert_eq!(
1708 TransitionCondition::LlmChoice,
1709 TransitionCondition::LlmChoice
1710 );
1711 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1712 }
1713
1714 #[test]
1715 fn test_edge_transform_compact_and_custom_equality() {
1716 let a = EdgeTransform::Compact {
1717 prompt: Some("p".to_string()),
1718 };
1719 let b = EdgeTransform::Compact {
1720 prompt: Some("p".to_string()),
1721 };
1722 assert_eq!(a, b);
1723
1724 let c1 = EdgeTransform::Custom {
1725 carry: vec!["a".to_string()],
1726 compact: vec!["b".to_string()],
1727 clear: vec!["c".to_string()],
1728 compact_prompt: Some("p".to_string()),
1729 };
1730 let c2 = c1.clone();
1731 assert_eq!(c1, c2);
1732
1733 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1734 }
1735
1736 #[test]
1737 fn test_stage_accepts_messages_default_true() {
1738 let stage = Stage::new(
1739 "test".to_string(),
1740 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1741 );
1742 assert!(stage.accepts_messages);
1743 }
1744
1745 #[test]
1746 fn test_stage_accepts_messages_serde_roundtrip() {
1747 let mut stage = Stage::new(
1749 "report".to_string(),
1750 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1751 );
1752 stage.accepts_messages = false;
1753
1754 let json = serde_json::to_string(&stage).expect("should serialize");
1755 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1756 assert!(!deserialized.accepts_messages);
1757 }
1758
1759 #[test]
1760 fn test_stage_accepts_messages_json_default() {
1761 let json = r#"{
1763 "name": "analyze",
1764 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1765 "available_tools": [],
1766 "mode": "Autonomous",
1767 "config": {},
1768 "tool_permissions": {},
1769 "requires_children": false
1770 }"#;
1771 let stage: Stage = serde_json::from_str(json).expect("should parse");
1772 assert!(stage.accepts_messages);
1773 }
1774
1775 #[test]
1776 fn test_has_terminal_path_unknown_stage_returns_false() {
1777 let stages = vec![Stage::new("start".to_string(), make_model())];
1781 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1782 let mut visited = std::collections::HashSet::new();
1783 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1784 }
1785
1786 #[test]
1787 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1788 let regions = vec![
1789 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1790 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1791 ];
1792 let layout = ContextLayout::new(regions, 200);
1793 let stages = vec![Stage::new("start".to_string(), make_model())];
1794 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
1795 assert_eq!(
1796 bp.validate().unwrap_err(),
1797 ValidationError::Region {
1798 region: "dup".to_string(),
1799 message: "duplicate region name".to_string(),
1800 }
1801 );
1802 }
1803
1804 #[test]
1805 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
1806 let stages = vec![Stage::new("".to_string(), make_model())];
1807 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1808 assert_eq!(
1809 bp.validate().unwrap_err(),
1810 ValidationError::Stage {
1811 stage: "(empty)".to_string(),
1812 message: "stage name cannot be empty".to_string(),
1813 }
1814 );
1815 }
1816
1817 #[test]
1818 fn test_file_tracking_config_defaults() {
1819 let json = r#"{"region": "files"}"#;
1820 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
1821 assert_eq!(config.region, "files");
1822 assert!(config.track_reads);
1823 assert!(config.track_writes);
1824 assert!(config.max_file_tokens.is_none());
1825 }
1826
1827 #[test]
1828 fn test_file_tracking_config_serde_roundtrip() {
1829 let config = FileTrackingConfig {
1830 region: "files".to_string(),
1831 track_reads: true,
1832 track_writes: false,
1833 max_file_tokens: Some(5000),
1834 };
1835 let json = serde_json::to_string(&config).unwrap();
1836 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
1837 assert_eq!(back.region, "files");
1838 assert!(back.track_reads);
1839 assert!(!back.track_writes);
1840 assert_eq!(back.max_file_tokens, Some(5000));
1841 }
1842
1843 #[test]
1844 fn test_blueprint_file_tracking_default_none() {
1845 let stages = vec![Stage::new("plan".to_string(), make_model())];
1846 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1847 assert!(bp.file_tracking.is_none());
1848 }
1849
1850 #[test]
1851 fn test_blueprint_file_tracking_serde_roundtrip() {
1852 let stages = vec![Stage::new("plan".to_string(), make_model())];
1853 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1854 bp.file_tracking = Some(FileTrackingConfig {
1855 region: "files".to_string(),
1856 track_reads: true,
1857 track_writes: true,
1858 max_file_tokens: Some(3000),
1859 });
1860 let json = serde_json::to_string(&bp).unwrap();
1861 let back: Blueprint = serde_json::from_str(&json).unwrap();
1862 let ft = back.file_tracking.unwrap();
1863 assert_eq!(ft.region, "files");
1864 assert_eq!(ft.max_file_tokens, Some(3000));
1865 }
1866
1867 #[test]
1868 fn test_tool_result_routing_default() {
1869 let routing = ToolResultRouting::default();
1870 assert_eq!(routing.default_region, "tool_results");
1871 assert!(routing.persist);
1872 assert!(routing.tool_overrides.is_empty());
1873 assert!(routing.max_result_tokens.is_none());
1874 }
1875
1876 #[test]
1877 fn test_stage_new_has_no_tool_result_routing() {
1878 let stage = Stage::new("plan".to_string(), make_model());
1879 assert!(stage.tool_result_routing.is_none());
1880 }
1881
1882 #[test]
1883 fn test_tool_result_routing_serde_roundtrip() {
1884 let mut routing = ToolResultRouting {
1885 default_region: "custom_region".to_string(),
1886 persist: false,
1887 max_result_tokens: Some(4096),
1888 ..Default::default()
1889 };
1890 routing
1891 .tool_overrides
1892 .insert("read_file".to_string(), "file_reads".to_string());
1893
1894 let json = serde_json::to_string(&routing).unwrap();
1895 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
1896
1897 assert_eq!(back.default_region, "custom_region");
1898 assert!(!back.persist);
1899 assert_eq!(back.max_result_tokens, Some(4096));
1900 assert_eq!(
1901 back.tool_overrides.get("read_file").map(String::as_str),
1902 Some("file_reads")
1903 );
1904 }
1905
1906 #[test]
1907 fn test_stage_with_tool_result_routing_serde_roundtrip() {
1908 let stages = vec![{
1909 let mut s = Stage::new("plan".to_string(), make_model());
1910 s.tool_result_routing = Some(ToolResultRouting {
1911 default_region: "results".to_string(),
1912 tool_overrides: HashMap::new(),
1913 persist: true,
1914 max_result_tokens: Some(2048),
1915 tool_max_result_tokens: HashMap::new(),
1916 });
1917 s
1918 }];
1919 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1920 let json = serde_json::to_string(&bp).unwrap();
1921 let back: Blueprint = serde_json::from_str(&json).unwrap();
1922
1923 let routing = back.stages[0]
1924 .tool_result_routing
1925 .as_ref()
1926 .expect("tool_result_routing should be Some");
1927 assert_eq!(routing.default_region, "results");
1928 assert!(routing.persist);
1929 assert_eq!(routing.max_result_tokens, Some(2048));
1930 assert!(routing.tool_overrides.is_empty());
1931 }
1932
1933 fn fanout_config() -> FanOutConfig {
1936 FanOutConfig {
1937 worker_agent: None,
1938 worker_stage: Some("fix_worker".to_string()),
1939 worker_query: None,
1940 merge_stage: Some("merge".to_string()),
1941 max_workers: 3,
1942 on_worker_failure: WorkerFailurePolicy::Continue,
1943 split_prompt: "split".to_string(),
1944 results_region: None,
1945 max_items: None,
1946 max_attempts: None,
1947 }
1948 }
1949
1950 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
1955 let mut fan = Stage::new("parallel".to_string(), make_model());
1956 fan.mode = StageMode::FanOut { config };
1957 let mut worker = Stage::new("fix_worker".to_string(), make_model());
1958 worker.allow_as_worker = worker_allowed;
1959 let mut merge = Stage::new("merge".to_string(), make_model());
1960 merge.transitions = Some(HashMap::new()); Blueprint::new(
1962 "t".into(),
1963 "d".into(),
1964 vec![fan, worker, merge],
1965 make_layout(),
1966 )
1967 }
1968
1969 #[test]
1970 fn fanout_stagemode_partial_eq_and_default_policy() {
1971 let a = StageMode::FanOut {
1972 config: fanout_config(),
1973 };
1974 let b = StageMode::FanOut {
1975 config: fanout_config(),
1976 };
1977 assert_eq!(a, b);
1978 let mut other = fanout_config();
1979 other.max_workers = 99;
1980 assert_ne!(a, StageMode::FanOut { config: other });
1981 assert_ne!(a, StageMode::Autonomous);
1982 assert_eq!(
1983 WorkerFailurePolicy::default(),
1984 WorkerFailurePolicy::Continue
1985 );
1986 }
1987
1988 #[test]
1989 fn fanout_config_serde_roundtrip_and_max_workers_default() {
1990 let toml = r#"
1991worker_agent = "fixer"
1992split_prompt = "go"
1993on_worker_failure = "fail_all"
1994"#;
1995 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
1996 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
1997 assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
1998 assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
1999 assert_eq!(
2000 FanOutConfig {
2001 max_workers: 0,
2002 ..fanout_config()
2003 }
2004 .worker_cap(),
2005 None
2006 );
2007 assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2008 let json = serde_json::to_string(&fanout_config()).unwrap();
2010 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2011 assert_eq!(back, fanout_config());
2012 }
2013
2014 #[test]
2015 fn fanout_validate_ok_with_allowed_worker_stage() {
2016 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2017 }
2018
2019 #[test]
2020 fn fanout_validate_rejects_worker_stage_not_opted_in() {
2021 let err = fanout_blueprint(false, fanout_config())
2022 .validate()
2023 .unwrap_err();
2024 assert!(err.to_string().contains("allow_as_worker"));
2025 }
2026
2027 #[test]
2028 fn fanout_validate_rejects_missing_worker_stage() {
2029 let mut cfg = fanout_config();
2030 cfg.worker_stage = Some("nope".to_string());
2031 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2032 assert!(err.to_string().contains("does not exist"));
2033 }
2034
2035 #[test]
2036 fn fanout_validate_rejects_missing_merge_stage() {
2037 let mut cfg = fanout_config();
2038 cfg.merge_stage = Some("nomerge".to_string());
2039 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2040 assert!(err.to_string().contains("merge_stage"));
2041 }
2042
2043 #[test]
2044 fn fanout_validate_rejects_wrong_worker_source_count() {
2045 let mut cfg = fanout_config();
2047 cfg.worker_stage = None;
2048 assert!(fanout_blueprint(true, cfg).validate().is_err());
2049 let mut cfg2 = fanout_config();
2051 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2053 }
2054
2055 #[test]
2056 fn fanout_terminal_path_runs_through_merge_stage() {
2057 let mut cfg = fanout_config();
2059 cfg.worker_stage = None;
2060 cfg.worker_agent = Some("external".to_string());
2061 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2062 }
2063
2064 #[test]
2065 fn fanout_validate_ok_without_merge_stage() {
2066 let mut cfg = fanout_config();
2069 cfg.merge_stage = None;
2070 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2071 }
2072}