Local Runtime Message History#
Overview#
LocalThreadRuntimeCore (local-thread-runtime-core.ts) manages the in-memory lifecycle of conversation history for local runtimes. It wraps a MessageRepository branching tree and adds a persistence layer (via the optional history adapter), run-gating logic, and careful sequencing of concurrent writes. Understanding how it works is essential when debugging phantom message deletions, unexpected branch switches, or stale persisted history.
resetHead and the New-Message Guard#
performRoundtrip calls this.repository.resetHead(message.id) to promote the new assistant message to head — but only when the message is being created for the first time (i.e., !hasStoredMessage).
Before PR #8110, resetHead was called on every roundtrip, including resumes and tool-call continuation steps. This caused two bugs:
- Completing a human tool deleted later turns. If the user sent another message after a paused answer, applying
addToolResultresumed the paused roundtrip, which calledresetHeadagain and evicted every descendant message. - A running multi-step answer undid the user's branch switch. Each continuation step called
resetHead, moving the head back to the running answer even after the user had manually switched to an earlier branch.
The fix (PR #8110): resetHead is now conditional — resumes and continuations update the existing message in-place via addOrUpdateMessage, leaving the tree structure untouched.
Follow-Up and Paused Run Transitions#
When a new turn is appended while a paused roundtrip is still in flight, _cancelPause is called :
- If the message's roundtrip is still registered in
_roundtripsInFlight, the message ID is added to_followedDuringRunand a cancelled snapshot is staged for history. - If not,
withCancelledPauseis applied immediately in-memory and persisted.
The _followedDuringRun flag prevents the paused run's own final _persistSettled call from overwriting the cancelled snapshot with a completed one. In performRoundtrip's finally block , if followedDuringRun is true, the run applies withCancelledPause to any stored version and persists that, not its own terminal state.
withCancelledPause marks all unresolved tool-call approvals as resolution: "cancelled" and sets the message status to { type: "incomplete", reason: "cancelled" }.
Message Deletion and Follow-Up Mark Cleanup#
deleteMessage removes a message from the repository and calls the history adapter's delete method. It does not automatically clear the _followedDuringRun mark on the deleted message's parent.
This creates a race condition documented in issue #8558: if the follow-up turn is deleted, the _followedDuringRun marker on the parent can suppress the resumed run's terminal history append, leaving an append-only history with only the cancelled snapshot while the local UI shows the completed message.
PR #8663 addresses this by clearing the _followedDuringRun mark when the turn that set it is deleted and no child turns remain, allowing a subsequent resume to append its completion normally.
History Persistence Sequencing#
The runtime uses a per-message-ID write chain (_chainHistoryWrite) to guarantee ordered persistence.
- Each message ID has at most one pending write chain. The first write for an ID runs synchronously (no queuing overhead); subsequent writes for the same ID are chained via
.then. _persistSettleddetermines whether toappend(for messages the history has never received, tracked in_unwrittenMessages) orupdate(for previously persisted messages, if the adapter supports it).- Paused messages are written to history only when the adapter supports
update; an append-only adapter would otherwise strand a half-finished run as the last entry. - When a resume comes from a pause that the history already recorded (and the adapter has no
update), the runtime re-appends the settled terminal state as a new entry.
History Load Barrier#
On initial load, __internal_load fetches from the history adapter, applies withLocalPauseReasons to normalize imported statuses, then calls repository.import. The _getHistoryLoadBarrier method gates any append or startRun call until the load settles, preventing messages from landing on a branch that the import is about to discard.
Pause Status Normalization on Load#
withLocalPauseReasons post-processes every message in an imported repository:
- A message followed by a later turn (i.e., its ID appears as a
parentId) getswithCancelledPauseapplied — its approvals are cancelled and status set toincomplete/cancelled. - A standalone paused message (no children) gets
withLocalPauseReason, which downgrades anyinterruptstatus whose payload lacks unresolved tool-call interrupts tocompleteortool-calls, since the local runtime cannot act on a bare interrupt without a payload.
This prevents imported interrupt statuses — which may have come from a JSON round-trip or an external store — from stranding the thread in an unactionable pause.
Key Entry Points#
| Symbol | Location | Purpose |
|---|---|---|
performRoundtrip | Core run loop; owns resetHead, history writes, and pause settlement | |
_cancelPause | Cancels in-flight pauses when a follow-up turn is appended | |
_persistSettled | Routes append vs update to the history adapter | |
_chainHistoryWrite | Serializes per-message history writes | |
withLocalPauseReasons | Normalizes pause statuses on repository import | |
__internal_load | Loads and imports persisted history; gates concurrent appends | |
deleteMessage | Deletes from repository and calls adapter; does not auto-clear follow-up marks |
Related Articles#
- MessageRepository Branch Pointer Invariant — covers
resetHeaddescendant eviction and ancestornext-pointer repair at the data-structure level. - AG-UI Message History Management — covers the AG-UI-specific linear history model, optimistic IDs, and
fromAgUiMessages.