"""Tests for subgraph persistence behavior (sync). Covers three checkpointer settings for subgraph state: - checkpointer=False: no persistence, even when parent has a checkpointer - checkpointer=None (default): "stateless" — inherits parent checkpointer for interrupt support, but state resets each invocation. This is the common case when an agent is invoked from inside a tool used by another agent. - checkpointer=True: "stateful" — state accumulates across invocations on the same thread id """ from uuid import uuid4 from langchain_core.messages import AIMessage, HumanMessage from langgraph.checkpoint.base import BaseCheckpointSaver from typing_extensions import TypedDict from langgraph.graph import START, StateGraph from langgraph.graph.message import MessagesState from langgraph.types import Command, Interrupt, interrupt from tests.any_str import AnyStr class ParentState(TypedDict): result: str # -- checkpointer=None (stateless) -- def test_stateless_interrupt_resume( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a subgraph compiled with checkpointer=None (the default) can still support interrupt/resume when invoked from inside a parent graph that has a checkpointer. This is the "stateless" pattern — the subgraph inherits the parent's checkpointer just enough to pause and resume, but does not retain any state across separate parent invocations. This pattern commonly appears when an agent is invoked from inside a tool used by another agent. """ # Build a subgraph that interrupts before echoing. # Two nodes: "process" interrupts then echoes, "respond" returns "Done". def process(state: MessagesState) -> dict: interrupt("continue?") return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } def respond(state: MessagesState) -> dict: return {"messages": [AIMessage(content="Done")]} inner = ( StateGraph(MessagesState) .add_node("process", process) .add_node("respond", respond) .add_edge(START, "process") .add_edge("process", "respond") .compile() ) def call_inner(state: ParentState) -> dict: resp = inner.invoke({"messages": [HumanMessage(content="apples")]}) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} # First invoke hits the interrupt result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } # Resume completes the subgraph result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} def test_stateless_state_resets( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a subgraph compiled with checkpointer=None (the default) does not retain any message history between separate parent invocations. Each time the parent graph invokes the subgraph, it starts with a clean slate. This confirms the "stateless" behavior: even though the parent has a checkpointer, the subgraph state is not persisted across calls. """ # Build a simple echo subgraph: echoes "Processing: " def echo(state: MessagesState) -> dict: return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } inner = ( StateGraph(MessagesState) .add_node("echo", echo) .add_edge(START, "echo") .compile() ) subgraph_messages: list[list[str]] = [] call_count = 0 def call_inner(state: ParentState) -> dict: nonlocal call_count call_count += 1 topic = "apples" if call_count == 1 else "bananas" resp = inner.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} result1 = parent.invoke({"result": ""}, config) assert result1 == {"result": "Processing: tell me about apples"} result2 = parent.invoke({"result": ""}, config) assert result2 == {"result": "Processing: tell me about bananas"} # Both invocations produce fresh history — no memory of prior call assert subgraph_messages[0] == [ "tell me about apples", "Processing: tell me about apples", ] assert subgraph_messages[1] == [ "tell me about bananas", "Processing: tell me about bananas", ] def test_stateless_state_resets_with_interrupt( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a subgraph compiled with checkpointer=None resets its state between parent invocations even when interrupt/resume is used. The subgraph is invoked twice from the parent, each time with an interrupt that must be resumed. After both invoke+resume cycles, each subgraph run should only contain its own messages — no bleed-over from the previous run. """ # Build a subgraph that interrupts before echoing, then responds "Done" def process(state: MessagesState) -> dict: interrupt("continue?") return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } def respond(state: MessagesState) -> dict: return {"messages": [AIMessage(content="Done")]} inner = ( StateGraph(MessagesState) .add_node("process", process) .add_node("respond", respond) .add_edge(START, "process") .add_edge("process", "respond") .compile() ) subgraph_messages: list[list[str]] = [] call_count = 0 def call_inner(state: ParentState) -> dict: nonlocal call_count call_count += 1 topic = "apples" if call_count == 1 else "bananas" resp = inner.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} # First invoke+resume cycle result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} # Second invoke+resume cycle result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} # Both invocations produce fresh history — no memory of prior call assert subgraph_messages[0] == [ "tell me about apples", "Processing: tell me about apples", "Done", ] assert subgraph_messages[1] == [ "tell me about bananas", "Processing: tell me about bananas", "Done", ] # -- checkpointer=False -- def test_checkpointer_false_no_persistence( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a subgraph compiled with checkpointer=False gets no persistence at all, even when the parent graph has a checkpointer. Unlike the default (checkpointer=None) which inherits just enough from the parent to support interrupt/resume, checkpointer=False explicitly opts out of all checkpoint behavior. Each invocation starts completely fresh. """ # Build a simple echo subgraph with checkpointer=False def echo(state: MessagesState) -> dict: return { "messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")] } inner = ( StateGraph(MessagesState) .add_node("echo", echo) .add_edge(START, "echo") .compile(checkpointer=False) ) subgraph_messages: list[list[str]] = [] call_count = 0 def call_inner(state: ParentState) -> dict: nonlocal call_count call_count += 1 topic = "apples" if call_count == 1 else "bananas" resp = inner.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} result1 = parent.invoke({"result": ""}, config) assert result1 == {"result": "Processed: tell me about apples"} result2 = parent.invoke({"result": ""}, config) assert result2 == {"result": "Processed: tell me about bananas"} # Both start fresh — no history from first call assert subgraph_messages[0] == [ "tell me about apples", "Processed: tell me about apples", ] assert subgraph_messages[1] == [ "tell me about bananas", "Processed: tell me about bananas", ] # -- checkpointer=True (stateful) -- def test_stateful_state_accumulates( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a subgraph compiled with checkpointer=True ("stateful") retains its message history across separate parent invocations. To enable this, the subgraph is wrapped in an outer graph compiled with checkpointer=True — this wrapper gives the inner subgraph its own persistent checkpoint namespace. After two parent calls, the second subgraph invocation should see messages from both the first and second calls. """ # Build a simple echo subgraph def echo(state: MessagesState) -> dict: return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } inner = ( StateGraph(MessagesState) .add_node("echo", echo) .add_edge(START, "echo") .compile() ) # Wrap the inner subgraph with checkpointer=True to enable stateful. # The wrapper graph gives the subgraph its own persistent checkpoint # namespace, keyed by the node name ("agent"). wrapper = ( StateGraph(MessagesState) .add_node("agent", inner) .add_edge(START, "agent") .compile(checkpointer=True) ) subgraph_messages: list[list[str]] = [] topics = ["apples", "bananas"] def call_inner(state: ParentState) -> dict: topic = topics[len(subgraph_messages)] resp = wrapper.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} result1 = parent.invoke({"result": ""}, config) assert result1 == {"result": "Processing: tell me about apples"} result2 = parent.invoke({"result": ""}, config) assert result2 == {"result": "Processing: tell me about bananas"} # First call: fresh history assert subgraph_messages[0] == [ "tell me about apples", "Processing: tell me about apples", ] # Second call: retains messages from first call assert subgraph_messages[1] == [ "tell me about apples", "Processing: tell me about apples", "tell me about bananas", "Processing: tell me about bananas", ] def test_stateful_state_accumulates_with_interrupt( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a stateful subgraph (checkpointer=True) retains its message history across parent invocations even when interrupt/resume is involved. The subgraph interrupts before echoing, then responds "Done". After two invoke+resume cycles, the second run should contain the full accumulated history from both calls. """ # Build a subgraph that interrupts before echoing, then responds "Done" def process(state: MessagesState) -> dict: interrupt("continue?") return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } def respond(state: MessagesState) -> dict: return {"messages": [AIMessage(content="Done")]} inner = ( StateGraph(MessagesState) .add_node("process", process) .add_node("respond", respond) .add_edge(START, "process") .add_edge("process", "respond") .compile() ) # Wrap with checkpointer=True for stateful wrapper = ( StateGraph(MessagesState) .add_node("agent", inner) .add_edge(START, "agent") .compile(checkpointer=True) ) subgraph_messages: list[list[str]] = [] topics = ["apples", "bananas"] def call_inner(state: ParentState) -> dict: topic = topics[len(subgraph_messages)] resp = wrapper.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} # First invoke+resume cycle result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} # Second invoke+resume cycle result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} # First call: fresh history assert subgraph_messages[0] == [ "tell me about apples", "Processing: tell me about apples", "Done", ] # Second call: retains messages from first call assert subgraph_messages[1] == [ "tell me about apples", "Processing: tell me about apples", "Done", "tell me about bananas", "Processing: tell me about bananas", "Done", ] def test_stateful_interrupt_resume( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that a stateful subgraph (checkpointer=True) correctly supports interrupt/resume while also accumulating state. Each invoke+resume pair triggers the subgraph, and after the second pair completes we verify both the per-step invoke outputs and the accumulated message history. This exercises the full lifecycle: interrupt, resume, state accumulation. """ # Build a subgraph that interrupts before echoing, then responds "Done" def process(state: MessagesState) -> dict: interrupt("continue?") return { "messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")] } def respond(state: MessagesState) -> dict: return {"messages": [AIMessage(content="Done")]} inner = ( StateGraph(MessagesState) .add_node("process", process) .add_node("respond", respond) .add_edge(START, "process") .add_edge("process", "respond") .compile() ) # Wrap with checkpointer=True for stateful wrapper = ( StateGraph(MessagesState) .add_node("agent", inner) .add_edge(START, "agent") .compile(checkpointer=True) ) subgraph_messages: list[list[str]] = [] topics = ["apples", "bananas"] def call_inner(state: ParentState) -> dict: topic = topics[len(subgraph_messages)] resp = wrapper.invoke( {"messages": [HumanMessage(content=f"tell me about {topic}")]} ) subgraph_messages.append([m.text for m in resp["messages"]]) return {"result": resp["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_inner", call_inner) .add_edge(START, "call_inner") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} # First invocation: hits interrupt result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } # Resume: completes first call result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} assert subgraph_messages[0] == [ "tell me about apples", "Processing: tell me about apples", "Done", ] # Second invocation: hits interrupt, state accumulated from first call result = parent.invoke({"result": ""}, config) assert result == { "result": "", "__interrupt__": [Interrupt(value="continue?", id=AnyStr())], } # Resume: completes second call with accumulated state result = parent.invoke(Command(resume=True), config) assert result == {"result": "Done"} assert subgraph_messages[1] == [ "tell me about apples", "Processing: tell me about apples", "Done", "tell me about bananas", "Processing: tell me about bananas", "Done", ] def test_stateful_namespace_isolation( sync_checkpointer: BaseCheckpointSaver, ) -> None: """Tests that two different stateful subgraphs (checkpointer=True) maintain completely independent state when they use different wrapper node names. A "fruit_agent" and "veggie_agent" are each wrapped in their own stateful graph. After two parent invocations, each agent should only see its own accumulated history with no cross-contamination between them. """ # Build two simple echo subgraphs with different prefixes def fruit_echo(state: MessagesState) -> dict: return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]} def veggie_echo(state: MessagesState) -> dict: return { "messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")] } fruit_inner = ( StateGraph(MessagesState) .add_node("echo", fruit_echo) .add_edge(START, "echo") .compile() ) veggie_inner = ( StateGraph(MessagesState) .add_node("echo", veggie_echo) .add_edge(START, "echo") .compile() ) # Wrap each with checkpointer=True, using different node names to get # independent checkpoint namespaces fruit = ( StateGraph(MessagesState) .add_node("fruit_agent", fruit_inner) .add_edge(START, "fruit_agent") .compile(checkpointer=True) ) veggie = ( StateGraph(MessagesState) .add_node("veggie_agent", veggie_inner) .add_edge(START, "veggie_agent") .compile(checkpointer=True) ) fruit_msgs: list[list[str]] = [] veggie_msgs: list[list[str]] = [] call_count = 0 def call_both(state: ParentState) -> dict: nonlocal call_count call_count += 1 suffix = "round 1" if call_count == 1 else "round 2" f = fruit.invoke({"messages": [HumanMessage(content=f"cherries {suffix}")]}) v = veggie.invoke({"messages": [HumanMessage(content=f"broccoli {suffix}")]}) fruit_msgs.append([m.text for m in f["messages"]]) veggie_msgs.append([m.text for m in v["messages"]]) return {"result": f["messages"][-1].text} parent = ( StateGraph(ParentState) .add_node("call_both", call_both) .add_edge(START, "call_both") .compile(checkpointer=sync_checkpointer) ) config = {"configurable": {"thread_id": str(uuid4())}} result1 = parent.invoke({"result": ""}, config) assert result1 == {"result": "Fruit: cherries round 1"} result2 = parent.invoke({"result": ""}, config) assert result2 == {"result": "Fruit: cherries round 2"} # First call: each agent sees only its own history assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"] assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"] # Second call: each accumulated independently — no cross-contamination assert fruit_msgs[1] == [ "cherries round 1", "Fruit: cherries round 1", "cherries round 2", "Fruit: cherries round 2", ] assert veggie_msgs[1] == [ "broccoli round 1", "Veggie: broccoli round 1", "broccoli round 2", "Veggie: broccoli round 2", ] def test_child_with_own_thread_id_keeps_namespace( sync_checkpointer: BaseCheckpointSaver, ) -> None: """A child graph invoked from inside a parent node with its own thread_id must store and read its checkpoint under its own namespace, not inherit the parent task's checkpoint_ns. """ class ChildState(TypedDict): count: int def child_node(state: ChildState) -> dict: return {"count": (state.get("count") or 0) + 1} child = ( StateGraph(ChildState) .add_node("n", child_node) .add_edge(START, "n") .compile(checkpointer=sync_checkpointer) ) child_thread = str(uuid4()) child_config = {"configurable": {"thread_id": child_thread}} def parent_node(state: ParentState) -> dict: child.invoke({}, config=child_config) return {"result": "ok"} parent = ( StateGraph(ParentState) .add_node("p", parent_node) .add_edge(START, "p") .compile(checkpointer=sync_checkpointer) ) parent_config = {"configurable": {"thread_id": str(uuid4())}} parent.invoke({"result": ""}, config=parent_config) state1 = child.get_state(child_config) assert state1.values.get("count") == 1 assert state1.config["configurable"]["checkpoint_ns"] == "" parent.invoke({"result": ""}, config=parent_config) state2 = child.get_state(child_config) assert state2.values.get("count") == 2