fabro/crates/attractor/tests/integration.rs
Bryan Helmkamp 9665fad133 Revert clippy config to defaults, remove all pedantic/nursery/cargo lint suppressions
Removed the workspace-level clippy lint config that enabled all, pedantic, nursery,
and cargo lint groups. Removed all #[allow(clippy::...)] annotations that were only
needed to suppress those extra lints, and fixed the few default clippy warnings that
were uncovered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:42:28 -05:00

6545 lines
No EOL
228 KiB
Rust

use std::collections::VecDeque;
use std::path::Path;
use std::sync::Arc;
use attractor::checkpoint::Checkpoint;
use attractor::context::Context;
use attractor::engine::{PipelineEngine, RunConfig};
use attractor::error::AttractorError;
use attractor::event::{EventEmitter, PipelineEvent};
use attractor::graph::{AttrValue, Edge, Graph, Node};
use attractor::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
use attractor::handler::conditional::ConditionalHandler;
use attractor::handler::exit::ExitHandler;
use attractor::handler::manager_loop::ManagerLoopHandler;
use attractor::handler::start::StartHandler;
use attractor::handler::tool::ToolHandler;
use attractor::handler::wait_human::WaitHumanHandler;
use attractor::handler::{Handler, HandlerRegistry};
use attractor::interviewer::auto_approve::AutoApproveInterviewer;
use attractor::interviewer::queue::QueueInterviewer;
use attractor::interviewer::recording::RecordingInterviewer;
use attractor::interviewer::{Answer, AnswerValue, Interviewer};
use attractor::outcome::{Outcome, StageStatus};
use attractor::parser::parse;
use attractor::stylesheet::{apply_stylesheet, parse_stylesheet};
use attractor::transform::{StylesheetApplicationTransform, Transform, VariableExpansionTransform};
use attractor::cli::backend::AgentBackend;
use attractor::handler::default_registry;
use attractor::validation::{validate, validate_or_raise, Severity};
use terminal::Styles;
// ---------------------------------------------------------------------------
// 1. Parse and validate all 3 spec examples (Section 2.13)
// ---------------------------------------------------------------------------
#[test]
fn parse_and_validate_simple_linear() {
let input = r#"digraph Simple {
graph [goal="Run tests and report"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
report [label="Report", prompt="Summarize the test results"]
start -> run_tests -> report -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Simple");
assert_eq!(graph.goal(), "Run tests and report");
assert_eq!(graph.nodes.len(), 4);
assert_eq!(graph.edges.len(), 3);
assert!(graph.find_start_node().is_some());
assert!(graph.find_exit_node().is_some());
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == attractor::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
#[test]
fn parse_and_validate_branching_with_conditions() {
let input = r#"digraph Branch {
graph [goal="Implement and validate a feature"]
rankdir=LR
node [shape=box, timeout="900s"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [label="Plan", prompt="Plan the implementation"]
implement [label="Implement", prompt="Implement the plan"]
validate [label="Validate", prompt="Run tests"]
gate [shape=diamond, label="Tests passing?"]
start -> plan -> implement -> validate -> gate
gate -> exit [label="Yes", condition="outcome=success"]
gate -> implement [label="No", condition="outcome!=success"]
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Branch");
assert_eq!(graph.nodes.len(), 6);
assert_eq!(graph.edges.len(), 6);
let gate_exit = graph
.edges
.iter()
.find(|e| e.from == "gate" && e.to == "exit")
.expect("gate -> exit edge should exist");
assert_eq!(gate_exit.condition(), Some("outcome=success"));
let gate_impl = graph
.edges
.iter()
.find(|e| e.from == "gate" && e.to == "implement")
.expect("gate -> implement edge should exist");
assert_eq!(gate_impl.condition(), Some("outcome!=success"));
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == attractor::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
#[test]
fn parse_and_validate_human_gate() {
let input = r#"digraph Review {
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
review_gate [
shape=hexagon,
label="Review Changes",
type="wait.human"
]
start -> review_gate
review_gate -> ship_it [label="[A] Approve"]
review_gate -> fixes [label="[F] Fix"]
ship_it -> exit
fixes -> review_gate
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Review");
assert_eq!(graph.nodes.len(), 5);
assert_eq!(graph.edges.len(), 5);
let gate = &graph.nodes["review_gate"];
assert_eq!(gate.node_type(), Some("wait.human"));
assert_eq!(gate.shape(), "hexagon");
assert_eq!(gate.label(), "Review Changes");
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == attractor::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
// ---------------------------------------------------------------------------
// 2. End-to-end linear pipeline
// ---------------------------------------------------------------------------
fn make_linear_registry() -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry
}
#[tokio::test]
async fn end_to_end_linear_pipeline() {
let input = r#"digraph Linear {
graph [goal="Build the feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
codergen_step [shape=box, label="Code", prompt="Implement the feature"]
start -> codergen_step -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Checkpoint should exist
let checkpoint_path = dir.path().join("checkpoint.json");
assert!(checkpoint_path.exists(), "checkpoint.json should exist");
let checkpoint = Checkpoint::load(&checkpoint_path).expect("checkpoint should load");
assert!(checkpoint.completed_nodes.contains(&"start".to_string()));
assert!(checkpoint
.completed_nodes
.contains(&"codergen_step".to_string()));
// Codergen handler writes prompt.md, response.md, status.json
let stage_dir = dir.path().join("codergen_step");
assert!(stage_dir.join("prompt.md").exists(), "prompt.md should exist");
assert!(
stage_dir.join("response.md").exists(),
"response.md should exist"
);
assert!(
stage_dir.join("status.json").exists(),
"status.json should exist"
);
let prompt_content = std::fs::read_to_string(stage_dir.join("prompt.md")).unwrap();
assert_eq!(prompt_content, "Implement the feature");
}
// ---------------------------------------------------------------------------
// 3. End-to-end branching pipeline
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_branching_pipeline() {
// Build a graph:
// start -> work -> gate (diamond)
// gate -> success_path [condition="outcome=success"]
// gate -> fail_path [condition="outcome=fail"]
// success_path -> exit
// fail_path -> exit
//
// Since work defaults to codergen (shape=box) which returns SUCCESS,
// the engine should route gate -> success_path via condition match.
let mut graph = Graph::new("BranchTest");
graph
.attrs
.insert("goal".to_string(), AttrValue::String("Test branching".to_string()));
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do work".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("diamond".to_string()));
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("success_path".to_string(), Node::new("success_path"));
graph
.nodes
.insert("fail_path".to_string(), Node::new("fail_path"));
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "gate"));
let mut gate_success = Edge::new("gate", "success_path");
gate_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(gate_success);
let mut gate_fail = Edge::new("gate", "fail_path");
gate_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(gate_fail);
graph.edges.push(Edge::new("success_path", "exit"));
graph.edges.push(Edge::new("fail_path", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry.register("conditional", Box::new(ConditionalHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"success_path".to_string()),
"should have traversed success_path"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"fail_path".to_string()),
"should NOT have traversed fail_path"
);
}
// ---------------------------------------------------------------------------
// 4. End-to-end human gate pipeline with QueueInterviewer
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_human_gate_pipeline() {
// Build a graph:
// start -> gate (hexagon, type=wait.human)
// gate -> approve [label="[A] Approve"]
// gate -> reject [label="[R] Reject"]
// approve -> exit
// reject -> exit
//
// QueueInterviewer pre-filled to select "R" -> should route to reject
let mut graph = Graph::new("HumanGateTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
// Pre-fill the queue with an answer selecting "R"
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("R".to_string()),
selected_option: None,
text: None,
}]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"reject".to_string()),
"should have traversed reject path"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"approve".to_string()),
"should NOT have traversed approve path"
);
}
// ---------------------------------------------------------------------------
// 5. Goal gate enforcement
// ---------------------------------------------------------------------------
/// A custom handler that always returns FAIL for testing goal gate enforcement.
struct AlwaysFailHandler;
#[async_trait::async_trait]
impl Handler for AlwaysFailHandler {
async fn execute(
&self,
node: &Node,
_context: &attractor::context::Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, attractor::error::AttractorError> {
Ok(Outcome::fail(format!("forced failure for {}", node.id)))
}
}
#[tokio::test]
async fn goal_gate_routes_to_retry_target_on_failure() {
// Pipeline:
// start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// gated_work always returns FAIL
//
// When engine reaches exit, it checks goal gates and finds gated_work failed.
// It should route back to retry_target (start).
//
// To avoid infinite loops, we set max_retries=0 on gated_work so it fails
// immediately each time. After looping once (start -> gated_work -> exit -> start
// -> gated_work -> exit), if goal gate is still unsatisfied and no retry_target
// changes, we need to limit iterations. The engine itself doesn't limit loops,
// so we test a simpler scenario: verify the error when retry_target is missing.
// Test: goal_gate with NO retry_target returns an error
let mut graph = Graph::new("GoalGateNoRetry");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
gated_work
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
gated_work.attrs.insert(
"type".to_string(),
AttrValue::String("always_fail".to_string()),
);
graph
.nodes
.insert("gated_work".to_string(), gated_work);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("always_fail", Box::new(AlwaysFailHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_ok(), "goal gate unsatisfied with no retry_target should return Ok(fail outcome)");
let outcome = result.unwrap();
assert_eq!(
outcome.status,
StageStatus::Fail,
"pipeline outcome should be 'fail' when goal gate unsatisfied"
);
let failure_reason = outcome.failure_reason.unwrap_or_default();
assert!(
failure_reason.contains("goal gate unsatisfied"),
"failure_reason should mention goal gate, got: {failure_reason}"
);
}
#[tokio::test]
async fn goal_gate_routes_to_retry_target_when_present() {
// Pipeline:
// start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// gated_work always fails via AlwaysFailHandler.
//
// When engine reaches exit and finds goal gate unsatisfied, it should route
// to the retry_target. Since AlwaysFailHandler always fails, this creates a
// loop. However, the gated_work node will emit a FAIL outcome, and the
// edge gated_work -> exit is unconditional, so it still reaches exit. After
// the first retry (start -> gated_work -> exit), goal gate is still failed
// and retry_target is still start, so it loops. To prevent an infinite loop
// in tests, we use a custom handler that fails the first time and succeeds
// the second time.
struct FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailThenSucceedHandler {
async fn execute(
&self,
_node: &Node,
_context: &attractor::context::Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, attractor::error::AttractorError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::fail("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = Graph::new("GoalGateRetry");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
gated_work
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
gated_work.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
gated_work.attrs.insert(
"type".to_string(),
AttrValue::String("fail_then_succeed".to_string()),
);
graph
.nodes
.insert("gated_work".to_string(), gated_work);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fail_then_succeed",
Box::new(FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should eventually succeed after retry");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// gated_work should appear in completed nodes (at least twice -- first fail, then succeed)
let gated_work_count = checkpoint
.completed_nodes
.iter()
.filter(|n| *n == "gated_work")
.count();
assert!(
gated_work_count >= 2,
"gated_work should have been executed at least twice, got {gated_work_count}"
);
}
// ---------------------------------------------------------------------------
// 6. Variable expansion transform
// ---------------------------------------------------------------------------
#[test]
fn variable_expansion_replaces_goal_in_prompts() {
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Fix all bugs".to_string()),
);
let mut plan_node = Node::new("plan");
plan_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan to achieve: $goal".to_string()),
);
graph.nodes.insert("plan".to_string(), plan_node);
let mut impl_node = Node::new("implement");
impl_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement $goal now".to_string()),
);
graph
.nodes
.insert("implement".to_string(), impl_node);
let mut no_var_node = Node::new("report");
no_var_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Generate a report".to_string()),
);
graph
.nodes
.insert("report".to_string(), no_var_node);
let transform = VariableExpansionTransform;
transform.apply(&mut graph);
let plan_prompt = graph.nodes["plan"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("plan prompt should exist");
assert_eq!(plan_prompt, "Plan to achieve: Fix all bugs");
let impl_prompt = graph.nodes["implement"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("implement prompt should exist");
assert_eq!(impl_prompt, "Implement Fix all bugs now");
let report_prompt = graph.nodes["report"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("report prompt should exist");
assert_eq!(report_prompt, "Generate a report");
}
// ---------------------------------------------------------------------------
// 7. Stylesheet application
// ---------------------------------------------------------------------------
#[test]
fn stylesheet_application_by_specificity() {
let stylesheet_text = r"
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
";
let mut graph = Graph::new("test");
graph.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(stylesheet_text.to_string()),
);
// plan node: no class, should get universal defaults
let plan = Node::new("plan");
graph.nodes.insert("plan".to_string(), plan);
// implement node: class="code", should get .code overrides
let mut implement = Node::new("implement");
implement.classes.push("code".to_string());
graph
.nodes
.insert("implement".to_string(), implement);
// critical_review node: class="code" AND id="critical_review", id wins
let mut critical = Node::new("critical_review");
critical.classes.push("code".to_string());
graph
.nodes
.insert("critical_review".to_string(), critical);
// explicit node: has explicit llm_model, should NOT be overridden
let mut explicit = Node::new("explicit_node");
explicit.attrs.insert(
"llm_model".to_string(),
AttrValue::String("my-custom-model".to_string()),
);
graph
.nodes
.insert("explicit_node".to_string(), explicit);
let transform = StylesheetApplicationTransform;
transform.apply(&mut graph);
// plan: universal -> claude-sonnet-4-5
assert_eq!(
graph.nodes["plan"].attrs.get("llm_model"),
Some(&AttrValue::String("claude-sonnet-4-5".to_string()))
);
assert_eq!(
graph.nodes["plan"].attrs.get("llm_provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// implement: .code -> claude-opus-4-6
assert_eq!(
graph.nodes["implement"].attrs.get("llm_model"),
Some(&AttrValue::String("claude-opus-4-6".to_string()))
);
assert_eq!(
graph.nodes["implement"].attrs.get("llm_provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// critical_review: #critical_review -> gpt-5.2 (id overrides class)
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_model"),
Some(&AttrValue::String("gpt-5.2".to_string()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_provider"),
Some(&AttrValue::String("openai".to_string()))
);
assert_eq!(
graph.nodes["critical_review"]
.attrs
.get("reasoning_effort"),
Some(&AttrValue::String("high".to_string()))
);
// explicit_node: explicit attr NOT overridden by universal
assert_eq!(
graph.nodes["explicit_node"].attrs.get("llm_model"),
Some(&AttrValue::String("my-custom-model".to_string()))
);
}
#[test]
fn stylesheet_application_via_parsed_graph() {
let input = r#"digraph StyleTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { llm_model: sonnet; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
start -> work -> exit
}"#;
let mut graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let transform = StylesheetApplicationTransform;
transform.apply(&mut graph);
// All nodes without explicit llm_model should get "sonnet"
assert_eq!(
graph.nodes["work"].attrs.get("llm_model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["start"].attrs.get("llm_model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["exit"].attrs.get("llm_model"),
Some(&AttrValue::String("sonnet".to_string()))
);
}
#[test]
fn stylesheet_parse_and_apply_directly() {
let stylesheet_text = "* { llm_model: base; } .fast { llm_model: turbo; }";
let stylesheet = parse_stylesheet(stylesheet_text).expect("stylesheet parse should succeed");
assert_eq!(stylesheet.rules.len(), 2);
let mut graph = Graph::new("test");
let plain = Node::new("a");
graph.nodes.insert("a".to_string(), plain);
let mut fast_node = Node::new("b");
fast_node.classes.push("fast".to_string());
graph.nodes.insert("b".to_string(), fast_node);
apply_stylesheet(&stylesheet, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
Some(&AttrValue::String("base".to_string()))
);
assert_eq!(
graph.nodes["b"].attrs.get("llm_model"),
Some(&AttrValue::String("turbo".to_string()))
);
}
// ---------------------------------------------------------------------------
// 8. Retry on failure (Gap #35.1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn retry_on_failure_then_succeed() {
// A handler that fails the first call and succeeds on the second.
struct RetryHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for RetryHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::retry("transient failure"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = Graph::new("RetryTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut retry_node = Node::new("work");
retry_node.attrs.insert(
"type".to_string(),
AttrValue::String("retry_handler".to_string()),
);
retry_node
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(3));
graph.nodes.insert("work".to_string(), retry_node);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"retry_handler",
Box::new(RetryHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("should succeed after retry");
assert_eq!(outcome.status, StageStatus::Success);
}
// ---------------------------------------------------------------------------
// 9. Pipeline with 10+ nodes (Gap #35.2)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn pipeline_with_many_nodes() {
// Build a linear pipeline: start -> n1 -> n2 -> ... -> n10 -> exit (12 nodes)
let mut graph = Graph::new("ManyNodes");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test large pipeline".to_string()),
);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let node_names: Vec<String> = (1..=10).map(|i| format!("step_{i}")).collect();
for name in &node_names {
let mut node = Node::new(name.clone());
node.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
node.attrs.insert(
"prompt".to_string(),
AttrValue::String(format!("Execute {name}")),
);
graph.nodes.insert(name.clone(), node);
}
graph.edges.push(Edge::new("start", &node_names[0]));
for pair in node_names.windows(2) {
graph.edges.push(Edge::new(&pair[0], &pair[1]));
}
graph.edges.push(Edge::new(
node_names.last().unwrap(),
"exit",
));
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("large pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// All 10 step nodes should be in completed_nodes
for name in &node_names {
assert!(
checkpoint.completed_nodes.contains(name),
"{name} should be in completed_nodes"
);
}
}
// ---------------------------------------------------------------------------
// 10. Checkpoint save and load round-trip (Gap #35.3)
// ---------------------------------------------------------------------------
#[test]
fn checkpoint_save_and_resume_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint.json");
let ctx = Context::new();
ctx.set("goal", serde_json::json!("Test checkpoint"));
ctx.set("progress", serde_json::json!(42));
ctx.append_log("started");
ctx.append_log("step_1 completed");
let mut retries = std::collections::HashMap::new();
retries.insert("step_1".to_string(), 1u32);
let checkpoint = Checkpoint::from_context(
&ctx,
"step_2",
vec![
"start".to_string(),
"step_1".to_string(),
],
retries,
std::collections::HashMap::new(),
None,
);
checkpoint.save(&path).expect("save should succeed");
let loaded = Checkpoint::load(&path).expect("load should succeed");
assert_eq!(loaded.current_node, "step_2");
assert_eq!(loaded.completed_nodes.len(), 2);
assert!(loaded.completed_nodes.contains(&"start".to_string()));
assert!(loaded.completed_nodes.contains(&"step_1".to_string()));
assert_eq!(loaded.node_retries.get("step_1"), Some(&1));
assert_eq!(
loaded.context_values.get("goal"),
Some(&serde_json::json!("Test checkpoint"))
);
assert_eq!(
loaded.context_values.get("progress"),
Some(&serde_json::json!(42))
);
assert_eq!(loaded.logs.len(), 2);
}
// ---------------------------------------------------------------------------
// 11. Smoke test with mock CodergenBackend (Gap #36)
// ---------------------------------------------------------------------------
struct MockCodergenBackend;
#[async_trait::async_trait]
impl CodergenBackend for MockCodergenBackend {
async fn run(
&self,
node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
) -> Result<CodergenResult, AttractorError> {
Ok(CodergenResult::Text {
text: format!(
"Response for {}: processed prompt '{}'",
node.id,
&prompt[..prompt.len().min(50)]
),
usage: None,
})
}
}
// ---------------------------------------------------------------------------
// Helpers for parity tests
// ---------------------------------------------------------------------------
/// A handler backed by a shared `AtomicU32` counter.
/// Returns Fail on call 0, Success on call >= 1.
struct CounterHandler {
call_count: Arc<std::sync::atomic::AtomicU32>,
}
#[async_trait::async_trait]
impl Handler for CounterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::fail("first call fails"))
} else {
Ok(Outcome::success())
}
}
}
/// A handler that sets `context_updates` = {"`my_flag"`: "set"}.
struct ContextSetterHandler;
#[async_trait::async_trait]
impl Handler for ContextSetterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("my_flag".to_string(), serde_json::json!("set"));
Ok(outcome)
}
}
fn collect_events(emitter: &mut EventEmitter) -> Arc<std::sync::Mutex<Vec<PipelineEvent>>> {
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(event.clone());
});
events
}
fn make_full_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry.register("conditional", Box::new(ConditionalHandler));
registry.register("tool", Box::new(ToolHandler));
registry.register(
"wait.human",
Box::new(WaitHumanHandler::new(interviewer)),
);
registry.register(
"stack.manager_loop",
Box::new(ManagerLoopHandler::new(None)),
);
registry
}
fn make_graph_with_start_exit(name: &str) -> Graph {
let mut graph = Graph::new(name);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
graph
}
#[tokio::test]
async fn smoke_test_with_mock_codergen_backend() {
// Pipeline:
// start -> plan -> gate (diamond)
// gate -> implement [condition="outcome=success"]
// gate -> fix [condition="outcome!=success"]
// implement -> exit
// fix -> exit
//
// codergen nodes use MockCodergenBackend which returns real Text responses.
// The gate is a conditional node. Since the mock backend returns success,
// we should route through implement.
let mut graph = Graph::new("SmokeTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Build and validate".to_string()),
);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut plan = Node::new("plan");
plan.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
plan.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan to achieve: $goal".to_string()),
);
graph.nodes.insert("plan".to_string(), plan);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("diamond".to_string()));
graph.nodes.insert("gate".to_string(), gate);
let mut implement = Node::new("implement");
implement
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
implement.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement the plan".to_string()),
);
graph
.nodes
.insert("implement".to_string(), implement);
let mut fix = Node::new("fix");
fix.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
fix.attrs.insert(
"prompt".to_string(),
AttrValue::String("Fix the issues".to_string()),
);
graph.nodes.insert("fix".to_string(), fix);
graph.edges.push(Edge::new("start", "plan"));
graph.edges.push(Edge::new("plan", "gate"));
let mut gate_impl = Edge::new("gate", "implement");
gate_impl.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(gate_impl);
let mut gate_fix = Edge::new("gate", "fix");
gate_fix.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome!=success".to_string()),
);
graph.edges.push(gate_fix);
graph.edges.push(Edge::new("implement", "exit"));
graph.edges.push(Edge::new("fix", "exit"));
let dir = tempfile::tempdir().unwrap();
let backend = Box::new(MockCodergenBackend);
let mut registry =
HandlerRegistry::new(Box::new(CodergenHandler::new(Some(backend))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("conditional", Box::new(ConditionalHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("smoke test should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"plan".to_string()),
"plan should have executed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"implement".to_string()),
"should route through implement (success path)"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"fix".to_string()),
"should NOT have traversed fix path"
);
// Verify response.md was written by the mock backend
let plan_response = std::fs::read_to_string(dir.path().join("plan").join("response.md"))
.expect("plan response should exist");
assert!(
plan_response.contains("Response for plan"),
"mock backend should have written response, got: {plan_response}"
);
// Verify prompt.md had $goal expanded by the CodergenHandler
let plan_prompt = std::fs::read_to_string(dir.path().join("plan").join("prompt.md"))
.expect("plan prompt should exist");
assert_eq!(plan_prompt, "Plan to achieve: Build and validate");
}
// ---------------------------------------------------------------------------
// 12. Parallel fan-out / fan-in integration test (Gap #14)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_parallel_fan_out_fan_in() {
use attractor::handler::fan_in::FanInHandler;
use attractor::handler::parallel::ParallelHandler;
let input = r#"digraph parallel_test {
start [shape=Mdiamond]
fan_out [shape=component]
branch_a [shape=box, prompt="Branch A work"]
branch_b [shape=box, prompt="Branch B work"]
fan_in_node [shape=tripleoctagon]
done [shape=Msquare]
start -> fan_out
fan_out -> branch_a
fan_out -> branch_b
branch_a -> fan_in_node
branch_b -> fan_in_node
fan_in_node -> done
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(
Box::new(CodergenHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("parallel pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// The parallel node (fan_out) and fan_in_node should be in completed_nodes.
// Branch nodes run inside the parallel handler, so they are not recorded
// individually by the engine -- but fan_out and fan_in_node are top-level.
assert!(
checkpoint
.completed_nodes
.contains(&"fan_out".to_string()),
"fan_out should have been executed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"fan_in_node".to_string()),
"fan_in_node should have been executed"
);
// Verify parallel.results was populated (both branches ran)
let parallel_results = checkpoint
.context_values
.get("parallel.results")
.expect("parallel.results should be in context");
let results_arr = parallel_results.as_array().expect("should be an array");
assert_eq!(results_arr.len(), 2, "should have 2 branch results");
}
// ---------------------------------------------------------------------------
// 13. Resume from checkpoint (P1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn resume_from_checkpoint_completes_pipeline() {
// Build a pipeline: start -> step_a -> step_b -> exit
// Create a checkpoint mid-pipeline (after step_a) and verify
// run_from_checkpoint completes from step_b onward.
let mut graph = Graph::new("ResumeTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test resume".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let step_a = Node::new("step_a");
graph.nodes.insert("step_a".to_string(), step_a);
let step_b = Node::new("step_b");
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
// Simulate a checkpoint saved after step_a completed.
// The checkpoint records step_a as current_node with next_node_id = step_b.
let ctx = Context::new();
ctx.set("graph.goal", serde_json::json!("Test resume"));
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Verify checkpoint written after resume contains step_b
let final_cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
final_cp.completed_nodes.contains(&"step_b".to_string()),
"step_b should have been executed after resume"
);
// step_a should also be present (carried over from the checkpoint)
assert!(
final_cp.completed_nodes.contains(&"step_a".to_string()),
"step_a should be preserved from checkpoint"
);
// start should also be present
assert!(
final_cp.completed_nodes.contains(&"start".to_string()),
"start should be preserved from checkpoint"
);
}
#[tokio::test]
async fn resume_from_checkpoint_preserves_goal_gate_outcomes() {
// Build: start -> gated_work (goal_gate=true) -> step_b -> exit
// Checkpoint after gated_work (success), resume at step_b.
// At exit, goal gate should pass because outcomes are restored.
let mut graph = Graph::new("ResumeGoalGateTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work.attrs.insert(
"goal_gate".to_string(),
AttrValue::Boolean(true),
);
graph.nodes.insert("gated_work".to_string(), gated_work);
let step_b = Node::new("step_b");
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
// Checkpoint: gated_work completed with success, next is step_b
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("gated_work".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"gated_work",
vec!["start".to_string(), "gated_work".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
// This should succeed because goal gate for gated_work is satisfied
// via restored outcomes
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume with goal gate should succeed");
assert_eq!(outcome.status, StageStatus::Success);
}
// ===========================================================================
// Parity tests — P1: Core pipeline behaviors
// ===========================================================================
#[tokio::test]
async fn graph_goal_in_context() {
let input = r#"digraph GoalTest {
graph [goal="Ship the widget"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Build it"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("graph.goal"),
Some(&serde_json::json!("Ship the widget"))
);
}
#[tokio::test]
async fn event_streaming_lifecycle() {
let input = r#"digraph EventTest {
start [shape=Mdiamond]
exit [shape=Msquare]
task [shape=box, prompt="Do something"]
start -> task -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = PipelineEngine::new(make_linear_registry(), emitter);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::StageStarted { name, .. } if name == "start")));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::StageCompleted { name, .. } if name == "start")));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::StageStarted { name, .. } if name == "task")));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::StageCompleted { name, .. } if name == "task")));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::CheckpointSaved { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineCompleted { .. })));
// PipelineStarted first, PipelineCompleted last
assert!(matches!(
collected.first().unwrap(),
PipelineEvent::PipelineStarted { .. }
));
assert!(matches!(
collected.last().unwrap(),
PipelineEvent::PipelineCompleted { .. }
));
}
#[tokio::test]
async fn context_flow_between_stages() {
let mut graph = make_graph_with_start_exit("ContextFlowTest");
let mut step_a = Node::new("step_a");
step_a
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
step_a.attrs.insert(
"prompt".to_string(),
AttrValue::String("Step A work".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
step_b.attrs.insert(
"prompt".to_string(),
AttrValue::String("Step B work".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("last_stage"),
Some(&serde_json::json!("step_b"))
);
let last_response = cp
.context_values
.get("last_response")
.unwrap()
.as_str()
.unwrap();
assert!(last_response.contains("[Simulated]"));
}
#[tokio::test]
async fn tool_handler_e2e() {
let mut graph = make_graph_with_start_exit("ToolTest");
let mut echo_task = Node::new("echo_task");
echo_task.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
echo_task.attrs.insert(
"tool_command".to_string(),
AttrValue::String("echo hello-from-tool".to_string()),
);
graph.nodes.insert("echo_task".to_string(), echo_task);
graph.edges.push(Edge::new("start", "echo_task"));
graph.edges.push(Edge::new("echo_task", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let engine = PipelineEngine::new(make_full_registry(interviewer), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let tool_output = cp
.context_values
.get("tool.output")
.expect("tool.output should exist");
assert!(tool_output.as_str().unwrap().contains("hello-from-tool"));
}
#[tokio::test]
async fn auto_approve_interviewer_e2e() {
let mut graph = make_graph_with_start_exit("AutoApproveTest");
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let engine = PipelineEngine::new(make_full_registry(interviewer), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"approve".to_string()));
assert!(!cp.completed_nodes.contains(&"reject".to_string()));
}
#[tokio::test]
async fn codergen_without_backend_simulated() {
let input = r#"digraph SimTest {
start [shape=Mdiamond]
exit [shape=Msquare]
code [shape=box, prompt="Write the code"]
start -> code -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let response =
std::fs::read_to_string(dir.path().join("code").join("response.md")).unwrap();
assert!(response.contains("[Simulated]"));
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let last_response = cp
.context_values
.get("last_response")
.unwrap()
.as_str()
.unwrap();
assert!(last_response.contains("[Simulated]"));
}
// ===========================================================================
// Parity tests — P2: Complex scenarios
// ===========================================================================
#[tokio::test]
async fn branching_loop_back_on_failure() {
struct FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailThenSucceedHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::fail("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = make_graph_with_start_exit("LoopTest");
let mut implement = Node::new("implement");
implement
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
implement.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement".to_string()),
);
graph.nodes.insert("implement".to_string(), implement);
let mut validate_node = Node::new("validate");
validate_node.attrs.insert(
"type".to_string(),
AttrValue::String("fail_then_succeed".to_string()),
);
graph
.nodes
.insert("validate".to_string(), validate_node);
graph.edges.push(Edge::new("start", "implement"));
graph.edges.push(Edge::new("implement", "validate"));
let mut e_success = Edge::new("validate", "exit");
e_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_success);
let mut e_fail = Edge::new("validate", "implement");
e_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(e_fail);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry.register(
"fail_then_succeed",
Box::new(FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let implement_count = cp
.completed_nodes
.iter()
.filter(|n| *n == "implement")
.count();
assert!(
implement_count >= 2,
"implement should appear at least 2x, got {implement_count}"
);
}
#[tokio::test]
async fn human_gate_loops_back() {
let mut graph = make_graph_with_start_exit("HumanLoopTest");
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph.nodes.insert("fix".to_string(), Node::new("fix"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_fix = Edge::new("gate", "fix");
e_fix.attrs.insert(
"label".to_string(),
AttrValue::String("[F] Fix".to_string()),
);
graph.edges.push(e_fix);
graph.edges.push(Edge::new("fix", "gate"));
graph.edges.push(Edge::new("approve", "exit"));
let answers = VecDeque::from([
Answer {
value: AnswerValue::Selected("F".to_string()),
selected_option: None,
text: None,
},
Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
text: None,
},
]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"wait.human",
Box::new(WaitHumanHandler::new(interviewer)),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let gate_count = cp
.completed_nodes
.iter()
.filter(|n| *n == "gate")
.count();
assert!(
gate_count >= 2,
"gate should appear at least 2x, got {gate_count}"
);
assert!(cp.completed_nodes.contains(&"approve".to_string()));
}
#[tokio::test]
async fn scenario_ship_a_feature() {
let dot = r#"digraph ShipFeature {
graph [goal="Ship the widget"]
rankdir=LR
start [shape=Mdiamond]
exit [shape=Msquare]
plan [shape=box, prompt="Plan to achieve: $goal"]
implement [shape=box, prompt="Implement the plan"]
test [shape=parallelogram, tool_command="echo PASS"]
review [shape=hexagon, label="Review Changes"]
start -> plan -> implement -> test -> review
review -> exit [label="[A] Approve"]
review -> implement [label="[F] Fix"]
}"#;
let mut graph = parse(dot).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
VariableExpansionTransform.apply(&mut graph);
assert_eq!(
graph.nodes["plan"].prompt().unwrap(),
"Plan to achieve: Ship the widget"
);
let interviewer = Arc::new(AutoApproveInterviewer);
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = PipelineEngine::new(make_full_registry(interviewer), emitter);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let tool_output = cp.context_values.get("tool.output").expect("tool.output");
assert!(tool_output.as_str().unwrap().contains("PASS"));
assert!(cp.completed_nodes.contains(&"plan".to_string()));
assert!(cp.completed_nodes.contains(&"implement".to_string()));
assert!(cp.completed_nodes.contains(&"test".to_string()));
assert!(cp.completed_nodes.contains(&"review".to_string()));
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineCompleted { .. })));
}
#[tokio::test]
async fn scenario_parallel_expert_review() {
use attractor::handler::fan_in::FanInHandler;
use attractor::handler::parallel::ParallelHandler;
let input = r#"digraph ParallelReview {
start [shape=Mdiamond]
fan_out [shape=component]
expert_a [shape=box, prompt="Expert A review"]
expert_b [shape=box, prompt="Expert B review"]
expert_c [shape=box, prompt="Expert C review"]
fan_in_node [shape=tripleoctagon]
review [shape=hexagon, label="Final Review"]
exit [shape=Msquare]
start -> fan_out
fan_out -> expert_a
fan_out -> expert_b
fan_out -> expert_c
expert_a -> fan_in_node
expert_b -> fan_in_node
expert_c -> fan_in_node
fan_in_node -> review
review -> exit [label="[A] Approve"]
review -> fan_out [label="[F] Redo"]
}"#;
let graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
let recorder = Arc::new(RecordingInterviewer::new(Box::new(AutoApproveInterviewer)));
let dir = tempfile::tempdir().unwrap();
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(Some(
Box::new(MockCodergenBackend),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register(
"wait.human",
Box::new(WaitHumanHandler::new(interviewer)),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let results = cp
.context_values
.get("parallel.results")
.expect("parallel.results");
assert_eq!(results.as_array().unwrap().len(), 3);
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1, "should have 1 interview recording");
assert!(cp.completed_nodes.contains(&"review".to_string()));
}
#[tokio::test]
async fn scenario_node_retries_on_retry_status() {
struct RetryHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for RetryHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::retry("transient failure"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = make_graph_with_start_exit("RetryScenarioTest");
let mut flaky = Node::new("flaky");
flaky.attrs.insert(
"type".to_string(),
AttrValue::String("retry_handler".to_string()),
);
flaky
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(2));
graph.nodes.insert("flaky".to_string(), flaky);
graph.edges.push(Edge::new("start", "flaky"));
graph.edges.push(Edge::new("flaky", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"retry_handler",
Box::new(RetryHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let retry_count = cp
.node_retries
.get("flaky")
.expect("flaky should have retries");
assert_eq!(*retry_count, 2, "should have been called 2x");
}
#[tokio::test]
async fn scenario_loop_restart_resets_context() {
let mut graph = make_graph_with_start_exit("LoopRestartTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("counter".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
let mut success_edge = Edge::new("work", "exit");
success_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(success_edge);
let mut fail_edge = Edge::new("work", "start");
fail_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
fail_edge
.attrs
.insert("loop_restart".to_string(), AttrValue::Boolean(true));
graph.edges.push(fail_edge);
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"counter",
Box::new(CounterHandler {
call_count: Arc::clone(&call_count),
}),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2);
}
#[tokio::test]
async fn scenario_bug_triage_router() {
let mut graph = make_graph_with_start_exit("TriageTest");
let mut triage = Node::new("triage");
triage.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("triage".to_string(), triage);
graph
.nodes
.insert("critical".to_string(), Node::new("critical"));
graph
.nodes
.insert("normal".to_string(), Node::new("normal"));
graph
.nodes
.insert("wontfix".to_string(), Node::new("wontfix"));
graph.edges.push(Edge::new("start", "triage"));
let mut e_critical = Edge::new("triage", "critical");
e_critical.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
e_critical
.attrs
.insert("weight".to_string(), AttrValue::Integer(10));
graph.edges.push(e_critical);
let mut e_normal = Edge::new("triage", "normal");
e_normal.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
e_normal
.attrs
.insert("weight".to_string(), AttrValue::Integer(5));
graph.edges.push(e_normal);
graph.edges.push(Edge::new("triage", "wontfix"));
graph.edges.push(Edge::new("critical", "exit"));
graph.edges.push(Edge::new("normal", "exit"));
graph.edges.push(Edge::new("wontfix", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("conditional", Box::new(ConditionalHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
cp.completed_nodes.contains(&"critical".to_string()),
"critical should be selected (highest weight)"
);
assert!(!cp.completed_nodes.contains(&"normal".to_string()));
assert!(!cp.completed_nodes.contains(&"wontfix".to_string()));
}
#[tokio::test]
async fn scenario_crash_recovery() {
let mut graph = make_graph_with_start_exit("CrashRecoveryTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("b".to_string(), Node::new("b"));
graph.nodes.insert("c".to_string(), Node::new("c"));
graph.edges.push(Edge::new("start", "a"));
graph.edges.push(Edge::new("a", "b"));
graph.edges.push(Edge::new("b", "c"));
graph.edges.push(Edge::new("c", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"a",
vec!["start".to_string(), "a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("b".to_string()),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"b".to_string()));
assert!(cp.completed_nodes.contains(&"c".to_string()));
assert!(cp.completed_nodes.contains(&"a".to_string()));
let a_count = cp.completed_nodes.iter().filter(|n| *n == "a").count();
assert_eq!(a_count, 1, "a should not be re-executed");
}
#[tokio::test]
async fn manager_loop_stop_condition_satisfied_e2e() {
struct DoneSetterHandler;
#[async_trait::async_trait]
impl Handler for DoneSetterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("done".to_string(), serde_json::json!("true"));
Ok(outcome)
}
}
let mut graph = make_graph_with_start_exit("ManagerStopTest");
let mut setter = Node::new("setter");
setter.attrs.insert(
"type".to_string(),
AttrValue::String("done_setter".to_string()),
);
graph.nodes.insert("setter".to_string(), setter);
let mut manager = Node::new("manager");
manager.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
manager.attrs.insert(
"manager.stop_condition".to_string(),
AttrValue::String("context.done=true".to_string()),
);
manager
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(10));
manager.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
graph.nodes.insert("manager".to_string(), manager);
graph.edges.push(Edge::new("start", "setter"));
graph.edges.push(Edge::new("setter", "manager"));
graph.edges.push(Edge::new("manager", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("done_setter", Box::new(DoneSetterHandler));
registry.register(
"stack.manager_loop",
Box::new(ManagerLoopHandler::new(None)),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let manager_outcome = cp.node_outcomes.get("manager").expect("manager outcome");
assert_eq!(manager_outcome.status, StageStatus::Success);
assert!(manager_outcome
.notes
.as_deref()
.unwrap()
.contains("Stop condition satisfied"));
// Overall pipeline succeeds because manager succeeded
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn manager_loop_max_cycles_exceeded_e2e() {
let mut graph = make_graph_with_start_exit("ManagerMaxCyclesTest");
let mut manager = Node::new("manager");
manager.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
manager
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(2));
manager.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
graph.nodes.insert("manager".to_string(), manager);
graph.edges.push(Edge::new("start", "manager"));
graph.edges.push(Edge::new("manager", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"stack.manager_loop",
Box::new(ManagerLoopHandler::new(None)),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let manager_outcome = cp.node_outcomes.get("manager").expect("manager outcome");
assert_eq!(manager_outcome.status, StageStatus::Fail);
assert!(manager_outcome
.failure_reason
.as_deref()
.unwrap()
.contains("Max cycles"));
// Overall pipeline outcome is from last completed node (manager) = Fail
assert_eq!(outcome.status, StageStatus::Fail);
}
// ===========================================================================
// Parity tests — P3: Validation
// ===========================================================================
#[test]
fn validation_missing_start_node() {
let mut graph = Graph::new("NoStartTest");
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let diagnostics = validate(&graph, &[]);
let start_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error && d.rule == "start_node")
.collect();
assert!(
!start_errors.is_empty(),
"should have start_node error diagnostic"
);
}
#[test]
fn validation_missing_exit_node() {
let mut graph = Graph::new("NoExitTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
graph
.nodes
.insert("work".to_string(), Node::new("work"));
graph.edges.push(Edge::new("start", "work"));
let diagnostics = validate(&graph, &[]);
let exit_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error && d.rule == "terminal_node")
.collect();
assert!(
!exit_errors.is_empty(),
"should have terminal_node error diagnostic"
);
}
#[test]
fn validation_orphan_unreachable_node() {
let mut graph = make_graph_with_start_exit("OrphanTest");
graph
.nodes
.insert("orphan".to_string(), Node::new("orphan"));
graph.edges.push(Edge::new("start", "exit"));
let diagnostics = validate(&graph, &[]);
let reachability_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "reachability")
.collect();
assert!(
!reachability_errors.is_empty(),
"should have reachability diagnostic for orphan node"
);
}
// ===========================================================================
// Parity tests — P4: Edge selection and cross-feature
// ===========================================================================
#[tokio::test]
async fn conditional_branching_success_fail_paths() {
let mut graph = make_graph_with_start_exit("CondBranchTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("always_fail".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph
.nodes
.insert("success_path".to_string(), Node::new("success_path"));
graph
.nodes
.insert("fail_path".to_string(), Node::new("fail_path"));
graph.edges.push(Edge::new("start", "work"));
let mut e_success = Edge::new("work", "success_path");
e_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_success);
let mut e_fail = Edge::new("work", "fail_path");
e_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(e_fail);
graph.edges.push(Edge::new("success_path", "exit"));
graph.edges.push(Edge::new("fail_path", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("always_fail", Box::new(AlwaysFailHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"fail_path".to_string()));
assert!(!cp.completed_nodes.contains(&"success_path".to_string()));
}
#[tokio::test]
async fn edge_selection_condition_match_wins_over_weight() {
let mut graph = make_graph_with_start_exit("CondVsWeightTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph
.nodes
.insert("cond_target".to_string(), Node::new("cond_target"));
graph.nodes.insert(
"weighted_target".to_string(),
Node::new("weighted_target"),
);
graph.edges.push(Edge::new("start", "a"));
let mut e_cond = Edge::new("a", "cond_target");
e_cond.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_cond);
let mut e_weight = Edge::new("a", "weighted_target");
e_weight
.attrs
.insert("weight".to_string(), AttrValue::Integer(100));
graph.edges.push(e_weight);
graph.edges.push(Edge::new("cond_target", "exit"));
graph.edges.push(Edge::new("weighted_target", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"cond_target".to_string()));
assert!(!cp
.completed_nodes
.contains(&"weighted_target".to_string()));
}
#[tokio::test]
async fn edge_selection_weight_breaks_ties() {
let mut graph = make_graph_with_start_exit("WeightTiesTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("low".to_string(), Node::new("low"));
graph.nodes.insert("high".to_string(), Node::new("high"));
graph.edges.push(Edge::new("start", "a"));
let mut e_low = Edge::new("a", "low");
e_low
.attrs
.insert("weight".to_string(), AttrValue::Integer(1));
graph.edges.push(e_low);
let mut e_high = Edge::new("a", "high");
e_high
.attrs
.insert("weight".to_string(), AttrValue::Integer(10));
graph.edges.push(e_high);
graph.edges.push(Edge::new("low", "exit"));
graph.edges.push(Edge::new("high", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"high".to_string()));
assert!(!cp.completed_nodes.contains(&"low".to_string()));
}
#[tokio::test]
async fn edge_selection_lexical_tiebreak() {
let mut graph = make_graph_with_start_exit("LexicalTieTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("beta".to_string(), Node::new("beta"));
graph
.nodes
.insert("alpha".to_string(), Node::new("alpha"));
graph.edges.push(Edge::new("start", "a"));
graph.edges.push(Edge::new("a", "beta"));
graph.edges.push(Edge::new("a", "alpha"));
graph.edges.push(Edge::new("beta", "exit"));
graph.edges.push(Edge::new("alpha", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"alpha".to_string()));
assert!(!cp.completed_nodes.contains(&"beta".to_string()));
}
#[tokio::test]
async fn context_updates_visible_across_nodes() {
let mut graph = make_graph_with_start_exit("ContextVisibilityTest");
let mut setter = Node::new("setter");
setter.attrs.insert(
"type".to_string(),
AttrValue::String("context_setter".to_string()),
);
graph.nodes.insert("setter".to_string(), setter);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph.nodes.insert("yes".to_string(), Node::new("yes"));
graph.nodes.insert("no".to_string(), Node::new("no"));
graph.edges.push(Edge::new("start", "setter"));
graph.edges.push(Edge::new("setter", "gate"));
let mut e_yes = Edge::new("gate", "yes");
e_yes.attrs.insert(
"condition".to_string(),
AttrValue::String("context.my_flag=set".to_string()),
);
graph.edges.push(e_yes);
graph.edges.push(Edge::new("gate", "no"));
graph.edges.push(Edge::new("yes", "exit"));
graph.edges.push(Edge::new("no", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("conditional", Box::new(ConditionalHandler));
registry.register("context_setter", Box::new(ContextSetterHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"yes".to_string()));
assert!(!cp.completed_nodes.contains(&"no".to_string()));
}
#[tokio::test]
async fn stylesheet_applies_model_override() {
let input = r#"digraph StylesheetTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { llm_model: custom-model; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
start -> work -> exit
}"#;
let mut graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
StylesheetApplicationTransform.apply(&mut graph);
assert_eq!(graph.nodes["work"].llm_model(), Some("custom-model"));
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn custom_handler_registration_and_execution() {
struct CustomHandler;
#[async_trait::async_trait]
impl Handler for CustomHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("custom.ran".to_string(), serde_json::json!("true"));
Ok(outcome)
}
}
let mut graph = make_graph_with_start_exit("CustomHandlerTest");
let mut custom = Node::new("custom");
custom.attrs.insert(
"type".to_string(),
AttrValue::String("my_custom".to_string()),
);
graph.nodes.insert("custom".to_string(), custom);
graph.edges.push(Edge::new("start", "custom"));
graph.edges.push(Edge::new("custom", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("my_custom", Box::new(CustomHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("custom.ran"),
Some(&serde_json::json!("true"))
);
}
#[tokio::test]
async fn integration_smoke_plan_implement_review_done() {
let dot = r#"digraph SmokeIntegration {
graph [
goal="Build the feature",
model_stylesheet="* { llm_model: test-model; }"
]
rankdir=LR
start [shape=Mdiamond]
exit [shape=Msquare]
plan [shape=box, prompt="Plan: $goal"]
implement [shape=box, prompt="Implement"]
review [shape=hexagon, label="Review"]
start -> plan -> implement -> review
review -> exit [label="[A] Approve"]
review -> implement [label="[F] Fix"]
}"#;
// Parse and validate
let mut graph = parse(dot).expect("parse");
let diagnostics = validate_or_raise(&graph, &[]).expect("validate");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty());
// Apply transforms
VariableExpansionTransform.apply(&mut graph);
StylesheetApplicationTransform.apply(&mut graph);
// Verify transforms applied
assert_eq!(
graph.nodes["plan"].prompt().unwrap(),
"Plan: Build the feature"
);
assert_eq!(graph.nodes["plan"].llm_model(), Some("test-model"));
// Run pipeline
let interviewer = Arc::new(AutoApproveInterviewer);
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = PipelineEngine::new(make_full_registry(interviewer), emitter);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
// Verify all nodes completed
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"plan".to_string()));
assert!(cp.completed_nodes.contains(&"implement".to_string()));
assert!(cp.completed_nodes.contains(&"review".to_string()));
// Verify prompt.md and response.md exist
assert!(dir.path().join("plan").join("prompt.md").exists());
assert!(dir.path().join("plan").join("response.md").exists());
// Verify events
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, PipelineEvent::PipelineCompleted { .. })));
}
// ===========================================================================
// 17. Full HTTP server lifecycle (TS Scenario 4)
// ===========================================================================
#[cfg(feature = "server")]
mod server_lifecycle {
use std::sync::Arc;
use std::time::Duration;
use attractor::handler::codergen::CodergenHandler;
use attractor::handler::exit::ExitHandler;
use attractor::handler::start::StartHandler;
use attractor::handler::wait_human::WaitHumanHandler;
use attractor::handler::HandlerRegistry;
use attractor::interviewer::Interviewer;
use attractor::server::{build_router, create_app_state};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
fn gate_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
registry
}
async fn body_json(body: Body) -> serde_json::Value {
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
const GATE_DOT: &str = r#"digraph GateTest {
graph [goal="Test gate"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
gate [shape=hexagon, type="wait.human", label="Approve?"]
done [shape=box, prompt="Finish"]
revise [shape=box, prompt="Revise"]
start -> work -> gate
gate -> done [label="[A] Approve"]
gate -> revise [label="[R] Revise"]
done -> exit
revise -> gate
}"#;
#[tokio::test]
async fn full_http_lifecycle_approve_and_complete() {
let state = create_app_state(gate_registry);
let app = build_router(Arc::clone(&state));
// 1. Start pipeline
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": GATE_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let pipeline_id = body["id"].as_str().unwrap().to_string();
// 2. Poll for question to appear (pipeline runs start -> work -> gate, then blocks)
let mut question_id = String::new();
for _ in 0..500 {
tokio::time::sleep(Duration::from_millis(10)).await;
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/questions"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let arr = body.as_array().unwrap();
if !arr.is_empty() {
question_id = arr[0]["id"].as_str().unwrap().to_string();
break;
}
}
assert!(!question_id.is_empty(), "question should have appeared");
// 3. Submit answer selecting first option (Approve)
let req = Request::builder()
.method("POST")
.uri(format!(
"/pipelines/{pipeline_id}/questions/{question_id}/answer"
))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "A"})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["accepted"], true);
// 4. Poll until completed
let mut final_status = String::new();
for _ in 0..500 {
tokio::time::sleep(Duration::from_millis(10)).await;
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let status = body["status"].as_str().unwrap().to_string();
if status == "completed" || status == "failed" {
final_status = status;
break;
}
}
assert_eq!(final_status, "completed");
// 5. Verify context endpoint returns an object
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/context"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ctx_body = body_json(response.into_body()).await;
assert!(ctx_body.is_object(), "context should be an object");
// 6. Verify no pending questions
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/questions"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert!(
body.as_array().unwrap().is_empty(),
"no pending questions after completion"
);
}
#[tokio::test]
async fn full_http_lifecycle_cancel() {
let state = create_app_state(gate_registry);
let app = build_router(Arc::clone(&state));
// Start a pipeline that will block at the human gate
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": GATE_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let pipeline_id = body["id"].as_str().unwrap().to_string();
// Wait briefly for pipeline to start running
tokio::time::sleep(Duration::from_millis(10)).await;
// Cancel it
let req = Request::builder()
.method("POST")
.uri(format!("/pipelines/{pipeline_id}/cancel"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["cancelled"], true);
// Verify status is cancelled
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["status"], "cancelled");
}
}
// ===========================================================================
// 18. SSE event stream content parsing (TS Scenario 8)
// ===========================================================================
#[cfg(feature = "server")]
mod sse_events {
use std::sync::Arc;
use std::time::Duration;
use attractor::handler::codergen::CodergenHandler;
use attractor::handler::exit::ExitHandler;
use attractor::handler::start::StartHandler;
use attractor::handler::HandlerRegistry;
use attractor::interviewer::Interviewer;
use attractor::server::{build_router, create_app_state};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
fn simple_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry
}
const SIMPLE_DOT: &str = r#"digraph SSETest {
graph [goal="Test SSE"]
start [shape=Mdiamond]
work [shape=box, prompt="Do work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
#[tokio::test]
async fn sse_stream_contains_expected_event_types() {
let state = create_app_state(simple_registry);
let app = build_router(Arc::clone(&state));
// Start pipeline
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": SIMPLE_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let pipeline_id = body["id"].as_str().unwrap().to_string();
// Get SSE stream
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/events"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(content_type.contains("text/event-stream"));
// Collect SSE frames with a timeout
let mut body = response.into_body();
let mut sse_data = String::new();
while let Ok(Some(Ok(frame))) = tokio::time::timeout(Duration::from_millis(500), body.frame()).await {
if let Some(data) = frame.data_ref() {
sse_data.push_str(&String::from_utf8_lossy(data));
}
}
// Parse SSE data lines and extract event types
let mut event_types: Vec<String> = Vec::new();
for line in sse_data.lines() {
if let Some(json_str) = line.strip_prefix("data:") {
let json_str = json_str.trim();
if let Ok(event) = serde_json::from_str::<serde_json::Value>(json_str) {
// The event is serialized as a tagged enum, so the type is the first key
if let Some(obj) = event.as_object() {
for key in obj.keys() {
event_types.push(key.clone());
}
} else if let Some(s) = event.as_str() {
event_types.push(s.to_string());
}
}
}
}
// Verify we got events (pipeline may have completed before we subscribed,
// so we check that the stream was valid SSE)
// If events were emitted before subscribe, the stream may be empty.
// That's OK -- the main assertion is content-type + valid SSE format.
// But if we got events, verify expected types.
if !event_types.is_empty() {
assert!(
event_types
.iter()
.any(|t| t == "StageStarted" || t == "StageCompleted"),
"should contain stage events, got: {event_types:?}"
);
}
// Pipeline is complete (SSE stream ended), verify checkpoint
// Small yield to let the spawned task update state
tokio::time::sleep(Duration::from_millis(10)).await;
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/checkpoint"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let cp_body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
// If pipeline completed, checkpoint should have completed_nodes
if !cp_body.is_null() {
let completed = cp_body["completed_nodes"].as_array();
if let Some(nodes) = completed {
let names: Vec<&str> = nodes.iter().filter_map(|v| v.as_str()).collect();
assert!(names.contains(&"work"), "work should be in completed_nodes");
}
}
}
}
// ===========================================================================
// 18b. Serve command: dry-run registry factory builds a working router
// ===========================================================================
#[cfg(feature = "server")]
mod serve_dry_run {
use std::sync::Arc;
use std::time::Duration;
use attractor::handler::default_registry;
use attractor::interviewer::Interviewer;
use attractor::server::{build_router, create_app_state};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
/// Build the router exactly as `serve_command` does in dry-run mode.
fn dry_run_app() -> axum::Router {
let factory = |interviewer: Arc<dyn Interviewer>| {
default_registry(interviewer, || None)
};
let state = create_app_state(factory);
build_router(state)
}
async fn body_json(body: Body) -> serde_json::Value {
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn dry_run_serve_starts_and_runs_pipeline() {
let app = dry_run_app();
// POST /pipelines to start a pipeline
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let pipeline_id = body["id"].as_str().unwrap().to_string();
assert!(!pipeline_id.is_empty());
// Wait for pipeline to complete
tokio::time::sleep(Duration::from_millis(500)).await;
// GET /pipelines/{id} to verify completion
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["status"].as_str().unwrap(), "completed");
}
#[tokio::test]
async fn dry_run_serve_rejects_invalid_dot() {
let app = dry_run_app();
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": "not valid dot"})).unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}
// ===========================================================================
// 19a. Sub-pipeline E2E (TS Scenario 9)
// ===========================================================================
#[tokio::test]
async fn sub_pipeline_e2e_through_engine() {
use attractor::handler::sub_pipeline::SubPipelineHandler;
let input = r#"digraph SubPipelineE2E {
graph [goal="Test sub-pipeline"]
start [shape=Mdiamond]
exit [shape=Msquare]
generate [shape=box, prompt="Generate code"]
validate [type="sub_pipeline", sub_pipeline.dot_source="digraph Child { start [shape=Mdiamond]; lint [shape=box, prompt=\"Lint\"]; test [shape=box, prompt=\"Test\"]; exit [shape=Msquare]; start -> lint -> test -> exit }"]
start -> generate -> validate -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry.register("sub_pipeline", Box::new(SubPipelineHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("sub-pipeline E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"generate".to_string()),
"generate should be in completed_nodes"
);
assert!(
checkpoint
.completed_nodes
.contains(&"validate".to_string()),
"validate should be in completed_nodes"
);
// Context should have last_stage set by the validate node's sub-pipeline
let last_stage = checkpoint.context_values.get("last_stage");
assert!(last_stage.is_some(), "last_stage should be set in context");
}
// ===========================================================================
// 19b. Manager loop with ChildObserver E2E (TS Scenario 10)
// ===========================================================================
#[tokio::test]
async fn manager_loop_with_child_observer_e2e() {
use attractor::handler::manager_loop::{ChildObserver, ManagerLoopHandler};
use std::sync::atomic::{AtomicU32, Ordering};
struct SimulatingChildObserver {
launch_count: AtomicU32,
observe_count: AtomicU32,
}
#[async_trait::async_trait]
impl ChildObserver for SimulatingChildObserver {
async fn launch_child(
&self,
_dotfile: &str,
_workdir: &str,
_context: &attractor::context::Context,
) -> Result<(), attractor::error::AttractorError> {
self.launch_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn observe(
&self,
context: &attractor::context::Context,
) -> Result<(), attractor::error::AttractorError> {
let cycle = self.observe_count.fetch_add(1, Ordering::SeqCst);
if cycle >= 2 {
context.set(
"context.stack.child.status",
serde_json::json!("completed"),
);
context.set(
"context.stack.child.outcome",
serde_json::json!("success"),
);
}
Ok(())
}
async fn steer(
&self,
_context: &attractor::context::Context,
_node: &attractor::graph::Node,
) -> Result<(), attractor::error::AttractorError> {
Ok(())
}
}
let mut graph = Graph::new("ManagerLoopE2E");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test manager loop".to_string()),
);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut supervisor = Node::new("supervisor");
supervisor.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
supervisor.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
supervisor
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(50));
supervisor.attrs.insert(
"manager.actions".to_string(),
AttrValue::String("observe,wait".to_string()),
);
supervisor.attrs.insert(
"manager.stop_condition".to_string(),
AttrValue::String(String::new()),
);
graph
.nodes
.insert("supervisor".to_string(), supervisor);
graph.edges.push(Edge::new("start", "supervisor"));
graph.edges.push(Edge::new("supervisor", "exit"));
let dir = tempfile::tempdir().unwrap();
let observer = SimulatingChildObserver {
launch_count: AtomicU32::new(0),
observe_count: AtomicU32::new(0),
};
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"stack.manager_loop",
Box::new(ManagerLoopHandler::new(Some(Box::new(observer)))),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("manager loop E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"supervisor".to_string()),
"supervisor should be in completed_nodes"
);
// The manager loop handler stores notes about child completion
let supervisor_outcome = checkpoint.node_outcomes.get("supervisor");
assert!(supervisor_outcome.is_some(), "supervisor outcome should exist");
let notes = supervisor_outcome.unwrap().notes.as_deref().unwrap_or("");
assert!(
notes.contains("Child completed"),
"notes should mention child completion, got: {notes}"
);
}
// ===========================================================================
// 19c. GraphMerge E2E (TS Scenario 11)
// ===========================================================================
#[tokio::test]
async fn graph_merge_e2e_through_engine() {
use attractor::transform::GraphMergeTransform;
// Module "val": lint -> test
let mut val_graph = Graph::new("val");
let mut lint = Node::new("lint");
lint.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
lint.attrs.insert(
"prompt".to_string(),
AttrValue::String("Lint the code".to_string()),
);
val_graph.nodes.insert("lint".to_string(), lint);
let mut test_node = Node::new("test");
test_node
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
test_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Run tests".to_string()),
);
val_graph.nodes.insert("test".to_string(), test_node);
val_graph.edges.push(Edge::new("lint", "test"));
// Module "dep": stage -> release
let mut dep_graph = Graph::new("dep");
let mut stage = Node::new("stage");
stage
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
stage.attrs.insert(
"prompt".to_string(),
AttrValue::String("Stage the release".to_string()),
);
dep_graph.nodes.insert("stage".to_string(), stage);
let mut release = Node::new("release");
release
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
release.attrs.insert(
"prompt".to_string(),
AttrValue::String("Release it".to_string()),
);
dep_graph.nodes.insert("release".to_string(), release);
dep_graph.edges.push(Edge::new("stage", "release"));
// Main graph: start, exit; edges connect modules
let mut main_graph = Graph::new("MergeE2E");
main_graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test graph merge".to_string()),
);
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
main_graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
main_graph.nodes.insert("exit".to_string(), exit);
// Apply merge transform
let merge = GraphMergeTransform::new(vec![val_graph, dep_graph]);
merge.apply(&mut main_graph);
// Add cross-module edges
main_graph.edges.push(Edge::new("start", "val.lint"));
main_graph.edges.push(Edge::new("val.test", "dep.stage"));
main_graph
.edges
.push(Edge::new("dep.release", "exit"));
// Verify merged nodes exist
assert!(main_graph.nodes.contains_key("val.lint"));
assert!(main_graph.nodes.contains_key("val.test"));
assert!(main_graph.nodes.contains_key("dep.stage"));
assert!(main_graph.nodes.contains_key("dep.release"));
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine
.run(&main_graph, &config)
.await
.expect("graph merge E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"val.lint".to_string()),
"val.lint should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"val.test".to_string()),
"val.test should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.stage".to_string()),
"dep.stage should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.release".to_string()),
"dep.release should be completed"
);
// Verify ordering: val.test appears before dep.stage
let val_test_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "val.test")
.expect("val.test should be in completed_nodes");
let dep_stage_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "dep.stage")
.expect("dep.stage should be in completed_nodes");
assert!(
val_test_pos < dep_stage_pos,
"val.test ({val_test_pos}) should execute before dep.stage ({dep_stage_pos})"
);
}
// ===========================================================================
// Context fidelity integration tests (spec Section 5.4)
// ===========================================================================
type SharedVec<T> = Arc<std::sync::Mutex<Vec<T>>>;
/// Shared capture storage for fidelity tests.
#[derive(Clone)]
struct FidelityCaptures {
fidelities: SharedVec<(String, String)>,
thread_ids: SharedVec<(String, Option<String>)>,
preambles: SharedVec<(String, String)>,
}
impl FidelityCaptures {
fn new() -> Self {
Self {
fidelities: Arc::new(std::sync::Mutex::new(Vec::new())),
thread_ids: Arc::new(std::sync::Mutex::new(Vec::new())),
preambles: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
}
/// A handler that captures the resolved fidelity and `thread_id` from the context.
struct FidelityCapturingHandler {
captures: FidelityCaptures,
}
#[async_trait::async_trait]
impl Handler for FidelityCapturingHandler {
async fn execute(
&self,
node: &Node,
context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &attractor::handler::EngineServices,
) -> Result<Outcome, AttractorError> {
let fidelity = context.get_string("internal.fidelity", "none");
self.captures
.fidelities
.lock()
.unwrap()
.push((node.id.clone(), fidelity));
let thread_id = context
.get("internal.thread_id")
.and_then(|v| v.as_str().map(String::from));
self.captures
.thread_ids
.lock()
.unwrap()
.push((node.id.clone(), thread_id));
let preamble = context.get_string("current.preamble", "");
self.captures
.preambles
.lock()
.unwrap()
.push((node.id.clone(), preamble));
Ok(Outcome::success())
}
}
#[tokio::test]
async fn fidelity_default_is_compact() {
let mut graph = make_graph_with_start_exit("FidelityDefaultTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities.len(), 1);
assert_eq!(fidelities[0].0, "work");
assert_eq!(fidelities[0].1, "compact");
let preambles = captures.preambles.lock().unwrap();
assert!(!preambles[0].1.is_empty(), "compact fidelity should produce a preamble");
}
#[tokio::test]
async fn fidelity_graph_default_applied() {
let mut graph = make_graph_with_start_exit("FidelityGraphDefaultTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "truncate");
}
#[tokio::test]
async fn fidelity_node_overrides_graph_default() {
let mut graph = make_graph_with_start_exit("FidelityNodeOverrideTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:medium");
}
#[tokio::test]
async fn fidelity_edge_overrides_node_and_graph() {
let mut graph = make_graph_with_start_exit("FidelityEdgeOverrideTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_with_fidelity = Edge::new("start", "work");
edge_with_fidelity.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
graph.edges.push(edge_with_fidelity);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:high");
}
#[tokio::test]
async fn fidelity_full_produces_empty_preamble() {
let mut graph = make_graph_with_start_exit("FidelityFullPreambleTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "full");
let preambles = captures.preambles.lock().unwrap();
assert_eq!(preambles[0].1, "", "full fidelity should produce empty preamble");
}
#[tokio::test]
async fn fidelity_truncate_preamble_minimal() {
let mut graph = make_graph_with_start_exit("FidelityTruncateTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test truncate mode".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let preambles = captures.preambles.lock().unwrap();
let preamble = &preambles[0].1;
assert!(
preamble.contains("Goal: Test truncate mode"),
"truncate preamble should contain the goal"
);
assert!(
preamble.contains("Run ID:"),
"truncate preamble should contain run ID"
);
assert!(
!preamble.contains("Completed stages:"),
"truncate should not include stage details"
);
}
#[tokio::test]
async fn fidelity_summary_low_mode() {
let mut graph = make_graph_with_start_exit("SummaryLow");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:low");
assert_eq!(fidelities[1].1, "summary:low");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:low preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_summary_medium_mode() {
let mut graph = make_graph_with_start_exit("SummaryMedium");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:medium");
assert_eq!(fidelities[1].1, "summary:medium");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:medium preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_summary_high_mode() {
let mut graph = make_graph_with_start_exit("SummaryHigh");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:high");
assert_eq!(fidelities[1].1, "summary:high");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:high preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_full_sets_thread_id_in_context() {
let mut graph = make_graph_with_start_exit("FidelityThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("my-session".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(thread_ids[0].1, Some("my-session".to_string()));
}
#[tokio::test]
async fn fidelity_full_nodes_share_thread_id() {
let mut graph = make_graph_with_start_exit("FidelitySharedThreadTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
step_a.attrs.insert(
"thread_id".to_string(),
AttrValue::String("shared-session".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
step_b.attrs.insert(
"thread_id".to_string(),
AttrValue::String("shared-session".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "step_a");
assert_eq!(thread_ids[0].1, Some("shared-session".to_string()));
assert_eq!(thread_ids[1].0, "step_b");
assert_eq!(thread_ids[1].1, Some("shared-session".to_string()));
}
#[tokio::test]
async fn fidelity_resume_degrades_full_to_summary_high() {
let mut graph = make_graph_with_start_exit("FidelityResumeTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("full"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(
fidelities[0].1, "summary:high",
"first node after resume from full fidelity should be degraded to summary:high"
);
}
#[tokio::test]
async fn fidelity_resume_degrade_only_affects_first_hop() {
let mut graph = make_graph_with_start_exit("FidelityResumeSingleHopTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
let mut step_c = Node::new("step_c");
step_c.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_c".to_string(), step_c);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "step_c"));
graph.edges.push(Edge::new("step_c", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("full"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(fidelities[0].1, "summary:high");
assert_eq!(fidelities[1].0, "step_c");
assert_eq!(fidelities[1].1, "full");
}
#[tokio::test]
async fn fidelity_resume_no_degrade_when_not_full() {
let mut graph = make_graph_with_start_exit("FidelityResumeNoDegrade");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("compact"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(fidelities[0].1, "full");
}
#[tokio::test]
async fn fidelity_stored_in_checkpoint_context() {
let mut graph = make_graph_with_start_exit("FidelityCheckpointTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
let work = Node::new("work");
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:low")),
"checkpoint should record the resolved fidelity"
);
}
#[tokio::test]
async fn fidelity_precedence_multi_node_pipeline() {
let mut graph = make_graph_with_start_exit("FidelityPrecedenceTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
let mut step_c = Node::new("step_c");
step_c.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_c".to_string(), step_c);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
let mut edge_b_c = Edge::new("step_b", "step_c");
edge_b_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
graph.edges.push(edge_b_c);
graph.edges.push(Edge::new("step_c", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_a");
assert_eq!(fidelities[0].1, "truncate");
assert_eq!(fidelities[1].0, "step_b");
assert_eq!(fidelities[1].1, "summary:medium");
assert_eq!(fidelities[2].0, "step_c");
assert_eq!(fidelities[2].1, "summary:high");
}
#[tokio::test]
async fn fidelity_compact_preamble_includes_completed_stages_and_context() {
let mut graph = make_graph_with_start_exit("FidelityCompactContentTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Build the widget".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let preambles = captures.preambles.lock().unwrap();
// step_b's preamble should contain structured summary of completed work
let step_b_preamble = &preambles[1].1;
assert!(
step_b_preamble.contains("Build the widget"),
"compact preamble should contain the goal"
);
assert!(
step_b_preamble.contains("Completed stages:"),
"compact preamble should include completed stages section"
);
assert!(
step_b_preamble.contains("step_a"),
"compact preamble should mention completed node step_a"
);
}
#[tokio::test]
async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
// summary:low should NOT include context values (only goal, run ID, stage count, recent stages).
// summary:medium should include context values.
// This verifies a behavioral difference between detail levels.
let mut graph_low = make_graph_with_start_exit("SummaryLowExcludesContext");
graph_low.attrs.insert("goal".to_string(), AttrValue::String("Context exclusion test".to_string()));
graph_low.attrs.insert("default_fidelity".to_string(), AttrValue::String("summary:low".to_string()));
let mut step_a_low = Node::new("step_a");
step_a_low.attrs.insert("type".to_string(), AttrValue::String("fidelity_capture".to_string()));
graph_low.nodes.insert("step_a".to_string(), step_a_low);
let mut step_b_low = Node::new("step_b");
step_b_low.attrs.insert("type".to_string(), AttrValue::String("fidelity_capture".to_string()));
graph_low.nodes.insert("step_b".to_string(), step_b_low);
graph_low.edges.push(Edge::new("start", "step_a"));
graph_low.edges.push(Edge::new("step_a", "step_b"));
graph_low.edges.push(Edge::new("step_b", "exit"));
let captures_low = FidelityCaptures::new();
let dir_low = tempfile::tempdir().unwrap();
let mut registry_low = HandlerRegistry::new(Box::new(StartHandler));
registry_low.register("start", Box::new(StartHandler));
registry_low.register("exit", Box::new(ExitHandler));
registry_low.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures_low.clone() }));
let engine_low = PipelineEngine::new(registry_low, EventEmitter::new());
let config_low = RunConfig { logs_root: dir_low.path().to_path_buf(), cancel_token: None };
engine_low.run(&graph_low, &config_low).await.expect("run low");
{
let preambles_low = captures_low.preambles.lock().unwrap();
let low_preamble = &preambles_low[1].1;
// summary:low should not include "Context values:" section
assert!(
!low_preamble.contains("Context values:"),
"summary:low preamble should not include context values section"
);
}
// Now run summary:medium and verify it DOES include context values
let mut graph_med = make_graph_with_start_exit("SummaryMedIncludesContext");
graph_med.attrs.insert("goal".to_string(), AttrValue::String("Context exclusion test".to_string()));
graph_med.attrs.insert("default_fidelity".to_string(), AttrValue::String("summary:medium".to_string()));
let mut step_a_med = Node::new("step_a");
step_a_med.attrs.insert("type".to_string(), AttrValue::String("fidelity_capture".to_string()));
graph_med.nodes.insert("step_a".to_string(), step_a_med);
let mut step_b_med = Node::new("step_b");
step_b_med.attrs.insert("type".to_string(), AttrValue::String("fidelity_capture".to_string()));
graph_med.nodes.insert("step_b".to_string(), step_b_med);
graph_med.edges.push(Edge::new("start", "step_a"));
graph_med.edges.push(Edge::new("step_a", "step_b"));
graph_med.edges.push(Edge::new("step_b", "exit"));
let captures_med = FidelityCaptures::new();
let dir_med = tempfile::tempdir().unwrap();
let mut registry_med = HandlerRegistry::new(Box::new(StartHandler));
registry_med.register("start", Box::new(StartHandler));
registry_med.register("exit", Box::new(ExitHandler));
registry_med.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures_med.clone() }));
let engine_med = PipelineEngine::new(registry_med, EventEmitter::new());
let config_med = RunConfig { logs_root: dir_med.path().to_path_buf(), cancel_token: None };
engine_med.run(&graph_med, &config_med).await.expect("run med");
let preambles_med = captures_med.preambles.lock().unwrap();
let med_preamble = &preambles_med[1].1;
// summary:medium should include "Context values:" section (graph.goal is always set)
assert!(
med_preamble.contains("Context values:"),
"summary:medium preamble should include context values section"
);
}
#[tokio::test]
async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() {
// When no thread_id is set on the node, edge, graph, or class,
// the thread ID should fall back to the previous node's ID.
let mut graph = make_graph_with_start_exit("ThreadFallbackTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
// step_a should have previous node = start
assert_eq!(thread_ids[0].0, "step_a");
assert_eq!(thread_ids[0].1, Some("start".to_string()));
// step_b should have previous node = step_a
assert_eq!(thread_ids[1].0, "step_b");
assert_eq!(thread_ids[1].1, Some("step_a".to_string()));
}
#[tokio::test]
async fn fidelity_thread_id_from_node_class_in_pipeline() {
// When a node has classes (from subgraph derivation), thread_id resolves
// from the first class name per spec step 4.
let mut graph = make_graph_with_start_exit("ThreadClassTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.classes = vec!["planning".to_string(), "review".to_string()];
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("planning".to_string()),
"thread_id should resolve from first class name"
);
}
#[tokio::test]
async fn fidelity_edge_thread_id_override_in_pipeline() {
// Edge thread_id should override the previous-node fallback.
let mut graph = make_graph_with_start_exit("EdgeThreadOverrideTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_to_work = Edge::new("start", "work");
edge_to_work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("edge-session".to_string()),
);
graph.edges.push(edge_to_work);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("edge-session".to_string()),
"edge thread_id should override the previous-node fallback"
);
}
#[tokio::test]
async fn fidelity_full_without_explicit_thread_id_uses_previous_node() {
// When fidelity=full but no explicit thread_id is set, thread resolution
// should still fall back to the previous node ID.
let mut graph = make_graph_with_start_exit("FullNoExplicitThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
// No thread_id set explicitly
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "full");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("start".to_string()),
"full fidelity without explicit thread_id should fall back to previous node"
);
let preambles = captures.preambles.lock().unwrap();
assert_eq!(preambles[0].1, "", "full fidelity should produce empty preamble");
}
#[tokio::test]
async fn fidelity_from_parsed_dot_pipeline() {
// Parse a DOT file with fidelity attributes and run the pipeline.
let input = r#"digraph FidelityDotTest {
graph [goal="Test DOT fidelity", default_fidelity="truncate"]
start [shape=Mdiamond]
exit [shape=Msquare]
step_a [type="fidelity_capture"]
step_b [type="fidelity_capture", fidelity="summary:medium"]
step_c [type="fidelity_capture"]
start -> step_a -> step_b
step_b -> step_c [fidelity="summary:high"]
step_c -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
// step_a: no node fidelity, no edge fidelity -> graph default "truncate"
assert_eq!(fidelities[0].0, "step_a");
assert_eq!(fidelities[0].1, "truncate");
// step_b: node fidelity "summary:medium" overrides graph default
assert_eq!(fidelities[1].0, "step_b");
assert_eq!(fidelities[1].1, "summary:medium");
// step_c: node has no fidelity but incoming edge has "summary:high" -> edge wins
assert_eq!(fidelities[2].0, "step_c");
assert_eq!(fidelities[2].1, "summary:high");
}
#[tokio::test]
async fn fidelity_checkpoint_roundtrip_preserves_fidelity() {
// Run a pipeline that sets a specific fidelity, save checkpoint,
// load it, and verify the fidelity value survives the roundtrip.
let mut graph = make_graph_with_start_exit("FidelityCheckpointRoundtripTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
let work = Node::new("work");
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
// Load, save, load again to verify roundtrip
let checkpoint_path = dir.path().join("checkpoint.json");
let cp1 = Checkpoint::load(&checkpoint_path).expect("first load");
assert_eq!(
cp1.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:high")),
);
let roundtrip_path = dir.path().join("checkpoint_roundtrip.json");
cp1.save(&roundtrip_path).expect("save");
let cp2 = Checkpoint::load(&roundtrip_path).expect("second load");
assert_eq!(
cp2.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:high")),
"fidelity should survive checkpoint save/load roundtrip"
);
}
#[tokio::test]
async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() {
// When both node and edge have thread_id, the node's takes precedence (spec step 1 > step 2).
let mut graph = make_graph_with_start_exit("NodeOverridesEdgeThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("node-thread".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_to_work = Edge::new("start", "work");
edge_to_work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("edge-thread".to_string()),
);
graph.edges.push(edge_to_work);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("node-thread".to_string()),
"node thread_id should take precedence over edge thread_id"
);
}
#[tokio::test]
async fn fidelity_resume_preserves_context_values_across_checkpoint() {
// After resuming from a checkpoint, context values from the checkpoint
// should be available to the resumed nodes. This tests that fidelity-related
// context survives the resume path.
let mut graph = make_graph_with_start_exit("FidelityResumeContextTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("compact"));
ctx.set("context.custom_key", serde_json::json!("custom_value"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures.clone() }));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(
fidelities[0].1, "summary:low",
"resumed node should use its own fidelity (no degrade since checkpoint was compact, not full)"
);
// Verify the final checkpoint still has the fidelity
let final_cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
final_cp.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:low")),
);
}
// ===========================================================================
// 20. Real LLM pipeline tests (requires ANTHROPIC_API_KEY)
// ===========================================================================
mod real_llm {
use std::sync::Arc;
use async_trait::async_trait;
use attractor::context::Context;
use attractor::error::AttractorError;
use attractor::graph::Node;
use attractor::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
use llm::client::Client;
use llm::types::{Message, Request};
struct LlmCodergenBackend {
client: Arc<Client>,
model: String,
}
#[async_trait]
impl CodergenBackend for LlmCodergenBackend {
async fn run(
&self,
_node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
) -> Result<CodergenResult, AttractorError> {
let request = Request {
model: self.model.clone(),
messages: vec![Message::user(prompt)],
provider: Some("anthropic".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: Some(0.0),
top_p: None,
max_tokens: Some(200),
stop_sequences: None,
reasoning_effort: None,
metadata: None,
provider_options: None,
};
let response = self
.client
.complete(&request)
.await
.map_err(|e| AttractorError::Handler(e.to_string()))?;
Ok(CodergenResult::Text { text: response.text(), usage: None })
}
}
async fn make_llm_client() -> Option<Arc<Client>> {
let _ = dotenvy::dotenv();
if std::env::var("ANTHROPIC_API_KEY").is_err() {
return None;
}
let client = Client::from_env()
.await
.expect("unified-llm client should initialize from env");
Some(Arc::new(client))
}
fn make_llm_backend(client: Arc<Client>) -> Box<LlmCodergenBackend> {
Box::new(LlmCodergenBackend {
client,
model: "claude-haiku-4-5-20251001".to_string(),
})
}
use attractor::checkpoint::Checkpoint;
use attractor::engine::{PipelineEngine, RunConfig};
use attractor::event::EventEmitter;
use attractor::graph::{AttrValue, Edge, Graph};
use attractor::handler::exit::ExitHandler;
use attractor::handler::start::StartHandler;
use attractor::handler::wait_human::WaitHumanHandler;
use attractor::handler::HandlerRegistry;
use attractor::interviewer::auto_approve::AutoApproveInterviewer;
use attractor::outcome::StageStatus;
#[tokio::test]
#[ignore]
async fn real_llm_linear_pipeline() {
let client = if let Some(c) = make_llm_client().await { c } else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMLinear");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Describe a sorting algorithm".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut plan = Node::new("plan");
plan.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
plan.attrs.insert(
"prompt".to_string(),
AttrValue::String(
"Briefly describe quicksort in 2-3 sentences.".to_string(),
),
);
graph.nodes.insert("plan".to_string(), plan);
let mut review = Node::new("review");
review.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
review.attrs.insert(
"prompt".to_string(),
AttrValue::String(
"Review the previous description and add one improvement suggestion."
.to_string(),
),
);
graph.nodes.insert("review".to_string(), review);
graph.edges.push(Edge::new("start", "plan"));
graph.edges.push(Edge::new("plan", "review"));
graph.edges.push(Edge::new("review", "exit"));
let dir = tempfile::tempdir().unwrap();
let backend = make_llm_backend(client);
let mut registry =
HandlerRegistry::new(Box::new(CodergenHandler::new(Some(backend))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(make_llm_backend(
make_llm_client().await.unwrap(),
)))),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(checkpoint.completed_nodes.contains(&"plan".to_string()));
assert!(checkpoint.completed_nodes.contains(&"review".to_string()));
let last_stage = checkpoint
.context_values
.get("last_stage")
.and_then(|v| v.as_str());
assert_eq!(last_stage, Some("review"));
// Verify actual LLM responses were written
let plan_response =
std::fs::read_to_string(dir.path().join("plan").join("response.md")).unwrap();
assert!(
!plan_response.is_empty(),
"LLM should have generated a response"
);
assert!(
!plan_response.contains("[Simulated]"),
"response should be from real LLM, not simulated"
);
}
#[tokio::test]
#[ignore]
async fn real_llm_two_stage_pipeline() {
let client = if let Some(c) = make_llm_client().await { c } else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMTwoStage");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Generate and review".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut generate = Node::new("generate");
generate.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
generate.attrs.insert(
"prompt".to_string(),
AttrValue::String("Write a haiku about programming.".to_string()),
);
graph.nodes.insert("generate".to_string(), generate);
let mut review = Node::new("review");
review.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
review.attrs.insert(
"prompt".to_string(),
AttrValue::String("Rate the haiku on a scale of 1-10.".to_string()),
);
graph.nodes.insert("review".to_string(), review);
graph.edges.push(Edge::new("start", "generate"));
graph.edges.push(Edge::new("generate", "review"));
graph.edges.push(Edge::new("review", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(make_llm_backend(client)))),
);
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM two-stage pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let last_stage = checkpoint
.context_values
.get("last_stage")
.and_then(|v| v.as_str());
assert_eq!(last_stage, Some("review"));
}
#[tokio::test]
#[ignore]
async fn real_llm_human_gate_auto_approve() {
let client = if let Some(c) = make_llm_client().await { c } else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMGate");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Write and approve".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut write = Node::new("write");
write.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
write.attrs.insert(
"prompt".to_string(),
AttrValue::String("Write a one-line greeting.".to_string()),
);
graph.nodes.insert("write".to_string(), write);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Approve?".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
let mut ship = Node::new("ship");
ship.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
ship.attrs.insert(
"prompt".to_string(),
AttrValue::String("Ship the greeting.".to_string()),
);
graph.nodes.insert("ship".to_string(), ship);
let mut revise = Node::new("revise");
revise.attrs.insert(
"shape".to_string(),
AttrValue::String("box".to_string()),
);
revise.attrs.insert(
"prompt".to_string(),
AttrValue::String("Revise the greeting.".to_string()),
);
graph.nodes.insert("revise".to_string(), revise);
graph.edges.push(Edge::new("start", "write"));
graph.edges.push(Edge::new("write", "gate"));
let mut approve_edge = Edge::new("gate", "ship");
approve_edge.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(approve_edge);
let mut revise_edge = Edge::new("gate", "revise");
revise_edge.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Revise".to_string()),
);
graph.edges.push(revise_edge);
graph.edges.push(Edge::new("ship", "exit"));
graph.edges.push(Edge::new("revise", "gate"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(make_llm_backend(client)))),
);
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM gate pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"write".to_string()),
"write should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"gate".to_string()),
"gate should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"ship".to_string()),
"ship should be completed (auto-approve selects first option)"
);
assert!(
!checkpoint.completed_nodes.contains(&"revise".to_string()),
"revise should NOT be traversed with auto-approve"
);
}
}
// ---------------------------------------------------------------------------
// Wait.human freeform edge integration tests (Section 4.6)
// ---------------------------------------------------------------------------
/// Freeform-only human gate: free-text input routes through the freeform edge
/// and stores the text in human.gate.text context variable.
#[tokio::test]
async fn human_gate_freeform_only_routes_text() {
// Graph: start -> gate -> freeform_target -> exit
// gate has only a freeform edge (no fixed choices)
let mut graph = Graph::new("FreeformOnlyTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Enter feedback".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("freeform_target", "exit"));
let answers = VecDeque::from([Answer::text("my free text input")]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"should have routed through freeform_target"
);
assert_eq!(
checkpoint.context_values.get("human.gate.text"),
Some(&serde_json::json!("my free text input")),
"human.gate.text should contain the freeform input"
);
assert_eq!(
checkpoint.context_values.get("human.gate.selected"),
Some(&serde_json::json!("freeform")),
"human.gate.selected should be 'freeform'"
);
assert_eq!(
checkpoint.context_values.get("human.gate.label"),
Some(&serde_json::json!("my free text input")),
"human.gate.label should contain the freeform text"
);
}
/// Human gate with both fixed choices and a freeform edge:
/// when the answer matches a fixed choice, it routes to the fixed choice target.
#[tokio::test]
async fn human_gate_freeform_with_fixed_choice_match() {
// Graph: start -> gate -> {approve, reject, freeform_target} -> exit
// gate has fixed choices plus a freeform edge
// Answer selects "A" which matches "Approve" -> routes to approve
let mut graph = Graph::new("FreeformFixedMatchTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
// Answer selects "A" which matches the Approve choice
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
text: None,
}]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"approve".to_string()),
"fixed choice match should route to approve"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"should NOT route through freeform when fixed choice matches"
);
}
/// Human gate with both fixed choices and a freeform edge:
/// when the answer does NOT match any fixed choice, it falls through to the freeform edge.
#[tokio::test]
async fn human_gate_freeform_fallback_on_unmatched_text() {
// Graph: start -> gate -> {approve, reject, freeform_target} -> exit
// gate has fixed choices plus a freeform edge
// Answer is free text that doesn't match any choice -> routes to freeform_target
let mut graph = Graph::new("FreeformFallbackTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
// Free-text answer that doesn't match any fixed choice
let answers = VecDeque::from([Answer::text("I need more context before deciding")]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"unmatched text should fall through to freeform_target"
);
assert!(
!checkpoint.completed_nodes.contains(&"approve".to_string()),
"should NOT route to approve"
);
assert!(
!checkpoint.completed_nodes.contains(&"reject".to_string()),
"should NOT route to reject"
);
assert_eq!(
checkpoint.context_values.get("human.gate.text"),
Some(&serde_json::json!("I need more context before deciding")),
"human.gate.text should contain the freeform input"
);
assert_eq!(
checkpoint.context_values.get("human.gate.selected"),
Some(&serde_json::json!("freeform")),
"human.gate.selected should be 'freeform' for freeform fallback"
);
assert_eq!(
checkpoint.context_values.get("human.gate.label"),
Some(&serde_json::json!("I need more context before deciding")),
"human.gate.label should contain the freeform text"
);
}
/// Verifies that the Question presented to the interviewer has `allow_freeform=true`
/// when a freeform edge is present on the human gate.
#[tokio::test]
async fn human_gate_freeform_sets_allow_freeform_on_question() {
// Graph: start -> gate -> {approve, freeform_target} -> exit
// gate has a fixed choice plus a freeform edge
// We use RecordingInterviewer to capture the question and verify allow_freeform
let mut graph = Graph::new("AllowFreeformTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick or type".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
text: None,
}]);
let inner = QueueInterviewer::new(answers);
let recorder = Arc::new(RecordingInterviewer::new(Box::new(inner)));
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1, "should have recorded exactly one question");
assert!(
recordings[0].0.allow_freeform,
"Question should have allow_freeform=true when a freeform edge is present"
);
}
/// Verifies that the Question presented to the interviewer has `allow_freeform=false`
/// when no freeform edge is present on the human gate (fixed choices only).
#[tokio::test]
async fn human_gate_without_freeform_sets_allow_freeform_false() {
// Graph: start -> gate -> {approve, reject} -> exit
// gate has only fixed choices, no freeform edge
let mut graph = Graph::new("NoFreeformTest");
let mut start = Node::new("start");
start
.attrs
.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string()));
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs
.insert("shape".to_string(), AttrValue::String("Msquare".to_string()));
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs
.insert("shape".to_string(), AttrValue::String("hexagon".to_string()));
gate.attrs.insert(
"type".to_string(),
AttrValue::String("wait.human".to_string()),
);
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick one".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
text: None,
}]);
let inner = QueueInterviewer::new(answers);
let recorder = Arc::new(RecordingInterviewer::new(Box::new(inner)));
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("wait.human", Box::new(WaitHumanHandler::new(interviewer)));
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1, "should have recorded exactly one question");
assert!(
!recordings[0].0.allow_freeform,
"Question should have allow_freeform=false when no freeform edge is present"
);
}
// ---------------------------------------------------------------------------
// Subgraph features (Section 2.10)
// ---------------------------------------------------------------------------
#[test]
fn subgraph_node_defaults_scoped_to_subgraph() {
let input = r#"digraph SubgraphDefaults {
graph [goal="Test subgraph defaults"]
start [shape=Mdiamond]
exit [shape=Msquare]
subgraph cluster_loop {
label = "Loop A"
node [thread_id="loop-a", timeout="900s"]
plan [label="Plan next step"]
implement [label="Implement", timeout="1800s"]
}
outside [label="Outside node"]
start -> plan -> implement -> outside -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
// Plan inherits both thread_id and timeout from subgraph defaults
let plan = &graph.nodes["plan"];
assert_eq!(plan.thread_id(), Some("loop-a"));
assert_eq!(
plan.timeout(),
Some(std::time::Duration::from_secs(900))
);
// Implement inherits thread_id but overrides timeout
let implement = &graph.nodes["implement"];
assert_eq!(implement.thread_id(), Some("loop-a"));
assert_eq!(
implement.timeout(),
Some(std::time::Duration::from_secs(1800))
);
// Outside node should NOT have subgraph defaults
let outside = &graph.nodes["outside"];
assert_eq!(outside.thread_id(), None);
assert_eq!(outside.timeout(), None);
}
#[test]
fn subgraph_class_derived_from_label() {
let input = r#"digraph SubgraphClass {
graph [goal="Test class derivation"]
start [shape=Mdiamond]
exit [shape=Msquare]
subgraph cluster_loop {
label = "Loop A"
plan [label="Plan"]
implement [label="Implement"]
}
start -> plan -> implement -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
// Nodes inside subgraph receive derived class "loop-a"
assert!(graph.nodes["plan"].classes.contains(&"loop-a".to_string()));
assert!(graph.nodes["implement"].classes.contains(&"loop-a".to_string()));
// Nodes outside subgraph do not get the class
assert!(!graph.nodes["start"].classes.contains(&"loop-a".to_string()));
assert!(!graph.nodes["exit"].classes.contains(&"loop-a".to_string()));
}
#[test]
fn subgraph_class_derivation_strips_special_chars() {
let input = r#"digraph SubgraphClassStrip {
graph [goal="Test class derivation with special chars"]
subgraph cluster_review {
label = "Code Review!!!"
reviewer [label="Reviewer"]
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// "Code Review!!!" -> lowercase "code review!!!" -> spaces to hyphens "code-review!!!"
// -> strip non-alphanumeric except hyphens -> "code-review"
assert!(graph.nodes["reviewer"].classes.contains(&"code-review".to_string()));
}
#[test]
fn subgraph_scoping_does_not_leak_to_outer_scope() {
let input = r#"digraph SubgraphScoping {
graph [goal="Test scoping"]
node [timeout="300s"]
subgraph cluster_inner {
label = "Inner"
node [timeout="900s"]
inner_node [label="Inner"]
}
outer_node [label="Outer"]
}"#;
let graph = parse(input).expect("parsing should succeed");
// Inner node gets the subgraph-scoped timeout of 900s
let inner = &graph.nodes["inner_node"];
assert_eq!(
inner.timeout(),
Some(std::time::Duration::from_secs(900))
);
// Outer node gets the graph-level default of 300s, not the subgraph's 900s
let outer = &graph.nodes["outer_node"];
assert_eq!(
outer.timeout(),
Some(std::time::Duration::from_secs(300))
);
}
#[test]
fn subgraph_global_defaults_plus_subgraph_defaults() {
let input = r#"digraph SubgraphMerge {
graph [goal="Test merged defaults"]
node [shape=box, timeout="300s"]
subgraph cluster_loop {
label = "Loop"
node [thread_id="loop-thread"]
step [label="Step"]
}
plain [label="Plain"]
}"#;
let graph = parse(input).expect("parsing should succeed");
// Step should have both the global shape=box + timeout=300s and subgraph thread_id
let step = &graph.nodes["step"];
assert_eq!(step.shape(), "box");
assert_eq!(step.thread_id(), Some("loop-thread"));
assert_eq!(
step.timeout(),
Some(std::time::Duration::from_secs(300))
);
// Plain should have the global defaults but no thread_id
let plain = &graph.nodes["plain"];
assert_eq!(plain.shape(), "box");
assert_eq!(plain.thread_id(), None);
assert_eq!(
plain.timeout(),
Some(std::time::Duration::from_secs(300))
);
}
#[test]
fn subgraph_edges_inherit_class() {
let input = r#"digraph SubgraphEdgeClass {
graph [goal="Test edge nodes get class"]
subgraph cluster_loop {
label = "My Loop"
a [label="A"]
b [label="B"]
a -> b
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// Both nodes referenced in edges within the subgraph get the derived class
assert!(graph.nodes["a"].classes.contains(&"my-loop".to_string()));
assert!(graph.nodes["b"].classes.contains(&"my-loop".to_string()));
}
#[test]
fn subgraph_without_label_no_class_derived() {
let input = r#"digraph SubgraphNoLabel {
graph [goal="Test subgraph without label"]
subgraph cluster_unnamed {
node [timeout="600s"]
worker [label="Worker"]
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// No label means no class should be derived
let worker = &graph.nodes["worker"];
assert!(worker.classes.is_empty());
// But the default should still apply
assert_eq!(
worker.timeout(),
Some(std::time::Duration::from_secs(600))
);
}
// ---------------------------------------------------------------------------
// Tool Call Hooks (Section 9.7)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tool_hooks_pre_success_allows_pipeline_to_proceed() {
let input = r#"digraph HookTest {
graph [goal="Test pre-hook success"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do work", tool_hooks.pre="exit 0"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// The work node should have executed normally
let stage_dir = dir.path().join("work");
assert!(
stage_dir.join("prompt.md").exists(),
"prompt.md should exist when pre-hook succeeds"
);
assert!(
stage_dir.join("response.md").exists(),
"response.md should exist when pre-hook succeeds"
);
}
#[tokio::test]
async fn tool_hooks_pre_failure_skips_tool_call() {
let input = r#"digraph HookTest {
graph [goal="Test pre-hook failure"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do work", tool_hooks.pre="exit 1"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run should complete");
// The pipeline should still complete (skipped is not a fatal status),
// but the work node's handler returns Skipped when pre-hook fails.
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"work".to_string()),
"work should appear in completed_nodes even when skipped"
);
// response.md should NOT exist because the LLM call was skipped
let stage_dir = dir.path().join("work");
assert!(
!stage_dir.join("response.md").exists(),
"response.md should not exist when pre-hook skips tool call"
);
}
#[tokio::test]
async fn tool_hooks_post_success_does_not_affect_outcome() {
let input = r#"digraph HookTest {
graph [goal="Test post-hook success"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do work", tool_hooks.post="exit 0"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let stage_dir = dir.path().join("work");
assert!(
stage_dir.join("response.md").exists(),
"response.md should exist when post-hook succeeds"
);
}
#[tokio::test]
async fn tool_hooks_post_failure_does_not_block_pipeline() {
let input = r#"digraph HookTest {
graph [goal="Test post-hook failure"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do work", tool_hooks.post="exit 1"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
// Post-hook failure should not block the pipeline (spec 9.7)
assert_eq!(outcome.status, StageStatus::Success);
let stage_dir = dir.path().join("work");
assert!(
stage_dir.join("response.md").exists(),
"response.md should exist even when post-hook fails"
);
}
#[tokio::test]
async fn tool_hooks_graph_level_applies_to_all_nodes() {
let input = r#"digraph HookTest {
graph [goal="Test graph-level hooks", tool_hooks.pre="exit 0"]
start [shape=Mdiamond]
exit [shape=Msquare]
step1 [shape=box, label="Step1", prompt="First step"]
step2 [shape=box, label="Step2", prompt="Second step"]
start -> step1 -> step2 -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Both steps should have executed since graph-level pre-hook exits 0
assert!(
dir.path().join("step1").join("response.md").exists(),
"step1 should execute with graph-level pre-hook success"
);
assert!(
dir.path().join("step2").join("response.md").exists(),
"step2 should execute with graph-level pre-hook success"
);
}
#[tokio::test]
async fn tool_hooks_node_level_overrides_graph_level() {
let input = r#"digraph HookTest {
graph [goal="Test node override", tool_hooks.pre="exit 0"]
start [shape=Mdiamond]
exit [shape=Msquare]
step1 [shape=box, label="Step1", prompt="First step", tool_hooks.pre="exit 1"]
step2 [shape=box, label="Step2", prompt="Second step"]
start -> step1 -> step2 -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
let _outcome = engine.run(&graph, &config).await.expect("run should complete");
// step1 has node-level pre-hook "exit 1" which overrides graph-level "exit 0"
// So step1's tool call should be skipped (no response.md)
assert!(
!dir.path().join("step1").join("response.md").exists(),
"step1 should be skipped because node-level pre-hook overrides graph-level"
);
// step2 inherits graph-level "exit 0", so it should execute normally
assert!(
dir.path().join("step2").join("response.md").exists(),
"step2 should execute with inherited graph-level pre-hook"
);
}
#[tokio::test]
async fn tool_hooks_pre_receives_node_id_env_var() {
// Use a pre-hook that writes the ATTRACTOR_NODE_ID env var to a file
let dir = tempfile::tempdir().unwrap();
let marker_path = dir.path().join("node_id.txt");
let hook_cmd = format!(
"echo $ATTRACTOR_NODE_ID > {}",
marker_path.display()
);
let input = format!(
r#"digraph HookTest {{
graph [goal="Test env vars"]
start [shape=Mdiamond]
exit [shape=Msquare]
my_step [shape=box, label="MyStep", prompt="Do work", tool_hooks.pre="{hook_cmd}"]
start -> my_step -> exit
}}"#
);
let graph = parse(&input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let engine = PipelineEngine::new(make_linear_registry(), EventEmitter::new());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
};
engine.run(&graph, &config).await.expect("run should succeed");
let written = std::fs::read_to_string(&marker_path)
.expect("marker file should exist");
assert_eq!(
written.trim(),
"my_step",
"ATTRACTOR_NODE_ID should contain the node id"
);
}
#[test]
fn parse_tool_hooks_from_dot_syntax() {
let input = r#"digraph HookTest {
graph [goal="Test parsing", tool_hooks.pre="echo pre", tool_hooks.post="echo post"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do it", tool_hooks.pre="node pre"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
// Graph-level hooks
assert_eq!(
graph.attrs.get("tool_hooks.pre").and_then(|v| v.as_str()),
Some("echo pre")
);
assert_eq!(
graph.attrs.get("tool_hooks.post").and_then(|v| v.as_str()),
Some("echo post")
);
// Node-level hook overrides
let work = &graph.nodes["work"];
assert_eq!(
work.attrs.get("tool_hooks.pre").and_then(|v| v.as_str()),
Some("node pre")
);
}
// ---------------------------------------------------------------------------
// E2E test with real LLM
// ---------------------------------------------------------------------------
static TEST_STYLES: Styles = Styles::new(false);
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn attractor_e2e_with_real_llm() {
dotenvy::dotenv().ok();
let dir = tempfile::tempdir().unwrap();
let dir_path = dir.path().to_str().unwrap().to_string();
let dot = format!(
r#"digraph E2E {{
graph [goal="Create a test file"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [
shape=box,
label="Work",
prompt="Create a file called hello.txt in {dir_path} containing exactly 'Hello from LLM'. Do not output anything else.",
goal_gate=true
]
start -> work -> exit
}}"#
);
let graph = parse(&dot).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let interviewer: Arc<dyn Interviewer> = Arc::new(AutoApproveInterviewer);
let model = "claude-haiku-4-5-20251001".to_string();
let registry = default_registry(interviewer, move || {
Some(Box::new(AgentBackend::new(
model.clone(),
None,
0,
&TEST_STYLES,
false,
)) as Box<dyn attractor::handler::codergen::CodergenBackend>)
});
let logs_dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(registry, EventEmitter::new());
let config = RunConfig {
logs_root: logs_dir.path().to_path_buf(),
cancel_token: None,
};
let outcome = engine.run(&graph, &config).await.expect("run should succeed");
// 1. Pipeline completed successfully
assert_eq!(outcome.status, StageStatus::Success);
// 2. Artifacts exist
let work_dir = logs_dir.path().join("work");
assert!(work_dir.join("prompt.md").exists(), "prompt.md should exist");
assert!(
work_dir.join("response.md").exists(),
"response.md should exist"
);
assert!(
work_dir.join("status.json").exists(),
"status.json should exist"
);
// 3. Goal gate: check checkpoint node outcomes
let checkpoint = Checkpoint::load(&logs_dir.path().join("checkpoint.json"))
.expect("checkpoint should load");
let work_outcome = checkpoint
.node_outcomes
.get("work")
.expect("work outcome should exist");
assert!(
work_outcome.status == StageStatus::Success
|| work_outcome.status == StageStatus::PartialSuccess,
"work goal gate should be Success or PartialSuccess, got {:?}",
work_outcome.status
);
// 4. Checkpoint: completed_nodes contains "work"
assert!(
checkpoint.completed_nodes.contains(&"work".to_string()),
"completed_nodes should contain 'work'"
);
}