tinyagents/graph/testkit/
mod.rs1pub 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
61pub 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
78pub 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
108pub 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
146pub 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
175pub 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
190pub 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
222pub 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
237pub 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 pub fn new(fail_times: usize) -> Self {
284 Self {
285 attempts: Arc::new(AtomicUsize::new(0)),
286 fail_times,
287 }
288 }
289
290 pub fn attempts(&self) -> usize {
293 self.attempts.load(Ordering::Relaxed)
294 }
295
296 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
326impl GraphEventRecorder {
331 pub fn new() -> Self {
333 Self {
334 sink: CollectingSink::new(),
335 }
336 }
337
338 pub fn sink(&self) -> Arc<dyn GraphEventSink> {
341 Arc::new(self.sink.clone())
342 }
343
344 pub fn events(&self) -> Vec<GraphEvent> {
346 self.sink.events()
347 }
348
349 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 pub fn collector(&self) -> StreamCollector {
360 StreamCollector::new(self.sink.events())
361 }
362}
363
364impl StreamCollector {
369 pub fn new(events: Vec<GraphEvent>) -> Self {
371 Self { events }
372 }
373
374 pub fn events(&self) -> &[GraphEvent] {
376 &self.events
377 }
378
379 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 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 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 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 pub fn checkpoint_count(&self) -> usize {
426 self.events
427 .iter()
428 .filter(|e| matches!(e, GraphEvent::CheckpointSaved { .. }))
429 .count()
430 }
431
432 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
444impl<State> GraphRun<State> {
449 pub fn new(execution: GraphExecution<State>) -> Self {
451 Self {
452 execution,
453 events: Vec::new(),
454 history: Vec::new(),
455 }
456 }
457
458 pub fn with_events(mut self, events: Vec<GraphEvent>) -> Self {
460 self.events = events;
461 self
462 }
463
464 pub fn with_history(mut self, history: Vec<StateSnapshot<State>>) -> Self {
466 self.history = history;
467 self
468 }
469
470 pub fn collector(&self) -> StreamCollector {
472 StreamCollector::new(self.events.clone())
473 }
474}
475
476pub 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
513pub fn assert_graph<State>(run: &GraphRun<State>) -> GraphAssertions<'_, State> {
527 GraphAssertions { run }
528}
529
530impl<State> GraphAssertions<'_, State> {
531 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 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 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 pub fn state_history(&self, f: impl FnOnce(&[StateSnapshot<State>])) -> &Self {
595 f(&self.run.history);
596 self
597 }
598
599 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 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 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;