fix(message): assign UUIDs to ID-less messages in _messages_delta_reducer

Without UUID assignment, message eviction and RemoveMessage tombstoning
fail for messages created without explicit IDs. Matches the behavior of
add_messages. IDs are now assigned inline within the existing iterations
over state_msgs and msgs to avoid an extra pass.
This commit is contained in:
Sydney Runkle
2026-05-06 14:42:36 -04:00
parent b1331fb9a6
commit eddfb40703
2 changed files with 36 additions and 9 deletions
+14 -9
View File
@@ -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]
+22
View File
@@ -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.