Skip to main content

tinyagents/graph/testkit/
mod.rs

1//! Graph-test building blocks — deterministic node doubles, an event recorder, a
2//! stream projector, and a fluent run-assertion builder for the durable graph
3//! runtime.
4//!
5//! This is the *graph-level* counterpart to the harness
6//! [`testkit`](crate::harness::testkit) (model/tool doubles + trajectories):
7//! here the units under test are graph nodes and supersteps, so the doubles are
8//! node handlers and the assertions read a run's export/event/checkpoint truth.
9//! Because a node can recurse into a [subgraph](crate::graph::subgraph) or a
10//! [sub-agent](crate::graph::subagent_node), the same recorder that observes a
11//! top-level run also captures the events and child-run rollups of the nested
12//! runs it spawns, so recursion stays observable in tests.
13//!
14//! # Node doubles
15//!
16//! Each returns a closure ready for
17//! [`GraphBuilder::add_node`](crate::graph::GraphBuilder::add_node):
18//!
19//! | Helper | Behavior |
20//! |--------|----------|
21//! | [`noop_node`] | Routes onward with no state update |
22//! | [`scripted_update_node`] | Emits queued updates (saturating the last) |
23//! | [`scripted_route_node`] | Emits queued `goto` route-sets |
24//! | [`fanout_node`] | Emits one [`Send`](crate::graph::Send) per arg (fanout) |
25//! | [`failing_node`] | Always returns an error |
26//! | [`RetryCountingNode`] | Counts activations, fails the first N |
27//! | [`interrupting_node`] | Interrupts until resumed, then updates |
28//! | [`subgraph_test_node`] | Embeds a child graph (shared state) |
29//! | [`subagent_fake_node`] | Records a child run + updates (fake sub-agent) |
30//!
31//! # Observation & assertions
32//!
33//! [`GraphEventRecorder`] captures the [`GraphEvent`] stream; [`StreamCollector`]
34//! projects it; [`run_recorded`] runs a graph with the recorder wired and
35//! bundles the result, recorded events, and checkpoint history into a
36//! [`GraphRun`]; [`assert_graph`] opens a fluent [`GraphAssertions`] over it. The
37//! single [`GraphRun`] is the test truth — execution, events, and checkpoints
38//! all read from it.
39
40pub mod conformance;
41mod types;
42
43pub use types::{
44    GraphAssertions, GraphEventRecorder, GraphRun, RetryCountingNode, StreamCollector,
45};
46
47use std::sync::Arc;
48use std::sync::atomic::{AtomicUsize, Ordering};
49
50use serde_json::Value;
51
52use crate::graph::builder::{NodeContext, NodeFuture};
53use crate::graph::command::{Command, Interrupt, NodeResult, Send as SendPacket};
54use crate::graph::compiled::{CompiledGraph, GraphExecution, StateSnapshot};
55use crate::graph::recursion::ChildRun;
56use crate::graph::stream::{CollectingSink, GraphEvent, GraphEventSink};
57use crate::harness::ids::{GraphId, NodeId, RunId};
58use crate::harness::usage::UsageTotals;
59use crate::{Result, TinyAgentsError};
60
61// ---------------------------------------------------------------------------
62// Node doubles
63// ---------------------------------------------------------------------------
64
65/// A node that performs no state update and routes onward via its static or
66/// conditional edges (emits an empty [`Command`]).
67pub fn noop_node<State, Update>()
68-> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
69where
70    State: Send + 'static,
71    Update: Send + 'static,
72{
73    move |_state, _ctx| -> NodeFuture<Update> {
74        Box::pin(async move { Ok(NodeResult::Command(Command::new())) })
75    }
76}
77
78/// A node that emits a fixed queue of updates, one per activation.
79///
80/// The first activation returns the first update, the second the second, and so
81/// on; once the queue is drained every later activation re-emits the *last*
82/// update (so the node stays well-defined inside a loop). An empty queue makes
83/// every activation fail with [`TinyAgentsError::Graph`].
84pub fn scripted_update_node<State, Update>(
85    updates: impl IntoIterator<Item = Update>,
86) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
87where
88    State: Send + 'static,
89    Update: Clone + Send + Sync + 'static,
90{
91    let updates: Arc<Vec<Update>> = Arc::new(updates.into_iter().collect());
92    let idx = Arc::new(AtomicUsize::new(0));
93    move |_state, _ctx| -> NodeFuture<Update> {
94        let updates = updates.clone();
95        let idx = idx.clone();
96        Box::pin(async move {
97            if updates.is_empty() {
98                return Err(TinyAgentsError::Graph(
99                    "scripted_update_node has no scripted updates".to_string(),
100                ));
101            }
102            let i = idx.fetch_add(1, Ordering::Relaxed).min(updates.len() - 1);
103            Ok(NodeResult::Update(updates[i].clone()))
104        })
105    }
106}
107
108/// A node that routes via a fixed queue of `goto` target-sets, one set per
109/// activation (saturating the last set once drained).
110///
111/// Each item is the set of nodes to activate next; route to
112/// [`END`](crate::graph::END) to end a branch. An empty queue makes every
113/// activation fail with [`TinyAgentsError::Graph`].
114pub fn scripted_route_node<State, Update, I, R, N>(
115    routes: I,
116) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
117where
118    State: Send + 'static,
119    Update: Send + 'static,
120    I: IntoIterator<Item = R>,
121    R: IntoIterator<Item = N>,
122    N: Into<NodeId>,
123{
124    let routes: Arc<Vec<Vec<NodeId>>> = Arc::new(
125        routes
126            .into_iter()
127            .map(|r| r.into_iter().map(Into::into).collect())
128            .collect(),
129    );
130    let idx = Arc::new(AtomicUsize::new(0));
131    move |_state, _ctx| -> NodeFuture<Update> {
132        let routes = routes.clone();
133        let idx = idx.clone();
134        Box::pin(async move {
135            if routes.is_empty() {
136                return Err(TinyAgentsError::Graph(
137                    "scripted_route_node has no scripted routes".to_string(),
138                ));
139            }
140            let i = idx.fetch_add(1, Ordering::Relaxed).min(routes.len() - 1);
141            Ok(NodeResult::Command(Command::goto(routes[i].clone())))
142        })
143    }
144}
145
146/// A node that fans out to `target` once per `arg`, delivering each arg through
147/// [`NodeContext::send_arg`](crate::graph::NodeContext::send_arg).
148///
149/// This is the map-reduce / parallel-tool primitive: it emits one
150/// [`Send`](crate::graph::Send) per argument, so `target` is scheduled once for
151/// each work item with its own per-invocation input.
152pub fn fanout_node<State, Update>(
153    target: impl Into<NodeId>,
154    args: impl IntoIterator<Item = Value>,
155) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
156where
157    State: Send + 'static,
158    Update: Send + 'static,
159{
160    let target = target.into();
161    let args: Arc<Vec<Value>> = Arc::new(args.into_iter().collect());
162    move |_state, _ctx| -> NodeFuture<Update> {
163        let target = target.clone();
164        let args = args.clone();
165        Box::pin(async move {
166            let sends: Vec<SendPacket> = args
167                .iter()
168                .map(|a| SendPacket::new(target.clone(), a.clone()))
169                .collect();
170            Ok(NodeResult::Command(Command::send(sends)))
171        })
172    }
173}
174
175/// A node that always fails with [`TinyAgentsError::Graph`] carrying `message`.
176pub fn failing_node<State, Update>(
177    message: impl Into<String>,
178) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
179where
180    State: Send + 'static,
181    Update: Send + 'static,
182{
183    let message = message.into();
184    move |_state, _ctx| -> NodeFuture<Update> {
185        let message = message.clone();
186        Box::pin(async move { Err(TinyAgentsError::Graph(message)) })
187    }
188}
189
190/// A node that interrupts (pausing the run for human input) until a resume
191/// value arrives, then emits `on_resume`.
192///
193/// On an activation with no resume value it returns
194/// [`NodeResult::Interrupt`](crate::graph::NodeResult::Interrupt) carrying
195/// `payload`; on a resumed activation (a non-empty
196/// [`NodeContext::resume`](crate::graph::NodeContext)) it returns
197/// `NodeResult::Update(on_resume)`. Requires a checkpointer to actually pause
198/// and resume.
199pub fn interrupting_node<State, Update>(
200    payload: Value,
201    on_resume: Update,
202) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
203where
204    State: Send + 'static,
205    Update: Clone + Send + Sync + 'static,
206{
207    move |_state, ctx: NodeContext| -> NodeFuture<Update> {
208        let payload = payload.clone();
209        let on_resume = on_resume.clone();
210        Box::pin(async move {
211            match ctx.resume {
212                Some(_) => Ok(NodeResult::Update(on_resume)),
213                None => Ok(NodeResult::Interrupt(Interrupt::new(
214                    ctx.node_id.clone(),
215                    payload,
216                ))),
217            }
218        })
219    }
220}
221
222/// Embeds `child` as a shared-state subgraph node (a thin wrapper over
223/// [`shared_subgraph_node`](crate::graph::shared_subgraph_node)).
224///
225/// The child runs over the parent state and its final state becomes the parent
226/// update, recording a [`ChildRun`] onto the enclosing run so the subgraph is
227/// visible on the parent [`GraphExecution::child_runs`].
228pub fn subgraph_test_node<State>(
229    child: CompiledGraph<State, State>,
230) -> Box<dyn Fn(State, NodeContext) -> NodeFuture<State> + Send + Sync>
231where
232    State: Clone + Send + Sync + 'static,
233{
234    crate::graph::subgraph::shared_subgraph_node(child)
235}
236
237/// A fake sub-agent node: records a [`ChildRun`] (with `usage`) onto the
238/// enclosing run's child-run sink and emits `update`.
239///
240/// This mimics what [`subagent_node`](crate::graph::subagent_node) records,
241/// without needing a registry or a live agent, so tests can assert the
242/// parent-run child rollup ([`GraphExecution::child_runs`] /
243/// [`run_tree`](crate::graph::GraphExecution::run_tree)) deterministically. The
244/// child run preserves the enclosing run's `root_run_id`.
245pub fn subagent_fake_node<State, Update>(
246    agent: impl Into<String>,
247    update: Update,
248    usage: UsageTotals,
249) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
250where
251    State: Send + 'static,
252    Update: Clone + Send + Sync + 'static,
253{
254    let agent = agent.into();
255    move |_state, ctx: NodeContext| -> NodeFuture<Update> {
256        let agent = agent.clone();
257        let update = update.clone();
258        Box::pin(async move {
259            if let Some(sink) = &ctx.child_runs {
260                let root_run_id = ctx
261                    .root_run_id
262                    .clone()
263                    .unwrap_or_else(|| ctx.run_id.clone());
264                sink.record(ChildRun {
265                    node: ctx.node_id.clone(),
266                    graph_id: GraphId::new(format!("agent:{agent}")),
267                    run_id: RunId::new(format!(
268                        "subagent-fake-{}",
269                        crate::harness::ids::next_seq()
270                    )),
271                    root_run_id,
272                    usage,
273                });
274            }
275            Ok(NodeResult::Update(update))
276        })
277    }
278}
279
280impl RetryCountingNode {
281    /// Creates a counter whose nodes fail their first `fail_times` activations
282    /// and succeed afterwards.
283    pub fn new(fail_times: usize) -> Self {
284        Self {
285            attempts: Arc::new(AtomicUsize::new(0)),
286            fail_times,
287        }
288    }
289
290    /// The number of activations recorded so far across every handler this
291    /// counter produced.
292    pub fn attempts(&self) -> usize {
293        self.attempts.load(Ordering::Relaxed)
294    }
295
296    /// Produces a node handler sharing this counter: the first `fail_times`
297    /// activations return [`TinyAgentsError::Graph`]; later ones succeed with
298    /// `success`.
299    pub fn handler<State, Update>(
300        &self,
301        success: Update,
302    ) -> impl Fn(State, NodeContext) -> NodeFuture<Update> + Send + Sync + 'static
303    where
304        State: Send + 'static,
305        Update: Clone + Send + Sync + 'static,
306    {
307        let attempts = self.attempts.clone();
308        let fail_times = self.fail_times;
309        move |_state, _ctx| -> NodeFuture<Update> {
310            let attempts = attempts.clone();
311            let success = success.clone();
312            Box::pin(async move {
313                let n = attempts.fetch_add(1, Ordering::Relaxed) + 1;
314                if n <= fail_times {
315                    Err(TinyAgentsError::Graph(format!(
316                        "retry_counting_node: attempt {n} of {fail_times} failing"
317                    )))
318                } else {
319                    Ok(NodeResult::Update(success))
320                }
321            })
322        }
323    }
324}
325
326// ---------------------------------------------------------------------------
327// GraphEventRecorder
328// ---------------------------------------------------------------------------
329
330impl GraphEventRecorder {
331    /// Creates an empty recorder.
332    pub fn new() -> Self {
333        Self {
334            sink: CollectingSink::new(),
335        }
336    }
337
338    /// An [`Arc`]-wrapped event sink to hand to
339    /// [`CompiledGraph::with_event_sink`](crate::graph::CompiledGraph::with_event_sink).
340    pub fn sink(&self) -> Arc<dyn GraphEventSink> {
341        Arc::new(self.sink.clone())
342    }
343
344    /// A snapshot of the recorded events, in emission order.
345    pub fn events(&self) -> Vec<GraphEvent> {
346        self.sink.events()
347    }
348
349    /// The `kind()` string of each recorded event, in emission order.
350    pub fn kinds(&self) -> Vec<String> {
351        self.sink
352            .events()
353            .iter()
354            .map(|e| e.kind().to_string())
355            .collect()
356    }
357
358    /// A [`StreamCollector`] over the events recorded so far.
359    pub fn collector(&self) -> StreamCollector {
360        StreamCollector::new(self.sink.events())
361    }
362}
363
364// ---------------------------------------------------------------------------
365// StreamCollector
366// ---------------------------------------------------------------------------
367
368impl StreamCollector {
369    /// Wraps an owned event sequence for projection.
370    pub fn new(events: Vec<GraphEvent>) -> Self {
371        Self { events }
372    }
373
374    /// The recorded events, unprojected.
375    pub fn events(&self) -> &[GraphEvent] {
376        &self.events
377    }
378
379    /// The executed-node order (one entry per
380    /// [`GraphEvent::NodeCompleted`](crate::graph::GraphEvent)).
381    pub fn node_order(&self) -> Vec<NodeId> {
382        self.events
383            .iter()
384            .filter_map(|e| match e {
385                GraphEvent::NodeCompleted { node, .. } => Some(node.clone()),
386                _ => None,
387            })
388            .collect()
389    }
390
391    /// The nodes that wrote state, in order (one entry per `StateUpdated`).
392    pub fn updates(&self) -> Vec<NodeId> {
393        self.events
394            .iter()
395            .filter_map(|e| match e {
396                GraphEvent::StateUpdated { node, .. } => Some(node.clone()),
397                _ => None,
398            })
399            .collect()
400    }
401
402    /// The `(from, to)` routes the executor selected, in order.
403    pub fn routes(&self) -> Vec<(NodeId, NodeId)> {
404        self.events
405            .iter()
406            .filter_map(|e| match e {
407                GraphEvent::RouteSelected { node, target } => Some((node.clone(), target.clone())),
408                _ => None,
409            })
410            .collect()
411    }
412
413    /// The interrupts emitted during the run, in order.
414    pub fn interrupts(&self) -> Vec<Interrupt> {
415        self.events
416            .iter()
417            .filter_map(|e| match e {
418                GraphEvent::InterruptEmitted { interrupt } => Some(interrupt.clone()),
419                _ => None,
420            })
421            .collect()
422    }
423
424    /// The number of persisted checkpoints (one per `CheckpointSaved`).
425    pub fn checkpoint_count(&self) -> usize {
426        self.events
427            .iter()
428            .filter(|e| matches!(e, GraphEvent::CheckpointSaved { .. }))
429            .count()
430    }
431
432    /// The custom node writes, as `(name, data)` pairs in order.
433    pub fn custom(&self) -> Vec<(String, Value)> {
434        self.events
435            .iter()
436            .filter_map(|e| match e {
437                GraphEvent::Custom { name, data } => Some((name.clone(), data.clone())),
438                _ => None,
439            })
440            .collect()
441    }
442}
443
444// ---------------------------------------------------------------------------
445// GraphRun + run_recorded
446// ---------------------------------------------------------------------------
447
448impl<State> GraphRun<State> {
449    /// Bundles an execution with no recorded events or checkpoint history.
450    pub fn new(execution: GraphExecution<State>) -> Self {
451        Self {
452            execution,
453            events: Vec::new(),
454            history: Vec::new(),
455        }
456    }
457
458    /// Attaches a recorded event stream.
459    pub fn with_events(mut self, events: Vec<GraphEvent>) -> Self {
460        self.events = events;
461        self
462    }
463
464    /// Attaches a checkpoint history (newest-first).
465    pub fn with_history(mut self, history: Vec<StateSnapshot<State>>) -> Self {
466        self.history = history;
467        self
468    }
469
470    /// The recorded events as a [`StreamCollector`].
471    pub fn collector(&self) -> StreamCollector {
472        StreamCollector::new(self.events.clone())
473    }
474}
475
476/// Runs `graph` to completion with an event recorder wired in and bundles the
477/// result into a [`GraphRun`].
478///
479/// When `thread` is `Some`, the run executes under that thread id (so a
480/// configured checkpointer persists boundary checkpoints) and the thread's
481/// checkpoint history is collected into [`GraphRun::history`]; when `None`, the
482/// run executes without a thread (no checkpoints, empty history). The graph is
483/// cloned so the caller's instance is untouched.
484pub async fn run_recorded<State, Update>(
485    graph: &CompiledGraph<State, Update>,
486    thread: Option<&str>,
487    state: State,
488) -> Result<GraphRun<State>>
489where
490    State: Clone + Send + Sync + 'static,
491    Update: Send + 'static,
492{
493    let recorder = GraphEventRecorder::new();
494    let graph = graph.clone().with_event_sink(recorder.sink());
495    let execution = match thread {
496        Some(thread) => graph.run_with_thread(thread, state).await?,
497        None => graph.run(state).await?,
498    };
499    let history = match thread {
500        Some(thread) => graph
501            .get_state_history(thread, None)
502            .await
503            .unwrap_or_default(),
504        None => Vec::new(),
505    };
506    Ok(GraphRun {
507        execution,
508        events: recorder.events(),
509        history,
510    })
511}
512
513// ---------------------------------------------------------------------------
514// assert_graph
515// ---------------------------------------------------------------------------
516
517/// Opens a fluent [`GraphAssertions`] over a [`GraphRun`].
518///
519/// ```ignore
520/// assert_graph(&run)
521///     .visited(["agent", "tools", "agent"])
522///     .routed("agent", "tools")
523///     .checkpoint_count(3)
524///     .completed();
525/// ```
526pub fn assert_graph<State>(run: &GraphRun<State>) -> GraphAssertions<'_, State> {
527    GraphAssertions { run }
528}
529
530impl<State> GraphAssertions<'_, State> {
531    /// Asserts the run visited exactly `expected`, in order (repeats included).
532    pub fn visited<I, N>(&self, expected: I) -> &Self
533    where
534        I: IntoIterator<Item = N>,
535        N: Into<NodeId>,
536    {
537        let expected: Vec<NodeId> = expected.into_iter().map(Into::into).collect();
538        assert_eq!(
539            self.run.execution.visited, expected,
540            "assert_graph: expected visited {expected:?} but run visited {:?}",
541            self.run.execution.visited
542        );
543        self
544    }
545
546    /// Asserts the executor selected a route from `from` to `to`.
547    ///
548    /// Reads the recorded `RouteSelected` events when present; when no events
549    /// were recorded it falls back to adjacency in the visited sequence (`to`
550    /// immediately follows `from`).
551    pub fn routed(&self, from: impl Into<NodeId>, to: impl Into<NodeId>) -> &Self {
552        let from = from.into();
553        let to = to.into();
554        let by_event = self.run.events.iter().any(|e| {
555            matches!(
556                e,
557                GraphEvent::RouteSelected { node, target }
558                    if *node == from && *target == to
559            )
560        });
561        let by_visited = || {
562            self.run
563                .execution
564                .visited
565                .windows(2)
566                .any(|w| w[0] == from && w[1] == to)
567        };
568        assert!(
569            by_event || (self.run.events.is_empty() && by_visited()),
570            "assert_graph: expected a route from `{from}` to `{to}` but none was found"
571        );
572        self
573    }
574
575    /// Asserts the run persisted exactly `n` checkpoints.
576    ///
577    /// Counts the recorded `CheckpointSaved` events when present, else falls
578    /// back to the collected checkpoint-history length.
579    pub fn checkpoint_count(&self, n: usize) -> &Self {
580        let count = if self.run.events.is_empty() {
581            self.run.history.len()
582        } else {
583            self.run.collector().checkpoint_count()
584        };
585        assert_eq!(
586            count, n,
587            "assert_graph: expected {n} checkpoint(s) but found {count}"
588        );
589        self
590    }
591
592    /// Asserts against the thread's checkpoint history (newest-first) via a
593    /// caller-supplied predicate closure.
594    pub fn state_history(&self, f: impl FnOnce(&[StateSnapshot<State>])) -> &Self {
595        f(&self.run.history);
596        self
597    }
598
599    /// Asserts against the latest checkpoint snapshot via a caller-supplied
600    /// closure. Panics when the run has no checkpoint history.
601    pub fn checkpoint(&self, f: impl FnOnce(&StateSnapshot<State>)) -> &Self {
602        let latest = self
603            .run
604            .history
605            .first()
606            .expect("assert_graph: expected a checkpoint but the run history is empty");
607        f(latest);
608        self
609    }
610
611    /// Asserts the run completed (no pending interrupts and a `Completed`
612    /// status).
613    pub fn completed(&self) -> &Self {
614        assert!(
615            !self.run.execution.is_interrupted(),
616            "assert_graph: expected the run to complete but it was interrupted: {:?}",
617            self.run.execution.interrupts
618        );
619        assert_eq!(
620            self.run.execution.status.status,
621            crate::harness::ids::ExecutionStatus::Completed,
622            "assert_graph: expected a Completed status but found {:?}",
623            self.run.execution.status.status
624        );
625        self
626    }
627
628    /// Asserts the run paused on an interrupt rather than completing.
629    pub fn interrupted(&self) -> &Self {
630        assert!(
631            self.run.execution.is_interrupted(),
632            "assert_graph: expected the run to be interrupted but it completed"
633        );
634        self
635    }
636}
637
638#[cfg(test)]
639mod test;