fix(sdk): handle None state in messages delta reducer (#3636)

Fixes #3564

---

`DeltaChannel.replay_writes` can pass `state=None` when a thread's
earliest checkpoint never seeded `messages: []`. The reducer now treats
`None` as an empty list, preventing `convert_to_messages` from receiving
a `None` argument. Regression tests for langchain-ai/deepagents#3564
included.

---

Note: intentionally diverging from upstream, will port back later
This commit is contained in:
Mason Daugherty
2026-05-27 15:50:34 -04:00
committed by GitHub
parent e298206407
commit 5a6d920d9d
2 changed files with 31 additions and 3 deletions
@@ -29,7 +29,7 @@ from langgraph.graph.message import REMOVE_ALL_MESSAGES
def _messages_delta_reducer( # noqa: C901, PLR0912
state: list[AnyMessage], writes: list[list[AnyMessage]]
state: list[AnyMessage] | None, writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""Batch reducer for use with `DeltaChannel` on the messages key.
@@ -51,8 +51,11 @@ def _messages_delta_reducer( # noqa: C901, PLR0912
flat.append(w)
# Steady state: the reducer's own output is already typed BaseMessages,
# so skip convert_to_messages on the fast path. Only raw input (initial
# dicts, deserialized blobs) hits the slow path.
state_msgs = state if state and isinstance(state[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(state))
# dicts, deserialized blobs) hits the slow path. `state` is `None` on
# `DeltaChannel.replay_writes` for threads whose earliest checkpoint did
# not seed `messages: []`; treat that as the empty list so the slow path
# doesn't pass `None` into `convert_to_messages`.
state_msgs = state if state and isinstance(state[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(state or []))
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
# REMOVE_ALL_MESSAGES resets everything; find the last sentinel and
@@ -91,6 +91,31 @@ def test_human_message_id_stable_across_invocations_sync() -> None:
)
def test_reducer_handles_none_base_state() -> None:
"""`DeltaChannel.replay_writes` passes `state=None` when the earliest
checkpoint for a thread did not seed `messages: []`. The reducer must
treat that the same as an empty base.
Regression test for https://github.com/langchain-ai/deepagents/issues/3564.
""" # noqa: D205
msg = HumanMessage(content="hi", id="h1")
result = _messages_delta_reducer(None, [[msg]])
assert result == [msg]
# Empty writes against a None base should also not crash.
assert _messages_delta_reducer(None, []) == []
assert _messages_delta_reducer(None, [[]]) == []
def test_reducer_handles_none_base_state_with_dict_messages() -> None:
"""None-base replay still coerces raw over-the-wire message payloads."""
result = _messages_delta_reducer(None, [[{"role": "user", "content": "hi"}]])
assert len(result) == 1
assert isinstance(result[0], HumanMessage)
assert result[0].content == "hi"
@pytest.mark.anyio
async def test_human_message_id_stable_across_invocations_async() -> None:
"""Same check via ainvoke (AsyncPregelLoop path)."""