"""Tests for the BinaryOperatorAggregate -> DeltaChannel migration path. A thread written under `BinaryOperatorAggregate(...)` must keep working after its annotation is swapped to `DeltaChannel(...)` on the same checkpointer — pre-migration state visible at each *settled* ancestor checkpoint is preserved, and post-migration writes fold on top through the reducer. Mechanism under test: the saver's public `get_delta_channel_history(config, channels)` walks the parent chain; when it encounters an ancestor whose `channel_values[channel]` is a real value, it populates that channel's `seed` in the returned `DeltaChannelHistory`. If the walk reaches the root without finding a stored value, the `seed` key is omitted (TypedDict absence indicates "start empty"). `DeltaChannel.from_checkpoint(seed)` uses it as the base value, and `replay_writes(writes)` folds on-path deltas. Scenarios covered: 1. **Basic migration (sync + async)**: build pre-migration state with `BinaryOperatorAggregate`, swap the annotation to `DeltaChannel` on the same checkpointer, and verify that every settled pre-migration super-step boundary (`next=('__start__',)`) round-trips exactly under the delta-channel view. 2. **Time travel into a pre-migration checkpoint** after migration — `graph.get_state(pre_migration_config)` at a settled ancestor returns the same state as under the binop channel. 3. **Continuing a migrated thread**: driving one more super-step after migration produces a state that includes the pre-migration settled prefix plus the new delta write — proving `from_checkpoint(seed)` + `replay_writes` correctly fold post-migration deltas onto the pre-migration seed. 4. **Base-saver fallback path**: a third-party-style subclass that removes the optimized `InMemorySaver` override and falls back to `BaseCheckpointSaver.get_delta_channel_history` must produce the same result as the optimized path. 5. **Channel-type isolation across threads**: two threads on the same checkpointer under the delta-channel graph — one freshly-started, one migrated from pre-migration state — don't cross-contaminate. The parent-chain walk is scoped to the thread. TODO: add postgres variants in the existing `libs/checkpoint-postgres` test files (different fixture setup; not this file). """ from __future__ import annotations import operator from typing import Annotated, Any import pytest from langchain_core.messages import AIMessage, HumanMessage from langgraph.checkpoint.memory import InMemorySaver from typing_extensions import TypedDict from langgraph.channels.binop import BinaryOperatorAggregate from langgraph.channels.delta import DeltaChannel from langgraph.graph import END, START, StateGraph from langgraph.graph.message import _messages_delta_reducer, add_messages pytestmark = pytest.mark.anyio # --------------------------------------------------------------------------- # Graph factories # # A minimal reducer (`operator.add` on lists of str) with a noop node keeps # state change localized to the HumanMessage-like payload passed through # `invoke`. That isolates the pre/post-migration parity assertions to # channel-hydration semantics. # --------------------------------------------------------------------------- def _noop(_state: Any) -> dict: return {} def _list_concat(state: list, writes: list) -> list: result = list(state) for w in writes: result.extend(w if isinstance(w, list) else [w]) return result def _binop_graph(checkpointer: Any) -> Any: class BinopState(TypedDict): items: Annotated[list, BinaryOperatorAggregate(list, operator.add)] return ( StateGraph(BinopState) .add_node("noop", _noop) .add_edge(START, "noop") .add_edge("noop", END) .compile(checkpointer=checkpointer) ) def _delta_graph(checkpointer: Any) -> Any: class DeltaState(TypedDict): items: Annotated[list, DeltaChannel(_list_concat)] return ( StateGraph(DeltaState) .add_node("noop", _noop) .add_edge(START, "noop") .add_edge("noop", END) .compile(checkpointer=checkpointer) ) def _drive(graph: Any, config: dict, tag: str, n: int) -> None: for i in range(n): graph.invoke({"items": [f"{tag}{i}"]}, config) async def _adrive(graph: Any, config: dict, tag: str, n: int) -> None: for i in range(n): await graph.ainvoke({"items": [f"{tag}{i}"]}, config) def _settled_boundaries(history: list) -> list[tuple[dict, list]]: """Return `[(config, items), ...]` for every checkpoint in `history` whose `next == ('__start__',)` — the stable boundaries between invokes. """ return [ (s.config, list(s.values.get("items", []))) for s in history if s.next == ("__start__",) ] # --------------------------------------------------------------------------- # 1. Basic migration (sync + async) # --------------------------------------------------------------------------- def test_basic_migration_preserves_pre_migration_state() -> None: """Build state under `BinaryOperatorAggregate`, migrate to `DeltaChannel` on the same checkpointer, and verify that every settled pre-migration super-step boundary round-trips exactly. Settled boundaries (`next=('__start__',)`) are the stable hydration targets for the migration path: writes that produced the NEXT super-step are kept as `pending_writes` on the ancestor, so walking from a descendant finds the ancestor's blob as the seed and reconstructs the correct state. """ checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "basic-sync"}} # Pre-migration: accumulate items across 3 invokes. binop = _binop_graph(checkpointer) _drive(binop, config, "u", 3) pre_boundaries = _settled_boundaries(list(binop.get_state_history(config))) assert len(pre_boundaries) >= 2, "expected multiple settled boundaries" # Migrate: swap the annotation on the same checkpointer. delta = _delta_graph(checkpointer) for cfg, items in pre_boundaries: snap = delta.get_state(cfg) assert list(snap.values.get("items", [])) == items, ( f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: " f"expected {items}, got {snap.values.get('items', [])}" ) async def test_basic_migration_preserves_pre_migration_state_async() -> None: """Async variant of the basic migration scenario.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "basic-async"}} binop = _binop_graph(checkpointer) await _adrive(binop, config, "u", 3) pre_history = [s async for s in binop.aget_state_history(config)] pre_boundaries = _settled_boundaries(pre_history) assert len(pre_boundaries) >= 2 delta = _delta_graph(checkpointer) for cfg, items in pre_boundaries: snap = await delta.aget_state(cfg) assert list(snap.values.get("items", [])) == items, ( f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}" ) # --------------------------------------------------------------------------- # 2. Time travel into a pre-migration checkpoint after migration # --------------------------------------------------------------------------- def test_time_travel_into_pre_migration_checkpoint() -> None: """After migration, `graph.get_state(pre_migration_config)` at a settled ancestor returns the state as stored at that point.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "time-travel"}} binop = _binop_graph(checkpointer) _drive(binop, config, "u", 3) pre_boundaries = _settled_boundaries(list(binop.get_state_history(config))) assert pre_boundaries, "no settled ancestors to time-travel to" delta = _delta_graph(checkpointer) # Pick the oldest non-empty boundary — a long distance to walk back. non_empty = [(cfg, items) for cfg, items in pre_boundaries if items] assert non_empty, "expected at least one non-empty boundary" target_cfg, expected_items = non_empty[-1] snap = delta.get_state(target_cfg) assert list(snap.values.get("items", [])) == expected_items # --------------------------------------------------------------------------- # 3. Continuing a migrated thread: deltas fold onto pre-migration seed # --------------------------------------------------------------------------- def test_continuing_migrated_thread_folds_deltas_on_seed() -> None: """Resume a pre-migration settled ancestor via `invoke(None, cfg)` under the delta-channel graph. Since the pre-migration checkpoint has an existing `pending_writes` entry (the input for the NEXT super-step), re-running from that ancestor reproduces the same post-ancestor state as the original binop run. This proves the seed-terminator + write-replay pipeline works end-to-end across the migration boundary. """ checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "continue"}} binop = _binop_graph(checkpointer) _drive(binop, config, "u", 2) # Pick the oldest settled boundary with non-empty state. pre_boundaries = _settled_boundaries(list(binop.get_state_history(config))) target_cfg, seed_items = next( (cfg, items) for cfg, items in reversed(pre_boundaries) if items ) assert seed_items, "need a non-empty seed boundary" # Migrate and resume from the pre-migration ancestor. `invoke(None, # cfg)` replays the pending writes staged at `cfg` under the new # channel; the reducer folds those deltas onto the seed. delta = _delta_graph(checkpointer) result = delta.invoke(None, target_cfg) # The resumed state must include the pre-migration seed items in order. result_items = list(result.get("items", [])) for idx, prefix_item in enumerate(seed_items): assert result_items[idx] == prefix_item, ( f"pre-migration seed item at {idx} not preserved: " f"got {result_items[: idx + 1]}, expected {seed_items}" ) # --------------------------------------------------------------------------- # 4. Base-saver fallback path # --------------------------------------------------------------------------- class _ThirdPartyStyleSaver(InMemorySaver): """Simulates a third-party saver that inherits the reference `get_delta_channel_history` implementation from `BaseCheckpointSaver` rather than overriding it. We rebind the two methods to the base-class versions (via MRO) so the fallback path is exercised even though the storage layer is still the in-memory one. """ # MRO: [_ThirdPartyStyleSaver, InMemorySaver, BaseCheckpointSaver, ...] get_delta_channel_history = ( # type: ignore[assignment] InMemorySaver.__mro__[1].get_delta_channel_history # type: ignore[attr-defined] ) aget_delta_channel_history = ( # type: ignore[assignment] InMemorySaver.__mro__[1].aget_delta_channel_history # type: ignore[attr-defined] ) def test_base_saver_fallback_matches_optimized_override() -> None: """The reference `BaseCheckpointSaver` implementation must produce the same migration behavior as the optimized `InMemorySaver` override. We drive the same migration scenario through both savers and assert per-snapshot parity in the delta-channel view.""" # Fast path: optimized InMemorySaver override. fast_saver = InMemorySaver() fast_config = {"configurable": {"thread_id": "fast"}} fast_binop = _binop_graph(fast_saver) _drive(fast_binop, fast_config, "u", 3) fast_delta = _delta_graph(fast_saver) fast_history = [ (s.next, list(s.values.get("items", []))) for s in fast_delta.get_state_history(fast_config) ] # Slow path: base-class fallback. slow_saver = _ThirdPartyStyleSaver() slow_config = {"configurable": {"thread_id": "slow"}} slow_binop = _binop_graph(slow_saver) _drive(slow_binop, slow_config, "u", 3) slow_delta = _delta_graph(slow_saver) slow_history = [ (s.next, list(s.values.get("items", []))) for s in slow_delta.get_state_history(slow_config) ] assert slow_history == fast_history, ( "base-saver fallback should match optimized-override behavior; " f"fast={fast_history}, slow={slow_history}" ) # --------------------------------------------------------------------------- # 5. Thread isolation under mixed-generation storage # --------------------------------------------------------------------------- def test_delta_and_migrated_threads_do_not_cross_contaminate() -> None: """Two threads sharing a checkpointer — one migrated from pre-migration state, one freshly-started under DeltaChannel — must maintain independent state. The parent-chain walk in `get_delta_channel_history` must be scoped to the target thread. """ checkpointer = InMemorySaver() migrated_cfg = {"configurable": {"thread_id": "migrated"}} fresh_cfg = {"configurable": {"thread_id": "fresh"}} # Thread A: pre-migration build-up. binop = _binop_graph(checkpointer) _drive(binop, migrated_cfg, "m", 2) # Thread B: fresh delta-channel run. delta = _delta_graph(checkpointer) _drive(delta, fresh_cfg, "f", 2) # Thread A: migrate and confirm its state is anchored in its own # thread's pre-migration history (tag 'm'), never mixing in tag 'f'. migrated_boundaries = _settled_boundaries( list(delta.get_state_history(migrated_cfg)) ) assert migrated_boundaries, "migrated thread has no settled boundaries" for _, items in migrated_boundaries: for it in items: assert it.startswith("m"), ( f"migrated thread leaked item from other thread: {it}" ) # Thread B: settled boundaries must only contain 'f' tags. fresh_boundaries = _settled_boundaries(list(delta.get_state_history(fresh_cfg))) assert fresh_boundaries, "fresh thread has no settled boundaries" for _, items in fresh_boundaries: for it in items: assert it.startswith("f"), ( f"fresh thread leaked item from migrated thread: {it}" ) # --------------------------------------------------------------------------- # 6. Tip-of-pre-migration hydration: the latest checkpoint from a binop-run # thread has a real accumulated value in its own `channel_values["items"]`. # When hydrated under the delta-channel graph via `get_state(config)` with no # `checkpoint_id`, the short-circuit must use that value directly instead of # walking ancestors (which would skip the tip's own blob). # --------------------------------------------------------------------------- def test_tip_of_pre_migration_hydrates_directly() -> None: """`graph.get_state(config)` at the latest (pre-migration) checkpoint returns the full accumulated list stored in that checkpoint's own `channel_values`. The hydration must not walk ancestors past it.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "tip-sync"}} binop = _binop_graph(checkpointer) _drive(binop, config, "u", 3) binop_tip = binop.get_state(config) expected_items = list(binop_tip.values.get("items", [])) assert expected_items == ["u0", "u1", "u2"], ( f"sanity: pre-migration tip should accumulate all 3 items, got {expected_items}" ) delta = _delta_graph(checkpointer) snap = delta.get_state(config) assert list(snap.values.get("items", [])) == expected_items, ( f"tip hydration mismatch: expected {expected_items}, " f"got {snap.values.get('items', [])}" ) async def test_tip_of_pre_migration_hydrates_directly_async() -> None: """Async variant of the tip-of-pre-migration hydration scenario.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "tip-async"}} binop = _binop_graph(checkpointer) await _adrive(binop, config, "u", 3) binop_tip = await binop.aget_state(config) expected_items = list(binop_tip.values.get("items", [])) assert expected_items == ["u0", "u1", "u2"] delta = _delta_graph(checkpointer) snap = await delta.aget_state(config) assert list(snap.values.get("items", [])) == expected_items, ( f"async tip hydration mismatch: expected {expected_items}, " f"got {snap.values.get('items', [])}" ) # --------------------------------------------------------------------------- # 7. `update_state` after migration writes a real value to the new # checkpoint's `channel_values` (not a sentinel). Hydration must use it # directly — the ancestor walk would skip this blob and return stale state. # --------------------------------------------------------------------------- def test_update_state_after_migration_uses_written_value() -> None: """After migrating and running at least one post-migration super-step (so the thread's tip has its delta channel absent from `channel_values`, or stored as a `_DeltaSnapshot` on a snapshot step), `update_state` writes a concrete value into a new checkpoint's `channel_values`. `get_state` must reflect that concrete value.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "update-state"}} # Pre-migration: accumulate a little state. binop = _binop_graph(checkpointer) _drive(binop, config, "u", 2) # Migrate and run one more super-step so the tip is a post-migration # checkpoint where the delta channel is absent from `channel_values` # (no snapshot fired this step). delta = _delta_graph(checkpointer) delta.invoke({"items": ["post"]}, config) # `update_state` writes a concrete value into a new checkpoint's blob # via the reducer against the hydrated prior state. delta.update_state(config, {"items": ["x", "y"]}) snap = delta.get_state(config) updated_items = list(snap.values.get("items", [])) # Must include the "x","y" update; without the hydration fix, the # update_state-written blob would be skipped in favor of an ancestor # walk, and the update values would disappear. assert "x" in updated_items and "y" in updated_items, ( f"update_state values missing from snapshot: {updated_items}" ) # The "x","y" items should be folded onto the prior accumulated state, # not stand alone. This verifies the update-written blob is used # directly by `get_state` (no ancestor walk past it). assert len(updated_items) >= 4, ( f"update_state snapshot should preserve pre-update state, got {updated_items}" ) assert updated_items[-2:] == ["x", "y"], ( f"update_state deltas should be at the tail, got {updated_items}" ) # --------------------------------------------------------------------------- # 8. Fork from an `update_state` checkpoint: a new run branched off the # update_state-produced checkpoint must see that checkpoint's concrete # `channel_values` as its base, with new deltas folded on top. # --------------------------------------------------------------------------- def test_fork_from_update_state_checkpoint() -> None: """Branching a new run from the checkpoint produced by `update_state` must use that checkpoint's concrete blob as the base. Additional deltas from the forked run fold onto it through the reducer.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "fork"}} # Pre-migration build-up, then migrate and add one post-migration step. binop = _binop_graph(checkpointer) _drive(binop, config, "u", 2) delta = _delta_graph(checkpointer) delta.invoke({"items": ["post"]}, config) # Apply `update_state` and capture the returned config (references # the new checkpoint produced by the update). update_cfg = delta.update_state(config, {"items": ["x", "y"]}) update_snap = delta.get_state(update_cfg) base_items = list(update_snap.values.get("items", [])) assert "x" in base_items and "y" in base_items, ( f"update_state values missing from snapshot: {base_items}" ) assert base_items[-2:] == ["x", "y"], ( f"sanity: update_state deltas should be at the tail, got {base_items}" ) # Fork: invoke from the update_state checkpoint with a new delta. forked = delta.invoke({"items": ["fork0"]}, update_cfg) forked_items = list(forked.get("items", [])) # The fork must see the update_state-written blob as its base (not # walk past it), and the new delta must fold on top of it. assert forked_items[: len(base_items)] == base_items, ( f"fork lost update_state base: base={base_items}, forked={forked_items}" ) assert forked_items[-1] == "fork0", f"fork delta not appended: {forked_items}" # --------------------------------------------------------------------------- # 9. Migration from `add_messages` → `DeltaChannel(_messages_delta_reducer)` # # `add_messages` is the primary real-world use case: it creates a # BinaryOperatorAggregate with dedup-by-ID and RemoveMessage semantics. # After swapping the annotation to DeltaChannel, pre-migration blobs # (plain lists of Message objects) must be used directly as the seed. # --------------------------------------------------------------------------- def _add_messages_graph(checkpointer: Any) -> Any: class MessagesState(TypedDict): messages: Annotated[list, add_messages] return ( StateGraph(MessagesState) .add_node("noop", _noop) .add_edge(START, "noop") .add_edge("noop", END) .compile(checkpointer=checkpointer) ) def _delta_messages_graph(checkpointer: Any) -> Any: class DeltaMessagesState(TypedDict): messages: Annotated[list, DeltaChannel(_messages_delta_reducer)] return ( StateGraph(DeltaMessagesState) .add_node("noop", _noop) .add_edge(START, "noop") .add_edge("noop", END) .compile(checkpointer=checkpointer) ) def test_add_messages_to_delta_migration_preserves_message_history() -> None: """Migration from `add_messages` to `DeltaChannel(_messages_delta_reducer)` preserves message ordering and IDs at both the tip and settled ancestor boundaries. The pre-migration blob is a plain list of Message objects; DeltaChannel must use it directly as the seed without walking ancestors past it. """ checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "add-messages-migration"}} pre_graph = _add_messages_graph(checkpointer) pre_graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config) pre_graph.invoke({"messages": [AIMessage(content="hi", id="a1")]}, config) pre_graph.invoke({"messages": [HumanMessage(content="thanks", id="h2")]}, config) pre_tip = pre_graph.get_state(config) assert [m.id for m in pre_tip.values["messages"]] == ["h1", "a1", "h2"] delta_graph = _delta_messages_graph(checkpointer) # Tip: latest checkpoint has a full list blob — must use it directly. snap = delta_graph.get_state(config) assert [m.id for m in snap.values["messages"]] == ["h1", "a1", "h2"], ( f"tip hydration mismatch: got {[m.id for m in snap.values['messages']]}" ) # Settled ancestor boundaries must also match. pre_settled = [ [m.id for m in s.values.get("messages", [])] for s in pre_graph.get_state_history(config) if s.next == ("__start__",) ] delta_settled = [ [m.id for m in s.values.get("messages", [])] for s in delta_graph.get_state_history(config) if s.next == ("__start__",) ] assert delta_settled == pre_settled, ( f"settled boundary mismatch after migration: " f"pre={pre_settled}, delta={delta_settled}" ) async def test_add_messages_to_delta_migration_preserves_message_history_async() -> ( None ): """Async variant of the add_messages migration test.""" checkpointer = InMemorySaver() config = {"configurable": {"thread_id": "add-messages-migration-async"}} pre_graph = _add_messages_graph(checkpointer) await pre_graph.ainvoke( {"messages": [HumanMessage(content="hello", id="h1")]}, config ) await pre_graph.ainvoke({"messages": [AIMessage(content="hi", id="a1")]}, config) delta_graph = _delta_messages_graph(checkpointer) snap = await delta_graph.aget_state(config) assert [m.id for m in snap.values["messages"]] == ["h1", "a1"], ( f"async tip hydration mismatch: got {[m.id for m in snap.values['messages']]}" )