AG-UI Message History Management#
AgUiThreadRuntimeCore is the central class in @assistant-ui/react-ag-ui that owns the conversation history for a single thread. It maintains a flat, ordered ThreadMessage[] array where message position implies parentage — each message's implicit parent is the one before it . All mutations (append, edit, reload, cancel) converge through a small set of private methods that operate on this array.
Linear Message Architecture#
The runtime stores messages as a plain array — not a tree. The history is "reset to a head" by slicing: resetHead(parentId) truncates this.messages to everything up to and including the given parentId. This is how edits and reloads discard future messages without needing a tree structure.
New messages are pushed to the end:
appendEntrycallsresetHeadthen appends the newThreadMessage.startRunalso callsresetHead(parentId)before inserting an assistant placeholder, ensuring the assistant reply always lands at the end of the slice.
The conversion layer keeps the agent's external format and the internal format in sync: toAgUiMessages serializes the internal array into AG-UI Message[] for each agent run, and fromAgUiMessages deserializes AG-UI messages back, folding role: "tool" messages into the preceding assistant message's tool-call parts.
Optimistic Message ID Handling#
Before a run starts, the runtime inserts an assistant placeholder with an __optimistic__-prefixed id . This placeholder :
- Is given
metadata.isOptimistic: trueso that the coreMessageRepository'sevictOffBranchOptimisticMessagesmechanism can clean it up if needed. - Has
status: { type: "running" }.
When the agent emits TEXT_MESSAGE_START (or any event that surfaces a canonical server id via onServerMessageId), reassignAssistantId(oldId, newId) swaps the placeholder into the real id by:
- Stripping
metadata.isOptimisticfrom the renamed message so the now-real message is never evicted . - Migrating the pending history-parent entry from
oldId→newIdinassistantHistoryParents. - If the server id already exists in the array (duplicate delivery), dropping the placeholder entirely.
If no server id arrives before the run settles, updateAssistantMessage detects the terminal status and assigns a stable local id via generateId(), again going through reassignAssistantId to strip the optimistic flag .
Why this matters: Without isOptimistic: true, the core repository would retain both the placeholder node and the server-id node as sibling branches, surfacing a blank "branch 1/2" in the UI on every turn. This was the bug fixed in PR #4317.
Branching Preservation Utilities#
While AgUiThreadRuntimeCore itself is linear, the surrounding infrastructure supports full branching via ExportedMessageRepository (from @assistant-ui/core):
ExportedMessageRepository.fromArray(messages)— builds a linear repository from a flat array, each message's parent being the previous one.ExportedMessageRepository.fromBranchableArray(messages, { headId })— builds a branching tree from messages that carry an explicitparentId, allowing multiple children per node .
The AG-UI runtime integrates with these through the optional ThreadHistoryAdapter passed to AgUiThreadRuntimeCore . On load, the adapter's load() result feeds into applyExternalMessages, which replaces the internal array and resets recordedHistoryIds. Subsequent messages are appended to history via appendHistoryItem, which deduplicates via the recordedHistoryIds set.
Loading persisted AG-UI history — the public fromAgUiMessages export (added in PR #4264) supports the page-load flow:
fetch persisted AG-UI messages
→ fromAgUiMessages(msgs, options) // returns ThreadMessageLike[]
→ ExportedMessageRepository.fromArray(...)
→ history adapter's load()
→ AgUiThreadRuntimeCore.applyExternalMessages()
This reuses the same conversion code path used internally for MESSAGES_SNAPSHOT events .
Thread State Coordination#
Several mechanisms prevent race conditions and phantom state during runs:
Tool result preservation. The RunAggregator rebuilds assistant content entirely from stream events, so frontend-injected tool results (via addToolResult) would be lost on the next update. preserveToolResults merges previously resolved tool-call parts back into each new content snapshot before applying it.
Deferred tool-call continuation. If a TOOL_CALL_RESULT event arrives while a run is still draining (between RUN_FINISHED and stream close), the follow-up run is deferred to pendingResumeMessageId and dispatched after startRun returns , ensuring no two runs ever overlap.
Cross-run tool results. TOOL_CALL_RESULT events for tool calls that belong to a previous run (not the current aggregator) are routed directly to the historical message via applyCrossRunToolResult rather than the active aggregator, avoiding a false auto-resume trigger.
History deduplication. recordedHistoryIds (a Set<string>) prevents double-appending to the ThreadHistoryAdapter when the same message is processed multiple times (e.g., after an id reassignment) .
Interrupt guards. assertNoPendingInterrupts() throws before append, reload, or resume if the last assistant message is in requires-action / interrupt status, ensuring the thread cannot be advanced past an unresolved interrupt.