#2707·ag-ui

[Bug]: MESSAGES_SNAPSHOT keeps client order and appends re-keyed messages after the final answer

Author: itsklimovCreated Sep 9, 2026Updated Sep 17, 2026

Pre-flight Checklist

  • I have searched existing issues and this hasn't been reported yet.
  • I am using the latest version AG-UI.

Describe the Bug

defaultApplyEvents merges a MESSAGES_SNAPSHOT by keeping the client's existing message order and appending every snapshot message it has not seen yet at the end. The snapshot's own order is never used.

This breaks as soon as a backend re-keys a message. A common case is a tool result: the client streams it under the id from TOOL_CALL_RESULT, the backend persists it under a canonical id, and the next snapshot carries the canonical copy. The client drops the streamed copy and appends the canonical copy after the final assistant answer. A later snapshot cannot repair the order because every id now survives and nothing is appended.

The same rule also lets a snapshot that repeats an id produce two messages, while RUN_STARTED input handling already guarantees that the reducer never duplicates messages by id.

#2184 made activity all-or-nothing, but the trade-off noted there still stands: a backend that stores activity for one activity type cannot send it in a snapshot without deleting every other activity type the client still owns. The fix for the ordering bug is the place where that scope can be expressed, so I would like to handle both in one PR unless you prefer them split.

Steps to Reproduce

  1. Client messages: user, assistant(tool call), tool(id: result-browser), assistant("Applied.").
  2. Apply MESSAGES_SNAPSHOT with user, assistant(tool call), tool(id: result-canonical), assistant("Applied.").
  3. Read agent.messages.
typescript
const snapshot = [user, toolCall, { ...result, id: "result-canonical" }, answer];
const messages = await applySnapshot([user, toolCall, { ...result, id: "result-browser" }, answer], snapshot);
// messages.map(m => m.id) is ["u", "call", "answer", "result-canonical"]

The existing test preserves activity position when a message ID changes in snapshot encodes this: it expects ["m1", "asst-1", "act-1", "asst-2", "tool-canon"], with the renamed tool result after the final answer.

Expected Behavior

The snapshot owns transcript order: ["u", "call", "result-canonical", "answer"], and applying the same snapshot again is a no-op.

Client-only messages the snapshot leaves out (activity and reasoning under the existing rules) keep their place relative to the messages around them: before the next surviving message, or after the last survivor when nothing after them survives.

A snapshot may declare which activity types it owns, so a producer that rebuilds one activity kind from durable data can replace that kind, including replacing it with nothing, while other producers' activity survives on the client.

Environment

  • @ag-ui/client 0.0.59 (main at 48ee0d56)
  • Node 22, macOS

Logs & Errors

No response

Additional Context

I have a PR ready for this and will open it once the issue is triaged. @ag-ui-protocol/copilotkit could you assign it to me?