use adk_graph::checkpoint::MemoryCheckpointer;
use adk_graph::edge::{END, Router, START};
use adk_graph::error::GraphError;
use adk_graph::graph::StateGraph;
use adk_graph::node::{ExecutionConfig, NodeOutput};
use adk_graph::state::State;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
async fn run_then_resume(
graph: &adk_graph::graph::CompiledGraph,
thread: &str,
max_resumes: usize,
) -> (usize, Option<State>) {
let mut interrupts = 0;
let mut input = State::new();
for _ in 0..=max_resumes {
match graph.invoke(input.clone(), ExecutionConfig::new(thread)).await {
Ok(state) => return (interrupts, Some(state)),
Err(GraphError::Interrupted(_)) => {
interrupts += 1;
input = State::new();
}
Err(other) => panic!("unexpected error: {other}"),
}
}
(interrupts, None)
}
#[tokio::test]
async fn a_static_interrupt_can_be_resumed_past() {
let gated_runs = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&gated_runs);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn(
"open",
|_ctx| async move { Ok(NodeOutput::new().with_update("value", json!(1))) },
)
.add_node_fn("gated", move |ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
let value = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(NodeOutput::new().with_update("value", json!(value + 10)))
}
})
.add_edge(START, "open")
.add_edge("open", "gated")
.add_edge("gated", END)
.compile()
.unwrap()
.with_checkpointer(MemoryCheckpointer::new())
.with_interrupt_before(&["gated"]);
let (interrupts, final_state) = run_then_resume(&graph, "resume-past", 3).await;
assert_eq!(interrupts, 1, "the interrupt must be raised once, not on every resume");
assert_eq!(
gated_runs.load(Ordering::SeqCst),
1,
"the gated node must execute after the resume"
);
let state = final_state.expect("the run must complete after one resume");
assert_eq!(state.get("value").and_then(|v| v.as_i64()), Some(11));
}
#[tokio::test]
async fn resuming_past_one_interrupt_leaves_the_next_armed() {
let graph = StateGraph::with_channels(&["value"])
.add_node_fn(
"first",
|_ctx| async move { Ok(NodeOutput::new().with_update("value", json!(1))) },
)
.add_node_fn("second", |ctx| async move {
let value = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(NodeOutput::new().with_update("value", json!(value + 1)))
})
.add_edge(START, "first")
.add_edge("first", "second")
.add_edge("second", END)
.compile()
.unwrap()
.with_checkpointer(MemoryCheckpointer::new())
.with_interrupt_before(&["first", "second"]);
let (interrupts, final_state) = run_then_resume(&graph, "two-gates", 4).await;
assert_eq!(interrupts, 2, "both gates must fire exactly once");
let state = final_state.expect("the run must complete after two resumes");
assert_eq!(state.get("value").and_then(|v| v.as_i64()), Some(2));
}
#[tokio::test]
async fn an_interrupt_inside_a_cycle_fires_on_every_arrival() {
let graph = StateGraph::with_channels(&["count"])
.add_node_fn("tick", |ctx| async move {
let count = ctx.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(NodeOutput::new().with_update("count", json!(count + 1)))
})
.add_node_fn("gate", |_ctx| async move { Ok(NodeOutput::new()) })
.add_edge(START, "tick")
.add_edge("tick", "gate")
.add_conditional_edges(
"gate",
Router::custom(|state: &State| {
let count = state.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
if count >= 3 { END.to_string() } else { "tick".to_string() }
}),
[("tick", "tick"), (END, END)],
)
.compile()
.unwrap()
.with_checkpointer(MemoryCheckpointer::new())
.with_interrupt_before(&["gate"]);
let (interrupts, final_state) = run_then_resume(&graph, "cyclic-gate", 8).await;
assert_eq!(interrupts, 3, "the gate is scheduled three times, so it asks three times");
let state = final_state.expect("the run must complete");
assert_eq!(state.get("count").and_then(|v| v.as_i64()), Some(3));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn a_static_interrupt_can_be_resumed_past_with_sqlite() {
use adk_graph::checkpoint::SqliteCheckpointer;
let gated_runs = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&gated_runs);
let checkpointer = SqliteCheckpointer::new("sqlite::memory:")
.await
.expect("open an in-memory sqlite checkpointer");
let graph = StateGraph::with_channels(&["value"])
.add_node_fn(
"open",
|_ctx| async move { Ok(NodeOutput::new().with_update("value", json!(1))) },
)
.add_node_fn("gated", move |ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
let value = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(NodeOutput::new().with_update("value", json!(value + 10)))
}
})
.add_edge(START, "open")
.add_edge("open", "gated")
.add_edge("gated", END)
.compile()
.unwrap()
.with_checkpointer(checkpointer)
.with_interrupt_before(&["gated"]);
let (interrupts, final_state) = run_then_resume(&graph, "sqlite-resume", 3).await;
assert_eq!(interrupts, 1, "the marker must survive the sqlite round trip");
assert_eq!(gated_runs.load(Ordering::SeqCst), 1);
let state = final_state.expect("the run must complete after one resume");
assert_eq!(state.get("value").and_then(|v| v.as_i64()), Some(11));
}
#[tokio::test]
async fn an_interrupt_after_a_node_can_be_resumed_past() {
let first_runs = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&first_runs);
let graph = StateGraph::with_channels(&["value"])
.add_node_fn("first", move |_ctx| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(NodeOutput::new().with_update("value", json!(1)))
}
})
.add_node_fn("second", |ctx| async move {
let value = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(NodeOutput::new().with_update("value", json!(value + 10)))
})
.add_edge(START, "first")
.add_edge("first", "second")
.add_edge("second", END)
.compile()
.unwrap()
.with_checkpointer(MemoryCheckpointer::new())
.with_interrupt_after(&["first"]);
let (interrupts, final_state) = run_then_resume(&graph, "after-gate", 3).await;
assert_eq!(interrupts, 1, "the gate must fire once");
assert_eq!(
first_runs.load(Ordering::SeqCst),
1,
"the node must not run again on resume: it already applied its update"
);
let state = final_state.expect("the run must complete after one resume");
assert_eq!(state.get("value").and_then(|v| v.as_i64()), Some(11));
}