use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use rust_decimal::Decimal;
use serde_json::json;
use ironflow_core::decision::DecisionOutput;
use ironflow_core::provider::AgentProvider;
use ironflow_core::providers::claude::ClaudeCodeProvider;
use ironflow_core::providers::record_replay::RecordReplayProvider;
use ironflow_core::providers::record_replay_decision::{
RecordReplayDecisionProvider, hash_request,
};
use ironflow_engine::config::{DecisionConfig, ShellConfig};
use ironflow_engine::context::WorkflowContext;
use ironflow_engine::engine::Engine;
use ironflow_engine::error::EngineError;
use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::{RunStatus, StepKind, StepStatus, TriggerKind};
const STATE: &str = "Help! My payouts have been failing for 3 days.";
fn build_config(escalate_below: Option<f64>) -> DecisionConfig {
let mut config = DecisionConfig::new(STATE)
.noul("is_urgent", "Does this convey urgency?")
.choice(
"department",
"Which team?",
&["billing", "technical", "sales"],
)
.score(
"frustration",
"How frustrated?",
&["Calm", "Frustrated", "Very angry"],
);
if let Some(t) = escalate_below {
config = config.escalate_below(t);
}
config
}
fn real_triage_output() -> DecisionOutput {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/decisions/triage-output.json"
);
let json = fs::read_to_string(path).expect("recorded triage fixture must exist");
serde_json::from_str(&json).expect("recorded fixture parses into DecisionOutput")
}
struct TempDir(String);
impl Drop for TempDir {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).ok();
}
}
fn temp_fixtures() -> (String, TempDir) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = format!(
"/tmp/ironflow-decision-e2e-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
fs::create_dir_all(&dir).unwrap();
(dir.clone(), TempDir(dir))
}
fn write_fixture(dir: &str, config: &DecisionConfig, output: &DecisionOutput) {
let request = config.to_request();
let hash = hash_request(&request);
let fixture = json!({ "request": request, "output": output });
let path = PathBuf::from(dir).join(format!("{hash}.json"));
fs::write(path, serde_json::to_string_pretty(&fixture).unwrap()).unwrap();
}
fn agent_provider() -> Arc<dyn AgentProvider> {
Arc::new(RecordReplayProvider::replay(
ClaudeCodeProvider::new(),
"/tmp/ironflow-fixtures",
))
}
struct TriageWorkflow {
escalate_below: Option<f64>,
}
impl WorkflowHandler for TriageWorkflow {
fn name(&self) -> &str {
"triage"
}
fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async move {
let out = ctx
.decision("triage", build_config(self.escalate_below))
.await?;
let _ = out.noul("is_urgent")?;
let team = out.choice("department")?.choice.clone();
let cmd = format!("echo routed to {team}");
ctx.shell("route", ShellConfig::new(&cmd)).await?;
Ok(())
})
}
}
#[tokio::test]
async fn decision_happy_path_completes_and_exposes_typed_answers() {
let (dir, _guard) = temp_fixtures();
let config = build_config(Some(0.5));
write_fixture(&dir, &config, &real_triage_output());
let store = Arc::new(InMemoryStore::new());
let mut engine = Engine::new(store, agent_provider())
.with_decision_provider(Arc::new(RecordReplayDecisionProvider::replay(&dir)));
engine
.register(TriageWorkflow {
escalate_below: Some(0.5),
})
.unwrap();
let result = engine
.run_handler("triage", TriggerKind::Api, json!({}))
.await
.unwrap();
assert_eq!(result.run.status.state, RunStatus::Completed);
let steps = engine.store().list_steps(result.run.id).await.unwrap();
let decision = steps.iter().find(|s| s.name == "triage").unwrap();
assert_eq!(decision.kind, StepKind::Decision);
assert_eq!(decision.status.state, StepStatus::Completed);
let stored: DecisionOutput = serde_json::from_value(decision.output.clone().unwrap()).unwrap();
assert_eq!(stored.choice("department").unwrap().choice, "billing");
assert!(steps.iter().any(|s| s.name == "route"));
}
#[tokio::test]
async fn decision_escalates_below_threshold_then_replays_on_resume() {
let (dir, _guard) = temp_fixtures();
let config = build_config(Some(0.9));
write_fixture(&dir, &config, &real_triage_output());
let store = Arc::new(InMemoryStore::new());
let mut engine = Engine::new(store, agent_provider())
.with_decision_provider(Arc::new(RecordReplayDecisionProvider::replay(&dir)));
engine
.register(TriageWorkflow {
escalate_below: Some(0.9),
})
.unwrap();
let result = engine
.run_handler("triage", TriggerKind::Api, json!({}))
.await
.unwrap();
assert_eq!(result.run.status.state, RunStatus::AwaitingApproval);
let steps = engine.store().list_steps(result.run.id).await.unwrap();
let decision = steps.iter().find(|s| s.name == "triage").unwrap();
assert_eq!(decision.status.state, StepStatus::AwaitingApproval);
assert!(!steps.iter().any(|s| s.name == "route"));
engine
.store()
.update_run_status(result.run.id, RunStatus::Running)
.await
.unwrap();
let resumed = engine.resume_run(result.run.id).await.unwrap();
assert_eq!(resumed.run.status.state, RunStatus::Completed);
assert_eq!(resumed.run.cost_usd, real_triage_output().usage.cost_usd());
let steps = engine.store().list_steps(result.run.id).await.unwrap();
let decision = steps.iter().find(|s| s.name == "triage").unwrap();
assert_eq!(decision.status.state, StepStatus::Completed);
let stored: DecisionOutput = serde_json::from_value(decision.output.clone().unwrap()).unwrap();
assert_eq!(stored.choice("department").unwrap().choice, "billing");
assert!(steps.iter().any(|s| s.name == "route"));
}
#[tokio::test]
async fn decision_without_provider_fails_explicitly() {
let store = Arc::new(InMemoryStore::new());
let mut engine = Engine::new(store, agent_provider());
engine
.register(TriageWorkflow {
escalate_below: None,
})
.unwrap();
let err = engine
.run_handler("triage", TriggerKind::Api, json!({}))
.await
.unwrap_err();
assert!(
matches!(err, EngineError::NoDecisionProvider { .. }),
"expected NoDecisionProvider, got: {err:?}"
);
assert!(err.to_string().contains("decision provider"));
}
#[tokio::test]
async fn decision_cost_is_imputed_to_run_budget() {
let (dir, _guard) = temp_fixtures();
let config = build_config(Some(0.5));
write_fixture(&dir, &config, &real_triage_output());
let store = Arc::new(InMemoryStore::new());
let mut engine = Engine::new(store, agent_provider())
.with_decision_provider(Arc::new(RecordReplayDecisionProvider::replay(&dir)));
engine
.register(TriageWorkflow {
escalate_below: Some(0.5),
})
.unwrap();
let result = engine
.run_handler("triage", TriggerKind::Api, json!({}))
.await
.unwrap();
assert_eq!(result.run.status.state, RunStatus::Completed);
let expected = real_triage_output().usage.cost_usd();
assert!(expected > Decimal::ZERO);
assert_eq!(result.run.cost_usd, expected);
}
#[allow(dead_code)]
fn approval_required_is_an_error(e: EngineError) -> bool {
matches!(e, EngineError::ApprovalRequired { .. })
}