1use crate::error::ValidationError;
9use crate::layout::{ContextLayout, RegionSeed};
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14const 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 names
353 }
354
355 fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
363 let known = self.known_region_names();
364 let checklists: std::collections::HashSet<&str> = self
365 .context_layout
366 .regions
367 .iter()
368 .chain(
369 self.stages
370 .iter()
371 .filter_map(|s| s.context_layout.as_ref())
372 .flat_map(|l| l.regions.iter()),
373 )
374 .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
375 .map(|r| r.name.as_str())
376 .collect();
377
378 for stage in &self.stages {
379 let bad = |message: String| ValidationError::Stage {
380 stage: stage.name.clone(),
381 message,
382 };
383
384 if let Some(routing) = &stage.tool_result_routing {
385 let visible = self.regions_visible_to(stage);
393 let dead_drop = |key: &str, region: &str| ValidationError::Stage {
394 stage: stage.name.clone(),
395 message: format!(
396 "tool_routing.{key} sends results to region '{region}', \
397 which this stage's context does not include, so it \
398 could not read them back. Add '{region}' to \
399 [stages.{}.context.regions], or route somewhere the \
400 stage can see.",
401 stage.name
402 ),
403 };
404 if !visible.contains(routing.default_region.as_str()) {
405 return Err(dead_drop("default_region", &routing.default_region));
406 }
407 for (tool, region) in &routing.tool_overrides {
408 if !visible.contains(region.as_str()) {
409 return Err(dead_drop(&format!("overrides.{tool}"), region));
410 }
411 }
412 }
413
414 for edge in stage.transitions.iter().flat_map(|t| t.values()) {
415 let Some(gate) = &edge.gate else { continue };
416 for (key, region) in [
417 ("region", gate.region.as_ref()),
418 (
419 "require_region_updated",
420 gate.require_region_updated.as_ref(),
421 ),
422 ("require_no_open_items", gate.require_no_open_items.as_ref()),
423 ] {
424 let Some(region) = region else { continue };
425 if !known.contains(region.as_str()) {
426 return Err(bad(format!(
427 "transition to '{}': gate.{key} names region \
428 '{region}', which no stage declares",
429 edge.target
430 )));
431 }
432 }
433 if let Some(region) = &gate.require_no_open_items
437 && !checklists.contains(region.as_str())
438 {
439 return Err(bad(format!(
440 "transition to '{}': gate.require_no_open_items names \
441 region '{region}', which is not a checklist region \
442 (set kind = \"checklist\" on it)",
443 edge.target
444 )));
445 }
446 }
447 }
448 Ok(())
449 }
450
451 fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
453 let stage_names: std::collections::HashSet<&str> =
454 self.stages.iter().map(|s| s.name.as_str()).collect();
455
456 if let Some(entry) = &self.entry_stage
458 && !stage_names.contains(entry.as_str())
459 {
460 return Err(ValidationError::Graph(format!(
461 "entry_stage '{}' does not match any defined stage",
462 entry
463 )));
464 }
465
466 for stage in &self.stages {
472 if let StageMode::FanOut { config } = &stage.mode {
473 let sources = [
474 config.worker_agent.is_some(),
475 config.worker_stage.is_some(),
476 config.worker_query.is_some(),
477 ]
478 .iter()
479 .filter(|&&set| set)
480 .count();
481 if sources != 1 {
482 return Err(ValidationError::Stage {
483 stage: stage.name.clone(),
484 message: "fan_out stage must set exactly one of worker_agent, \
485 worker_stage, or worker_query"
486 .to_string(),
487 });
488 }
489 if let Some(ws) = &config.worker_stage {
490 match self.stages.iter().find(|s| &s.name == ws) {
491 None => {
492 return Err(ValidationError::Stage {
493 stage: stage.name.clone(),
494 message: format!("fan_out worker_stage '{}' does not exist", ws),
495 });
496 }
497 Some(target) if !target.allow_as_worker => {
498 return Err(ValidationError::Stage {
499 stage: stage.name.clone(),
500 message: format!(
501 "fan_out worker_stage '{}' must set allow_as_worker = true",
502 ws
503 ),
504 });
505 }
506 Some(_) => {}
507 }
508 }
509 if let Some(ms) = &config.merge_stage
510 && !stage_names.contains(ms.as_str())
511 {
512 return Err(ValidationError::Stage {
513 stage: stage.name.clone(),
514 message: format!("fan_out merge_stage '{}' does not exist", ms),
515 });
516 }
517 }
518 }
519
520 let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
521 if !has_any_transitions {
522 return Ok(());
524 }
525
526 for stage in &self.stages {
528 if let Some(ref transitions) = stage.transitions {
529 for (target_name, edge) in transitions {
530 if !stage_names.contains(target_name.as_str()) {
531 return Err(ValidationError::Transition {
532 from: stage.name.clone(),
533 to: target_name.clone(),
534 message: "target stage does not exist".to_string(),
535 });
536 }
537 if edge.condition == TransitionCondition::Stuck
541 && !edge.stuck.is_some_and(|c| c.is_armed())
542 {
543 return Err(ValidationError::Transition {
544 from: stage.name.clone(),
545 to: target_name.clone(),
546 message: "condition = \"stuck\" requires at least one \
547 stuck_after_* threshold (the edge could never fire)"
548 .to_string(),
549 });
550 }
551 }
552
553 for (target_name, edge) in transitions {
557 let Some(gate) = &edge.gate else { continue };
558 if !gate.require_modifications {
559 continue;
560 }
561 let can_modify = stage.available_tools.iter().any(|t| {
562 MODIFYING_TOOLS.contains(&t.as_str())
563 || gate.tools.iter().any(|extra| extra == t)
564 });
565 if !can_modify {
566 return Err(ValidationError::Transition {
567 from: stage.name.clone(),
568 to: target_name.clone(),
569 message: "gate requires modifications, but the stage has no \
570 file-modifying tool in available_tools"
571 .to_string(),
572 });
573 }
574 }
575
576 if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
578 return Err(ValidationError::Stage {
579 stage: stage.name.clone(),
580 message: "self-loop transition requires max_revisits".to_string(),
581 });
582 }
583 }
584 }
585
586 let entry = self.resolve_entry_stage_name();
589 let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
590 if !has_terminal {
591 return Err(ValidationError::Graph(
592 "no terminal path exists from entry stage - agent would never complete".to_string(),
593 ));
594 }
595
596 Ok(())
597 }
598
599 pub fn resolve_entry_stage_name(&self) -> String {
601 self.entry_stage.clone().unwrap_or_else(|| {
602 self.stages
603 .first()
604 .map(|s| s.name.clone())
605 .unwrap_or_default()
606 })
607 }
608
609 fn has_terminal_path(
611 &self,
612 stage_name: &str,
613 visited: &mut std::collections::HashSet<String>,
614 ) -> bool {
615 if visited.contains(stage_name) {
616 return false;
617 }
618 visited.insert(stage_name.to_string());
619
620 let stage = self.stages.iter().find(|s| s.name == stage_name);
621 let stage = match stage {
622 Some(s) => s,
623 None => return false,
629 };
630
631 if let StageMode::FanOut {
634 config:
635 FanOutConfig {
636 merge_stage: Some(ms),
637 ..
638 },
639 } = &stage.mode
640 {
641 return self.has_terminal_path(ms, visited);
642 }
643
644 match &stage.transitions {
645 None => {
646 let idx = self
648 .stages
649 .iter()
650 .position(|s| s.name == stage_name)
651 .unwrap_or(0);
652 if idx + 1 >= self.stages.len() {
653 return true; }
655 self.has_terminal_path(&self.stages[idx + 1].name, visited)
656 }
657 Some(transitions) => {
658 if transitions.is_empty() {
659 return true; }
661 for target in transitions.keys() {
663 if self.has_terminal_path(target, visited) {
664 return true;
665 }
666 }
667 false
675 }
676 }
677 }
678
679 pub fn find_stage(&self, name: &str) -> Option<&Stage> {
681 self.stages.iter().find(|s| s.name == name)
682 }
683}
684
685mod model;
689pub use model::*;
690mod stage;
691pub use stage::*;
692mod transition;
693pub use transition::*;
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::layout::ContextLayout;
699 use crate::layout::RegionDefinition;
700 use crate::region::RegionKind;
701
702 fn bp_with_regions(regions_toml: &str) -> Blueprint {
705 crate::manifest::parse_manifest(&format!(
706 r#"
707[agent]
708name = "asked"
709
710[stages.main]
711mode = "autonomous"
712model = {{ provider = "anthropic", model = "m" }}
713
714[context.regions]
715{regions_toml}
716"#
717 ))
718 .expect("fixture parses")
719 }
720
721 #[test]
722 fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
723 assert!(
726 bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
727 .accepts_task()
728 );
729 assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
730 }
731
732 #[test]
733 fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
734 let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
735 assert!(!bp.accepts_task());
736 assert_eq!(bp.caller_inputs(), ["diff"]);
737 }
738
739 #[test]
740 fn the_refusal_names_what_the_agent_takes_instead() {
741 let bp = bp_with_regions(
742 r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
743criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
744 );
745 let msg = bp.task_refusal();
746 assert!(msg.contains("agent 'asked'"), "{msg}");
747 assert!(msg.contains("it takes: diff, criteria"), "{msg}");
748 }
749
750 #[test]
751 fn the_refusal_says_so_when_the_agent_takes_nothing() {
752 let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
753 assert!(bp.caller_inputs().is_empty());
754 let msg = bp.task_refusal();
758 assert!(msg.contains("it takes no caller input at all"), "{msg}");
759 }
760
761 #[test]
762 fn resolve_nudge_defaults_when_nothing_is_configured() {
763 let normal = resolve_nudge(None, None, None, false);
766 assert!(normal.enabled);
767 assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
768 assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
769 let reviewed = resolve_nudge(None, None, None, true);
770 assert!(!reviewed.enabled);
771 assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
773 assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
774 }
775
776 #[test]
777 fn resolve_nudge_cascades_each_field_independently() {
778 let global = NudgeConfig {
779 enabled: Some(true),
780 max: Some(10),
781 text: Some("global".to_string()),
782 };
783 let agent = NudgeConfig {
784 max: Some(2),
785 ..Default::default()
786 };
787 let stage = NudgeConfig {
788 text: Some("stage".to_string()),
789 ..Default::default()
790 };
791 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
792 assert!(resolved.enabled);
794 assert_eq!(resolved.max, 2);
795 assert_eq!(resolved.text, "stage");
796 let stage_all = NudgeConfig {
798 enabled: Some(false),
799 max: Some(0),
800 text: Some("s".to_string()),
801 };
802 let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
803 assert_eq!(
804 resolved,
805 ResolvedNudge {
806 enabled: false,
807 max: 0,
808 text: "s".to_string()
809 }
810 );
811 }
812
813 #[test]
814 fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
815 let on = NudgeConfig {
818 enabled: Some(true),
819 ..Default::default()
820 };
821 assert!(resolve_nudge(None, None, Some(&on), true).enabled);
822 assert!(resolve_nudge(None, Some(&on), None, true).enabled);
823 assert!(resolve_nudge(Some(&on), None, None, true).enabled);
824 let off = NudgeConfig {
825 enabled: Some(false),
826 ..Default::default()
827 };
828 assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
829 }
830
831 #[test]
832 fn test_blueprint_creation() {
833 let regions = vec![RegionDefinition::new(
834 "test".to_string(),
835 RegionKind::Pinned,
836 5000,
837 )];
838 let layout = ContextLayout::new(regions, 10000);
839
840 let stages = vec![Stage::new(
841 "analyze".to_string(),
842 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
843 )];
844
845 let blueprint = Blueprint::new(
846 "test-agent".to_string(),
847 "A test agent".to_string(),
848 stages,
849 layout,
850 );
851
852 assert_eq!(blueprint.name, "test-agent");
853 assert_eq!(blueprint.stages.len(), 1);
854 }
855
856 #[test]
857 fn test_blueprint_with_transforms_version() {
858 let stages = vec![Stage::new("plan".to_string(), make_model())];
859 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
860 .with_transforms(vec![ContextTransform {
861 from_blueprint: "a".to_string(),
862 to_blueprint: "b".to_string(),
863 mappings: vec![],
864 }])
865 .with_version("2.0.0".to_string());
866
867 assert_eq!(bp.transforms.len(), 1);
868 assert_eq!(bp.version, "2.0.0");
869 }
870
871 #[test]
872 fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
873 let stages = vec![Stage::new("plan".to_string(), make_model())];
874 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
875 bp.metadata.insert(
877 "tool_perm:bash".to_string(),
878 serde_json::Value::String("deny".to_string()),
879 );
880 bp.metadata
882 .insert("title".to_string(), serde_json::Value::String("x".into()));
883 bp.metadata
885 .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
886
887 let perms = bp.agent_tool_permissions();
888 assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
889 assert!(!perms.contains_key("title"));
890 assert!(!perms.contains_key("weird"));
891 assert_eq!(perms.len(), 1);
892 }
893
894 #[test]
895 fn test_blueprint_validate_runs_transform_validation() {
896 let stages = vec![Stage::new("plan".to_string(), make_model())];
899 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
900 bp.transforms.push(ContextTransform {
901 from_blueprint: "a".to_string(),
902 to_blueprint: "b".to_string(),
903 mappings: vec![RegionMapping {
904 from_region: "test".to_string(),
905 to_region: "test".to_string(),
906 transform: None,
907 }],
908 });
909 assert!(bp.validate().is_ok());
910 }
911
912 #[test]
913 fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
914 let stages = vec![Stage::new("plan".to_string(), make_model())];
915 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
916 bp.transforms.push(ContextTransform {
917 from_blueprint: "a".to_string(),
918 to_blueprint: "b".to_string(),
919 mappings: vec![RegionMapping {
920 from_region: "test".to_string(),
921 to_region: "nonexistent".to_string(),
922 transform: None,
923 }],
924 });
925 let err = bp.validate().unwrap_err();
926 assert_eq!(
927 err,
928 ValidationError::Region {
929 region: "nonexistent".to_string(),
930 message: "transform target region not found in layout".to_string(),
931 }
932 );
933 }
934
935 #[test]
936 fn test_mixed_linear_and_graph_mode_terminal_path() {
937 let mut plan = Stage::new("plan".to_string(), make_model());
941 let impl_stage = Stage::new("impl".to_string(), make_model());
942 let review = Stage::new("review".to_string(), make_model());
943
944 let mut transitions = HashMap::new();
945 transitions.insert(
946 "impl".to_string(),
947 TransitionEdge {
948 target: "impl".to_string(),
949 condition: TransitionCondition::Always,
950 hint: None,
951 transform: EdgeTransform::Direct,
952 gate: None,
953 stuck: None,
954 },
955 );
956 plan.transitions = Some(transitions);
957
958 let bp = Blueprint::new(
959 "t".into(),
960 "".into(),
961 vec![plan, impl_stage, review],
962 make_layout(),
963 );
964 assert!(bp.validate().is_ok());
965 }
966
967 #[test]
968 fn test_stage_validation() {
969 let stage = Stage::new(
970 "test".to_string(),
971 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
972 );
973 assert!(stage.validate().is_ok());
974
975 let empty_stage = Stage::new(
976 "".to_string(),
977 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
978 );
979 assert!(empty_stage.validate().is_err());
980 }
981
982 #[test]
983 fn test_stage_validate_with_valid_context_layout_is_ok() {
984 let mut stage = Stage::new("test".to_string(), make_model());
985 stage.context_layout = Some(make_layout());
986 assert!(stage.validate().is_ok());
987 }
988
989 #[test]
990 fn test_stage_validate_with_invalid_context_layout_is_err() {
991 let regions = vec![
993 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
994 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
995 ];
996 let mut stage = Stage::new("test".to_string(), make_model());
997 stage.context_layout = Some(ContextLayout::new(regions, 200));
998 assert!(stage.validate().is_err());
999 }
1000
1001 #[test]
1002 fn test_stage_with_tools_context_layout_description() {
1003 let stage = Stage::new("test".to_string(), make_model())
1004 .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1005 .with_context_layout(make_layout())
1006 .with_description("does things".to_string());
1007
1008 assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1009 assert!(stage.context_layout.is_some());
1010 assert_eq!(stage.description.as_deref(), Some("does things"));
1011 }
1012
1013 #[test]
1014 fn test_stage_with_mode() {
1015 let stage = Stage::new("test".to_string(), make_model())
1016 .with_mode(StageMode::InteractivePoints { points: vec![] });
1017 assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1018 }
1019
1020 #[test]
1021 fn test_stage_allow_complete_defaults_false() {
1022 let stage = Stage::new("review".to_string(), make_model());
1023 assert!(!stage.allow_complete);
1024 }
1025
1026 #[test]
1027 fn test_stage_allow_complete_serde_default_when_missing() {
1028 let json = r#"{
1031 "name": "review",
1032 "description": null,
1033 "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1034 "available_tools": [],
1035 "max_iterations": null,
1036 "context_layout": null,
1037 "config": {},
1038 "transitions": null,
1039 "max_revisits": null,
1040 "transition_prompt": null
1041 }"#;
1042 let stage: Stage = serde_json::from_str(json).unwrap();
1043 assert!(!stage.allow_complete);
1044 assert!(stage.accepts_messages);
1045 }
1046
1047 #[test]
1048 fn test_stage_allow_complete_roundtrip() {
1049 let mut stage = Stage::new("review".to_string(), make_model());
1050 stage.allow_complete = true;
1051 let json = serde_json::to_string(&stage).unwrap();
1052 let back: Stage = serde_json::from_str(&json).unwrap();
1053 assert!(back.allow_complete);
1054 }
1055
1056 #[test]
1057 fn test_interaction_point_directives_default_empty() {
1058 let point = InteractionPoint {
1059 name: "plan_approval".to_string(),
1060 prompt: "Approve?".to_string(),
1061 required: true,
1062 unattended: UnattendedPolicy::AutoApprove,
1063 style: InteractionStyle::MultipleChoice,
1064 options: vec!["Approve".to_string(), "Revise".to_string()],
1065 directives: HashMap::new(),
1066 abort_options: Vec::new(),
1067 edit_options: Vec::new(),
1068 document_region: None,
1069 };
1070 assert!(point.directives.is_empty());
1071 assert!(point.abort_options.is_empty());
1072 assert!(point.edit_options.is_empty());
1073 }
1074
1075 #[test]
1076 fn test_interaction_point_directives_roundtrip() {
1077 let mut directives = HashMap::new();
1078 directives.insert(
1079 "Revise".to_string(),
1080 "Ask what to change, then re-plan.".to_string(),
1081 );
1082 let point = InteractionPoint {
1083 name: "plan_approval".to_string(),
1084 prompt: "Approve?".to_string(),
1085 required: true,
1086 unattended: UnattendedPolicy::Ask,
1087 style: InteractionStyle::MultipleChoice,
1088 options: vec!["Approve".to_string(), "Revise".to_string()],
1089 directives,
1090 abort_options: vec!["Abort".to_string()],
1091 edit_options: vec!["Add detail".to_string()],
1092 document_region: Some("plan".to_string()),
1093 };
1094 let json = serde_json::to_string(&point).unwrap();
1095 let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1096 assert_eq!(
1097 back.directives.get("Revise").map(|s| s.as_str()),
1098 Some("Ask what to change, then re-plan.")
1099 );
1100 assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1101 assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1102 assert_eq!(back.unattended, UnattendedPolicy::Ask);
1105 }
1106
1107 #[test]
1108 fn test_interaction_point_directives_serde_default_when_missing() {
1109 let json = r#"{
1110 "name": "plan_approval",
1111 "prompt": "Approve?",
1112 "required": true,
1113 "style": "multiple_choice",
1114 "options": ["Approve", "Revise"]
1115 }"#;
1116 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1117 assert!(point.directives.is_empty());
1118 assert!(point.abort_options.is_empty());
1119 }
1120
1121 #[test]
1122 fn test_interaction_point_followups_alias_still_deserializes() {
1123 let json = r#"{
1125 "name": "plan_approval",
1126 "prompt": "Approve?",
1127 "required": true,
1128 "style": "multiple_choice",
1129 "options": ["Approve", "Revise"],
1130 "followups": { "Revise": "What to change?" }
1131 }"#;
1132 let point: InteractionPoint = serde_json::from_str(json).unwrap();
1133 assert_eq!(
1134 point.directives.get("Revise").map(|s| s.as_str()),
1135 Some("What to change?")
1136 );
1137 }
1138
1139 #[test]
1140 fn test_model_config_new_creates_single_entry() {
1141 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1142 assert_eq!(mc.models.len(), 1);
1143 assert_eq!(mc.models[0].provider, "anthropic");
1144 assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1145 assert!(mc.allow_user_default);
1146 }
1147
1148 #[test]
1149 fn test_model_config_with_multiple_models() {
1150 let mc = ModelConfig {
1151 models: vec![
1152 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1153 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1154 ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1155 ],
1156 allow_user_default: true,
1157 parameters: HashMap::new(),
1158 request_timeout_secs: None,
1159 };
1160 assert_eq!(mc.models.len(), 3);
1161 assert_eq!(mc.models[0].provider, "anthropic");
1162 assert_eq!(mc.models[1].provider, "openai");
1163 assert_eq!(mc.models[2].provider, "ollama");
1164 }
1165
1166 #[test]
1167 fn test_model_config_serde_roundtrip() {
1168 let mc = ModelConfig {
1169 models: vec![
1170 ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1171 ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1172 ],
1173 allow_user_default: false,
1174 parameters: HashMap::new(),
1175 request_timeout_secs: None,
1176 };
1177 let json = serde_json::to_string(&mc).unwrap();
1178 let back: ModelConfig = serde_json::from_str(&json).unwrap();
1179 assert_eq!(back.models.len(), 2);
1180 assert_eq!(back.models[0].provider, "anthropic");
1181 assert_eq!(back.models[1].provider, "openai");
1182 assert!(!back.allow_user_default);
1183 }
1184
1185 #[test]
1186 fn test_model_config_serde_defaults_when_fields_missing() {
1187 let json = r#"{"parameters": {}}"#;
1189 let mc: ModelConfig = serde_json::from_str(json).unwrap();
1190 assert!(mc.models.is_empty());
1191 assert!(mc.allow_user_default);
1192 }
1193
1194 #[test]
1195 fn test_model_config_convenience_accessors() {
1196 let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1197 assert_eq!(mc.provider(), "anthropic");
1198 assert_eq!(mc.model(), "claude-sonnet-4-6");
1199 }
1200
1201 #[test]
1202 fn test_model_config_convenience_accessors_empty_models() {
1203 let mc = ModelConfig {
1204 models: vec![],
1205 allow_user_default: true,
1206 parameters: HashMap::new(),
1207 request_timeout_secs: None,
1208 };
1209 assert_eq!(mc.provider(), "anthropic");
1210 assert_eq!(mc.model(), "claude-sonnet-4-6");
1211 }
1212
1213 fn make_model() -> ModelConfig {
1214 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1215 }
1216
1217 fn make_layout() -> ContextLayout {
1218 let regions = vec![RegionDefinition::new(
1219 "test".to_string(),
1220 RegionKind::Pinned,
1221 5000,
1222 )];
1223 ContextLayout::new(regions, 10000)
1224 }
1225
1226 #[test]
1227 fn test_graph_validation_entry_stage_exists() {
1228 let stages = vec![Stage::new("plan".to_string(), make_model())];
1229 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1230 bp.entry_stage = Some("nonexistent".to_string());
1231 assert!(bp.validate().is_err());
1232 }
1233
1234 #[test]
1235 fn test_graph_validation_entry_stage_valid() {
1236 let stages = vec![Stage::new("plan".to_string(), make_model())];
1237 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1238 bp.entry_stage = Some("plan".to_string());
1239 assert!(bp.validate().is_ok());
1240 }
1241
1242 #[test]
1243 fn test_graph_validation_transition_target_missing() {
1244 let mut stage = Stage::new("plan".to_string(), make_model());
1245 let mut transitions = HashMap::new();
1246 transitions.insert(
1247 "nonexistent".to_string(),
1248 TransitionEdge {
1249 target: "nonexistent".to_string(),
1250 condition: TransitionCondition::Always,
1251 hint: None,
1252 transform: EdgeTransform::Direct,
1253 gate: None,
1254 stuck: None,
1255 },
1256 );
1257 stage.transitions = Some(transitions);
1258 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1259 assert!(bp.validate().is_err());
1260 }
1261
1262 #[test]
1266 fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1267 let gated = |tools: &[&str], extra: &[&str]| {
1268 let mut stage = Stage::new("impl".to_string(), make_model());
1269 stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1270 let mut transitions = HashMap::new();
1271 transitions.insert(
1272 "review".to_string(),
1273 TransitionEdge {
1274 target: "review".to_string(),
1275 condition: TransitionCondition::Always,
1276 hint: None,
1277 transform: EdgeTransform::Direct,
1278 stuck: None,
1279 gate: Some(TransitionGate {
1280 require_modifications: true,
1281 tools: extra.iter().map(|t| t.to_string()).collect(),
1282 ..Default::default()
1283 }),
1284 },
1285 );
1286 stage.transitions = Some(transitions);
1287 Blueprint::new(
1288 "t".into(),
1289 "".into(),
1290 vec![stage, Stage::new("review".to_string(), make_model())],
1291 make_layout(),
1292 )
1293 };
1294 let err = gated(&["read_file"], &[]).validate().unwrap_err();
1295 assert!(err.to_string().contains("no file-modifying tool"));
1296 assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1298 assert!(
1300 gated(&["read_file", "patch_file"], &["patch_file"])
1301 .validate()
1302 .is_ok()
1303 );
1304 let mut off = gated(&["read_file"], &[]);
1306 off.stages[0]
1307 .transitions
1308 .as_mut()
1309 .unwrap()
1310 .get_mut("review")
1311 .unwrap()
1312 .gate = Some(TransitionGate::default());
1313 assert!(off.validate().is_ok());
1314 off.stages[0]
1316 .transitions
1317 .as_mut()
1318 .unwrap()
1319 .get_mut("review")
1320 .unwrap()
1321 .gate = None;
1322 assert!(off.validate().is_ok());
1323 }
1324
1325 #[test]
1326 fn test_graph_validation_self_loop_requires_max_revisits() {
1327 let mut stage = Stage::new("impl".to_string(), make_model());
1328 let mut transitions = HashMap::new();
1329 transitions.insert(
1330 "impl".to_string(),
1331 TransitionEdge {
1332 target: "impl".to_string(),
1333 condition: TransitionCondition::Always,
1334 hint: None,
1335 transform: EdgeTransform::Direct,
1336 gate: None,
1337 stuck: None,
1338 },
1339 );
1340 stage.transitions = Some(transitions);
1341 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1342 assert!(bp.validate().is_err());
1343 }
1344
1345 #[test]
1346 fn test_graph_validation_self_loop_with_max_revisits_ok() {
1347 let mut stage = Stage::new("impl".to_string(), make_model());
1348 stage.max_revisits = Some(3);
1349 let mut transitions = HashMap::new();
1350 transitions.insert(
1351 "impl".to_string(),
1352 TransitionEdge {
1353 target: "impl".to_string(),
1354 condition: TransitionCondition::Always,
1355 hint: None,
1356 transform: EdgeTransform::Direct,
1357 gate: None,
1358 stuck: None,
1359 },
1360 );
1361 stage.transitions = Some(transitions);
1362 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1363 let err = bp
1369 .validate()
1370 .expect_err("an exhaustion-only graph is invalid");
1371 assert!(err.to_string().contains("no terminal path"), "{err}");
1372 }
1373
1374 #[test]
1375 fn test_graph_validation_terminal_path_exists() {
1376 let mut plan = Stage::new("plan".to_string(), make_model());
1377 let mut review = Stage::new("review".to_string(), make_model());
1378 review.transitions = Some(HashMap::new()); let mut transitions = HashMap::new();
1381 transitions.insert(
1382 "review".to_string(),
1383 TransitionEdge {
1384 target: "review".to_string(),
1385 condition: TransitionCondition::Always,
1386 hint: None,
1387 transform: EdgeTransform::Direct,
1388 gate: None,
1389 stuck: None,
1390 },
1391 );
1392 plan.transitions = Some(transitions);
1393
1394 let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1395 assert!(bp.validate().is_ok());
1396 }
1397
1398 #[test]
1399 fn test_graph_no_terminal_path() {
1400 let mut a = Stage::new("a".to_string(), make_model());
1402 let mut b = Stage::new("b".to_string(), make_model());
1403
1404 let mut a_transitions = HashMap::new();
1405 a_transitions.insert(
1406 "b".to_string(),
1407 TransitionEdge {
1408 target: "b".to_string(),
1409 condition: TransitionCondition::Always,
1410 hint: None,
1411 transform: EdgeTransform::Direct,
1412 gate: None,
1413 stuck: None,
1414 },
1415 );
1416 a.transitions = Some(a_transitions);
1417
1418 let mut b_transitions = HashMap::new();
1419 b_transitions.insert(
1420 "a".to_string(),
1421 TransitionEdge {
1422 target: "a".to_string(),
1423 condition: TransitionCondition::Always,
1424 hint: None,
1425 transform: EdgeTransform::Direct,
1426 gate: None,
1427 stuck: None,
1428 },
1429 );
1430 b.transitions = Some(b_transitions);
1431
1432 let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1433 assert!(bp.validate().is_err());
1434 }
1435
1436 #[test]
1437 fn test_linear_stages_still_validate() {
1438 let stages = vec![
1440 Stage::new("plan".to_string(), make_model()),
1441 Stage::new("impl".to_string(), make_model()),
1442 Stage::new("review".to_string(), make_model()),
1443 ];
1444 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1445 assert!(bp.validate().is_ok());
1446 }
1447
1448 #[test]
1449 fn test_resolve_entry_stage_name() {
1450 let stages = vec![
1451 Stage::new("plan".to_string(), make_model()),
1452 Stage::new("impl".to_string(), make_model()),
1453 ];
1454 let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1455 assert_eq!(bp.resolve_entry_stage_name(), "plan");
1456
1457 bp.entry_stage = Some("impl".to_string());
1458 assert_eq!(bp.resolve_entry_stage_name(), "impl");
1459 }
1460
1461 #[test]
1462 fn test_find_stage() {
1463 let stages = vec![
1464 Stage::new("plan".to_string(), make_model()),
1465 Stage::new("impl".to_string(), make_model()),
1466 ];
1467 let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1468 assert!(bp.find_stage("plan").is_some());
1469 assert!(bp.find_stage("impl").is_some());
1470 assert!(bp.find_stage("nonexistent").is_none());
1471 }
1472
1473 #[test]
1474 fn test_transition_condition_default() {
1475 let cond = TransitionCondition::default();
1476 assert_eq!(cond, TransitionCondition::Always);
1477 }
1478
1479 #[test]
1480 fn test_edge_transform_default() {
1481 let t = EdgeTransform::default();
1482 assert_eq!(t, EdgeTransform::Direct);
1483 }
1484
1485 #[test]
1486 fn test_stage_mode_equality() {
1487 assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1488 assert_eq!(StageMode::Interactive, StageMode::Interactive);
1489 assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1490 }
1491
1492 #[test]
1493 fn test_interaction_style_equality() {
1494 assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1495 assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1496 }
1497
1498 #[test]
1501 fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1502 assert!(!StuckConfig::default().is_armed());
1503 for cfg in [
1504 StuckConfig {
1505 after_iterations: Some(1),
1506 ..Default::default()
1507 },
1508 StuckConfig {
1509 after_minutes: Some(1),
1510 ..Default::default()
1511 },
1512 StuckConfig {
1513 after_same_file_edits: Some(1),
1514 ..Default::default()
1515 },
1516 StuckConfig {
1517 after_tool_calls: Some(1),
1518 ..Default::default()
1519 },
1520 ] {
1521 assert!(cfg.is_armed(), "{cfg:?} should be armed");
1522 }
1523 }
1524
1525 #[test]
1526 fn transition_condition_stuck_round_trips_as_snake_case() {
1527 let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1528 assert_eq!(json, "\"stuck\"");
1529 let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1530 assert_eq!(back, TransitionCondition::Stuck);
1531 assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1532 }
1533
1534 #[test]
1535 fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1536 let plain = TransitionEdge {
1537 target: "b".to_string(),
1538 condition: TransitionCondition::Always,
1539 hint: None,
1540 transform: EdgeTransform::Direct,
1541 gate: None,
1542 stuck: None,
1543 };
1544 let json = serde_json::to_string(&plain).unwrap();
1545 assert!(
1546 !json.contains("stuck"),
1547 "absent config must be skipped: {json}"
1548 );
1549
1550 let armed = TransitionEdge {
1551 condition: TransitionCondition::Stuck,
1552 stuck: Some(StuckConfig {
1553 after_iterations: Some(20),
1554 after_minutes: Some(10),
1555 after_same_file_edits: Some(3),
1556 after_tool_calls: Some(60),
1557 }),
1558 ..plain
1559 };
1560 let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1561 .expect("armed edge round-trips");
1562 assert_eq!(back.condition, TransitionCondition::Stuck);
1563 assert_eq!(back.stuck, armed.stuck);
1564 }
1565
1566 #[test]
1569 fn validate_rejects_a_stuck_edge_with_no_threshold() {
1570 let build = |stuck| {
1571 let mut a = Stage::new("a".to_string(), make_model());
1572 let b = Stage::new("b".to_string(), make_model());
1573 let mut transitions = std::collections::HashMap::new();
1574 transitions.insert(
1575 "b".to_string(),
1576 TransitionEdge {
1577 target: "b".to_string(),
1578 condition: TransitionCondition::Stuck,
1579 hint: None,
1580 transform: EdgeTransform::Direct,
1581 gate: None,
1582 stuck,
1583 },
1584 );
1585 a.transitions = Some(transitions);
1586 Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1587 };
1588
1589 for dead in [None, Some(StuckConfig::default())] {
1590 let err = build(dead)
1591 .validate()
1592 .expect_err("dead stuck edge rejected");
1593 assert!(
1594 format!("{err:?}").contains("stuck_after_"),
1595 "unexpected error: {err:?}"
1596 );
1597 }
1598
1599 assert!(
1601 build(Some(StuckConfig {
1602 after_iterations: Some(5),
1603 ..Default::default()
1604 }))
1605 .validate()
1606 .is_ok()
1607 );
1608 }
1609
1610 #[test]
1614 fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1615 let mut stage = Stage::new("plan".to_string(), make_model());
1616 stage.available_tools = vec!["read_file".to_string()];
1617 stage.required_tools = vec!["ask_user_text".to_string()];
1618 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1619
1620 let err = bp.validate().expect_err("a tool it cannot call");
1621 let text = format!("{err:?}");
1622 assert!(text.contains("ask_user_text"), "names the tool: {text}");
1623 assert!(text.contains("available_tools"), "says why: {text}");
1624 }
1625
1626 #[test]
1627 fn validate_accepts_a_required_tool_the_stage_offers() {
1628 let mut stage = Stage::new("plan".to_string(), make_model());
1629 stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1630 stage.required_tools = vec!["ask_user_text".to_string()];
1631 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1632
1633 bp.validate().expect("the tool is on offer");
1634 }
1635
1636 #[test]
1640 fn validate_rejects_require_output_without_the_submit_tool() {
1641 let mut stage = Stage::new("summary".to_string(), make_model());
1642 stage.available_tools = vec!["read_file".to_string()];
1643 stage.require_output = true;
1644 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1645
1646 let err = bp.validate().expect_err("no way to submit");
1647 let text = format!("{err:?}");
1648 assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
1649 assert!(text.contains("require_output"), "says why: {text}");
1650 }
1651
1652 #[test]
1653 fn validate_accepts_require_output_when_the_stage_can_submit() {
1654 let mut stage = Stage::new("summary".to_string(), make_model());
1655 stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
1656 stage.require_output = true;
1657 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1658
1659 bp.validate().expect("the stage can submit");
1660 }
1661
1662 #[test]
1665 fn validate_accepts_a_declared_shape_without_require_output() {
1666 let mut stage = Stage::new("summary".to_string(), make_model());
1667 stage.available_tools = vec!["read_file".to_string()];
1668 stage.output = Some(crate::output::OutputSpec {
1669 format: Some("a2ui".to_string()),
1670 ..Default::default()
1671 });
1672 let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1673
1674 bp.validate().expect("declaring a shape demands nothing");
1675 }
1676
1677 #[test]
1678 fn output_mode_compares_equal_only_to_itself() {
1679 assert_eq!(StageMode::Output, StageMode::Output);
1680 assert_ne!(StageMode::Output, StageMode::Autonomous);
1681 assert_ne!(StageMode::Autonomous, StageMode::Output);
1682 }
1683
1684 #[test]
1685 fn test_transition_condition_equality() {
1686 assert_eq!(
1687 TransitionCondition::LlmChoice,
1688 TransitionCondition::LlmChoice
1689 );
1690 assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
1691 }
1692
1693 #[test]
1694 fn test_edge_transform_compact_and_custom_equality() {
1695 let a = EdgeTransform::Compact {
1696 prompt: Some("p".to_string()),
1697 };
1698 let b = EdgeTransform::Compact {
1699 prompt: Some("p".to_string()),
1700 };
1701 assert_eq!(a, b);
1702
1703 let c1 = EdgeTransform::Custom {
1704 carry: vec!["a".to_string()],
1705 compact: vec!["b".to_string()],
1706 clear: vec!["c".to_string()],
1707 compact_prompt: Some("p".to_string()),
1708 };
1709 let c2 = c1.clone();
1710 assert_eq!(c1, c2);
1711
1712 assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
1713 }
1714
1715 #[test]
1716 fn test_stage_accepts_messages_default_true() {
1717 let stage = Stage::new(
1718 "test".to_string(),
1719 ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1720 );
1721 assert!(stage.accepts_messages);
1722 }
1723
1724 #[test]
1725 fn test_stage_accepts_messages_serde_roundtrip() {
1726 let mut stage = Stage::new(
1728 "report".to_string(),
1729 ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
1730 );
1731 stage.accepts_messages = false;
1732
1733 let json = serde_json::to_string(&stage).expect("should serialize");
1734 let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
1735 assert!(!deserialized.accepts_messages);
1736 }
1737
1738 #[test]
1739 fn test_stage_accepts_messages_json_default() {
1740 let json = r#"{
1742 "name": "analyze",
1743 "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
1744 "available_tools": [],
1745 "mode": "Autonomous",
1746 "config": {},
1747 "tool_permissions": {},
1748 "requires_children": false
1749 }"#;
1750 let stage: Stage = serde_json::from_str(json).expect("should parse");
1751 assert!(stage.accepts_messages);
1752 }
1753
1754 #[test]
1755 fn test_has_terminal_path_unknown_stage_returns_false() {
1756 let stages = vec![Stage::new("start".to_string(), make_model())];
1760 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1761 let mut visited = std::collections::HashSet::new();
1762 assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
1763 }
1764
1765 #[test]
1766 fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
1767 let regions = vec![
1768 RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1769 RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1770 ];
1771 let layout = ContextLayout::new(regions, 200);
1772 let stages = vec![Stage::new("start".to_string(), make_model())];
1773 let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
1774 assert_eq!(
1775 bp.validate().unwrap_err(),
1776 ValidationError::Region {
1777 region: "dup".to_string(),
1778 message: "duplicate region name".to_string(),
1779 }
1780 );
1781 }
1782
1783 #[test]
1784 fn test_blueprint_validate_fails_when_stage_has_empty_name() {
1785 let stages = vec![Stage::new("".to_string(), make_model())];
1786 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1787 assert_eq!(
1788 bp.validate().unwrap_err(),
1789 ValidationError::Stage {
1790 stage: "(empty)".to_string(),
1791 message: "stage name cannot be empty".to_string(),
1792 }
1793 );
1794 }
1795
1796 #[test]
1797 fn test_file_tracking_config_defaults() {
1798 let json = r#"{"region": "files"}"#;
1799 let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
1800 assert_eq!(config.region, "files");
1801 assert!(config.track_reads);
1802 assert!(config.track_writes);
1803 assert!(config.max_file_tokens.is_none());
1804 }
1805
1806 #[test]
1807 fn test_file_tracking_config_serde_roundtrip() {
1808 let config = FileTrackingConfig {
1809 region: "files".to_string(),
1810 track_reads: true,
1811 track_writes: false,
1812 max_file_tokens: Some(5000),
1813 };
1814 let json = serde_json::to_string(&config).unwrap();
1815 let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
1816 assert_eq!(back.region, "files");
1817 assert!(back.track_reads);
1818 assert!(!back.track_writes);
1819 assert_eq!(back.max_file_tokens, Some(5000));
1820 }
1821
1822 #[test]
1823 fn test_blueprint_file_tracking_default_none() {
1824 let stages = vec![Stage::new("plan".to_string(), make_model())];
1825 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1826 assert!(bp.file_tracking.is_none());
1827 }
1828
1829 #[test]
1830 fn test_blueprint_file_tracking_serde_roundtrip() {
1831 let stages = vec![Stage::new("plan".to_string(), make_model())];
1832 let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1833 bp.file_tracking = Some(FileTrackingConfig {
1834 region: "files".to_string(),
1835 track_reads: true,
1836 track_writes: true,
1837 max_file_tokens: Some(3000),
1838 });
1839 let json = serde_json::to_string(&bp).unwrap();
1840 let back: Blueprint = serde_json::from_str(&json).unwrap();
1841 let ft = back.file_tracking.unwrap();
1842 assert_eq!(ft.region, "files");
1843 assert_eq!(ft.max_file_tokens, Some(3000));
1844 }
1845
1846 #[test]
1847 fn test_tool_result_routing_default() {
1848 let routing = ToolResultRouting::default();
1849 assert_eq!(routing.default_region, "tool_results");
1850 assert!(routing.persist);
1851 assert!(routing.tool_overrides.is_empty());
1852 assert!(routing.max_result_tokens.is_none());
1853 }
1854
1855 #[test]
1856 fn test_stage_new_has_no_tool_result_routing() {
1857 let stage = Stage::new("plan".to_string(), make_model());
1858 assert!(stage.tool_result_routing.is_none());
1859 }
1860
1861 #[test]
1862 fn test_tool_result_routing_serde_roundtrip() {
1863 let mut routing = ToolResultRouting {
1864 default_region: "custom_region".to_string(),
1865 persist: false,
1866 max_result_tokens: Some(4096),
1867 ..Default::default()
1868 };
1869 routing
1870 .tool_overrides
1871 .insert("read_file".to_string(), "file_reads".to_string());
1872
1873 let json = serde_json::to_string(&routing).unwrap();
1874 let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
1875
1876 assert_eq!(back.default_region, "custom_region");
1877 assert!(!back.persist);
1878 assert_eq!(back.max_result_tokens, Some(4096));
1879 assert_eq!(
1880 back.tool_overrides.get("read_file").map(String::as_str),
1881 Some("file_reads")
1882 );
1883 }
1884
1885 #[test]
1886 fn test_stage_with_tool_result_routing_serde_roundtrip() {
1887 let stages = vec![{
1888 let mut s = Stage::new("plan".to_string(), make_model());
1889 s.tool_result_routing = Some(ToolResultRouting {
1890 default_region: "results".to_string(),
1891 tool_overrides: HashMap::new(),
1892 persist: true,
1893 max_result_tokens: Some(2048),
1894 tool_max_result_tokens: HashMap::new(),
1895 });
1896 s
1897 }];
1898 let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1899 let json = serde_json::to_string(&bp).unwrap();
1900 let back: Blueprint = serde_json::from_str(&json).unwrap();
1901
1902 let routing = back.stages[0]
1903 .tool_result_routing
1904 .as_ref()
1905 .expect("tool_result_routing should be Some");
1906 assert_eq!(routing.default_region, "results");
1907 assert!(routing.persist);
1908 assert_eq!(routing.max_result_tokens, Some(2048));
1909 assert!(routing.tool_overrides.is_empty());
1910 }
1911
1912 fn fanout_config() -> FanOutConfig {
1915 FanOutConfig {
1916 worker_agent: None,
1917 worker_stage: Some("fix_worker".to_string()),
1918 worker_query: None,
1919 merge_stage: Some("merge".to_string()),
1920 max_workers: 3,
1921 on_worker_failure: WorkerFailurePolicy::Continue,
1922 split_prompt: "split".to_string(),
1923 results_region: None,
1924 max_items: None,
1925 }
1926 }
1927
1928 fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
1933 let mut fan = Stage::new("parallel".to_string(), make_model());
1934 fan.mode = StageMode::FanOut { config };
1935 let mut worker = Stage::new("fix_worker".to_string(), make_model());
1936 worker.allow_as_worker = worker_allowed;
1937 let mut merge = Stage::new("merge".to_string(), make_model());
1938 merge.transitions = Some(HashMap::new()); Blueprint::new(
1940 "t".into(),
1941 "d".into(),
1942 vec![fan, worker, merge],
1943 make_layout(),
1944 )
1945 }
1946
1947 #[test]
1948 fn fanout_stagemode_partial_eq_and_default_policy() {
1949 let a = StageMode::FanOut {
1950 config: fanout_config(),
1951 };
1952 let b = StageMode::FanOut {
1953 config: fanout_config(),
1954 };
1955 assert_eq!(a, b);
1956 let mut other = fanout_config();
1957 other.max_workers = 99;
1958 assert_ne!(a, StageMode::FanOut { config: other });
1959 assert_ne!(a, StageMode::Autonomous);
1960 assert_eq!(
1961 WorkerFailurePolicy::default(),
1962 WorkerFailurePolicy::Continue
1963 );
1964 }
1965
1966 #[test]
1967 fn fanout_config_serde_roundtrip_and_max_workers_default() {
1968 let toml = r#"
1969worker_agent = "fixer"
1970split_prompt = "go"
1971on_worker_failure = "fail_all"
1972"#;
1973 let cfg: FanOutConfig = toml::from_str(toml).unwrap();
1974 assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
1975 assert_eq!(cfg.max_workers, 4); assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
1977 let json = serde_json::to_string(&fanout_config()).unwrap();
1979 let back: FanOutConfig = serde_json::from_str(&json).unwrap();
1980 assert_eq!(back, fanout_config());
1981 }
1982
1983 #[test]
1984 fn fanout_validate_ok_with_allowed_worker_stage() {
1985 assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
1986 }
1987
1988 #[test]
1989 fn fanout_validate_rejects_worker_stage_not_opted_in() {
1990 let err = fanout_blueprint(false, fanout_config())
1991 .validate()
1992 .unwrap_err();
1993 assert!(err.to_string().contains("allow_as_worker"));
1994 }
1995
1996 #[test]
1997 fn fanout_validate_rejects_missing_worker_stage() {
1998 let mut cfg = fanout_config();
1999 cfg.worker_stage = Some("nope".to_string());
2000 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2001 assert!(err.to_string().contains("does not exist"));
2002 }
2003
2004 #[test]
2005 fn fanout_validate_rejects_missing_merge_stage() {
2006 let mut cfg = fanout_config();
2007 cfg.merge_stage = Some("nomerge".to_string());
2008 let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2009 assert!(err.to_string().contains("merge_stage"));
2010 }
2011
2012 #[test]
2013 fn fanout_validate_rejects_wrong_worker_source_count() {
2014 let mut cfg = fanout_config();
2016 cfg.worker_stage = None;
2017 assert!(fanout_blueprint(true, cfg).validate().is_err());
2018 let mut cfg2 = fanout_config();
2020 cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
2022 }
2023
2024 #[test]
2025 fn fanout_terminal_path_runs_through_merge_stage() {
2026 let mut cfg = fanout_config();
2028 cfg.worker_stage = None;
2029 cfg.worker_agent = Some("external".to_string());
2030 assert!(fanout_blueprint(false, cfg).validate().is_ok());
2031 }
2032
2033 #[test]
2034 fn fanout_validate_ok_without_merge_stage() {
2035 let mut cfg = fanout_config();
2038 cfg.merge_stage = None;
2039 assert!(fanout_blueprint(true, cfg).validate().is_ok());
2040 }
2041}