deer-flow/backend/tests/test_checkpoint_patches.py
Aari d1aeea2c3e
fix(checkpoint): unwrap Overwrite first writes into empty channels (#4383)
* fix(gateway): stop persisting Overwrite wrappers into empty reducer channels on branch

Thread branching (and POST /state on a never-written channel) wraps copied
reducer values in Overwrite. Upstream BinaryOperatorAggregate.update seeds
an empty (MISSING) channel with values[0] verbatim without unwrapping, so
Union-typed channels (sandbox/goal/todos/promoted) stored the wrapper
literally and the next consumer crashed with TypeError: 'Overwrite' object
is not subscriptable (#4380). Patch the channel to unwrap the first write
(mirroring DeltaChannel semantics), and stop copying thread-scoped channels
(sandbox, thread_data) into branches: the parent's sandbox_id would bind
the branch to the parent's workspace and release lifecycle.

* refactor(checkpoint): drop private _get_overwrite import for a local Overwrite check

Importing langgraph's underscored _get_overwrite at module top level meant an
upstream refactor that drops it - plausibly the same release that fixes the
bug - would fail this module's import and crash startup before the probe can
stand the patch down. Replace it with a local helper on the public Overwrite
type, and fix two test docstring nits.

* refactor(checkpoint): write patch flags via their constants to avoid drift

Both saver patches read their "already patched" idempotence flag through a
module constant (_PATCH_FLAG / _BINOP_PATCH_FLAG) but wrote it as a hard-coded
attribute literal, so renaming the constant would silently break the guard and
double-apply the patch. Write via the same constant (setattr), dropping the
now-unneeded attr-defined ignores.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-26 10:44:50 +08:00

92 lines
3.8 KiB
Python

"""Guards for the BinaryOperatorAggregate first-write Overwrite patch (#4380).
Upstream ``BinaryOperatorAggregate.update`` has a fast path for empty channels
(``self.value is MISSING``) that stores the first incoming value without
unwrapping ``Overwrite``. Reducer channels whose type is a Union
(``SandboxState | None``, ``GoalState | None``, ...) have no constructible
default, so they start MISSING — a replace-style write into a fresh thread
(thread branching) or a never-written channel (state update) persists the
wrapper literally, and the next consumer crashes with ``TypeError:
'Overwrite' object is not subscriptable``. ``DeltaChannel`` already unwraps in
the same situation; the patch aligns ``BinaryOperatorAggregate`` with it.
"""
import pytest
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.errors import InvalidUpdateError
from langgraph.types import Overwrite
import deerflow.agents.thread_state # noqa: F401 - applies checkpoint patches at import
from deerflow import checkpoint_patches
def _replace(existing, new):
return new
def _empty_union_channel() -> BinaryOperatorAggregate:
"""A channel shaped like ``sandbox``/``goal``/``todos``: Union type, starts MISSING."""
channel = BinaryOperatorAggregate(dict | None, _replace)
channel.key = "probe"
return channel
def test_first_write_overwrite_unwraps_into_empty_channel() -> None:
channel = _empty_union_channel()
channel.update([Overwrite({"sandbox_id": "local:thread-2"})])
stored = channel.get()
assert not isinstance(stored, Overwrite)
# The exact read that crashed in #4380 (SandboxMiddleware.aafter_agent).
assert stored["sandbox_id"] == "local:thread-2"
def test_overwrite_into_populated_channel_still_replaces() -> None:
channel = _empty_union_channel()
channel.update([{"sandbox_id": "old"}])
channel.update([Overwrite({"sandbox_id": "new"})])
assert channel.get() == {"sandbox_id": "new"}
def test_plain_first_write_still_seeds_then_reduces() -> None:
channel = _empty_union_channel()
channel.update([{"sandbox_id": "first"}])
assert channel.get() == {"sandbox_id": "first"}
channel.update([{"sandbox_id": "second"}])
assert channel.get() == {"sandbox_id": "second"}
def test_values_after_first_write_overwrite_are_ignored() -> None:
"""Upstream semantics: after an Overwrite, later plain values in the batch are skipped."""
channel = _empty_union_channel()
channel.update([Overwrite({"sandbox_id": "kept"}), {"sandbox_id": "ignored"}])
assert channel.get() == {"sandbox_id": "kept"}
def test_double_overwrite_in_one_batch_raises_on_empty_channel() -> None:
channel = _empty_union_channel()
with pytest.raises(InvalidUpdateError):
channel.update([Overwrite({"sandbox_id": "a"}), Overwrite({"sandbox_id": "b"})])
def test_binop_overwrite_patch_is_active() -> None:
"""The compatibility patch must be applied in every test/app process."""
assert getattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, False) is True
assert BinaryOperatorAggregate.update is checkpoint_patches._binop_update_unwrapping_empty_channel
def test_binop_overwrite_patch_stands_down_when_upstream_fixed(monkeypatch) -> None:
"""If upstream unwraps the first write itself, the patch must not reinstall."""
monkeypatch.setattr(checkpoint_patches, "_binop_first_write_stores_overwrite_wrapper", lambda: False)
monkeypatch.delattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, raising=False)
sentinel = object()
monkeypatch.setattr(BinaryOperatorAggregate, "update", sentinel)
checkpoint_patches.ensure_binop_overwrite_first_write_patch()
assert getattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, False) is False
assert BinaryOperatorAggregate.update is sentinel