diff --git a/libs/langgraph/langgraph/graph/message.py b/libs/langgraph/langgraph/graph/message.py index 64f375ad2..1e5fe1ca8 100644 --- a/libs/langgraph/langgraph/graph/message.py +++ b/libs/langgraph/langgraph/graph/message.py @@ -257,9 +257,10 @@ def _messages_delta_reducer( Raw dict / string / tuple inputs are coerced to typed `BaseMessage` objects so that HTTP-driven graphs work without a separate coercion - step. This is not full `add_messages` parity — `REMOVE_ALL_MESSAGES`, - unknown-id `RemoveMessage` errors, missing-id UUID assignment, and - `BaseMessageChunk` conversion are not handled here. + step. Messages without IDs are assigned UUIDs (matching `add_messages` + behavior) so that message eviction and `RemoveMessage` tombstoning work + correctly. `REMOVE_ALL_MESSAGES` and `BaseMessageChunk` conversion are + not handled here. Example:: @@ -289,15 +290,19 @@ def _messages_delta_reducer( state_msgs = cast("list[AnyMessage]", convert_to_messages(state)) msgs = cast("list[AnyMessage]", convert_to_messages(flat)) - index: dict[str, int] = { - m.id: i for i, m in enumerate(state_msgs) if m.id is not None - } + # Build index and assign missing IDs in one pass (parity with add_messages + # so that eviction and RemoveMessage tombstoning work on ID-less messages). + index: dict[str, int] = {} + for i, m in enumerate(state_msgs): + if m.id is None: + m.id = str(uuid.uuid4()) + index[m.id] = i result: list[AnyMessage | None] = list(state_msgs) for msg in msgs: + if msg.id is None: + msg.id = str(uuid.uuid4()) mid = msg.id - if mid is None: - result.append(msg) - elif isinstance(msg, RemoveMessage): + if isinstance(msg, RemoveMessage): if mid in index: result[index[mid]] = None del index[mid] diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py index 9dfa7158a..ecede949c 100644 --- a/libs/langgraph/tests/test_channels.py +++ b/libs/langgraph/tests/test_channels.py @@ -284,6 +284,28 @@ def test_messages_delta_reducer_tuple_write_is_one_message() -> None: assert result[0].content == "hi" +def test_messages_delta_reducer_assigns_uuid_to_id_less_messages() -> None: + """Messages without IDs get UUIDs assigned, matching add_messages behavior. + + Without UUID assignment, RemoveMessage tombstoning fails on messages that + were created without explicit IDs. + """ + m1 = HumanMessage(content="hi") + m2 = AIMessage(content="hello") + assert m1.id is None + assert m2.id is None + + result = _messages_delta_reducer([], [[m1, m2]]) + assert len(result) == 2 + assert result[0].id is not None + assert result[1].id is not None + + # RemoveMessage tombstoning must work on the now-assigned IDs. + result2 = _messages_delta_reducer(result, [RemoveMessage(id=result[1].id)]) + assert len(result2) == 1 + assert result2[0].content == "hi" + + def test_delta_channel_checkpoint_returns_missing() -> None: """checkpoint() always returns MISSING regardless of state.