fabro/crates/arc-workflows/tests/integration.rs
Bryan Helmkamp 4dd2980a56 Add parallel git branching with per-branch worktree isolation
Parallel branches now get isolated git worktrees so concurrent file
writes don't collide. Works across Local, Docker (bind-mount), and
Daytona (remote exec_command) environments.

Key changes:
- git.rs: add create_branch_at() and merge_ff_only() helpers
- engine.rs: add GitState struct, remote worktree helpers
  (git_create_branch_at_remote, git_add_worktree_remote, etc.)
- handler/mod.rs: add git_state field to EngineServices (RwLock)
- handler/parallel.rs: WorktreeEnv wrapper, per-branch worktree
  setup/teardown, checkpoint commits per branch, ff-merge winner
  before returning to engine
- handler/fan_in.rs: ff-merge to winner's HEAD, set best_head_sha
- E2E tests for Host (local) and Daytona (remote) modes

When git_state is None, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-01 14:24:00 -05:00

9404 lines
312 KiB
Rust

use std::collections::VecDeque;
use std::path::Path;
use std::sync::Arc;
use arc_llm::provider::Provider;
use arc_util::terminal::Styles;
use arc_workflows::checkpoint::Checkpoint;
use arc_workflows::cli::backend::AgentBackend;
use arc_workflows::context::Context;
use arc_workflows::engine::{PipelineEngine, RunConfig};
use arc_workflows::error::ArcError;
use arc_workflows::event::{EventEmitter, PipelineEvent};
use arc_workflows::graph::{AttrValue, Edge, Graph, Node};
use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
use arc_workflows::handler::conditional::ConditionalHandler;
use arc_workflows::handler::default_registry;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::manager_loop::ManagerLoopHandler;
use arc_workflows::handler::script::ScriptHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::wait_human::WaitHumanHandler;
use arc_workflows::handler::{Handler, HandlerRegistry};
use arc_workflows::interviewer::auto_approve::AutoApproveInterviewer;
use arc_workflows::interviewer::queue::QueueInterviewer;
use arc_workflows::interviewer::recording::RecordingInterviewer;
use arc_workflows::interviewer::{Answer, AnswerValue, Interviewer};
use arc_workflows::outcome::{Outcome, StageStatus};
use arc_workflows::parser::parse;
use arc_workflows::stylesheet::{apply_stylesheet, parse_stylesheet};
use arc_workflows::transform::{
StylesheetApplicationTransform, Transform, VariableExpansionTransform,
};
use arc_workflows::validation::{validate, validate_or_raise, Severity};
fn local_env() -> Arc<dyn arc_agent::ExecutionEnvironment> {
Arc::new(arc_agent::LocalExecutionEnvironment::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
))
}
// ---------------------------------------------------------------------------
// 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 == arc_workflows::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"]
}"#;
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(), None);
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == arc_workflows::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 == arc_workflows::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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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!(
prompt_content.ends_with("Implement the feature"),
"prompt should end with original prompt, got: {prompt_content}"
);
}
// ---------------------------------------------------------------------------
// 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::context::Context,
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::context::Context,
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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>,
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
) -> Result<CodergenResult, ArcError> {
Ok(CodergenResult::Text {
text: format!(
"Response for {}: processed prompt '{}'",
node.id,
&prompt[..prompt.len().min(50)]
),
usage: None,
files_touched: Vec::new(),
})
}
}
// ---------------------------------------------------------------------------
// 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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 a context_update with a large value (>100KB) to trigger artifact offloading.
struct LargeOutputHandler;
#[async_trait::async_trait]
impl Handler for LargeOutputHandler {
async fn execute(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
let mut outcome = Outcome::success();
// 150KB string — well above the 100KB artifact threshold
let large_value = "x".repeat(150 * 1024);
outcome.context_updates.insert(
format!("response.{}", node.id),
serde_json::json!(large_value),
);
Ok(outcome)
}
}
/// 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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("script", Box::new(ScriptHandler));
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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("nodes").join("plan").join("prompt.md"))
.expect("plan prompt should exist");
assert!(
plan_prompt.ends_with("Plan to achieve: Build and validate"),
"prompt should end with original prompt, got: {plan_prompt}"
);
}
// ---------------------------------------------------------------------------
// 12. Parallel fan-out / fan-in integration test (Gap #14)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_parallel_fan_out_fan_in() {
use arc_workflows::handler::fan_in::FanInHandler;
use arc_workflows::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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(), Arc::new(emitter), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(
"script".to_string(),
AttrValue::String("echo hello-from-script".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),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 script_output = cp
.context_values
.get("script.output")
.expect("script.output should exist");
assert!(script_output
.as_str()
.unwrap()
.contains("hello-from-script"));
}
#[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),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
engine.run(&graph, &config).await.expect("run");
let response =
std::fs::read_to_string(dir.path().join("nodes").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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, script="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),
Arc::new(emitter),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 script_output = cp
.context_values
.get("script.output")
.expect("script.output");
assert!(script_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 arc_workflows::handler::fan_in::FanInHandler;
use arc_workflows::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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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),
Arc::new(emitter),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes")
.join("plan")
.join("prompt.md")
.exists());
assert!(dir
.path()
.join("nodes")
.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 { .. })));
}
// ===========================================================================
// 19a. Sub-pipeline E2E (TS Scenario 9)
// ===========================================================================
#[tokio::test]
async fn sub_pipeline_e2e_through_engine() {
use arc_workflows::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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 arc_workflows::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: &arc_workflows::context::Context,
) -> Result<(), arc_workflows::error::ArcError> {
self.launch_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn observe(
&self,
context: &arc_workflows::context::Context,
) -> Result<(), arc_workflows::error::ArcError> {
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: &arc_workflows::context::Context,
_node: &arc_workflows::graph::Node,
) -> Result<(), arc_workflows::error::ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 arc_workflows::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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config_low = RunConfig {
logs_root: dir_low.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config_med = RunConfig {
logs_root: dir_med.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 stage details (unlike summary:low which omits them)
assert!(
med_preamble.contains("step_a"),
"summary:medium preamble should include completed stage step_a"
);
// Verify medium and low differ: medium shows more recent stages
let preambles_low = captures_low.preambles.lock().unwrap();
let low_preamble = &preambles_low[1].1;
assert!(
!low_preamble.contains("## Context"),
"summary:low preamble should not include context 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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 arc_workflows::context::Context;
use arc_workflows::error::ArcError;
use arc_workflows::graph::Node;
use arc_workflows::handler::codergen::{CodergenBackend, CodergenHandler, CodergenResult};
use arc_llm::client::Client;
use arc_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>,
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_execution_env: &Arc<dyn arc_agent::ExecutionEnvironment>,
) -> Result<CodergenResult, ArcError> {
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| ArcError::Handler(e.to_string()))?;
Ok(CodergenResult::Text {
text: response.text(),
usage: None,
files_touched: Vec::new(),
})
}
}
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 super::local_env;
use arc_workflows::checkpoint::Checkpoint;
use arc_workflows::engine::{PipelineEngine, RunConfig};
use arc_workflows::event::EventEmitter;
use arc_workflows::graph::{AttrValue, Edge, Graph};
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::wait_human::WaitHumanHandler;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::interviewer::auto_approve::AutoApproveInterviewer;
use arc_workflows::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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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"
);
}
#[tokio::test]
#[ignore]
async fn real_llm_one_shot_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("RealLLMOneShot");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Classify a fruit".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 classify = Node::new("classify");
classify
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
classify.attrs.insert(
"prompt".to_string(),
AttrValue::String(
"Reply with exactly one word: is an apple a fruit or vegetable?".to_string(),
),
);
classify.attrs.insert(
"codergen_mode".to_string(),
AttrValue::String("one_shot".to_string()),
);
classify.attrs.insert(
"llm_model".to_string(),
AttrValue::String("claude-haiku-4-5-20251001".to_string()),
);
graph.nodes.insert("classify".to_string(), classify);
graph.edges.push(Edge::new("start", "classify"));
graph.edges.push(Edge::new("classify", "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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(30),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("one_shot pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let response_path = dir
.path()
.join("nodes")
.join("classify")
.join("response.md");
let response = std::fs::read_to_string(&response_path).unwrap();
assert!(!response.is_empty(), "response.md should be non-empty");
}
}
// ---------------------------------------------------------------------------
// 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let stage_dir = dir.path().join("nodes").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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes")
.join("step1")
.join("response.md")
.exists(),
"step1 should execute with graph-level pre-hook success"
);
assert!(
dir.path()
.join("nodes")
.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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes")
.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("nodes")
.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 ARC_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 $ARC_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(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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",
"ARC_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 arc_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(),
Provider::Anthropic,
0,
&TEST_STYLES,
))
as Box<dyn arc_workflows::handler::codergen::CodergenBackend>)
});
let logs_dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: logs_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: 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("nodes").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'"
);
}
// ---------------------------------------------------------------------------
// Fidelity preamble injection: verify prompt.md contains preamble + prompt
// for each fidelity mode, using script → codergen pipeline with no live LLM.
// ---------------------------------------------------------------------------
/// Build a `start -> run_tests (script) -> report (codergen) -> exit` pipeline
/// with the given fidelity and goal, then return the contents of `report/prompt.md`.
async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String {
let mut graph = Graph::new("FidelityPromptTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Validate the build".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String(fidelity.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);
// Script node that produces test output via stdout
let mut run_tests = Node::new("run_tests");
run_tests.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
run_tests.attrs.insert(
"script".to_string(),
AttrValue::String("echo '10 passed, 0 failed'".to_string()),
);
graph.nodes.insert("run_tests".to_string(), run_tests);
// Codergen node that should receive the preamble
let mut report = Node::new("report");
report
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
report.attrs.insert(
"prompt".to_string(),
AttrValue::String("Summarize the test results".to_string()),
);
graph.nodes.insert("report".to_string(), report);
graph.edges.push(Edge::new("start", "run_tests"));
graph.edges.push(Edge::new("run_tests", "report"));
graph.edges.push(Edge::new("report", "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("script", Box::new(ScriptHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new(MockCodergenBackend)))),
);
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
std::fs::read_to_string(dir.path().join("nodes").join("report").join("prompt.md"))
.expect("report/prompt.md should exist")
}
#[tokio::test]
async fn fidelity_prompt_compact() {
let prompt = run_fidelity_prompt_pipeline("compact").await;
// Preamble should contain goal, completed stages with handler details, and context
assert!(
prompt.contains("Validate the build"),
"compact: should contain goal"
);
assert!(
prompt.contains("## Completed stages"),
"compact: should list completed stages"
);
assert!(
prompt.contains("**run_tests**"),
"compact: should mention run_tests node in bold"
);
assert!(
prompt.contains("Script:"),
"compact: should show script sub-item for run_tests"
);
assert!(
prompt.contains("Stdout:"),
"compact: should show stdout sub-item for run_tests"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"compact: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_truncate() {
let prompt = run_fidelity_prompt_pipeline("truncate").await;
// Truncate is minimal: goal + run ID only, no completed stages
assert!(
prompt.contains("Validate the build"),
"truncate: should contain goal"
);
assert!(
!prompt.contains("Completed stages:"),
"truncate: should NOT list completed stages"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"truncate: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_low() {
let prompt = run_fidelity_prompt_pipeline("summary:low").await;
// summary:low includes goal, stage count, recent stages, but NOT context values
assert!(
prompt.contains("Validate the build"),
"summary:low: should contain goal"
);
assert!(
!prompt.contains("Context values:"),
"summary:low: should NOT include context values"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:low: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_medium() {
let prompt = run_fidelity_prompt_pipeline("summary:medium").await;
// summary:medium includes goal, stages, and compact handler details
assert!(
prompt.contains("Validate the build"),
"summary:medium: should contain goal"
);
assert!(
prompt.contains("run_tests"),
"summary:medium: should mention run_tests"
);
assert!(
prompt.contains("Script:"),
"summary:medium: should show script sub-item for run_tests"
);
assert!(
prompt.contains("Stdout:"),
"summary:medium: should show stdout sub-item for run_tests"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:medium: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_high() {
let prompt = run_fidelity_prompt_pipeline("summary:high").await;
// summary:high includes goal, all stages as ## Stage headings
assert!(
prompt.contains("Validate the build"),
"summary:high: should contain goal"
);
assert!(
prompt.contains("## Stage: run_tests"),
"summary:high: should have stage heading for run_tests"
);
assert!(
prompt.contains("## Stage: start"),
"summary:high: should have stage heading for start"
);
assert!(
prompt.contains("Pipeline progress:"),
"summary:high: should show pipeline progress"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:high: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_full_has_no_preamble() {
let prompt = run_fidelity_prompt_pipeline("full").await;
// Full fidelity produces empty preamble — prompt is just the original
assert_eq!(
prompt, "Summarize the test results",
"full: should be bare prompt with no preamble, got:\n{prompt}"
);
}
// ---------------------------------------------------------------------------
// Artifact offloading integration test
// ---------------------------------------------------------------------------
#[tokio::test]
async fn large_context_values_are_offloaded_to_artifact_store() {
// Pipeline: start -> big_output -> exit
// big_output uses LargeOutputHandler which returns a >100KB context_update.
let mut graph = make_graph_with_start_exit("ArtifactOffload");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test artifact offloading".to_string()),
);
let mut big_output = Node::new("big_output");
big_output.attrs.insert(
"label".to_string(),
AttrValue::String("Big Output".to_string()),
);
graph.nodes.insert("big_output".to_string(), big_output);
graph.edges.push(Edge::new("start", "big_output"));
graph.edges.push(Edge::new("big_output", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(LargeOutputHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = PipelineEngine::new(registry, Arc::new(emitter), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// The checkpoint context should contain an artifact pointer, not the full value
let checkpoint =
arc_workflows::checkpoint::Checkpoint::load(&dir.path().join("checkpoint.json"))
.expect("checkpoint should load");
let pointer_value = checkpoint
.context_values
.get("response.big_output")
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
assert!(
pointer_str.starts_with("file://"),
"value should be an artifact pointer, got: {pointer_str}"
);
// The artifact file should exist on disk
let artifact_file = dir
.path()
.join("artifacts")
.join("response.big_output.json");
assert!(
artifact_file.exists(),
"artifact file should exist at {artifact_file:?}"
);
// The artifact file should contain the original large value
let artifact_content =
std::fs::read_to_string(&artifact_file).expect("should read artifact file");
let artifact_value: serde_json::Value =
serde_json::from_str(&artifact_content).expect("should parse artifact JSON");
let artifact_str = artifact_value.as_str().expect("should be a string");
assert_eq!(
artifact_str.len(),
150 * 1024,
"artifact should contain the original 150KB value"
);
// PipelineCompleted event should report artifact_count > 0
let evts = events.lock().unwrap();
let completed_event = evts
.iter()
.find(|e| matches!(e, PipelineEvent::PipelineCompleted { .. }))
.expect("should have PipelineCompleted event");
if let PipelineEvent::PipelineCompleted { artifact_count, .. } = completed_event {
assert!(
*artifact_count > 0,
"artifact_count should be > 0, got {artifact_count}"
);
}
}
// ---------------------------------------------------------------------------
// Artifact sync to remote execution environments
// ---------------------------------------------------------------------------
/// A mock execution environment where `file_exists` always returns false,
/// simulating a remote container that doesn't have local artifact files.
struct RemoteMockEnv {
working_dir: String,
written: std::sync::Mutex<Vec<(String, String)>>,
}
impl RemoteMockEnv {
fn new(working_dir: &str) -> Self {
Self {
working_dir: working_dir.to_string(),
written: std::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl arc_agent::ExecutionEnvironment for RemoteMockEnv {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> std::result::Result<String, String> {
Err("not implemented".to_string())
}
async fn write_file(&self, path: &str, content: &str) -> std::result::Result<(), String> {
self.written
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
async fn file_exists(&self, _path: &str) -> std::result::Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> std::result::Result<Vec<arc_agent::DirEntry>, String> {
Err("not implemented".to_string())
}
async fn exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> std::result::Result<arc_agent::ExecResult, String> {
Err("not implemented".to_string())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &arc_agent::GrepOptions,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
}
async fn initialize(&self) -> std::result::Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> std::result::Result<(), String> {
Ok(())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 5.15".to_string()
}
}
#[tokio::test]
async fn artifact_pointers_rewritten_for_remote_execution_env() {
// Pipeline: start -> big_output -> exit
// big_output uses LargeOutputHandler which returns a >100KB context_update.
// RemoteMockEnv simulates a container where local files don't exist.
let mut graph = make_graph_with_start_exit("ArtifactSync");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test artifact sync to remote env".to_string()),
);
let mut big_output = Node::new("big_output");
big_output.attrs.insert(
"label".to_string(),
AttrValue::String("Big Output".to_string()),
);
graph.nodes.insert("big_output".to_string(), big_output);
graph.edges.push(Edge::new("start", "big_output"));
graph.edges.push(Edge::new("big_output", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(LargeOutputHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let remote_env = Arc::new(RemoteMockEnv::new("/sandbox"));
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// The checkpoint context should contain a pointer rewritten for the remote env
let checkpoint =
Checkpoint::load(&dir.path().join("checkpoint.json")).expect("checkpoint should load");
let pointer_value = checkpoint
.context_values
.get("response.big_output")
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
assert!(
pointer_str.starts_with("file:///sandbox/.arc/artifacts/"),
"pointer should reference remote path, got: {pointer_str}"
);
// The RemoteMockEnv should have received exactly one write with >100KB content
let written = remote_env.written.lock().unwrap();
assert_eq!(written.len(), 1, "should have written 1 artifact");
assert!(
written[0].1.len() > 100 * 1024,
"written content should be >100KB, got {} bytes",
written[0].1.len()
);
}
// ---------------------------------------------------------------------------
// Node directory visit-count naming
// ---------------------------------------------------------------------------
/// Verify that revisited nodes get distinct stage directories:
/// visit 1 → `nodes/{id}/`
/// visit 2 → `nodes/{id}-attempt_2/`
#[tokio::test]
async fn node_dir_uses_visit_count_on_revisit() {
// Handler that fails on first call, succeeds on second.
struct FailOnceHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailOnceHandler {
async fn execute(
&self,
_node: &Node,
_context: &arc_workflows::context::Context,
_graph: &Graph,
_logs_root: &Path,
_services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, arc_workflows::error::ArcError> {
let n = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
Ok(Outcome::fail("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
// Graph: start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// First visit fails → goal gate unsatisfied → retries from start
// Second visit succeeds → pipeline completes
let mut graph = Graph::new("VisitCountTest");
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_once".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_once",
Box::new(FailOnceHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// First visit: nodes/gated_work/status.json
let first = dir
.path()
.join("nodes")
.join("gated_work")
.join("status.json");
assert!(
first.exists(),
"first visit directory should exist at {}",
first.display()
);
// Second visit: nodes/gated_work-attempt_2/status.json
let second = dir
.path()
.join("nodes")
.join("gated_work-attempt_2")
.join("status.json");
assert!(
second.exists(),
"second visit directory should exist at {}",
second.display()
);
// Verify distinct content (first = fail, second = success)
let first_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&first).unwrap()).unwrap();
let second_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&second).unwrap()).unwrap();
assert_eq!(first_json["status"], "fail");
assert_eq!(second_json["status"], "success");
}
// ---------------------------------------------------------------------------
// CLI Backend end-to-end tests
// ---------------------------------------------------------------------------
use arc_workflows::cli::cli_backend::{BackendRouter, CliBackend};
/// A mock execution environment for CLI backend e2e tests.
/// Records all exec_command and write_file calls, and returns configurable
/// responses based on command content.
struct CliTestEnv {
/// All commands passed to exec_command, in order.
commands: std::sync::Mutex<Vec<String>>,
/// All (path, content) pairs from write_file.
written_files: std::sync::Mutex<Vec<(String, String)>>,
/// The stdout to return when the CLI command (not git) is executed.
cli_stdout: String,
/// Files returned by "git diff --name-only" AFTER the CLI runs.
/// First call returns empty (before), second returns these (after).
git_diff_call_count: std::sync::atomic::AtomicU32,
git_diff_after: String,
}
impl CliTestEnv {
fn new(cli_stdout: &str) -> Self {
Self {
commands: std::sync::Mutex::new(Vec::new()),
written_files: std::sync::Mutex::new(Vec::new()),
cli_stdout: cli_stdout.to_string(),
git_diff_call_count: std::sync::atomic::AtomicU32::new(0),
git_diff_after: String::new(),
}
}
fn with_git_diff_after(mut self, files: &str) -> Self {
self.git_diff_after = files.to_string();
self
}
fn recorded_commands(&self) -> Vec<String> {
self.commands.lock().unwrap().clone()
}
fn recorded_written_files(&self) -> Vec<(String, String)> {
self.written_files.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl arc_agent::ExecutionEnvironment for CliTestEnv {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
Ok(String::new())
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
self.written_files
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<arc_agent::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<arc_agent::ExecResult, String> {
self.commands.lock().unwrap().push(command.to_string());
// git diff calls: first pair returns empty (before), second pair returns configured files
if command.starts_with("git diff") || command.starts_with("git ls-files") {
let call_num = self
.git_diff_call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Calls 0,1 = before snapshot (empty), calls 2,3 = after snapshot
let stdout = if call_num >= 2 && command.starts_with("git diff") {
self.git_diff_after.clone()
} else {
String::new()
};
return Ok(arc_agent::ExecResult {
stdout,
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 5,
});
}
// CLI command: return configured stdout
Ok(arc_agent::ExecResult {
stdout: self.cli_stdout.clone(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 100,
})
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &arc_agent::GrepOptions,
) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
Ok(())
}
fn working_directory(&self) -> &str {
"/tmp/test"
}
fn platform(&self) -> &str {
"darwin"
}
fn os_version(&self) -> String {
"Darwin 24.0.0".into()
}
}
// -- Cycle 8: CliBackend::run() e2e via mock ExecutionEnvironment --
#[tokio::test]
async fn cli_backend_run_writes_prompt_and_calls_exec() {
let claude_output = r#"{"type":"result","result":"I fixed the bug.","usage":{"input_tokens":500,"output_tokens":200}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn arc_agent::ExecutionEnvironment> = test_env.clone();
let backend = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("fix_code");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Fix the authentication bug",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("CLI backend should succeed");
// Verify prompt was written
let written = test_env.recorded_written_files();
assert_eq!(
written.len(),
1,
"should write exactly one file (the prompt)"
);
assert_eq!(written[0].0, "/tmp/arc_cli_prompt.txt");
assert_eq!(written[0].1, "Fix the authentication bug");
// Verify the CLI command was called
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("claude"))
.expect("should call claude CLI");
assert!(cli_cmd.contains("-p"), "should use pipe mode");
assert!(
cli_cmd.contains("claude-opus-4-6"),
"should use correct model"
);
assert!(
cli_cmd.contains("/tmp/arc_cli_prompt.txt"),
"should reference prompt file"
);
// Verify parsed response
match result {
CodergenResult::Text {
text,
usage,
files_touched,
} => {
assert_eq!(text, "I fixed the bug.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 500);
assert_eq!(usage.output_tokens, 200);
assert!(files_touched.is_empty(), "no files changed before/after");
}
CodergenResult::Full(_) => panic!("expected Text result, got Full"),
}
}
#[tokio::test]
async fn cli_backend_run_detects_changed_files() {
let claude_output = r#"{"type":"result","result":"Created new file.","usage":{"input_tokens":100,"output_tokens":50}}"#;
let env: Arc<dyn arc_agent::ExecutionEnvironment> =
Arc::new(CliTestEnv::new(claude_output).with_git_diff_after("src/main.rs\nsrc/lib.rs\n"));
let backend = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Add a new feature",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("CLI backend should succeed");
match result {
CodergenResult::Text { files_touched, .. } => {
assert_eq!(files_touched, vec!["src/lib.rs", "src/main.rs"]);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn cli_backend_run_with_codex_provider() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Implemented the feature.\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":300,\"output_tokens\":150}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn arc_agent::ExecutionEnvironment> = test_env.clone();
let backend = CliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi);
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Build the API",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("CLI backend should succeed");
// Verify codex command was called
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("codex"))
.expect("should call codex CLI");
assert!(cli_cmd.contains("exec --json"), "should use exec mode");
assert!(
cli_cmd.contains("gpt-5.3-codex"),
"should use correct model"
);
match result {
CodergenResult::Text { text, usage, .. } => {
assert_eq!(text, "Implemented the feature.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 300);
assert_eq!(usage.output_tokens, 150);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn cli_backend_run_fails_on_nonzero_exit() {
let env = Arc::new(CliTestEnv::new(""));
// Override exec_command to return non-zero for the CLI call
struct FailingCliEnv;
#[async_trait::async_trait]
impl arc_agent::ExecutionEnvironment for FailingCliEnv {
async fn read_file(
&self,
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
Ok(String::new())
}
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
async fn delete_file(&self, _: &str) -> Result<(), String> {
Ok(())
}
async fn file_exists(&self, _: &str) -> Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<arc_agent::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_: u64,
_: Option<&str>,
_: Option<&std::collections::HashMap<String, String>>,
_: Option<tokio_util::sync::CancellationToken>,
) -> Result<arc_agent::ExecResult, String> {
if command.starts_with("git") {
return Ok(arc_agent::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
});
}
Ok(arc_agent::ExecResult {
stdout: String::new(),
stderr: "command not found: claude".into(),
exit_code: 127,
timed_out: false,
duration_ms: 0,
})
}
async fn grep(
&self,
_: &str,
_: &str,
_: &arc_agent::GrepOptions,
) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
Ok(())
}
fn working_directory(&self) -> &str {
"/tmp"
}
fn platform(&self) -> &str {
"darwin"
}
fn os_version(&self) -> String {
"Darwin 24.0.0".into()
}
}
let failing_env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(FailingCliEnv);
let backend = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let _ = env; // unused, just for the above struct
let result = backend
.run(
&node,
"do something",
&context,
None,
&emitter,
dir.path(),
&failing_env,
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("should fail on non-zero exit"),
};
assert!(
err.to_string().contains("exited with code 127"),
"error: {err}"
);
assert!(
err.to_string().contains("command not found"),
"error: {err}"
);
}
#[tokio::test]
async fn cli_backend_run_fails_on_unparseable_output() {
let env: Arc<dyn arc_agent::ExecutionEnvironment> =
Arc::new(CliTestEnv::new("this is not json at all"));
let backend = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"do something",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("should fail on unparseable output"),
};
assert!(
err.to_string().contains("Failed to parse CLI output"),
"error: {err}"
);
}
#[tokio::test]
async fn cli_backend_run_uses_node_model_override() {
let claude_output =
r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn arc_agent::ExecutionEnvironment> = test_env.clone();
let backend = CliBackend::new("default-model".into(), Provider::Anthropic);
let mut node = Node::new("step");
node.attrs.insert(
"llm_model".to_string(),
AttrValue::String("claude-sonnet-4-5".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(&node, "test", &context, None, &emitter, dir.path(), &env)
.await
.expect("should succeed");
let commands = test_env.recorded_commands();
let cli_cmd = commands.iter().find(|c| c.contains("claude")).unwrap();
assert!(
cli_cmd.contains("claude-sonnet-4-5"),
"should use node's model override, not default: {cli_cmd}"
);
assert!(
!cli_cmd.contains("default-model"),
"should NOT use default model: {cli_cmd}"
);
}
#[tokio::test]
async fn cli_backend_run_uses_node_provider_override() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"ok\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn arc_agent::ExecutionEnvironment> = test_env.clone();
let backend = CliBackend::new("default-model".into(), Provider::Anthropic);
let mut node = Node::new("step");
node.attrs.insert(
"llm_provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"llm_model".to_string(),
AttrValue::String("gpt-5.3-codex".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(&node, "test", &context, None, &emitter, dir.path(), &env)
.await
.expect("should succeed");
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("codex"))
.expect("should call codex based on provider override");
assert!(cli_cmd.contains("gpt-5.3-codex"));
}
#[tokio::test]
async fn cli_backend_run_writes_provider_used_json() {
let claude_output =
r#"{"type":"result","result":"done","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(CliTestEnv::new(claude_output));
let backend = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(&node, "test", &context, None, &emitter, dir.path(), &env)
.await
.expect("should succeed");
let provider_path = dir.path().join("provider_used.json");
assert!(provider_path.exists(), "should write provider_used.json");
let provider_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&provider_path).unwrap()).unwrap();
assert_eq!(provider_json["mode"], "cli");
assert_eq!(provider_json["provider"], "anthropic");
assert_eq!(provider_json["model"], "claude-opus-4-6");
assert!(provider_json["command"]
.as_str()
.unwrap()
.contains("claude"));
}
// -- BackendRouter e2e: delegates to correct backend --
#[tokio::test]
async fn backend_router_delegates_to_cli_for_cli_node() {
let claude_output = r#"{"type":"result","result":"CLI response","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(CliTestEnv::new(claude_output));
let api_backend = Box::new(MockCodergenBackend); // would return "Response for ..."
let cli = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("cli_step");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Fix the bug".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Fix the bug",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert_eq!(
text, "CLI response",
"should use CLI backend response, not mock API"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn backend_router_delegates_to_api_for_normal_node() {
let env = local_env();
let api_backend = Box::new(MockCodergenBackend);
let cli = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("api_step");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan the work".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Plan the work",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert!(
text.starts_with("Response for api_step"),
"should use API mock response: {text}"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn backend_router_delegates_to_cli_for_backend_attr() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Codex did it\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(CliTestEnv::new(codex_output));
let api_backend = Box::new(MockCodergenBackend);
let cli = CliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi);
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("codex_step");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
node.attrs.insert(
"llm_provider".to_string(),
AttrValue::String("openai".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Build it",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert_eq!(
text, "Codex did it",
"should route to CLI backend for backend=cli"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
// -- Full pipeline e2e with BackendRouter --
#[tokio::test]
async fn full_pipeline_with_cli_backend_node() {
// Pipeline: start -> api_work -> cli_work -> exit
// api_work uses MockCodergenBackend (API), cli_work has backend="cli"
let claude_output = r#"{"type":"result","result":"CLI completed the task.","usage":{"input_tokens":100,"output_tokens":50}}"#;
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(CliTestEnv::new(claude_output));
let mut graph = Graph::new("CliPipelineTest");
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 api_work = Node::new("api_work");
api_work
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
api_work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan the work".to_string()),
);
graph.nodes.insert("api_work".to_string(), api_work);
let mut cli_work = Node::new("cli_work");
cli_work
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
cli_work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement via CLI".to_string()),
);
cli_work
.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
graph.nodes.insert("cli_work".to_string(), cli_work);
graph.edges.push(Edge::new("start", "api_work"));
graph.edges.push(Edge::new("api_work", "cli_work"));
graph.edges.push(Edge::new("cli_work", "exit"));
// Build engine with BackendRouter
let api = MockCodergenBackend;
let cli = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(api), cli);
let codergen_handler = CodergenHandler::new(Some(Box::new(router)));
let mut registry = HandlerRegistry::new(Box::new(codergen_handler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new({
// Second BackendRouter for the "codergen" handler
let api2 = MockCodergenBackend;
let cli2 = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
BackendRouter::new(Box::new(api2), cli2)
})))),
);
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), env);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Verify api_work used mock (its response.md should contain "Response for")
let api_response = std::fs::read_to_string(
dir.path()
.join("nodes")
.join("api_work")
.join("response.md"),
)
.unwrap();
assert!(
api_response.starts_with("Response for api_work"),
"API node should use mock: {api_response}"
);
// Verify cli_work used CLI backend (its response.md should contain CLI response)
let cli_response = std::fs::read_to_string(
dir.path()
.join("nodes")
.join("cli_work")
.join("response.md"),
)
.unwrap();
assert_eq!(
cli_response, "CLI completed the task.",
"CLI node should use CLI backend: {cli_response}"
);
// Verify cli_work wrote provider_used.json with mode=cli
let provider_json: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(
dir.path()
.join("nodes")
.join("cli_work")
.join("provider_used.json"),
)
.unwrap(),
)
.unwrap();
assert_eq!(provider_json["mode"], "cli");
}
// -- Stylesheet applies backend property to nodes in a full pipeline --
#[tokio::test]
async fn stylesheet_backend_property_routes_to_cli() {
let claude_output = r#"{"type":"result","result":"Styled CLI response.","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(CliTestEnv::new(claude_output));
let mut graph = Graph::new("StylesheetTest");
graph.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(".cli-node { backend: cli; }".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()),
);
work.classes.push("cli-node".to_string());
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// Apply stylesheet
let ss = parse_stylesheet(graph.model_stylesheet()).unwrap();
apply_stylesheet(&ss, &mut graph);
// Verify the stylesheet applied the backend property
assert_eq!(
graph.nodes["work"].backend(),
Some("cli"),
"stylesheet should set backend=cli on .cli-node"
);
// Run the pipeline
let api = MockCodergenBackend;
let cli = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(api), cli);
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(Some(Box::new(router)))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let api2 = MockCodergenBackend;
let cli2 = CliBackend::new("claude-opus-4-6".into(), Provider::Anthropic);
let router2 = BackendRouter::new(Box::new(api2), cli2);
registry.register(
"codergen",
Box::new(CodergenHandler::new(Some(Box::new(router2)))),
);
let dir = tempfile::tempdir().unwrap();
let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), env);
let config = RunConfig {
logs_root: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint: None,
base_sha: None,
run_branch: None,
meta_branch: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let response =
std::fs::read_to_string(dir.path().join("nodes").join("work").join("response.md")).unwrap();
assert_eq!(
response, "Styled CLI response.",
"stylesheet-driven node should use CLI backend"
);
}
// ---------------------------------------------------------------------------
// Real CLI backend e2e tests (require actual CLI tools installed)
// ---------------------------------------------------------------------------
use arc_workflows::cli::cli_backend::parse_cli_response;
/// Run a real CLI tool via LocalExecutionEnvironment and verify the full flow.
async fn run_real_cli_test(provider: Provider, model: &str) {
let env = local_env();
let backend = CliBackend::new(model.to_string(), provider);
let mut node = Node::new("real_cli_test");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("What is 2+2? Reply with just the number.".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"What is 2+2? Reply with just the number.",
&context,
None,
&emitter,
dir.path(),
&env,
)
.await
.unwrap_or_else(|_| panic!("CLI backend ({provider}/{model}) should succeed"));
match result {
CodergenResult::Text { text, usage, .. } => {
assert!(
text.contains('4'),
"{provider}/{model}: expected response to contain '4', got: {text}"
);
let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage"));
assert!(
usage.input_tokens > 0,
"{provider}/{model}: input_tokens should be > 0, got {}",
usage.input_tokens
);
}
CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"),
}
// Verify log files were written
let provider_path = dir.path().join("provider_used.json");
assert!(
provider_path.exists(),
"{provider}/{model}: provider_used.json should exist"
);
let provider_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&provider_path).unwrap()).unwrap();
assert_eq!(provider_json["mode"], "cli");
assert_eq!(provider_json["provider"], provider.as_str());
}
#[tokio::test]
#[ignore] // requires `claude` CLI installed
async fn real_cli_claude() {
run_real_cli_test(Provider::Anthropic, "haiku").await;
}
#[tokio::test]
#[ignore] // requires `codex` CLI installed and OpenAI auth
async fn real_cli_codex() {
run_real_cli_test(Provider::OpenAi, "").await;
}
#[tokio::test]
#[ignore] // requires `gemini` CLI installed and Google auth
async fn real_cli_gemini() {
run_real_cli_test(Provider::Gemini, "gemini-2.5-flash").await;
}
/// Verify parse_cli_response works against real Claude CLI output captured from stream-json.
#[test]
fn parse_real_claude_stream_json() {
// Real output captured from: claude -p --output-format stream-json --model haiku "What is 2+2?"
let output = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc"}
{"type":"assistant","message":{"content":[{"type":"text","text":"4"}]}}
{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"num_turns":1,"result":"4","usage":{"input_tokens":9,"output_tokens":5}}"#;
let response = parse_cli_response(Provider::Anthropic, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 9);
assert_eq!(response.output_tokens, 5);
}
/// Verify parse_cli_response works against real Codex CLI output.
#[test]
fn parse_real_codex_ndjson() {
// Real output captured from: echo "What is 2+2?" | codex exec --json
let output = r#"{"type":"thread.started","thread_id":"019ca1ec-1e86-79b2-b2b2-b1d963f1aea2"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming simple numeric reply**"}}
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"4"}}
{"type":"turn.completed","usage":{"input_tokens":7999,"cached_input_tokens":7040,"output_tokens":33}}"#;
let response = parse_cli_response(Provider::OpenAi, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 7999);
assert_eq!(response.output_tokens, 33);
}
/// Verify parse_cli_response works against real Gemini CLI output.
#[test]
fn parse_real_gemini_json() {
// Real output captured from: gemini "What is 2+2?" -m gemini-2.5-flash --sandbox -o json
let output = r#"{"session_id":"abc","response":"4","stats":{"models":{"gemini-2.5-flash":{"api":{"totalRequests":1,"totalErrors":0,"totalLatencyMs":618},"tokens":{"input":123,"prompt":8911,"candidates":1,"total":8912,"cached":8788,"thoughts":0,"tool":0}}},"tools":{"totalCalls":0},"files":{"totalLinesAdded":0,"totalLinesRemoved":0}}}"#;
let response = parse_cli_response(Provider::Gemini, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 123);
assert_eq!(response.output_tokens, 1);
}
// ---------------------------------------------------------------------------
// Git checkpoint e2e — Host mode (Docker / Local)
// ---------------------------------------------------------------------------
use arc_workflows::engine::GitCheckpointMode;
use arc_workflows::handler::fan_in::FanInHandler;
use arc_workflows::handler::parallel::ParallelHandler;
/// A handler that writes a file named `{node_id}.txt` into the execution environment's
/// working directory. Used to verify git worktree isolation in parallel branches.
struct FileWriterHandler;
#[async_trait::async_trait]
impl Handler for FileWriterHandler {
async fn execute(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_logs_root: &Path,
services: &arc_workflows::handler::EngineServices,
) -> Result<Outcome, ArcError> {
let work_dir = services.execution_env.working_directory().to_string();
let file_path = format!("{}/{}.txt", work_dir, node.id);
services
.execution_env
.write_file(&file_path, &format!("written by {}", node.id))
.await
.map_err(|e| ArcError::Handler(format!("write_file failed: {e}")))?;
Ok(Outcome::success())
}
}
/// End-to-end test: pipeline with `GitCheckpointMode::Host` emits `GitCheckpoint`
/// events with valid commit SHAs and writes `diff.patch` per stage.
#[tokio::test]
async fn git_checkpoint_host_emits_events_and_diff_patch() {
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Create a branch and worktree (like cli/run.rs setup_worktree)
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::process::Command::new("git")
.args(["branch", "arc/run/test-docker", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg("arc/run/test-docker")
.current_dir(repo.path())
.output()
.unwrap();
// Write a file in the worktree so there's something to commit
std::fs::write(worktree_path.join("hello.txt"), "from docker test").unwrap();
// 3. Build a simple pipeline: start -> work -> exit
let mut graph = Graph::new("DockerGitCheckpoint");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test Host git checkpoint".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("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// 4. Set up event collection and engine
let logs_dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(
arc_agent::LocalExecutionEnvironment::new(worktree_path.clone()),
);
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, Arc::new(emitter), env);
let config = RunConfig {
logs_root: logs_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-docker".into(),
git_checkpoint: Some(GitCheckpointMode::Host(worktree_path.clone())),
base_sha: Some(base_sha.clone()),
run_branch: Some("arc/run/test-docker".to_string()),
meta_branch: None,
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// 6. Assert GitCheckpoint events were emitted
let events = events.lock().unwrap();
let git_events: Vec<_> = events
.iter()
.filter_map(|e| {
if let PipelineEvent::GitCheckpoint {
node_id,
git_commit_sha,
..
} = e
{
Some((node_id.clone(), git_commit_sha.clone()))
} else {
None
}
})
.collect();
// start, work, exit = 3 nodes, each gets a checkpoint commit
assert!(
git_events.len() >= 2,
"expected at least 2 GitCheckpoint events, got {}",
git_events.len()
);
// Each SHA should be a valid 40-char hex string
assert!(
git_events
.iter()
.all(|(_, sha)| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())),
"all SHAs should be 40-char hex, got: {git_events:?}"
);
// 7. Assert diff.patch was written for the "work" node
let work_diff = logs_dir
.path()
.join("nodes")
.join("work")
.join("diff.patch");
assert!(work_diff.exists(), "diff.patch should exist for work node");
// 8. Verify checkpoint.json has git_commit_sha
let checkpoint =
Checkpoint::load(&logs_dir.path().join("checkpoint.json")).expect("checkpoint should load");
assert!(
checkpoint.git_commit_sha.is_some(),
"checkpoint should have git_commit_sha"
);
// 9. Assert final.patch exists and contains the changes
let final_patch = logs_dir.path().join("final.patch");
assert!(
final_patch.exists(),
"final.patch should exist in logs_root"
);
let patch_content = std::fs::read_to_string(&final_patch).unwrap();
assert!(
patch_content.contains("hello.txt"),
"final.patch should contain hello.txt changes"
);
// Cleanup worktree
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
/// End-to-end test: pipeline with `GitCheckpointMode::Host` + `meta_branch` writes
/// shadow branch with checkpoint data and includes `Arc-Checkpoint` trailer in run-branch commits.
#[tokio::test]
async fn git_checkpoint_host_writes_shadow_branch() {
use arc_workflows::git::MetadataStore;
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Create a branch and worktree
let run_id = "test-shadow";
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::process::Command::new("git")
.args(["branch", &format!("arc/run/{run_id}"), "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg(format!("arc/run/{run_id}"))
.current_dir(repo.path())
.output()
.unwrap();
// Write a file in the worktree so there's something to commit
std::fs::write(worktree_path.join("shadow_test.txt"), "shadow branch test").unwrap();
// 3. Build a simple pipeline: start -> work -> exit
let mut graph = Graph::new("ShadowBranchTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test shadow branch".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("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// 4. Set up engine with meta_branch
let logs_dir = tempfile::tempdir().unwrap();
// Write graph.dot so init_run can read it
std::fs::write(logs_dir.path().join("graph.dot"), "digraph {}").unwrap();
let emitter = EventEmitter::new();
let env: Arc<dyn arc_agent::ExecutionEnvironment> = Arc::new(
arc_agent::LocalExecutionEnvironment::new(worktree_path.clone()),
);
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = PipelineEngine::new(registry, Arc::new(emitter), env);
let meta_branch = MetadataStore::branch_name(run_id);
let config = RunConfig {
logs_root: logs_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: run_id.into(),
git_checkpoint: Some(GitCheckpointMode::Host(worktree_path.clone())),
base_sha: Some(base_sha),
run_branch: Some(format!("arc/run/{run_id}")),
meta_branch: Some(meta_branch),
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// 6. Assert shadow branch has checkpoint data on the host repo
let checkpoint = MetadataStore::read_checkpoint(repo.path(), run_id)
.expect("read_checkpoint should not error")
.expect("shadow branch should contain checkpoint data");
assert!(
!checkpoint.completed_nodes.is_empty(),
"checkpoint should have completed nodes"
);
assert!(
checkpoint.completed_nodes.contains(&"work".to_string()),
"checkpoint should contain the 'work' node"
);
// 7. Assert run-branch commit has Arc-Checkpoint trailer pointing to shadow SHA
let output = std::process::Command::new("git")
.args(["log", "--format=%B", "-1"])
.current_dir(&worktree_path)
.output()
.unwrap();
let commit_msg = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(
commit_msg.contains("Arc-Checkpoint:"),
"run-branch commit should have Arc-Checkpoint trailer, got:\n{commit_msg}"
);
assert!(
commit_msg.contains("Arc-Run:"),
"run-branch commit should have Arc-Run trailer, got:\n{commit_msg}"
);
assert!(
commit_msg.contains("Arc-Completed:"),
"run-branch commit should have Arc-Completed trailer, got:\n{commit_msg}"
);
// 8. Verify round-trip: shadow checkpoint's completed_nodes matches expected
let manifest = MetadataStore::read_manifest(repo.path(), run_id)
.expect("read_manifest should not error")
.expect("shadow branch should contain manifest");
assert_eq!(manifest["run_id"], run_id);
// Cleanup worktree
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
// ---------------------------------------------------------------------------
// Host e2e: parallel git branching with worktree isolation
// ---------------------------------------------------------------------------
/// End-to-end: parallel branches get isolated worktrees, fan-in fast-forwards to winner.
///
/// Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit
///
/// Each branch writes a unique file. After fan-in, only the winner's file should
/// be present in the main worktree.
#[tokio::test]
async fn parallel_git_branching_host_e2e() {
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Set up run branch and worktree (same as cli/run.rs)
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let run_id = "par-git-test";
let run_branch = format!("arc/run/{run_id}");
std::process::Command::new("git")
.args(["branch", &run_branch, "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg(&run_branch)
.current_dir(repo.path())
.output()
.unwrap();
// 3. Build pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit
let mut graph = Graph::new("ParallelGitBranching");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test parallel git 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 fan_out = Node::new("fan_out");
fan_out
.attrs
.insert("shape".to_string(), AttrValue::String("component".to_string()));
graph.nodes.insert("fan_out".to_string(), fan_out);
let branch_a = Node::new("branch_a");
graph.nodes.insert("branch_a".to_string(), branch_a);
let branch_b = Node::new("branch_b");
graph.nodes.insert("branch_b".to_string(), branch_b);
let mut fan_in = Node::new("fan_in");
fan_in.attrs.insert(
"shape".to_string(),
AttrValue::String("tripleoctagon".to_string()),
);
graph.nodes.insert("fan_in".to_string(), fan_in);
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.edges.push(Edge::new("start", "fan_out"));
graph.edges.push(Edge::new("fan_out", "branch_a"));
graph.edges.push(Edge::new("fan_out", "branch_b"));
graph.edges.push(Edge::new("branch_a", "fan_in"));
graph.edges.push(Edge::new("branch_b", "fan_in"));
graph.edges.push(Edge::new("fan_in", "exit"));
// 4. Set up engine with FileWriterHandler for branches
let logs_dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let env: Arc<dyn arc_agent::ExecutionEnvironment> =
Arc::new(arc_agent::LocalExecutionEnvironment::new(worktree_path.clone()));
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(None)), // heuristic select — picks branch_a (lexical tiebreak)
);
let engine = PipelineEngine::new(registry, Arc::new(emitter), env);
let config = RunConfig {
logs_root: logs_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: run_id.into(),
git_checkpoint: Some(GitCheckpointMode::Host(worktree_path.clone())),
base_sha: Some(base_sha.clone()),
run_branch: Some(run_branch.clone()),
meta_branch: None,
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("parallel pipeline should succeed");
assert_eq!(
outcome.status,
StageStatus::Success,
"pipeline failed: {:?}",
outcome.failure_reason
);
// 6. Verify parallel.results has head_sha for each branch
let checkpoint = Checkpoint::load(&logs_dir.path().join("checkpoint.json"))
.expect("checkpoint should load");
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");
// Both branches should have head_sha
let branch_a_result = results_arr
.iter()
.find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_a"))
.expect("branch_a result should exist");
let branch_b_result = results_arr
.iter()
.find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_b"))
.expect("branch_b result should exist");
let sha_a = branch_a_result
.get("head_sha")
.and_then(|v| v.as_str())
.expect("branch_a should have head_sha");
let sha_b = branch_b_result
.get("head_sha")
.and_then(|v| v.as_str())
.expect("branch_b should have head_sha");
assert_eq!(sha_a.len(), 40, "SHA should be 40 hex chars");
assert_eq!(sha_b.len(), 40, "SHA should be 40 hex chars");
assert_ne!(sha_a, sha_b, "branch SHAs should differ");
// 7. Verify fan_in selected a winner and set best_head_sha
let best_id = checkpoint
.context_values
.get("parallel.fan_in.best_id")
.and_then(|v| v.as_str().map(String::from))
.expect("fan_in should have selected a best_id");
let best_head_sha = checkpoint
.context_values
.get("parallel.fan_in.best_head_sha")
.and_then(|v| v.as_str().map(String::from))
.expect("fan_in should have set best_head_sha");
// Heuristic select with both success: lexical tiebreak picks "branch_a"
assert_eq!(best_id, "branch_a", "heuristic should pick branch_a (lexical)");
// 8. Verify winner's file is in the main worktree, loser's is NOT
let winner_file = worktree_path.join(format!("{best_id}.txt"));
assert!(
winner_file.exists(),
"winner's file ({best_id}.txt) should exist in main worktree after ff-merge"
);
let winner_content = std::fs::read_to_string(&winner_file).unwrap();
assert!(
winner_content.contains(&format!("written by {best_id}")),
"winner's file should have correct content"
);
let loser_id = if best_id == "branch_a" {
"branch_b"
} else {
"branch_a"
};
let loser_file = worktree_path.join(format!("{loser_id}.txt"));
assert!(
!loser_file.exists(),
"loser's file ({loser_id}.txt) should NOT exist in main worktree"
);
// 9. Verify the main worktree HEAD matches the winner's head_sha
let main_head = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&worktree_path)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
// After fan-in ff-only + engine's own checkpoint commits, HEAD should be a
// descendant of best_head_sha.
let is_ancestor = std::process::Command::new("git")
.args(["merge-base", "--is-ancestor", &best_head_sha, &main_head])
.current_dir(&worktree_path)
.output()
.unwrap();
assert!(
is_ancestor.status.success(),
"best_head_sha ({best_head_sha}) should be an ancestor of current HEAD ({main_head})"
);
// 10. Verify parallel branch refs still exist (for debugging)
let branch_ref_a = format!("arc/run/parallel/{run_id}/fan_out/branch_a");
let ref_check = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch_ref_a])
.current_dir(repo.path())
.output()
.unwrap();
assert!(
ref_check.status.success(),
"parallel branch ref should still exist for debugging"
);
// 11. Verify final.patch contains the winner's changes
let final_patch = logs_dir.path().join("final.patch");
assert!(
final_patch.exists(),
"final.patch should exist in logs_root"
);
let patch_content = std::fs::read_to_string(&final_patch).unwrap();
assert!(
patch_content.contains(&format!("{best_id}.txt")),
"final.patch should contain winner's file"
);
assert!(
!patch_content.contains(&format!("{loser_id}.txt")),
"final.patch should NOT contain loser's file"
);
// 12. Verify events
let events = events.lock().unwrap();
let parallel_started: Vec<_> = events
.iter()
.filter(|e| matches!(e, PipelineEvent::ParallelStarted { .. }))
.collect();
assert_eq!(
parallel_started.len(),
1,
"should have exactly one ParallelStarted event"
);
let parallel_completed: Vec<_> = events
.iter()
.filter(|e| matches!(e, PipelineEvent::ParallelCompleted { .. }))
.collect();
assert_eq!(
parallel_completed.len(),
1,
"should have exactly one ParallelCompleted event"
);
// Cleanup
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
// Daytona parallel git branching test is in daytona_integration.rs