use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
use crate::agent::flow::WorkflowCtx;
use crate::agent::flow::event::{self, OnWorkflowEvent, WorkflowEvent};
use crate::agent::flow::journal::ResumeMode;
use crate::agent::flow::{agent, pipeline, workflow};
use crate::agent::test_helpers::MockProvider;
use crate::llm::types::{
CompletionRequest, CompletionResponse, ContentBlock, RESPOND_TOOL_NAME, StopReason, TokenUsage,
};
use crate::llm::{BoxedProvider, LlmProvider};
fn event_sink() -> (Arc<Mutex<Vec<WorkflowEvent>>>, Arc<OnWorkflowEvent>) {
let events = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
let cb: Arc<OnWorkflowEvent> = Arc::new(move |e: WorkflowEvent| {
captured.lock().expect("events lock").push(e);
});
(events, cb)
}
struct NotifyProvider {
entered: Arc<tokio::sync::Notify>,
}
impl LlmProvider for NotifyProvider {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, crate::error::Error> {
self.entered.notify_one();
std::future::pending::<()>().await;
unreachable!("the run is cancelled out from under this future")
}
fn model_name(&self) -> Option<&str> {
Some("notify-mock")
}
}
struct CountingProvider {
calls: Arc<AtomicUsize>,
}
impl LlmProvider for CountingProvider {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, crate::error::Error> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: format!("live-{n}"),
}],
stop_reason: StopReason::EndTurn,
reasoning: None,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
},
model: None,
})
}
fn model_name(&self) -> Option<&str> {
Some("counting-mock")
}
}
struct RespondProvider {
payload: serde_json::Value,
}
impl LlmProvider for RespondProvider {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, crate::error::Error> {
Ok(CompletionResponse {
content: vec![ContentBlock::ToolUse {
id: "resp-1".into(),
name: RESPOND_TOOL_NAME.into(),
input: self.payload.clone(),
}],
stop_reason: StopReason::ToolUse,
reasoning: None,
usage: TokenUsage {
input_tokens: 5,
output_tokens: 5,
..Default::default()
},
model: None,
})
}
fn model_name(&self) -> Option<&str> {
Some("respond-mock")
}
}
struct PanicProvider;
impl LlmProvider for PanicProvider {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, crate::error::Error> {
panic!("provider must NOT be called on a journal hit");
}
fn model_name(&self) -> Option<&str> {
Some("panic-mock")
}
}
fn counting_ctx(calls: &Arc<AtomicUsize>, path: &std::path::Path, mode: ResumeMode) -> WorkflowCtx {
WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(calls),
})))
.journal(path, mode)
.expect("open journal")
.build()
.expect("build ctx")
}
#[tokio::test]
async fn duplicate_prompts_replay_in_occurrence_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dup.jsonl");
let calls1 = Arc::new(AtomicUsize::new(0));
let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
let a = agent(&ctx1, "dup").run().await.expect("run ok");
let b = agent(&ctx1, "dup").run().await.expect("run ok");
assert_eq!(a.as_deref(), Some("live-0"), "first call = occurrence 0");
assert_eq!(b.as_deref(), Some("live-1"), "second call = occurrence 1");
assert_eq!(calls1.load(Ordering::SeqCst), 2);
let calls2 = Arc::new(AtomicUsize::new(0));
let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
let a2 = agent(&ctx2, "dup").run().await.expect("run ok");
let b2 = agent(&ctx2, "dup").run().await.expect("run ok");
assert_eq!(a2.as_deref(), Some("live-0"), "first replay = occurrence 0");
assert_eq!(
b2.as_deref(),
Some("live-1"),
"second replay = occurrence 1"
);
assert_eq!(
calls2.load(Ordering::SeqCst),
0,
"both replays must skip the provider"
);
}
#[tokio::test]
async fn cancelled_miss_does_not_append_to_journal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cancel.jsonl");
let calls1 = Arc::new(AtomicUsize::new(0));
let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
ctx1.stop();
let out = agent(&ctx1, "task").run().await.expect("run ok");
assert!(out.is_none(), "cancelled run yields Ok(None)");
assert_eq!(calls1.load(Ordering::SeqCst), 0, "provider not called");
let calls2 = Arc::new(AtomicUsize::new(0));
let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
let out2 = agent(&ctx2, "task").run().await.expect("run ok");
assert_eq!(
out2.as_deref(),
Some("live-0"),
"resume runs live (journal not poisoned)"
);
assert_eq!(
calls2.load(Ordering::SeqCst),
1,
"the cancelled miss must not have been journaled"
);
}
#[tokio::test]
async fn schema_marker_changes_journal_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("schema.jsonl");
let calls1 = Arc::new(AtomicUsize::new(0));
let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
agent(&ctx1, "p").run().await.expect("run ok");
assert_eq!(calls1.load(Ordering::SeqCst), 1);
let calls2 = Arc::new(AtomicUsize::new(0));
let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
let _res = agent(&ctx2, "p")
.schema_value(json!({ "type": "object" }))
.run()
.await;
assert!(
calls2.load(Ordering::SeqCst) >= 1,
"the schema call must MISS the no-schema journal entry and run live"
);
}
#[tokio::test]
async fn reordered_schema_keys_hit_same_journal_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("reorder.jsonl");
let schema_a = json!({ "type": "object", "required": ["x"] });
let schema_b = json!({ "required": ["x"], "type": "object" });
let ctx1 = WorkflowCtx::builder(Arc::new(BoxedProvider::new(RespondProvider {
payload: json!({ "x": 1 }),
})))
.journal(&path, ResumeMode::Fresh)
.expect("open journal")
.build()
.expect("build ctx");
let first = agent(&ctx1, "p")
.schema_value(schema_a)
.run()
.await
.expect("run ok");
assert_eq!(first, Some(json!({ "x": 1 })));
let ctx2 = WorkflowCtx::builder(Arc::new(BoxedProvider::new(PanicProvider)))
.journal(&path, ResumeMode::Resume)
.expect("open journal")
.build()
.expect("build ctx");
let replayed = agent(&ctx2, "p")
.schema_value(schema_b)
.run()
.await
.expect("run ok");
assert_eq!(
replayed,
Some(json!({ "x": 1 })),
"reordered-but-equal schema keys must hit the same journal entry"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inflight_cancel_returns_none_records_no_spend_and_emits_skipped() {
let entered = Arc::new(tokio::sync::Notify::new());
let provider = Arc::new(BoxedProvider::new(NotifyProvider {
entered: Arc::clone(&entered),
}));
let (events, cb) = event_sink();
let ctx = WorkflowCtx::builder(provider)
.on_event(cb)
.build()
.expect("build ctx");
let run_ctx = ctx.clone();
let handle = tokio::spawn(async move { agent(&run_ctx, "task").label("late").run().await });
entered.notified().await;
ctx.stop();
let out = handle.await.expect("join").expect("run ok");
assert!(out.is_none(), "in-flight cancel must yield Ok(None)");
assert_eq!(ctx.budget().spent(), 0, "a cancelled run records no spend");
assert_eq!(
ctx.spawned(),
1,
"permit + backstop ran before the cancel point"
);
let evs = events.lock().expect("events lock");
assert!(
evs.iter()
.any(|e| matches!(e, WorkflowEvent::AgentStarted { .. })),
"AgentStarted fires before the in-flight cancel: {evs:?}"
);
assert!(
evs.iter()
.any(|e| matches!(e, WorkflowEvent::AgentSkipped { .. })),
"in-flight cancel emits AgentSkipped: {evs:?}"
);
assert!(
!evs.iter()
.any(|e| matches!(e, WorkflowEvent::AgentFinished { .. })),
"a cancelled run must NOT emit AgentFinished: {evs:?}"
);
}
#[tokio::test]
async fn replay_emits_only_agent_replayed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("replay-ev.jsonl");
let calls1 = Arc::new(AtomicUsize::new(0));
let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
agent(&ctx1, "p").run().await.expect("run ok");
let (events, cb) = event_sink();
let calls2 = Arc::new(AtomicUsize::new(0));
let ctx2 = WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(&calls2),
})))
.journal(&path, ResumeMode::Resume)
.expect("open journal")
.on_event(cb)
.build()
.expect("build ctx");
let out = agent(&ctx2, "p").run().await.expect("run ok");
assert_eq!(out.as_deref(), Some("live-0"));
assert_eq!(calls2.load(Ordering::SeqCst), 0, "replay calls no provider");
let evs = events.lock().expect("events lock");
let replayed = evs
.iter()
.filter(|e| matches!(e, WorkflowEvent::AgentReplayed { .. }))
.count();
let started = evs
.iter()
.filter(|e| matches!(e, WorkflowEvent::AgentStarted { .. }))
.count();
let finished = evs
.iter()
.filter(|e| matches!(e, WorkflowEvent::AgentFinished { .. }))
.count();
assert_eq!(replayed, 1, "exactly one AgentReplayed on replay: {evs:?}");
assert_eq!(started, 0, "no AgentStarted on replay: {evs:?}");
assert_eq!(finished, 0, "no AgentFinished on replay: {evs:?}");
}
#[tokio::test]
async fn phase_snapshot_captured_at_construction_and_overridable() {
let (events, cb) = event_sink();
let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(MockProvider::new(vec![
MockProvider::text_response("a", 1, 1),
MockProvider::text_response("b", 1, 1),
]))))
.on_event(cb)
.build()
.expect("build ctx");
let guard = event::phase(&ctx, "A");
let call_a = agent(&ctx, "x");
let call_c = agent(&ctx, "y").phase("C");
drop(guard);
call_a.run().await.expect("run ok");
call_c.run().await.expect("run ok");
let evs = events.lock().expect("events lock");
let phases: Vec<Option<String>> = evs
.iter()
.filter_map(|e| match e {
WorkflowEvent::AgentStarted { phase, .. } => Some(phase.clone()),
_ => None,
})
.collect();
assert!(
phases.contains(&Some("A".to_string())),
"the construction-time snapshot ('A') must survive the guard drop: {phases:?}"
);
assert!(
phases.contains(&Some("C".to_string())),
"an explicit .phase('C') override must win: {phases:?}"
);
}
fn plain_counting_ctx(calls: &Arc<AtomicUsize>) -> WorkflowCtx {
WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(calls),
})))
.build()
.expect("build ctx")
}
#[tokio::test]
async fn workflow_runs_body_with_child_sharing_budget() {
let calls = Arc::new(AtomicUsize::new(0));
let ctx = plain_counting_ctx(&calls);
let result = workflow(&ctx, "research", |child| async move {
agent(&child, "task").run().await
})
.await
.expect("workflow ok");
assert_eq!(result.as_deref(), Some("live-0"), "body value is returned");
assert_eq!(calls.load(Ordering::SeqCst), 1, "the body actually ran");
assert_eq!(
ctx.budget().spent(),
15,
"the child's spend counts against the shared parent budget"
);
}
#[tokio::test]
async fn workflow_nested_one_level_only_returns_config_error() {
let calls = Arc::new(AtomicUsize::new(0));
let ctx = plain_counting_ctx(&calls);
let result: Result<(), crate::error::Error> = workflow(&ctx, "outer", |child| async move {
workflow(&child, "inner", |_grandchild| async move { Ok(()) }).await
})
.await;
assert!(
matches!(result, Err(crate::error::Error::Config(_))),
"nesting a second workflow() level must return Error::Config, got {result:?}"
);
}
#[tokio::test]
async fn zero_stage_pipeline_returns_null_per_item() {
let calls = Arc::new(AtomicUsize::new(0));
let ctx = plain_counting_ctx(&calls);
let out = pipeline(&ctx, vec![10usize, 20usize, 30usize]).run().await;
assert_eq!(
out,
vec![Some(Value::Null), Some(Value::Null), Some(Value::Null)],
"a zero-stage pipeline yields the initial Null accumulator per item"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"no stages -> no agent calls"
);
}
#[tokio::test]
async fn pipeline_threads_accumulator_between_stages() {
let calls = Arc::new(AtomicUsize::new(0));
let ctx = plain_counting_ctx(&calls);
let out = pipeline(&ctx, vec![7i64])
.stage(|prev, _item, _idx| async move {
assert_eq!(
prev,
Value::Null,
"stage 0 starts from the Null accumulator"
);
Ok(Value::from(100i64))
})
.stage(|prev, _item, _idx| async move {
assert_eq!(
prev,
Value::from(100i64),
"stage 1 must receive stage 0's returned value"
);
Ok(Value::from(prev.as_i64().unwrap() + 1))
})
.run()
.await;
assert_eq!(
out,
vec![Some(Value::from(101i64))],
"the final accumulator is threaded through both stages"
);
}
struct AlwaysNoJudge;
impl LlmProvider for AlwaysNoJudge {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, crate::error::Error> {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "GOAL_MET: NO: keep going".to_string(),
}],
stop_reason: StopReason::EndTurn,
reasoning: None,
usage: TokenUsage {
input_tokens: 1,
output_tokens: 1,
..Default::default()
},
model: None,
})
}
fn model_name(&self) -> Option<&str> {
Some("always-no-judge")
}
}
#[tokio::test]
async fn goal_driven_leaf_accrues_continuation_spend_into_shared_budget() {
let calls = Arc::new(AtomicUsize::new(0));
let worker = Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(&calls),
}));
let ctx = WorkflowCtx::builder(worker)
.budget(10_000)
.build()
.expect("build ctx");
let judge = Arc::new(BoxedProvider::new(AlwaysNoJudge));
let goal = crate::agent::goal::GoalCondition::new("ship it", judge).with_max_continuations(2);
let out = agent(&ctx, "task").goal(goal).run().await.expect("run ok");
assert!(out.is_some(), "the leaf returns its final text");
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"1 initial completion + 2 goal continuations = 3 worker turns"
);
assert_eq!(
ctx.budget().spent(),
51,
"worker 3×(10+5)=45 + judge 3×(1+1)=6 = 51 accrues into the shared budget \
(continuation spend AND the independent judge's tokens are accounted)"
);
}
#[tokio::test]
async fn exhausted_budget_bounds_goal_leaf() {
let calls = Arc::new(AtomicUsize::new(0));
let worker = Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(&calls),
}));
let ctx = WorkflowCtx::builder(worker)
.budget(1)
.build()
.expect("build ctx");
ctx.budget().record(&TokenUsage {
input_tokens: 5,
..Default::default()
});
let judge = Arc::new(BoxedProvider::new(AlwaysNoJudge));
let goal =
crate::agent::goal::GoalCondition::new("ship it", judge).with_max_continuations(1000);
let result = agent(&ctx, "task").goal(goal).run().await;
assert!(
matches!(result, Err(crate::error::Error::BudgetExceeded { .. })),
"an exhausted budget must abort admission (control error), got {result:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"the goal loop never starts — the worker is never called"
);
assert!(
ctx.is_cancelled(),
"the budget breach fires run-wide cancellation"
);
}
#[tokio::test]
async fn solo_goal_leaf_stops_on_budget_cap_not_continuation_cap() {
let calls = Arc::new(AtomicUsize::new(0));
let worker = Arc::new(BoxedProvider::new(CountingProvider {
calls: Arc::clone(&calls),
}));
let ctx = WorkflowCtx::builder(worker)
.budget(40)
.build()
.expect("build ctx");
let judge = Arc::new(BoxedProvider::new(AlwaysNoJudge));
let goal =
crate::agent::goal::GoalCondition::new("ship it", judge).with_max_continuations(1000);
let result = agent(&ctx, "task").goal(goal).run().await;
let is_budget_exceeded = match &result {
Err(crate::error::Error::BudgetExceeded { .. }) => true,
Err(crate::error::Error::WithPartialUsage { source, .. }) => {
matches!(**source, crate::error::Error::BudgetExceeded { .. })
}
_ => false,
};
assert!(
is_budget_exceeded,
"the budget-derived token cap must stop the loop, got {result:?}"
);
assert!(
calls.load(Ordering::SeqCst) < 10,
"the leaf stops on the budget cap, bounded well below the 1000 continuation \
cap — got {} worker turns",
calls.load(Ordering::SeqCst)
);
}