Skip to article
HB CodeDocsGet the app

HandbookArchitecture

Timeline: history pages, live overlay, settlement

How one ordered conversation is assembled from saved history pages, a revisioned live overlay, and pending sends, and why identity rather than text or time decides what exists.

Checked against the source on 5 October 2026

On this page

The timeline joins three inputs into one list

A row shows once per key, however many inputs hold a copy
A row shows once per key, however many inputs hold a copyYour pending sends, the live overlay, and history pages can each hold a copy of the same row. A client keeps one row per typed key: a history copy wins over a live copy, a pending echo hides once any row carries its operation ID, and two messages with the same text stay two rows because their keys differ. The keys and messages are examples.
useruser-message::op-41
activityitem-7
agent-replyitem-9
useruser-message::op-42
typed keytype, then ID
pending sendoutbox echo
live overlayorigin: live
history pageorigin: canonical
you seeone row per key
npm testrunning · hidden
run it againsending · hidden
run it againsent earlier
run it againsent earlier
npm testexit 0
npm testexit 0
All tests passstreaming
All tests passstreaming
run it againlive row
run it againlive row
“run it again” shows twice: same text, different operation ID. Text is never identity.
Order comes from timestamp, then sourceOrder, then key, never from arrival.
  • Typed keytype + "\0" + id

    A history copy and a live copy of the same row carry the same key. A client keeps one row per key and never matches rows by their text.

  • History over liveorigin: canonical

    When a history copy and a live copy share a key, the client shows the history copy. The desktop drops the live copy from the overlay once that row settles.

  • Pending echouser-message::<operationId>

    The desktop draws a message you send at once, under the key its history row will have, and hides that echo once any row carries its operation ID.

InputAuthorityWhen to reconcile
Canonical history windowThe desktop history route and its boundary token.On initial load, refresh, and older-page splice.
Live overlayDesktop settlementEpoch, revision, and typed live blocks.On valid current-target snapshots or live updates.
Outbox and submitted echoDesktop custody and acceptance. The local UI can show pending intent.When the outbox changes or canonical evidence confirms an accepted operation.
Viewport and measurementsThe selected client view.When switching target, loading older content, or following new activity.

Typed identity and canonical order

Each block has a typed identity key, the block type joined to the block ID with a NUL byte. The five block types are user, system, activity, agent-reply, and thinking. A block ID is unique only inside its type. The first figure shows how clients merge rows by that key. Timestamps are order metadata and never deduplication identity.

After every material merge a client sorts again: by timestamp, then by the sourceOrder vector from the desktop, then by the identity key. sourceOrder is a short list of numbers that compares element by element. Its first number ranks the source of the row, so a history row sorts before a live row with the same timestamp. A row without sourceOrder sorts after rows with it at the same timestamp.

Row sourcesourceOrderWhere the numbers come from
History, paginated[0, 1, ordinal, entryIndex]The ordinal of the saved item and the index of the row inside that item. Codex pages and Claude pages both use this form.
History, legacy Codex read[0, 0, ordinal, entryIndex]The position of the item in a thread/resume response.
Live row[2, sequence]A counter in the desktop process. The row gets its number once, when it first enters the run.
Status row of an active run[2, startedAt]The start time of the run in milliseconds, so each rebuild gives the same position.
Synthetic row[3, sequence]A row that the desktop made without a transcript item or a live event.

Both agents obey one rule for IDs: a live row must carry the ID that its saved row will have. Settlement and the client merge compare keys only. An adapter that gave a live row a different ID would show that row twice.

Desktop reads owned paginated Codex history through thread/items/list, newest first, at most 100 items per sidecar page. It preserves createdAtMs and updatedAtOrdinal, an unsupported public item appears as a diagnostic row that still advances the cursor, and an entry with neither or both of item and unsupportedItem fails the whole page. The boundaryToken a client receives is the SHA-256 of the rollout path, or of owned-thread-store plus the owner ID, so it identifies the history source rather than a page. oldestCursor is the prefix thread-items-v5: followed by base64url JSON that carries the thread ID, the raw sidecar cursor, a generation, that source token, the prior leading block key, and the window revision. A cursor for another thread is rejected. Desktop does not parse Codex JSONL and does not query the child’s private databases.

A Claude session uses the same typed keys, the same order, and the same row types. Only the history source differs. The desktop reads Claude’s saved transcript through the Claude Agent SDK and projects each message into timeline rows. The boundaryToken is the Claude session ID, or the subagent ID for a child thread. oldestCursor is JSON with four fields: session holds that ID, before holds the UUID of the oldest loaded message, anchor holds the leading block key, and revision holds the window revision. The desktop accepts at most 4,096 characters and rejects a cursor whose session differs from the open conversation or subagent. When the transcript no longer holds the before message, the page comes back empty with reloadRequired. A transcript that does not exist loads as an empty conversation. Saved Claude history starts after the last compaction, because the SDK returns only that part.

Live overlay revisions and the settlement epoch

A timeline contains canonical history plus live blocks that are not yet fully represented there. The desktop saves one overlay snapshot per target in the table chat_live_timeline_overlays, with the key app_session_id plus timeline_target_id. The target is the session for the main conversation, and the thread ID or subagent ID for a child. The snapshot format is version 2. It stores formatVersion, settlementEpoch, revision, and blocks containing wire data plus settlement metadata. The client receives only settlementEpoch, revision, and wire blocks. Internal settlement identity and provenance stay on desktop.

The overlay drops a live row only in a snapshot that carries its history row
The overlay drops a live row only in a snapshot that carries its history rowRow A is live on the desktop app and on a phone. When the desktop app loads a latest window whose history page holds A, the desktop removes A from the overlay and raises the settlement epoch in that same response. The phone refuses the next live update because its epoch is newer, keeps its rows, and loads its own latest window, so A turns into a history row without ever disappearing. The dashed bar shows what applying that update would have done. The epoch numbers are examples.
Desktop appchat view
Desktop overlaylive rows
Phonesame session
A · live row
A · history row
holds A · epoch 3
A · live row
A · history row
if applied: A gone
latest window
history with A + overlay without A
epoch 3 → 4
next update · epoch 4
newer epoch: not applied, rows kept
latest window
history with A + overlay without A
  • Settlement epochliveOverlay.settlementEpoch

    Rises only when a live row moves into history. A client takes a higher epoch only from a latest-window snapshot, because only that snapshot also carries the history rows.

  • RevisionliveOverlay.revision

    One global counter on the desktop. Within an epoch a higher revision replaces the whole overlay, a lower one is ignored, and the same revision with different rows makes the client reload.

  • Latest windowrun.chatLoad · run.chatReconcileSessions

    The desktop reads the newest history page, removes overlay rows whose keys that page holds, and returns both in one response. The desktop app calls the same builder. run.chatReconcileSessions does this for at most 16 sessions in one call.

liveOverlay is { settlementEpoch, revision, blocks } and contains the full unresolved live projection for one target. Within one settlement epoch an empty higher revision clears it. Revisions come from one durable global sequence, the single row of chat_live_timeline_overlay_revisions, so a deleted target cannot reuse an old revision. An empty target snapshot is persisted so the target keeps its epoch and revision. Overlay writes compare an expected revision inside a transaction, the counter is capped at JavaScript’s exact integer limit, 9,007,199,254,740,991, and a stale writer retries against current state rather than overwriting it.

The overlay holds two classes of rows. An ordinary live row has a saved counterpart in the agent’s history: a user message, reply text, a command, a tool call. The other rows are ephemeral. No history row ever replaces them.

Row classWhat it isHow it leaves the overlay
Ordinary live rowA row whose key also appears in saved history.A history read proves its key. The figure below shows the three reads.
Status rowThe thinking block with the ID thinking-<runId>. Its phase label comes from the newest activity row: Reasoning, Running command, Editing files, Reading, Using tool, Planning, or Working.A newer status row for the same target replaces it. A latest window drops it when another run is the current run. The Codex cleanup after a run removes it.
Context-compacting markerThe marker that shows while the agent compacts its context.A latest window drops it when its run is not the current run. The Codex cleanup after a run removes it.
Parent progress mirrorA copy of a child thread’s progress in the parent conversation.A mirror write from another run replaces it. A latest window drops it when another run is the current run. The Codex cleanup after a run removes it.
Approval cardAn activity row with an approval field.It stays with its decision. The Codex cleanup after a run removes it with the other ephemeral rows of that run. A Claude run has no such cleanup, so its card stays in the overlay.

The settlement epoch is a target safety fence. The desktop advances it in the same write that removes at least one ordinary live row because history now holds that row. Ephemeral rows never advance it. A publisher can also retract a row that it no longer shows, because no history row would ever settle such a row.

A latest window settles a live row for both agents, and only Codex has two more paths
A latest window settles a live row for both agents, and only Codex has two more pathsFour cases of the same live row A on one time axis, with the end of the run and an app restart as fixed moments. When a client loads a latest window during the run, the page holds A and the overlay drops it, for both agents. When no client loads, a Codex run ends with an exact scan that settles A. When the app quits before that scan, the cleanup journal row survives and the repair settles A after the restart. A Claude row stays in the overlay through the end of the run and the restart until the next latest window loads.
Both agentswindow in the run
Codexno window loads
Codexquit before scan
Claudeno window loads
run ends
app restarts
A · live row
page holds A, epoch +1
A · live row
exact scan finds A
A · live row, journal row saved
repair on open
A · live row, stays in the overlay
next latest window
  • Latest windowrun.chatLoad · run.chatReconcileSessions

    Shared by both agents. The desktop removes every overlay row whose key the loaded page holds, and raises the epoch in the same write.

  • Exact scanthread/items/list · 3 scans · 100 ms, 250 ms

    Codex only. After the terminal event the desktop scans history to the end for the exact keys of that run. A missed key gets a second scan after 100 ms and a third after 250 ms.

  • Restart repairjournal row: session, run

    Codex only. The journal row names the session and the run. It stays until a scan has settled every row of that run, and the repair starts when a target of the session opens.

Three reads can settle an ordinary row. The first is shared: when a latest window loads, the desktop removes every overlay row whose key that page holds. Both agents call the same function for it, and it is the only read that a Claude session has. The other two are Codex paths. At the end of a Codex run the desktop writes the session and the run into chat_pending_terminal_overlay_cleanups. Then it scans thread/items/list to the end for the exact keys of that run in every target of the session. A scan that misses a key runs again after 100 milliseconds and after 250 milliseconds, three scans in total. The whole cleanup has 2 minutes. If it fails or the app quits, the journal row stays, and the repair runs the same exact scan when a Codex target of that session opens.

Phones take several sessions in one call. run.chatReconcileSessions accepts at most 16 sessions and returns for each the latest window, the overlay, the outbox, and the active run. It allows 256 outbox entries per session, and it checks the 8 MiB limit while the response grows. Mobile applies a window and its overlay in one reducer operation.

Live delivery: one function sends to three sinks

Every live change leaves the desktop runtime through one function, emit_chat_stream_event. It feeds three sinks: the WebView event chat:stream, the WebView event chat:progress, and the relay event chat:timeline-updated. The event name inside the payload decides which sinks get it.

One write goes to three sinks, and each sink gets a different part
One write goes to three sinks, and each sink gets a different partA Codex run starts, writes the overlay three times inside 50 milliseconds, stays quiet, and completes. Each write goes to the relay at once. A subscribed desktop window gets one event with the newest snapshot when the 50 millisecond timer ends. After a full 5 second tick with no new write the runtime sends the saved overlay to the relay again, and no window gets that repeat. Only the started and completed events reach chat:progress. The revision numbers are examples.
Phonesrelay event
Desktop runtimeoverlay writes
Desktop windowschat:stream
Desktop windowschat:progress
started
rev 41
rev 42
rev 43
newest snapshot waits 50 ms
one event: rev 43, for the subscribed window
one 5 s tick with no new write
rev 43 again
reads the saved overlay, sends it to the relay only
completed
  • Block snapshotevent: blocks · liveOverlay

    The whole overlay of one target after a write. It goes to the relay and to chat:stream, and never to chat:progress.

  • Coalescing50 ms · 128 targets

    The first snapshot for a target starts the timer, and a newer snapshot replaces the waiting one. A run event sends the waiting snapshots first.

  • SubscriptionsubscriptionId · streamRevision

    A window gets snapshots only for the target that it subscribed to. Run events go to every window.

  • Relay eventchat:timeline-updated

    Sent only for a session in which a phone holds interest. The desktop does not coalesce it.

  • Repeatevery 5 s

    For each target of an active run with no new write since the last tick, the runtime reads the saved overlay and sends it to the relay again.

Payload eventchat:stream (desktop windows)chat:progress (desktop)Relay event (phones)
blocksOnly windows with a live subscription for that target, coalesced for 50 milliseconds.No.Yes, at once.
started, completed, failed, abortedEvery window.Yes, as event, appSessionId, runId, timestamp, and operationId.Yes. A failed event also carries agentErrorInfo.
runtimeEvery window.No.Yes.
agent-threadsEvery window.No.Yes.

An agent-threads payload has only event, appSessionId, timestamp, and a threadId of null. It tells every client that the agents list of the session changed: a child thread or a subagent started, or one of them has a new status. The client then loads the list again. No timeline row carries this fact. No row stands for a subagent or a workflow, and no row opens a thread. A session sends its first signal at once, and then at most one more signal at the end of a window of 1 second.

A blocks payload is a snapshot: the whole overlay of one target after a write, as liveOverlay. The producer saves the overlay first and sends the saved snapshot, so a client that missed an event loses nothing that the next snapshot does not carry.

A desktop window subscribes to one target at a time. The subscribe call returns the saved overlay, so the window starts from a complete state. After that the runtime sends the window only snapshots of its target and tags each with the subscriptionId and a streamRevision, a counter shared by all windows. The first snapshot for a target starts a 50 millisecond timer. A newer snapshot for the same target replaces the waiting one, and the timer sends one event. At most 128 targets wait at once. One more target sends all of them at once. A run event sends the waiting snapshots first, so a window never sees completed before the last rows.

The relay sink forwards a payload only for a session in which a phone holds interest. A blocks payload must also have a liveOverlay with exactly settlementEpoch, revision, and blocks, a revision above 0, and both numbers inside the exact integer range. The desktop does not coalesce this sink.

A phone can lose an event on the way. For that case a repair timer runs every 5 seconds while a run is active. For each target that had no new write since the last tick, it reads the saved overlay and sends it to the relay again. The repeat does not go to desktop windows. The shared live emitters arm this timer. The Codex adapter sends every row through them. The Claude adapter saves and sends its rows directly and uses a shared emitter only for an approval card, so a Claude run has the timer only after its first card.

Approvals use one shared model

An approval request from either agent becomes the same ChatApprovalRequest. It names the request ID, the thread, the turn, the item, and the decisions that this request allows. The desktop refuses a decision that is not in that list.

PartValues
KindcommandExecution, fileChange, permissions.
Decisionaccept, acceptForSession, acceptBroadly, decline, cancel.
Resultaccepted, acceptedForSession, acceptedBroadly, declined, cancelled, resolvedExternally.
Card statuspending, then the result. resolvedExternally shows as expired.

The card is a live activity row of kind event. Its ID starts with approval: and the request ID, followed by the thread ID, the turn ID, and the item ID, each with its length in front. The same ID carries the pending card and the decided card, so the decision replaces the card in place. The card is an ephemeral overlay row.

Clients answer with run.chatResolveApproval, which carries requestId, threadId, turnId, itemId, and decision. It carries no session ID. The dispatch layer asks the Claude adapter first, which answers when it holds an open permission with that request ID. Otherwise the Codex app-server that owns the request settles it.

The desktop writes the words of each row

The desktop owns the words and the facts of an activity row, and each client owns the layout. A client shows the words as they are and makes no words of its own. Desktop tests check these rules on the rows of the shared samples.

FieldRule
titleThe headline of the row, for example “Message to Android landing”.
subtitleThe context line, for example the summary of a message. It never repeats the title. A command row has none.
display.signalA fact, for example “Exit 1”, “Draft 2”, or “Sent”. It is never a status word, and it never repeats the title or the context line.
statusThe state of the row: in-progress, completed, failed, or declined. The context line and the signal never say a status word.
detailThe text that opens on a tap, with its format: markdown, plaintext, or shell. A plaintext detail has a languageHint only when the text is complete JSON, JSONL, a unified diff, or a FILE <path> dump. A file read has a sourcePath. A message row has no detail.

Each client shows a plaintext detail without a languageHint and without a sourcePath as plain wrapped text. It guesses no language, colors no keywords, and shows no line numbers.

Message rows carry one typed message fact

A row that is a message between two sessions or between two agents carries one typed fact, message. An activity row has it as activity.message. A user row that another session sent has it as message. The desktop makes every word of the fact. The desktop window and the phones show these words and make no words of their own.

FieldContent
directionsent or received.
kindmessage, or task on a received row that gives an agent its task.
from, toThe words for the two ends. They are never blank and never an ID. The end that shows the row is “This session”, or “This agent” in an agent thread.
peerThe kind of the other end, session or agent, and its agent, codex or claude. The agent is absent only when the call named a session that does not exist.
deliveryOnly on a sent row whose call ended: sent, needs-approval, or not-sent.
failureReasonOnly when delivery is not-sent: the first line of the reason, at most 220 characters.
linkThe other end of the message: appSessionId (absent: this session), threadId (absent: the main timeline), and an opaque anchor.

The words for the other end:

  • Another HB Code session: its title, “Untitled session” for a blank title, or “Unknown session” when the call named no session.
  • The lead agent of a Claude session: “Claude”.
  • A Claude subagent: its title in the agents list, else “Subagent”.
  • A Claude session outside HB Code: its name, else “Another Claude session”.
  • Codex: “Codex” for the root thread, and the words of the agent path for an agent thread, for example “Android landing”.

The desktop also makes the title of the row: “Message to <to>” on a sent row, and “Message from <from>” or “Task from <from>” on a received row. A user row has a title only together with message. Each client uses the title as the accessibility label and as the title of the message detail. display.signal is Sent, Needs approval, or Not sent, from delivery. A row has no signal while its call runs. A message row has no detail on the wire. Each client makes the detail from the title, the message fact, and the text of the row.

These rows are message rows: the result of a send_session_message call, the user row that another session sent, a Claude SendMessage call, the messages of the lead agent in a Claude subagent thread, a message from another Claude session, and a message that a Codex thread got from another agent. A send_session_message call that still runs stays the plain call row “Send message to session”.

A row links to its other end when a timeline of HB Code holds that end. A sent session message links to the user row in the recipient session, but only while its delivery is sent. The received user row links to the call row of the sender, in the main timeline or in the agent thread that sent the message. A Claude SendMessage to a subagent of this session links to the subagent thread at the time of the call. A lead message in a Claude subagent thread links to the main timeline at the time of the message. Codex rows and messages from another Claude session have no link. The desktop never compares text to find the other end.

The delivery of a sent session message follows the queue of the recipient. The table chat_session_messages keeps it: sent, needs-approval while the message waits for you, or declined after you removed the message from the queue. A declined message shows as not-sent with the reason “The user removed the message from the queue.” Each change of the queue publishes the row of the sender again.

How an older page joins the list

An older page attaches only at the row its cursor was cut from
An older page attaches only at the row its cursor was cut fromThe cursor remembers the oldest row the window held when it was issued, the seam. The desktop returns the older rows followed by that seam key, with a window revision one higher. A client prepends the rows only while that seam is still in place and the revision is newer, and otherwise drops the page. The next cursor’s seam is the top row of the page. Row names are examples.
run.chatLoadPageBefore pageSize · beforeCursor · expectedBoundaryToken
your window
older history, not loaded
item-120oldest row
newer rows
oldestCursor: seam item-120, revision 1
page from the desktop
3 older rowsitem-117 · item-118 · item-119
item-120seam key, not a new row
windowRevision 2 · orderedBlockKeys: older rows, then the seam
window after the splice
item-117
item-118 · item-119
item-120page attached above
newer rows
next oldestCursor: seam item-117, revision 2
seam
Seam missing (on phones: not the oldest row) or revision not newer: the page is dropped. Phones reload the latest window, and the desktop stops paging from that cursor.
Source token differs from expectedBoundaryToken: the desktop sends an empty page with reloadRequired, and every client reloads the latest window.
  • SeamboundaryBlockKeys

    The key of the oldest row the window held when the cursor was issued. The desktop reads it back out of the cursor and returns it after the older rows.

  • Window revisionwindowRevision

    One higher than the revision inside the cursor. A client applies a page only if this is higher than the revision it holds, so the same page never lands twice.

  • Cursorthread-items-v5:<base64url>

    JSON with the thread ID, the sidecar cursor, a generation, the source token, the seam key, and the revision. Clients send it back as beforeCursor, with their boundaryToken as expectedBoundaryToken.

Older pages come from run.chatLoadPageBefore and run.chatLoadThreadPageBefore with pageSize, beforeCursor, and expectedBoundaryToken. In a Codex session, a cursor whose source token differs from the current source fails with Timeline source changed, and one request fetches up to four sidecar pages until one yields a visible block. A response over 8 MiB fails with errorCode TIMELINE_SNAPSHOT_TOO_LARGE. Phones wait 115 seconds for run.chatLoad and run.chatLoadThread. The desktop gives those two methods a 95-second dispatch cap around a 90-second handler deadline, and every other method, older pages included, 45 seconds. Its RPC decoder accepts a pageSize up to 640 before the runtime enforces 100.

Current manifest policyValue
Initial timeline page12 entries.
Older page / maximum page100 / 100 entries.
Anchored window40 entries, for every client.
Initial timeline timeout115 seconds.
Paginated timeline timeout60 seconds.
Outbox command acknowledgement timeout20 seconds.
Initial timeline retryAt most 3 retries after the first request, only for the same current timeline target. Base delay 0.75 seconds, maximum 3 seconds.

An anchored load opens the other end at its row

A client follows a link in two steps. It opens the session and the thread of the link with its normal open path. Then it loads that timeline with an anchor. run.chatLoad, run.chatLoadThread, chat_load_command, and chat_load_thread_command take one typed anchor.

AnchorSource
{kind: "link", token}The message.link.anchor of a message row.
{kind: "newer", token}The newerCursor of an earlier answer for the same timeline.
{kind: "row", row: {type, id}}The type and the id of a block of the timeline that loads, as an answer gave them.

A token is opaque: the prefix ta1. and base64url JSON that the desktop wrote. A client never makes or reads a token. A blank token or a blank row key fails as invalid params on the relay and as a validation error in the desktop window. A token of the other kind, or a text that the desktop did not write, fails with “The timeline anchor is invalid; reload the timeline”.

The answer is the normal load answer with three more fields, which are always present. anchorRow is the block to center and mark. anchorNotice is a sentence of the desktop when the load opened no row, for example “This message is not in the saved history.” or “This row is not in the saved history.” A link load or a row load has exactly one of the two. newerCursor is set when the window is a page in the past. Such a page has no active run, and a client shows no live overlay rows below it. A load with the newer anchor gives the rows that follow the page, with no gap and no overlap. The client repeats this load until an answer has no newerCursor.

The desktop opens every anchored window with 40 rows, the value of mobileChatPolicy.timeline.anchoredPageSize, whatever pageSize the request names. A place in the newest 40 rows gives the normal newest window. For Claude, one host read, historyAround, finds the page that holds the place, and the place is in the middle of that page. For Codex, the desktop reads pages back from the newest page until a page holds the place. A row that is only in the live overlay has no saved place, so a link to it gives a notice.

No client has a Back control or a Forward control. The way back is the link on the row at the other end. In each client the name of the other end in the label line of the row is the link. A tap on another part of the row opens the message detail. When a gap recovery runs while a page in the past shows, the client loads that page again with a row anchor at the row that the reader reads. The reading position stays.

Desktop uses a virtualized React timeline

useTimelineVirtualizer adapts the vendored @timeline-virtualizer to stable row keys, row height estimates, measured elements, and inner reading anchors. It anchors short content to the end, follows appends only when autoScrollToLatest is enabled, and can restore a previous reading position and measurement cache. Direct DOM positioning is enabled for scrolling.

WorkspaceTimelineLayers distinguishes the parent conversation from the selected child thread. It assigns viewport ownership to the active target and uses retained state for the parent. Ownership includes a target, viewport ID, activation epoch, mode, and active flag. A selected thread change therefore affects view ownership as well as the row list.

Command rows carry descriptors, not output

A command row carries the command text and either an artifact descriptor or the output the agent retained. Rendering or reconciling a timeline never reads output bytes. An open viewer fetches them separately.

Useful regression cases

  • Switch projects while a history request is in flight. The old response must not replace the new target.
  • Move live content into canonical history and return an empty overlay with a newer settlement epoch. Old live rows must not reappear.
  • Send three snapshots for one target inside 50 milliseconds. A subscribed window must get one event with the newest overlay, and a completed event must arrive after it.
  • End a Claude run and load no window. The live rows must stay in the overlay and leave with the next latest window.
  • Load older messages while the assistant streams. Preserve the reading anchor and avoid duplicate rows.
  • Return an unavailable or malformed history response. Preserve readable history and expose recovery rather than showing a false empty conversation.
  • Follow a link to a row that is older than the newest window, then follow the link on that row back. Each load must open the exact row, and a page in the past must show no live rows below it.