mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 07:57:57 +00:00
* feat(checkpoint): dual-mode checkpoint storage with LangGraph DeltaChannel
Add a restart-required database.checkpoint_channel_mode ("full" default,
"delta") that stores the messages channel via LangGraph 1.2 DeltaChannel,
cutting checkpoint storage from O(n^2) to O(n) for append-only history.
Existing full checkpoints seed delta state transparently; no data migration.
- config: mode schema + freeze-on-first-use with
CheckpointModeReconfigurationError; mode marker persisted in checkpoint
metadata; unsafe delta->full downgrade rejected fail-closed with
CheckpointModeMismatchError (run-level error, failed state read)
- state: delta message state schema; CheckpointStateAccessor centralizes
materialized reads for all consumers (threads API, branches,
regeneration, compaction, state updates, memory, goal workers)
- runtime: raw writers (run durations, interrupted title, thread goal)
parent their checkpoints to the checkpoint they derive from, preserving
delta ancestry; rollback forks the pre-run lineage through a state
mutation graph with Overwrite restores; InMemorySaver delta-history
override delegates to the base walk (fixes dropped first write after
migration, also present upstream)
- tests: conformance suite over {memory, sqlite, postgres} covering
migration replay, stable message IDs, storage shape and writer
preservation; conftest fixture isolates the frozen mode between tests;
stale config fakes refreshed
- ci: backend unit tests gain a postgres service
* fix(checkpoint): close materialization gaps in goal flow, guard public factory
- Route goal-continuation message reads through CheckpointStateAccessor:
raw channel_values reads see the delta sentinel in delta mode, which
disabled goal continuation (stand_down=no_durable_end_of_turn) after
durable assistant turns. Raw tuples remain for tuple-only metadata
(checkpoint id, pending_writes).
- Reject checkpoint_channel_mode='delta' + checkpointer in
create_deerflow_agent at construction: factory-built persisted graphs
bypass mode-marker injection and the fail-closed gate, reproducing
silent mixed-mode state loss. Delta without persistence stays allowed.
- Import the postgres saver lazily (pytest.importorskip in the fixture)
so the documented default install collects the suite; add a CI job
running pytest --collect-only on uv sync --group dev without extras.
- Fix test_checkpointer fallback test to patch get_app_config at its
use site (provider module), making it deterministic when a local
config.yaml selects a persistent backend.
* fix(gateway): preserve extension-owned channels in state mutations, bump config version
- build_state_mutation_graph / build_checkpoint_state_mutation_accessor
accept an explicit state_schema; branch and POST /state now compile the
mutation graph from the thread's effective schema
(graph_state_schema on the assistant graph). The base-ThreadState
fallback silently discarded channels contributed by custom
AgentMiddleware.state_schema on branch (data loss) and returned a
false-success 200 on POST /state.
- POST /state validates values keys against the mutation graph's
channels and rejects unknown fields with 422 instead of ignoring
them; reducer detection covers extension channels
(BinaryOperatorAggregate or DeltaChannel) so Overwrite replace
semantics work for middleware reducers in both modes.
- Endpoint regression: custom AgentMiddleware.state_schema value
survives branch, updates through POST /state, and an unknown field
receives 422.
- config_version 26 -> 27 for the new database.checkpoint_channel_mode
(example, Helm chart values + README, support-bundle fixture), so
existing installs get the outdated-config warning and
make config-upgrade merges the field; covered by a test driving the
real example file and the real config-upgrade script.
* fix(gateway): resolve assistant schema via one boundary, copy branch reducer values with Overwrite
GET /threads/{id}/state now resolves the thread's assistant_id through a
single reusable boundary (thread metadata -> assistant_id -> effective
graph), so channels contributed by a custom AgentMiddleware.state_schema
are materialized instead of dropped by the default lead schema. POST
/state uses the same boundary instead of resolving the schema ad hoc.
Branch writes wrap every copied reducer channel in Overwrite (derived
from the effective mutation graph: BinaryOperatorAggregate + DeltaChannel),
not just messages, so already-aggregated values are never re-merged.
Regression tests use a real AgentMiddleware.state_schema with a
non-identity reducer in both full and delta modes: GET /state returns the
extension value, POST /state replaces it, branch preserves it
byte-for-byte; the unknown-field 422 is a separate assertion.
* refactor(checkpoint): collapse read-path round-trips and ship dual-mode parity tests
Address review round 4 on PR #4292:
- Push ahistory/history limit through Pregel into checkpointer.alist
(SQL LIMIT) instead of materializing all rows and breaking in Python
- Fold the read-side mode-compat gate onto the returned snapshot's
metadata; only writes keep the pre-write tuple fetch (fail-closed)
- Cache factory-built accessor graphs per (assistant_id, mode) with
factory-identity revalidation; state reads no longer build a lead
agent per request
- get_thread: one snapshot fetch + one raw pending_writes fetch on the
resolved checkpoint (post-checkpoint __error__ writes never surface
in snapshot.tasks; verified empirically)
- DeerFlowClient.get_thread: single checkpointer.list walk collects
pending_writes per checkpoint instead of N get_tuple calls
- InMemorySaver delta-history patch: stand-down when the upstream
override disappears, try/except guard, validated-version warning,
guard tests
- make_lead_agent mode precedence: first freeze is owned by app_config
(client-supplied configurable key ignored); once frozen, injected
key/app_config must match or fail closed
- Rollback: lock in non-message channel restoration via fork
inheritance with a dedicated reducer-channel test
- Add tests/test_threads_checkpoint_mode.py and
tests/test_gateway_checkpoint_mode.py referenced by AGENTS.md and
the PR validation section: lifecycle parity (memory + sqlite),
per-step blob-count storage guard, gateway endpoint parity
Counted-saver tests pin checkpoint round-trips for aget/ahistory so
these regressions cannot silently return.
* fix(checkpoint): precise mode-mismatch HTTP mapping, gate E2E, and accessor resilience
- threads router: map CheckpointModeMismatchError to 409 (with cause and
thread id) and CheckpointModeReconfigurationError to 503 across all state
endpoints instead of swallowing both into a generic 500
- gate coverage: seed a real delta checkpoint into AsyncSqliteSaver and
assert aget/aupdate/ahistory fail closed; assert 409 at the HTTP boundary
through the real route stack
- rollback: compile the restore mutation graph with the thread's effective
state schema per the build_state_mutation_graph contract
- inheritance contract locks: rollback and manual compaction preserve
middleware-contributed channels via checkpoint fork cloning
- services: revalidate the accessor-graph cache against app_config identity
so config.yaml hot-reloads never serve a stale compiled graph
- services: degrade full-mode state reads to raw checkpointer reads when the
agent factory is unavailable (delta gate still applies; delta mode has no
fallback)
- deps: override websockets==16.0 (langgraph-sdk 0.4.2's <16 pin silently
downgraded 16.0 -> 15.0.1; pin is not grounded in any API incompatibility)
and bump the langchain lower bound to what the lockfile actually resolves
* fix(checkpoint): include anchor checkpoint in degraded history walk + cover get_thread
- _RawCheckpointReadAccessor.ahistory: alist(before=...) is exclusive while
pregel's get_state_history treats config.checkpoint_id as the inclusive
start; fetch the anchor explicitly so both read paths paginate identically
- extend the degraded-path gateway test: GET /thread returns raw values, and
POST /history with before starts at the anchor checkpoint
* fix(gateway): preserve degraded checkpoint timestamps
* fix(gateway): harden degraded checkpoint access
* fix(gateway): resolve assistants for checkpoint reads
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
372 lines
15 KiB
Python
372 lines
15 KiB
Python
"""DeltaChannel migration, replay, and storage-shape contracts on real saver backends.
|
|
|
|
These tests pin the LangGraph 1.2 DeltaChannel upgrade path that production
|
|
relies on:
|
|
|
|
- full -> delta migration on the same thread (old plain-value blobs seed the
|
|
delta channel transparently),
|
|
- deterministic materialization with stable message ids (ids are stamped into
|
|
the persisted writes by ``put_writes``/``ensure_message_ids``),
|
|
- the on-disk storage shape (non-snapshot checkpoints omit ``messages`` from
|
|
``channel_values``; snapshot checkpoints store a ``_DeltaSnapshot`` blob),
|
|
- non-Delta raw writers (goal / run-duration metadata / interrupted-title
|
|
helper) preserving Delta ancestry and the downgrade markers.
|
|
|
|
Every contract runs against InMemorySaver, AsyncSqliteSaver, and - when
|
|
``TEST_POSTGRES_URI`` is set - AsyncPostgresSaver, because each backend
|
|
implements blob/version handling slightly differently.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import AsyncIterator, Callable
|
|
from contextlib import asynccontextmanager
|
|
from typing import Annotated, Any, TypedDict
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage
|
|
from langgraph.channels import DeltaChannel
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.graph.message import add_messages
|
|
from langgraph.types import Overwrite
|
|
|
|
from deerflow.agents.goal_state import GoalState
|
|
from deerflow.agents.thread_state import DeltaThreadState, merge_message_writes
|
|
from deerflow.runtime.checkpoint_mode import CHECKPOINT_MODE_METADATA_KEY, checkpoint_tuple_uses_delta
|
|
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
|
from deerflow.runtime.goal import write_thread_goal
|
|
from deerflow.runtime.runs.worker import _ensure_interrupted_title, persist_run_durations
|
|
|
|
|
|
class FullState(TypedDict):
|
|
messages: Annotated[list[AnyMessage], add_messages]
|
|
|
|
|
|
class DeltaState(TypedDict):
|
|
messages: Annotated[
|
|
list[AnyMessage],
|
|
DeltaChannel(merge_message_writes, snapshot_frequency=2),
|
|
]
|
|
|
|
|
|
def _thread_id() -> str:
|
|
return f"delta-contract-{uuid4().hex}"
|
|
|
|
|
|
def _config(thread_id: str) -> dict[str, Any]:
|
|
return {"configurable": {"thread_id": thread_id}}
|
|
|
|
|
|
def _noop(state: dict[str, Any]) -> dict[str, Any]:
|
|
return {}
|
|
|
|
|
|
def _build_graph(schema: Any, checkpointer: Any) -> Any:
|
|
builder = StateGraph(schema)
|
|
builder.add_node("noop", _noop)
|
|
builder.set_entry_point("noop")
|
|
builder.set_finish_point("noop")
|
|
return builder.compile(checkpointer=checkpointer)
|
|
|
|
|
|
def _goal(objective: str) -> GoalState:
|
|
return {
|
|
"objective": objective,
|
|
"status": "active",
|
|
"created_at": "2026-07-18T00:00:00+00:00",
|
|
"updated_at": "2026-07-18T00:00:00+00:00",
|
|
"continuation_count": 0,
|
|
"max_continuations": 3,
|
|
"no_progress_count": 0,
|
|
"max_no_progress_continuations": 2,
|
|
}
|
|
|
|
|
|
class _SaverEnv:
|
|
"""One saver instance with a reopen() that simulates a process restart.
|
|
|
|
Reopening swaps in a brand-new saver over the same bytes (SQLite file or
|
|
Postgres schema) so replay-after-reopen contracts prove persistence, not
|
|
in-process caching. InMemorySaver keeps no external bytes, so reopen is a
|
|
no-op stand-in there.
|
|
"""
|
|
|
|
def __init__(self, kind: str, open_saver: Callable[[], Any]) -> None:
|
|
self.kind = kind
|
|
self._open_saver = open_saver
|
|
self._cm: Any | None = None
|
|
self.saver: Any | None = None
|
|
|
|
async def __aenter__(self) -> _SaverEnv:
|
|
self._cm = self._open_saver()
|
|
self.saver = await self._cm.__aenter__()
|
|
setup = getattr(self.saver, "setup", None)
|
|
if setup is not None:
|
|
await setup()
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: Any) -> None:
|
|
if self._cm is not None:
|
|
await self._cm.__aexit__(*exc)
|
|
self._cm = None
|
|
self.saver = None
|
|
|
|
async def reopen(self) -> None:
|
|
if self.kind == "memory":
|
|
return
|
|
await self._cm.__aexit__(None, None, None)
|
|
self._cm = self._open_saver()
|
|
self.saver = await self._cm.__aenter__()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _open_sqlite(db_path: Any) -> AsyncIterator[Any]:
|
|
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as saver:
|
|
await saver.setup()
|
|
yield saver
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _open_postgres(uri: str) -> AsyncIterator[Any]:
|
|
aio = pytest.importorskip("langgraph.checkpoint.postgres.aio", reason="postgres extra not installed")
|
|
async with aio.AsyncPostgresSaver.from_conn_string(uri) as saver:
|
|
await saver.setup()
|
|
yield saver
|
|
|
|
|
|
@pytest.fixture(params=["memory", "sqlite", "postgres"])
|
|
async def saver_env(request: pytest.FixtureRequest, tmp_path: Any) -> AsyncIterator[_SaverEnv]:
|
|
kind = request.param
|
|
if kind == "memory":
|
|
saver = InMemorySaver()
|
|
|
|
@asynccontextmanager
|
|
async def open_memory() -> AsyncIterator[Any]:
|
|
yield saver
|
|
|
|
open_saver = open_memory
|
|
elif kind == "sqlite":
|
|
db_path = tmp_path / "delta-contract.sqlite"
|
|
|
|
def open_sqlite() -> Any:
|
|
return _open_sqlite(db_path)
|
|
|
|
open_saver = open_sqlite
|
|
else:
|
|
uri = os.environ.get("TEST_POSTGRES_URI")
|
|
if not uri:
|
|
pytest.skip("TEST_POSTGRES_URI is not set")
|
|
|
|
def open_postgres() -> Any:
|
|
return _open_postgres(uri)
|
|
|
|
open_saver = open_postgres
|
|
|
|
async with _SaverEnv(kind, open_saver) as env:
|
|
yield env
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_full_to_delta_migration_replays_on_same_thread(saver_env: _SaverEnv) -> None:
|
|
"""A thread written by a full (pre-delta) graph must keep replaying after
|
|
the process swaps to a delta graph: the old plain-value ``messages`` blob
|
|
seeds the delta channel, and later delta writes append on top."""
|
|
thread_id = _thread_id()
|
|
config = _config(thread_id)
|
|
|
|
full_graph = _build_graph(FullState, saver_env.saver)
|
|
await full_graph.ainvoke({"messages": [HumanMessage(id="h1", content="seed from full mode")]}, config)
|
|
|
|
delta_graph = _build_graph(DeltaState, saver_env.saver)
|
|
delta_accessor = CheckpointStateAccessor.bind(delta_graph, saver_env.saver, mode="delta")
|
|
|
|
migrated = await delta_accessor.aget(config)
|
|
assert [m.id for m in migrated.values["messages"]] == ["h1"]
|
|
assert migrated.values["messages"][0].content == "seed from full mode"
|
|
|
|
await delta_graph.ainvoke({"messages": [AIMessage(id="a1", content="delta reply")]}, config)
|
|
|
|
await saver_env.reopen()
|
|
delta_graph = _build_graph(DeltaState, saver_env.saver)
|
|
delta_accessor = CheckpointStateAccessor.bind(delta_graph, saver_env.saver, mode="delta")
|
|
|
|
replayed = await delta_accessor.aget(config)
|
|
assert [m.id for m in replayed.values["messages"]] == ["h1", "a1"]
|
|
assert [m.content for m in replayed.values["messages"]] == ["seed from full mode", "delta reply"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_materialization_is_deterministic_and_message_ids_are_stable(saver_env: _SaverEnv) -> None:
|
|
"""Materializing the same thread twice - and once more after a saver
|
|
reopen - must produce identical values, and the id LangGraph stamps onto
|
|
an id-less message must survive persistence so it can drive RemoveMessage."""
|
|
thread_id = _thread_id()
|
|
config = _config(thread_id)
|
|
graph = _build_graph(DeltaState, saver_env.saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver_env.saver, mode="delta")
|
|
|
|
await graph.ainvoke({"messages": [HumanMessage(content="no id on purpose")]}, config)
|
|
|
|
first = await accessor.aget(config)
|
|
second = await accessor.aget(config)
|
|
assert first.values == second.values
|
|
|
|
persisted_id = first.values["messages"][0].id
|
|
assert persisted_id
|
|
|
|
await saver_env.reopen()
|
|
graph = _build_graph(DeltaState, saver_env.saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver_env.saver, mode="delta")
|
|
|
|
reopened = await accessor.aget(config)
|
|
assert reopened.values == first.values
|
|
assert reopened.values["messages"][0].id == persisted_id
|
|
|
|
await graph.ainvoke({"messages": [RemoveMessage(id=persisted_id)]}, config)
|
|
after_remove = await accessor.aget(config)
|
|
assert after_remove.values["messages"] == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_delta_storage_shape_and_snapshot_cadence(saver_env: _SaverEnv) -> None:
|
|
"""Non-snapshot checkpoints must not carry ``messages`` in channel_values
|
|
(that is the whole storage win); every ``snapshot_frequency`` updates the
|
|
checkpoint carries a ``_DeltaSnapshot`` blob instead, and materialization
|
|
stays correct across both shapes."""
|
|
thread_id = _thread_id()
|
|
config = _config(thread_id)
|
|
graph = _build_graph(DeltaState, saver_env.saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver_env.saver, mode="delta")
|
|
|
|
await graph.ainvoke({"messages": [HumanMessage(id="m1", content="one")]}, config)
|
|
first_tuple = await saver_env.saver.aget_tuple(config)
|
|
assert first_tuple is not None
|
|
assert "messages" not in first_tuple.checkpoint["channel_values"]
|
|
# The node's output persists as pending writes attached to the checkpoint
|
|
# saved *before* its superstep (an ancestor of the latest checkpoint).
|
|
chain_has_messages_write = False
|
|
async for chain_tuple in saver_env.saver.alist(config):
|
|
if any(channel == "messages" for _, channel, _ in chain_tuple.pending_writes or []):
|
|
chain_has_messages_write = True
|
|
break
|
|
assert chain_has_messages_write
|
|
|
|
# Second update crosses snapshot_frequency=2 -> one checkpoint on the
|
|
# chain carries a _DeltaSnapshot blob for messages. (Which checkpoint
|
|
# reassembles it into channel_values differs per saver: InMemorySaver
|
|
# resolves the versioned blob on the latest checkpoint, AsyncSqliteSaver
|
|
# on its parent. The cadence contract is that the snapshot exists.)
|
|
await graph.ainvoke({"messages": [AIMessage(id="m2", content="two")]}, config)
|
|
snapshot_found = False
|
|
async for chain_tuple in saver_env.saver.alist(config):
|
|
if isinstance(chain_tuple.checkpoint["channel_values"].get("messages"), _DeltaSnapshot):
|
|
snapshot_found = True
|
|
break
|
|
assert snapshot_found
|
|
|
|
snapshot = await accessor.aget(config)
|
|
assert [m.id for m in snapshot.values["messages"]] == ["m1", "m2"]
|
|
assert [m.content for m in snapshot.values["messages"]] == ["one", "two"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_non_delta_writers_preserve_delta_messages_and_markers(saver_env: _SaverEnv) -> None:
|
|
"""Raw checkpoint writers that never touch the messages channel (thread
|
|
goal, run-duration metadata, interrupted-title helper) must not sever the
|
|
Delta parent lineage or drop the downgrade markers. Regression: a raw
|
|
``aput`` whose write_config omits ``checkpoint_id`` stores a parentless
|
|
checkpoint; replay from it then walks an empty ancestor chain and the
|
|
whole message history silently disappears."""
|
|
thread_id = _thread_id()
|
|
config = _config(thread_id)
|
|
write_config = {**config, "metadata": {CHECKPOINT_MODE_METADATA_KEY: "delta"}}
|
|
graph = _build_graph(DeltaThreadState, saver_env.saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver_env.saver, mode="delta")
|
|
|
|
await graph.ainvoke({"messages": Overwrite([HumanMessage(id="u1", content="hello goal writer")])}, write_config)
|
|
await graph.ainvoke({"messages": [AIMessage(id="a1", content="reply")]}, write_config)
|
|
|
|
await write_thread_goal(saver_env.saver, thread_id, _goal("keep messages alive"))
|
|
durations_written = await persist_run_durations(
|
|
checkpointer=saver_env.saver,
|
|
thread_id=thread_id,
|
|
durations={"run-1": 7},
|
|
)
|
|
assert durations_written
|
|
# Delta checkpoints carry no messages in channel_values, so the title
|
|
# helper has nothing to derive from and must stay inert (no write).
|
|
title = await _ensure_interrupted_title(checkpointer=saver_env.saver, thread_id=thread_id, app_config=None)
|
|
assert title is None
|
|
|
|
await saver_env.reopen()
|
|
graph = _build_graph(DeltaThreadState, saver_env.saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver_env.saver, mode="delta")
|
|
|
|
latest_tuple = await saver_env.saver.aget_tuple(config)
|
|
assert latest_tuple is not None
|
|
|
|
# Production materialized read path (same one the Gateway uses).
|
|
snapshot = await accessor.aget(config)
|
|
assert [m.id for m in snapshot.values["messages"]] == ["u1", "a1"]
|
|
assert [m.content for m in snapshot.values["messages"]] == ["hello goal writer", "reply"]
|
|
|
|
assert checkpoint_tuple_uses_delta(latest_tuple)
|
|
assert latest_tuple.metadata.get(CHECKPOINT_MODE_METADATA_KEY) == "delta"
|
|
assert latest_tuple.metadata.get("run_durations", {}).get("run-1") == 7
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# InMemorySaver delta-history patch guards (deerflow.checkpoint_patches)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_inmemory_delta_history_patch_is_active() -> None:
|
|
"""The compatibility patch must be applied in every test/app process."""
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow import checkpoint_patches
|
|
|
|
assert getattr(InMemorySaver, checkpoint_patches._PATCH_FLAG, False) is True
|
|
assert InMemorySaver.get_delta_channel_history is checkpoint_patches._get_delta_channel_history_via_base
|
|
assert InMemorySaver.aget_delta_channel_history is checkpoint_patches._aget_delta_channel_history_via_base
|
|
|
|
|
|
def test_inmemory_delta_history_patch_stands_down_without_upstream_override(monkeypatch) -> None:
|
|
"""If upstream removes its (buggy) override, the patch must not reinstall."""
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow import checkpoint_patches
|
|
|
|
monkeypatch.setattr(checkpoint_patches, "_upstream_override_present", lambda: False)
|
|
monkeypatch.delattr(InMemorySaver, checkpoint_patches._PATCH_FLAG, raising=False)
|
|
sentinel = object()
|
|
monkeypatch.setattr(InMemorySaver, "get_delta_channel_history", sentinel)
|
|
|
|
checkpoint_patches.ensure_inmemory_delta_history_patch()
|
|
|
|
assert getattr(InMemorySaver, checkpoint_patches._PATCH_FLAG, False) is False
|
|
assert InMemorySaver.get_delta_channel_history is sentinel
|
|
|
|
|
|
def test_inmemory_delta_history_patch_warns_on_unvalidated_langgraph(monkeypatch, caplog) -> None:
|
|
"""A langgraph newer than the validated version must log a re-inspection warning."""
|
|
import logging
|
|
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
from deerflow import checkpoint_patches
|
|
|
|
monkeypatch.setattr(checkpoint_patches.importlib.metadata, "version", lambda _name: "99.0.0")
|
|
monkeypatch.setattr(checkpoint_patches, "_upstream_override_present", lambda: False)
|
|
monkeypatch.delattr(InMemorySaver, checkpoint_patches._PATCH_FLAG, raising=False)
|
|
|
|
with caplog.at_level(logging.WARNING, logger=checkpoint_patches.__name__):
|
|
checkpoint_patches.ensure_inmemory_delta_history_patch()
|
|
|
|
assert any("newer than the version" in record.message for record in caplog.records)
|