use crate::error::ValidationError;
use crate::layout::ContextLayout;
use crate::lifecycle::CompactionConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Blueprint {
pub name: String,
pub description: String,
pub stages: Vec<Stage>,
pub context_layout: ContextLayout,
pub transforms: Vec<ContextTransform>,
pub version: String,
pub compaction_config: Option<CompactionConfig>,
pub max_child_depth: Option<usize>,
pub entry_stage: Option<String>,
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<crate::taint::SecurityConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_tool_hint: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repetition_detection: Option<RepetitionDetectionConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_tracking: Option<FileTrackingConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
#[serde(default)]
pub dynamic_tools: bool,
}
impl Blueprint {
pub fn new(
name: String,
description: String,
stages: Vec<Stage>,
context_layout: ContextLayout,
) -> Self {
Self {
name,
description,
stages,
context_layout,
transforms: Vec::new(),
version: "0.1.0".to_string(),
compaction_config: None,
max_child_depth: None,
entry_stage: None,
metadata: HashMap::new(),
security: None,
batch_tool_hint: None,
repetition_detection: None,
file_tracking: None,
sandbox: None,
dynamic_tools: false,
}
}
pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
self.metadata
.iter()
.filter_map(|(k, v)| {
Some((
k.strip_prefix("tool_perm:")?.to_string(),
v.as_str()?.to_string(),
))
})
.collect()
}
pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
self.transforms = transforms;
self
}
pub fn with_version(mut self, version: String) -> Self {
self.version = version;
self
}
pub fn validate(&self) -> std::result::Result<(), ValidationError> {
self.context_layout.validate()?;
for stage in &self.stages {
stage.validate()?;
}
for transform in &self.transforms {
transform.validate(&self.context_layout)?;
}
self.validate_graph()?;
Ok(())
}
fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
let stage_names: std::collections::HashSet<&str> =
self.stages.iter().map(|s| s.name.as_str()).collect();
if let Some(entry) = &self.entry_stage
&& !stage_names.contains(entry.as_str())
{
return Err(ValidationError::Graph(format!(
"entry_stage '{}' does not match any defined stage",
entry
)));
}
for stage in &self.stages {
if let StageMode::FanOut { config } = &stage.mode {
let sources = [
config.worker_agent.is_some(),
config.worker_stage.is_some(),
config.worker_query.is_some(),
]
.iter()
.filter(|&&set| set)
.count();
if sources != 1 {
return Err(ValidationError::Stage {
stage: stage.name.clone(),
message: "fan_out stage must set exactly one of worker_agent, \
worker_stage, or worker_query"
.to_string(),
});
}
if let Some(ws) = &config.worker_stage {
match self.stages.iter().find(|s| &s.name == ws) {
None => {
return Err(ValidationError::Stage {
stage: stage.name.clone(),
message: format!("fan_out worker_stage '{}' does not exist", ws),
});
}
Some(target) if !target.allow_as_worker => {
return Err(ValidationError::Stage {
stage: stage.name.clone(),
message: format!(
"fan_out worker_stage '{}' must set allow_as_worker = true",
ws
),
});
}
Some(_) => {}
}
}
if let Some(ms) = &config.merge_stage
&& !stage_names.contains(ms.as_str())
{
return Err(ValidationError::Stage {
stage: stage.name.clone(),
message: format!("fan_out merge_stage '{}' does not exist", ms),
});
}
}
}
let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
if !has_any_transitions {
return Ok(());
}
for stage in &self.stages {
if let Some(ref transitions) = stage.transitions {
for (target_name, edge) in transitions {
if !stage_names.contains(target_name.as_str()) {
return Err(ValidationError::Transition {
from: stage.name.clone(),
to: target_name.clone(),
message: "target stage does not exist".to_string(),
});
}
if edge.condition == TransitionCondition::Stuck
&& !edge.stuck.is_some_and(|c| c.is_armed())
{
return Err(ValidationError::Transition {
from: stage.name.clone(),
to: target_name.clone(),
message: "condition = \"stuck\" requires at least one \
stuck_after_* threshold (the edge could never fire)"
.to_string(),
});
}
}
for (target_name, edge) in transitions {
let Some(gate) = &edge.gate else { continue };
if !gate.require_modifications {
continue;
}
let can_modify = stage.available_tools.iter().any(|t| {
MODIFYING_TOOLS.contains(&t.as_str())
|| gate.tools.iter().any(|extra| extra == t)
});
if !can_modify {
return Err(ValidationError::Transition {
from: stage.name.clone(),
to: target_name.clone(),
message: "gate requires modifications, but the stage has no \
file-modifying tool in available_tools"
.to_string(),
});
}
}
if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
return Err(ValidationError::Stage {
stage: stage.name.clone(),
message: "self-loop transition requires max_revisits".to_string(),
});
}
}
}
let entry = self.resolve_entry_stage_name();
let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
if !has_terminal {
return Err(ValidationError::Graph(
"no terminal path exists from entry stage - agent would never complete".to_string(),
));
}
Ok(())
}
pub fn resolve_entry_stage_name(&self) -> String {
self.entry_stage.clone().unwrap_or_else(|| {
self.stages
.first()
.map(|s| s.name.clone())
.unwrap_or_default()
})
}
fn has_terminal_path(
&self,
stage_name: &str,
visited: &mut std::collections::HashSet<String>,
) -> bool {
if visited.contains(stage_name) {
return false;
}
visited.insert(stage_name.to_string());
let stage = self.stages.iter().find(|s| s.name == stage_name);
let stage = match stage {
Some(s) => s,
None => return false,
};
if let StageMode::FanOut {
config:
FanOutConfig {
merge_stage: Some(ms),
..
},
} = &stage.mode
{
return self.has_terminal_path(ms, visited);
}
match &stage.transitions {
None => {
let idx = self
.stages
.iter()
.position(|s| s.name == stage_name)
.unwrap_or(0);
if idx + 1 >= self.stages.len() {
return true; }
self.has_terminal_path(&self.stages[idx + 1].name, visited)
}
Some(transitions) => {
if transitions.is_empty() {
return true; }
for target in transitions.keys() {
if self.has_terminal_path(target, visited) {
return true;
}
}
transitions.keys().all(|target| {
self.stages
.iter()
.find(|s| s.name == *target)
.map(|s| s.max_revisits.is_some())
.unwrap_or(false)
})
}
}
}
pub fn find_stage(&self, name: &str) -> Option<&Stage> {
self.stages.iter().find(|s| s.name == name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTrackingConfig {
pub region: String,
#[serde(default = "default_true_val")]
pub track_reads: bool,
#[serde(default = "default_true_val")]
pub track_writes: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_tokens: Option<usize>,
}
fn default_true_val() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepetitionDetectionConfig {
pub max_repeat_calls: Option<usize>,
pub max_readonly_streak: Option<usize>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResultRouting {
pub default_region: String,
pub tool_overrides: HashMap<String, String>,
pub persist: bool,
pub max_result_tokens: Option<usize>,
}
impl Default for ToolResultRouting {
fn default() -> Self {
Self {
default_region: "tool_results".to_string(),
tool_overrides: HashMap::new(),
persist: true,
max_result_tokens: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub enum StageMode {
#[default]
Autonomous,
Interactive,
InteractivePoints {
points: Vec<InteractionPoint>,
},
FanOut {
config: FanOutConfig,
},
}
impl PartialEq for StageMode {
#[inline(never)]
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Autonomous, Self::Autonomous) | (Self::Interactive, Self::Interactive) => true,
(Self::InteractivePoints { points: a }, Self::InteractivePoints { points: b }) => {
a == b
}
(Self::FanOut { config: a }, Self::FanOut { config: b }) => a == b,
_ => false,
}
}
}
impl Eq for StageMode {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkerFailurePolicy {
#[default]
Continue,
FailAll,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FanOutConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_stage: Option<String>,
#[serde(default = "default_max_workers")]
pub max_workers: usize,
#[serde(default)]
pub on_worker_failure: WorkerFailurePolicy,
#[serde(default)]
pub split_prompt: String,
}
fn default_max_workers() -> usize {
4
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractionStyle {
#[default]
FreeText,
MultipleChoice,
Confirm,
}
impl PartialEq for InteractionStyle {
#[inline(never)]
fn eq(&self, other: &Self) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
}
}
impl Eq for InteractionStyle {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InteractionPoint {
pub name: String,
pub prompt: String,
pub required: bool,
#[serde(default)]
pub style: InteractionStyle,
#[serde(default)]
pub options: Vec<String>,
#[serde(default, alias = "followups")]
pub directives: HashMap<String, String>,
#[serde(default)]
pub abort_options: Vec<String>,
#[serde(default)]
pub edit_options: Vec<String>,
#[serde(default)]
pub document_region: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stage {
pub name: String,
pub description: Option<String>,
pub model: ModelConfig,
pub available_tools: Vec<String>,
pub max_iterations: Option<usize>,
#[serde(default)]
pub mode: StageMode,
pub context_layout: Option<ContextLayout>,
pub config: HashMap<String, serde_json::Value>,
#[serde(default)]
pub tool_permissions: HashMap<String, String>,
#[serde(default)]
pub requires_children: bool,
pub transitions: Option<HashMap<String, TransitionEdge>>,
pub max_revisits: Option<usize>,
pub transition_prompt: Option<String>,
#[serde(default = "default_true")]
pub accepts_messages: bool,
#[serde(default)]
pub allow_complete: bool,
#[serde(default)]
pub allow_as_worker: bool,
#[serde(default)]
pub security: Option<crate::taint::SecurityConfig>,
#[serde(default)]
pub batch_tool_hint: Option<bool>,
#[serde(default)]
pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
#[serde(default)]
pub tool_result_routing: Option<ToolResultRouting>,
}
fn default_true() -> bool {
true
}
impl Stage {
pub fn new(name: String, model: ModelConfig) -> Self {
Self {
name,
description: None,
model,
available_tools: Vec::new(),
max_iterations: None,
mode: StageMode::Autonomous,
context_layout: None,
config: HashMap::new(),
tool_permissions: HashMap::new(),
requires_children: false,
transitions: None,
max_revisits: None,
transition_prompt: None,
accepts_messages: true,
allow_complete: false,
allow_as_worker: false,
security: None,
batch_tool_hint: None,
sandbox: None,
tool_result_routing: None,
}
}
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
self.available_tools = tools;
self
}
pub fn with_mode(mut self, mode: StageMode) -> Self {
self.mode = mode;
self
}
pub fn with_context_layout(mut self, layout: ContextLayout) -> Self {
self.context_layout = Some(layout);
self
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
fn validate(&self) -> std::result::Result<(), ValidationError> {
if self.name.is_empty() {
return Err(ValidationError::Stage {
stage: "(empty)".to_string(),
message: "stage name cannot be empty".to_string(),
});
}
if let Some(layout) = &self.context_layout {
layout.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelEntry {
pub provider: String,
pub model: String,
}
impl ModelEntry {
pub fn new(provider: String, model: String) -> Self {
Self { provider, model }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
#[serde(default)]
pub models: Vec<ModelEntry>,
#[serde(default = "default_allow_user_default")]
pub allow_user_default: bool,
#[serde(default)]
pub parameters: HashMap<String, serde_json::Value>,
#[serde(default)]
pub request_timeout_secs: Option<u64>,
}
fn default_allow_user_default() -> bool {
true
}
impl ModelConfig {
pub fn new(provider: String, model: String) -> Self {
Self {
models: vec![ModelEntry::new(provider, model)],
allow_user_default: true,
parameters: HashMap::new(),
request_timeout_secs: None,
}
}
pub fn provider(&self) -> &str {
self.models
.first()
.map(|e| e.provider.as_str())
.unwrap_or("anthropic")
}
pub fn model(&self) -> &str {
self.models
.first()
.map(|e| e.model.as_str())
.unwrap_or("claude-sonnet-4-6")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextTransform {
pub from_blueprint: String,
pub to_blueprint: String,
pub mappings: Vec<RegionMapping>,
}
impl ContextTransform {
fn validate(&self, layout: &ContextLayout) -> std::result::Result<(), ValidationError> {
for mapping in &self.mappings {
if layout.get_region(&mapping.to_region).is_none() {
return Err(ValidationError::Region {
region: mapping.to_region.clone(),
message: "transform target region not found in layout".to_string(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionMapping {
pub from_region: String,
pub to_region: String,
pub transform: Option<ContentTransform>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionEdge {
pub target: String,
#[serde(default)]
pub condition: TransitionCondition,
pub hint: Option<String>,
#[serde(default)]
pub transform: EdgeTransform,
#[serde(default)]
pub gate: Option<TransitionGate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stuck: Option<StuckConfig>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StuckConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_iterations: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_minutes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_same_file_edits: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_tool_calls: Option<usize>,
}
impl StuckConfig {
pub fn is_armed(&self) -> bool {
self.after_iterations.is_some()
|| self.after_minutes.is_some()
|| self.after_same_file_edits.is_some()
|| self.after_tool_calls.is_some()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransitionGate {
#[serde(default)]
pub require_modifications: bool,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub max_attempts: Option<usize>,
}
pub const DEFAULT_GATE_ATTEMPTS: usize = 3;
pub const MODIFYING_TOOLS: &[&str] = &["write_file", "edit_file"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionCondition {
#[default]
Always,
Error,
MaxIterations,
LlmChoice,
Stuck,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeTransform {
#[default]
Direct,
Clear,
Compact {
#[serde(default)]
prompt: Option<String>,
},
Custom {
carry: Vec<String>,
compact: Vec<String>,
clear: Vec<String>,
compact_prompt: Option<String>,
},
}
impl PartialEq for EdgeTransform {
#[inline(never)]
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Direct, Self::Direct) | (Self::Clear, Self::Clear) => true,
(Self::Compact { prompt: a }, Self::Compact { prompt: b }) => a == b,
(
Self::Custom {
carry: ca,
compact: coa,
clear: cla,
compact_prompt: cpa,
},
Self::Custom {
carry: cb,
compact: cob,
clear: clb,
compact_prompt: cpb,
},
) => ca == cb && coa == cob && cla == clb && cpa == cpb,
_ => false,
}
}
}
impl Eq for EdgeTransform {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentTransform {
Direct,
Summarize,
Extract { fields: Vec<String> },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::ContextLayout;
use crate::layout::RegionDefinition;
use crate::region::RegionKind;
#[test]
fn test_blueprint_creation() {
let regions = vec![RegionDefinition::new(
"test".to_string(),
RegionKind::Pinned,
5000,
)];
let layout = ContextLayout::new(regions, 10000);
let stages = vec![Stage::new(
"analyze".to_string(),
ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
)];
let blueprint = Blueprint::new(
"test-agent".to_string(),
"A test agent".to_string(),
stages,
layout,
);
assert_eq!(blueprint.name, "test-agent");
assert_eq!(blueprint.stages.len(), 1);
}
#[test]
fn test_blueprint_with_transforms_version() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
.with_transforms(vec![ContextTransform {
from_blueprint: "a".to_string(),
to_blueprint: "b".to_string(),
mappings: vec![],
}])
.with_version("2.0.0".to_string());
assert_eq!(bp.transforms.len(), 1);
assert_eq!(bp.version, "2.0.0");
}
#[test]
fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
bp.metadata.insert(
"tool_perm:bash".to_string(),
serde_json::Value::String("deny".to_string()),
);
bp.metadata
.insert("title".to_string(), serde_json::Value::String("x".into()));
bp.metadata
.insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
let perms = bp.agent_tool_permissions();
assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
assert!(!perms.contains_key("title"));
assert!(!perms.contains_key("weird"));
assert_eq!(perms.len(), 1);
}
#[test]
fn test_blueprint_validate_runs_transform_validation() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
bp.transforms.push(ContextTransform {
from_blueprint: "a".to_string(),
to_blueprint: "b".to_string(),
mappings: vec![RegionMapping {
from_region: "test".to_string(),
to_region: "test".to_string(),
transform: None,
}],
});
assert!(bp.validate().is_ok());
}
#[test]
fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
bp.transforms.push(ContextTransform {
from_blueprint: "a".to_string(),
to_blueprint: "b".to_string(),
mappings: vec![RegionMapping {
from_region: "test".to_string(),
to_region: "nonexistent".to_string(),
transform: None,
}],
});
let err = bp.validate().unwrap_err();
assert_eq!(
err,
ValidationError::Region {
region: "nonexistent".to_string(),
message: "transform target region not found in layout".to_string(),
}
);
}
#[test]
fn test_mixed_linear_and_graph_mode_terminal_path() {
let mut plan = Stage::new("plan".to_string(), make_model());
let impl_stage = Stage::new("impl".to_string(), make_model());
let review = Stage::new("review".to_string(), make_model());
let mut transitions = HashMap::new();
transitions.insert(
"impl".to_string(),
TransitionEdge {
target: "impl".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
plan.transitions = Some(transitions);
let bp = Blueprint::new(
"t".into(),
"".into(),
vec![plan, impl_stage, review],
make_layout(),
);
assert!(bp.validate().is_ok());
}
#[test]
fn test_stage_validation() {
let stage = Stage::new(
"test".to_string(),
ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
);
assert!(stage.validate().is_ok());
let empty_stage = Stage::new(
"".to_string(),
ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
);
assert!(empty_stage.validate().is_err());
}
#[test]
fn test_stage_validate_with_valid_context_layout_is_ok() {
let mut stage = Stage::new("test".to_string(), make_model());
stage.context_layout = Some(make_layout());
assert!(stage.validate().is_ok());
}
#[test]
fn test_stage_validate_with_invalid_context_layout_is_err() {
let regions = vec![
RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
];
let mut stage = Stage::new("test".to_string(), make_model());
stage.context_layout = Some(ContextLayout::new(regions, 200));
assert!(stage.validate().is_err());
}
#[test]
fn test_stage_with_tools_context_layout_description() {
let stage = Stage::new("test".to_string(), make_model())
.with_tools(vec!["read_file".to_string(), "bash".to_string()])
.with_context_layout(make_layout())
.with_description("does things".to_string());
assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
assert!(stage.context_layout.is_some());
assert_eq!(stage.description.as_deref(), Some("does things"));
}
#[test]
fn test_stage_with_mode() {
let stage = Stage::new("test".to_string(), make_model())
.with_mode(StageMode::InteractivePoints { points: vec![] });
assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
}
#[test]
fn test_stage_allow_complete_defaults_false() {
let stage = Stage::new("review".to_string(), make_model());
assert!(!stage.allow_complete);
}
#[test]
fn test_stage_allow_complete_serde_default_when_missing() {
let json = r#"{
"name": "review",
"description": null,
"model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
"available_tools": [],
"max_iterations": null,
"context_layout": null,
"config": {},
"transitions": null,
"max_revisits": null,
"transition_prompt": null
}"#;
let stage: Stage = serde_json::from_str(json).unwrap();
assert!(!stage.allow_complete);
assert!(stage.accepts_messages);
}
#[test]
fn test_stage_allow_complete_roundtrip() {
let mut stage = Stage::new("review".to_string(), make_model());
stage.allow_complete = true;
let json = serde_json::to_string(&stage).unwrap();
let back: Stage = serde_json::from_str(&json).unwrap();
assert!(back.allow_complete);
}
#[test]
fn test_interaction_point_directives_default_empty() {
let point = InteractionPoint {
name: "plan_approval".to_string(),
prompt: "Approve?".to_string(),
required: true,
style: InteractionStyle::MultipleChoice,
options: vec!["Approve".to_string(), "Revise".to_string()],
directives: HashMap::new(),
abort_options: Vec::new(),
edit_options: Vec::new(),
document_region: None,
};
assert!(point.directives.is_empty());
assert!(point.abort_options.is_empty());
assert!(point.edit_options.is_empty());
}
#[test]
fn test_interaction_point_directives_roundtrip() {
let mut directives = HashMap::new();
directives.insert(
"Revise".to_string(),
"Ask what to change, then re-plan.".to_string(),
);
let point = InteractionPoint {
name: "plan_approval".to_string(),
prompt: "Approve?".to_string(),
required: true,
style: InteractionStyle::MultipleChoice,
options: vec!["Approve".to_string(), "Revise".to_string()],
directives,
abort_options: vec!["Abort".to_string()],
edit_options: vec!["Add detail".to_string()],
document_region: Some("plan".to_string()),
};
let json = serde_json::to_string(&point).unwrap();
let back: InteractionPoint = serde_json::from_str(&json).unwrap();
assert_eq!(
back.directives.get("Revise").map(|s| s.as_str()),
Some("Ask what to change, then re-plan.")
);
assert_eq!(back.abort_options, vec!["Abort".to_string()]);
assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
}
#[test]
fn test_interaction_point_directives_serde_default_when_missing() {
let json = r#"{
"name": "plan_approval",
"prompt": "Approve?",
"required": true,
"style": "multiple_choice",
"options": ["Approve", "Revise"]
}"#;
let point: InteractionPoint = serde_json::from_str(json).unwrap();
assert!(point.directives.is_empty());
assert!(point.abort_options.is_empty());
}
#[test]
fn test_interaction_point_followups_alias_still_deserializes() {
let json = r#"{
"name": "plan_approval",
"prompt": "Approve?",
"required": true,
"style": "multiple_choice",
"options": ["Approve", "Revise"],
"followups": { "Revise": "What to change?" }
}"#;
let point: InteractionPoint = serde_json::from_str(json).unwrap();
assert_eq!(
point.directives.get("Revise").map(|s| s.as_str()),
Some("What to change?")
);
}
#[test]
fn test_model_config_new_creates_single_entry() {
let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
assert_eq!(mc.models.len(), 1);
assert_eq!(mc.models[0].provider, "anthropic");
assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
assert!(mc.allow_user_default);
}
#[test]
fn test_model_config_with_multiple_models() {
let mc = ModelConfig {
models: vec![
ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
ModelEntry::new("ollama".to_string(), "llama3".to_string()),
],
allow_user_default: true,
parameters: HashMap::new(),
request_timeout_secs: None,
};
assert_eq!(mc.models.len(), 3);
assert_eq!(mc.models[0].provider, "anthropic");
assert_eq!(mc.models[1].provider, "openai");
assert_eq!(mc.models[2].provider, "ollama");
}
#[test]
fn test_model_config_serde_roundtrip() {
let mc = ModelConfig {
models: vec![
ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
],
allow_user_default: false,
parameters: HashMap::new(),
request_timeout_secs: None,
};
let json = serde_json::to_string(&mc).unwrap();
let back: ModelConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.models.len(), 2);
assert_eq!(back.models[0].provider, "anthropic");
assert_eq!(back.models[1].provider, "openai");
assert!(!back.allow_user_default);
}
#[test]
fn test_model_config_serde_defaults_when_fields_missing() {
let json = r#"{"parameters": {}}"#;
let mc: ModelConfig = serde_json::from_str(json).unwrap();
assert!(mc.models.is_empty());
assert!(mc.allow_user_default);
}
#[test]
fn test_model_config_convenience_accessors() {
let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
assert_eq!(mc.provider(), "anthropic");
assert_eq!(mc.model(), "claude-sonnet-4-6");
}
#[test]
fn test_model_config_convenience_accessors_empty_models() {
let mc = ModelConfig {
models: vec![],
allow_user_default: true,
parameters: HashMap::new(),
request_timeout_secs: None,
};
assert_eq!(mc.provider(), "anthropic");
assert_eq!(mc.model(), "claude-sonnet-4-6");
}
fn make_model() -> ModelConfig {
ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
}
fn make_layout() -> ContextLayout {
let regions = vec![RegionDefinition::new(
"test".to_string(),
RegionKind::Pinned,
5000,
)];
ContextLayout::new(regions, 10000)
}
#[test]
fn test_graph_validation_entry_stage_exists() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
bp.entry_stage = Some("nonexistent".to_string());
assert!(bp.validate().is_err());
}
#[test]
fn test_graph_validation_entry_stage_valid() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
bp.entry_stage = Some("plan".to_string());
assert!(bp.validate().is_ok());
}
#[test]
fn test_graph_validation_transition_target_missing() {
let mut stage = Stage::new("plan".to_string(), make_model());
let mut transitions = HashMap::new();
transitions.insert(
"nonexistent".to_string(),
TransitionEdge {
target: "nonexistent".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
stage.transitions = Some(transitions);
let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
assert!(bp.validate().is_err());
}
#[test]
fn test_graph_validation_modification_gate_needs_a_writing_stage() {
let gated = |tools: &[&str], extra: &[&str]| {
let mut stage = Stage::new("impl".to_string(), make_model());
stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
let mut transitions = HashMap::new();
transitions.insert(
"review".to_string(),
TransitionEdge {
target: "review".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
stuck: None,
gate: Some(TransitionGate {
require_modifications: true,
tools: extra.iter().map(|t| t.to_string()).collect(),
..Default::default()
}),
},
);
stage.transitions = Some(transitions);
Blueprint::new(
"t".into(),
"".into(),
vec![stage, Stage::new("review".to_string(), make_model())],
make_layout(),
)
};
let err = gated(&["read_file"], &[]).validate().unwrap_err();
assert!(err.to_string().contains("no file-modifying tool"));
assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
assert!(
gated(&["read_file", "patch_file"], &["patch_file"])
.validate()
.is_ok()
);
let mut off = gated(&["read_file"], &[]);
off.stages[0]
.transitions
.as_mut()
.unwrap()
.get_mut("review")
.unwrap()
.gate = Some(TransitionGate::default());
assert!(off.validate().is_ok());
off.stages[0]
.transitions
.as_mut()
.unwrap()
.get_mut("review")
.unwrap()
.gate = None;
assert!(off.validate().is_ok());
}
#[test]
fn test_graph_validation_self_loop_requires_max_revisits() {
let mut stage = Stage::new("impl".to_string(), make_model());
let mut transitions = HashMap::new();
transitions.insert(
"impl".to_string(),
TransitionEdge {
target: "impl".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
stage.transitions = Some(transitions);
let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
assert!(bp.validate().is_err());
}
#[test]
fn test_graph_validation_self_loop_with_max_revisits_ok() {
let mut stage = Stage::new("impl".to_string(), make_model());
stage.max_revisits = Some(3);
let mut transitions = HashMap::new();
transitions.insert(
"impl".to_string(),
TransitionEdge {
target: "impl".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
stage.transitions = Some(transitions);
let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
assert!(bp.validate().is_ok());
}
#[test]
fn test_graph_validation_terminal_path_exists() {
let mut plan = Stage::new("plan".to_string(), make_model());
let mut review = Stage::new("review".to_string(), make_model());
review.transitions = Some(HashMap::new());
let mut transitions = HashMap::new();
transitions.insert(
"review".to_string(),
TransitionEdge {
target: "review".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
plan.transitions = Some(transitions);
let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
assert!(bp.validate().is_ok());
}
#[test]
fn test_graph_no_terminal_path() {
let mut a = Stage::new("a".to_string(), make_model());
let mut b = Stage::new("b".to_string(), make_model());
let mut a_transitions = HashMap::new();
a_transitions.insert(
"b".to_string(),
TransitionEdge {
target: "b".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
a.transitions = Some(a_transitions);
let mut b_transitions = HashMap::new();
b_transitions.insert(
"a".to_string(),
TransitionEdge {
target: "a".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
},
);
b.transitions = Some(b_transitions);
let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
assert!(bp.validate().is_err());
}
#[test]
fn test_linear_stages_still_validate() {
let stages = vec![
Stage::new("plan".to_string(), make_model()),
Stage::new("impl".to_string(), make_model()),
Stage::new("review".to_string(), make_model()),
];
let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
assert!(bp.validate().is_ok());
}
#[test]
fn test_resolve_entry_stage_name() {
let stages = vec![
Stage::new("plan".to_string(), make_model()),
Stage::new("impl".to_string(), make_model()),
];
let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
assert_eq!(bp.resolve_entry_stage_name(), "plan");
bp.entry_stage = Some("impl".to_string());
assert_eq!(bp.resolve_entry_stage_name(), "impl");
}
#[test]
fn test_find_stage() {
let stages = vec![
Stage::new("plan".to_string(), make_model()),
Stage::new("impl".to_string(), make_model()),
];
let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
assert!(bp.find_stage("plan").is_some());
assert!(bp.find_stage("impl").is_some());
assert!(bp.find_stage("nonexistent").is_none());
}
#[test]
fn test_transition_condition_default() {
let cond = TransitionCondition::default();
assert_eq!(cond, TransitionCondition::Always);
}
#[test]
fn test_edge_transform_default() {
let t = EdgeTransform::default();
assert_eq!(t, EdgeTransform::Direct);
}
#[test]
fn test_stage_mode_equality() {
assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
assert_eq!(StageMode::Interactive, StageMode::Interactive);
assert_ne!(StageMode::Autonomous, StageMode::Interactive);
}
#[test]
fn test_interaction_style_equality() {
assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
}
#[test]
fn stuck_config_is_armed_only_when_a_threshold_is_set() {
assert!(!StuckConfig::default().is_armed());
for cfg in [
StuckConfig {
after_iterations: Some(1),
..Default::default()
},
StuckConfig {
after_minutes: Some(1),
..Default::default()
},
StuckConfig {
after_same_file_edits: Some(1),
..Default::default()
},
StuckConfig {
after_tool_calls: Some(1),
..Default::default()
},
] {
assert!(cfg.is_armed(), "{cfg:?} should be armed");
}
}
#[test]
fn transition_condition_stuck_round_trips_as_snake_case() {
let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
assert_eq!(json, "\"stuck\"");
let back: TransitionCondition = serde_json::from_str(&json).unwrap();
assert_eq!(back, TransitionCondition::Stuck);
assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
}
#[test]
fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
let plain = TransitionEdge {
target: "b".to_string(),
condition: TransitionCondition::Always,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck: None,
};
let json = serde_json::to_string(&plain).unwrap();
assert!(
!json.contains("stuck"),
"absent config must be skipped: {json}"
);
let armed = TransitionEdge {
condition: TransitionCondition::Stuck,
stuck: Some(StuckConfig {
after_iterations: Some(20),
after_minutes: Some(10),
after_same_file_edits: Some(3),
after_tool_calls: Some(60),
}),
..plain
};
let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
.expect("armed edge round-trips");
assert_eq!(back.condition, TransitionCondition::Stuck);
assert_eq!(back.stuck, armed.stuck);
}
#[test]
fn validate_rejects_a_stuck_edge_with_no_threshold() {
let build = |stuck| {
let mut a = Stage::new("a".to_string(), make_model());
let b = Stage::new("b".to_string(), make_model());
let mut transitions = std::collections::HashMap::new();
transitions.insert(
"b".to_string(),
TransitionEdge {
target: "b".to_string(),
condition: TransitionCondition::Stuck,
hint: None,
transform: EdgeTransform::Direct,
gate: None,
stuck,
},
);
a.transitions = Some(transitions);
Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
};
for dead in [None, Some(StuckConfig::default())] {
let err = build(dead)
.validate()
.expect_err("dead stuck edge rejected");
assert!(
format!("{err:?}").contains("stuck_after_"),
"unexpected error: {err:?}"
);
}
assert!(
build(Some(StuckConfig {
after_iterations: Some(5),
..Default::default()
}))
.validate()
.is_ok()
);
}
#[test]
fn test_transition_condition_equality() {
assert_eq!(
TransitionCondition::LlmChoice,
TransitionCondition::LlmChoice
);
assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
}
#[test]
fn test_edge_transform_compact_and_custom_equality() {
let a = EdgeTransform::Compact {
prompt: Some("p".to_string()),
};
let b = EdgeTransform::Compact {
prompt: Some("p".to_string()),
};
assert_eq!(a, b);
let c1 = EdgeTransform::Custom {
carry: vec!["a".to_string()],
compact: vec!["b".to_string()],
clear: vec!["c".to_string()],
compact_prompt: Some("p".to_string()),
};
let c2 = c1.clone();
assert_eq!(c1, c2);
assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
}
#[test]
fn test_stage_accepts_messages_default_true() {
let stage = Stage::new(
"test".to_string(),
ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
);
assert!(stage.accepts_messages);
}
#[test]
fn test_stage_accepts_messages_serde_roundtrip() {
let mut stage = Stage::new(
"report".to_string(),
ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
);
stage.accepts_messages = false;
let json = serde_json::to_string(&stage).expect("should serialize");
let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
assert!(!deserialized.accepts_messages);
}
#[test]
fn test_stage_accepts_messages_json_default() {
let json = r#"{
"name": "analyze",
"model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
"available_tools": [],
"mode": "Autonomous",
"config": {},
"tool_permissions": {},
"requires_children": false
}"#;
let stage: Stage = serde_json::from_str(json).expect("should parse");
assert!(stage.accepts_messages);
}
#[test]
fn test_has_terminal_path_unknown_stage_returns_false() {
let stages = vec![Stage::new("start".to_string(), make_model())];
let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
let mut visited = std::collections::HashSet::new();
assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
}
#[test]
fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
let regions = vec![
RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
];
let layout = ContextLayout::new(regions, 200);
let stages = vec![Stage::new("start".to_string(), make_model())];
let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
assert_eq!(
bp.validate().unwrap_err(),
ValidationError::Region {
region: "dup".to_string(),
message: "duplicate region name".to_string(),
}
);
}
#[test]
fn test_blueprint_validate_fails_when_stage_has_empty_name() {
let stages = vec![Stage::new("".to_string(), make_model())];
let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
assert_eq!(
bp.validate().unwrap_err(),
ValidationError::Stage {
stage: "(empty)".to_string(),
message: "stage name cannot be empty".to_string(),
}
);
}
#[test]
fn test_file_tracking_config_defaults() {
let json = r#"{"region": "files"}"#;
let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.region, "files");
assert!(config.track_reads);
assert!(config.track_writes);
assert!(config.max_file_tokens.is_none());
}
#[test]
fn test_file_tracking_config_serde_roundtrip() {
let config = FileTrackingConfig {
region: "files".to_string(),
track_reads: true,
track_writes: false,
max_file_tokens: Some(5000),
};
let json = serde_json::to_string(&config).unwrap();
let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.region, "files");
assert!(back.track_reads);
assert!(!back.track_writes);
assert_eq!(back.max_file_tokens, Some(5000));
}
#[test]
fn test_blueprint_file_tracking_default_none() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
assert!(bp.file_tracking.is_none());
}
#[test]
fn test_blueprint_file_tracking_serde_roundtrip() {
let stages = vec![Stage::new("plan".to_string(), make_model())];
let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
bp.file_tracking = Some(FileTrackingConfig {
region: "files".to_string(),
track_reads: true,
track_writes: true,
max_file_tokens: Some(3000),
});
let json = serde_json::to_string(&bp).unwrap();
let back: Blueprint = serde_json::from_str(&json).unwrap();
let ft = back.file_tracking.unwrap();
assert_eq!(ft.region, "files");
assert_eq!(ft.max_file_tokens, Some(3000));
}
#[test]
fn test_tool_result_routing_default() {
let routing = ToolResultRouting::default();
assert_eq!(routing.default_region, "tool_results");
assert!(routing.persist);
assert!(routing.tool_overrides.is_empty());
assert!(routing.max_result_tokens.is_none());
}
#[test]
fn test_stage_new_has_no_tool_result_routing() {
let stage = Stage::new("plan".to_string(), make_model());
assert!(stage.tool_result_routing.is_none());
}
#[test]
fn test_tool_result_routing_serde_roundtrip() {
let mut routing = ToolResultRouting {
default_region: "custom_region".to_string(),
persist: false,
max_result_tokens: Some(4096),
..Default::default()
};
routing
.tool_overrides
.insert("read_file".to_string(), "file_reads".to_string());
let json = serde_json::to_string(&routing).unwrap();
let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
assert_eq!(back.default_region, "custom_region");
assert!(!back.persist);
assert_eq!(back.max_result_tokens, Some(4096));
assert_eq!(
back.tool_overrides.get("read_file").map(String::as_str),
Some("file_reads")
);
}
#[test]
fn test_stage_with_tool_result_routing_serde_roundtrip() {
let stages = vec![{
let mut s = Stage::new("plan".to_string(), make_model());
s.tool_result_routing = Some(ToolResultRouting {
default_region: "results".to_string(),
tool_overrides: HashMap::new(),
persist: true,
max_result_tokens: Some(2048),
});
s
}];
let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
let json = serde_json::to_string(&bp).unwrap();
let back: Blueprint = serde_json::from_str(&json).unwrap();
let routing = back.stages[0]
.tool_result_routing
.as_ref()
.expect("tool_result_routing should be Some");
assert_eq!(routing.default_region, "results");
assert!(routing.persist);
assert_eq!(routing.max_result_tokens, Some(2048));
assert!(routing.tool_overrides.is_empty());
}
fn fanout_config() -> FanOutConfig {
FanOutConfig {
worker_agent: None,
worker_stage: Some("fix_worker".to_string()),
worker_query: None,
merge_stage: Some("merge".to_string()),
max_workers: 3,
on_worker_failure: WorkerFailurePolicy::Continue,
split_prompt: "split".to_string(),
}
}
fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
let mut fan = Stage::new("parallel".to_string(), make_model());
fan.mode = StageMode::FanOut { config };
let mut worker = Stage::new("fix_worker".to_string(), make_model());
worker.allow_as_worker = worker_allowed;
let mut merge = Stage::new("merge".to_string(), make_model());
merge.transitions = Some(HashMap::new()); Blueprint::new(
"t".into(),
"d".into(),
vec![fan, worker, merge],
make_layout(),
)
}
#[test]
fn fanout_stagemode_partial_eq_and_default_policy() {
let a = StageMode::FanOut {
config: fanout_config(),
};
let b = StageMode::FanOut {
config: fanout_config(),
};
assert_eq!(a, b);
let mut other = fanout_config();
other.max_workers = 99;
assert_ne!(a, StageMode::FanOut { config: other });
assert_ne!(a, StageMode::Autonomous);
assert_eq!(
WorkerFailurePolicy::default(),
WorkerFailurePolicy::Continue
);
}
#[test]
fn fanout_config_serde_roundtrip_and_max_workers_default() {
let toml = r#"
worker_agent = "fixer"
split_prompt = "go"
on_worker_failure = "fail_all"
"#;
let cfg: FanOutConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
assert_eq!(cfg.max_workers, 4); assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
let json = serde_json::to_string(&fanout_config()).unwrap();
let back: FanOutConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, fanout_config());
}
#[test]
fn fanout_validate_ok_with_allowed_worker_stage() {
assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
}
#[test]
fn fanout_validate_rejects_worker_stage_not_opted_in() {
let err = fanout_blueprint(false, fanout_config())
.validate()
.unwrap_err();
assert!(err.to_string().contains("allow_as_worker"));
}
#[test]
fn fanout_validate_rejects_missing_worker_stage() {
let mut cfg = fanout_config();
cfg.worker_stage = Some("nope".to_string());
let err = fanout_blueprint(true, cfg).validate().unwrap_err();
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn fanout_validate_rejects_missing_merge_stage() {
let mut cfg = fanout_config();
cfg.merge_stage = Some("nomerge".to_string());
let err = fanout_blueprint(true, cfg).validate().unwrap_err();
assert!(err.to_string().contains("merge_stage"));
}
#[test]
fn fanout_validate_rejects_wrong_worker_source_count() {
let mut cfg = fanout_config();
cfg.worker_stage = None;
assert!(fanout_blueprint(true, cfg).validate().is_err());
let mut cfg2 = fanout_config();
cfg2.worker_agent = Some("x".to_string()); assert!(fanout_blueprint(true, cfg2).validate().is_err());
}
#[test]
fn fanout_terminal_path_runs_through_merge_stage() {
let mut cfg = fanout_config();
cfg.worker_stage = None;
cfg.worker_agent = Some("external".to_string());
assert!(fanout_blueprint(false, cfg).validate().is_ok());
}
#[test]
fn fanout_validate_ok_without_merge_stage() {
let mut cfg = fanout_config();
cfg.merge_stage = None;
assert!(fanout_blueprint(true, cfg).validate().is_ok());
}
}