fix(langgraph): give a DeltaChannel message inside an Overwrite an id

ensure_message_ids skipped an Overwrite, so the messages inside it were
saved with no id.
This commit is contained in:
Elior Nataf Lackritz
2026-10-11 09:37:59 -04:00
parent 799c2f04fd
commit a3471dcc12
3 changed files with 44 additions and 2 deletions
+7 -1
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel
from langgraph._internal._config import filter_to_user_tags
from langgraph._internal._constants import NS_SEP
from langgraph.channels.binop import _get_overwrite
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
from langgraph.pregel.protocol import StreamChunk
from langgraph.types import Command
@@ -431,7 +432,7 @@ def ensure_message_ids(value: Any) -> None:
BaseMessages; every get_state() replay then produces a different UUID and
the same message appears with a different ID in each LangSmith trace.
Handles three input shapes:
Handles four input shapes:
- BaseMessage objects: assign a UUID if id is None.
- Dicts with a known "role" (OpenAI-style) or "type" (LangChain format) at
the root level: stamp "id" into the dict in-place. The reducer's
@@ -439,6 +440,7 @@ def ensure_message_ids(value: Any) -> None:
- Lists of the above: apply the same logic to each element, replacing dict
items with coerced BaseMessages so the shared list reference seen by
checkpoint_pending_writes and the background thread both get typed messages.
- An `Overwrite` of any of the above: apply the same logic to its value.
Mutating synchronously here (before the background thread is submitted) is
safe: the serialised bytes always reflect the post-coercion state.
@@ -459,3 +461,7 @@ def ensure_message_ids(value: Any) -> None:
if msg.id is None:
msg.id = str(uuid4())
value[i] = msg
else:
is_overwrite, overwrite_value = _get_overwrite(value)
if is_overwrite:
ensure_message_ids(overwrite_value)
@@ -17,6 +17,7 @@ from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
@@ -138,3 +139,24 @@ def test_delta_channel_dict_style_message_gets_stable_id() -> None:
assert len(set(ids)) == 1, (
f"dict-style HumanMessage id must be stable across get_state() calls; got {ids}"
)
def test_delta_channel_message_in_an_overwrite_gets_stable_id() -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "overwrite-id-stability"}}
graph.invoke({"messages": Overwrite([HumanMessage(content="hello")])}, config)
ids = [
next(
m.id
for m in graph.get_state(config).values["messages"]
if isinstance(m, HumanMessage)
)
for _ in range(3)
]
assert ids[0] is not None, "a message inside an Overwrite should get a UUID"
assert len(set(ids)) == 1, (
f"its id must be stable across get_state() calls; got {ids}"
)
@@ -31,7 +31,7 @@ from langgraph.channels.last_value import LastValue
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import StateSnapshot, StateUpdate
from langgraph.types import Overwrite, StateSnapshot, StateUpdate
pytestmark = pytest.mark.anyio
@@ -688,6 +688,20 @@ async def test_aupdate_state_gives_a_message_an_id_that_every_read_keeps() -> No
assert first == second
def test_update_state_gives_a_message_in_an_overwrite_an_id_that_every_read_keeps() -> (
None
):
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "t"}}
graph.invoke({"messages": [HumanMessage("a", id="a")]}, config)
graph.update_state(config, {"messages": Overwrite([HumanMessage("b")])})
first, second = _message_ids(graph, config), _message_ids(graph, config)
assert first[-1] is not None
assert first == second
def test_update_state_on_an_older_checkpoint_gives_a_message_an_id() -> None:
graph = _build_graph(InMemorySaver())
config = {"configurable": {"thread_id": "t"}}