* fix(app): keep open chats current and show resume progress Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume. * fix(app): prevent sync status from overlapping action errors Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze. * test(hub): expose complete creation and message retry failures Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload. * fix(app): dismiss retained chat menus before freezing An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension. Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion. * test(app): expect the reloaded plugin to contribute its pill again A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then. * fix(app): suspend hidden chats on web only react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten. * fix(creation): never read a receipt while replacing it Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated. Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
18 KiB
Timeline sync
Agent chat delivery has two paths:
- Live stream —
agent_streamWebSocket messages for immediacy. These may be delta-shaped lifecycle updates. - Authoritative history —
fetch_agent_timeline_requestfor correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon retains projected items in memory. Each source event advances the stream sequence, then replaces the previous tool state or merges into the current text item. Intermediate payloads are never retained for history or catch-up. Provider history is the durable transcript authority and rebuilds the projection when an agent resumes.
The invariants are:
A continuously subscribed client applies every committed row in order. Opening or resuming an agent establishes the daemon's current tail in one bounded request, with older history reachable through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events. Provider history hydration applies the same rule so reopening an agent cannot restore an oversized tool payload.
Presence is not delivery
Client heartbeat reports presence:
- device type
- app visibility
- focused agent
- last activity time
Heartbeat is used for notification routing. It must not be used as a correctness gate for agent_stream delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
Gap recovery is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries seqStart, seqEnd, sourceSeqRanges, and collapsed so clients can advance sequence cursors without rendering delta rows.
When live delivery detects a sequence gap, the app fetches direction: "after". If the daemon
responds with hasNewer: true, the app immediately fetches the next page from endCursor. Gap
recovery is complete only when hasNewer: false.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next after page refreshes the watchdog.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active writer, say — into a request and a log line every second on an idle app. The delay resets on success, reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
user action and owns its pending state: retrying is a status the sync model publishes, not a React
boolean, so the button reports in-flight and the callout returns to error when the attempt fails.
Reaching the history-start threshold loads one older page and preserves the visible content anchor. Cursor progress does not trigger another page. The user must leave and return to the threshold unless the anchored page still leaves the viewport at history start, as with short or compacted content; in that case pagination continues as one loading operation until the page fills the viewport or history is exhausted.
Durable item anchors
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
Actions that address a point in chat history, such as Fork, use the daemon timeline epoch plus the projected item's seqEnd. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork includes projected items through that checkpoint. If an item spans the checkpoint and changed afterward, the daemon refuses that fork with an actionable error: discarded historical payloads cannot be reconstructed from sequence metadata. Forking the current context remains available.
Resume behavior
Opening, reconnecting, and returning from app background establish the current timeline through bounded catch-up. Switching between continuously subscribed open chats needs no fetch. Focus alone does not mutate timeline state; the response is compared with the local authoritative range first.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot certify that the displayed conversation is current. The timeline owner publishes freshness; the view renders it without a toast timer or a separate resume workflow.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
- The same epoch and
window.maxSeqis an exact display no-op. The app advances synchronization bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport. - When the page overlaps or is adjacent to the local end cursor, only projected items newer than that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved local submissions; it never retains two discontiguous canonical ranges.
The installed tail carries hasOlder, so history skipped by a replacement remains reachable through
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
A plan approval keeps the original proposal's tool-call identity through resolution and provider history replay. The pending approval UI can hide that tool from presentation, but the client model must retain its position. Creating a new history card on rejection places it after the prompt that rejected it; changing steer-event ordering would also put new assistant output before that prompt.
Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client retains rendered items and sequence cursors, never a second cache of source events. Pagination uses the projected display anchor; live updates advance the source cursor without moving tools.
Clients advertising projected_subagent_timeline receive child streams and projected fetches.
Updated clients require features.projectedSubagentTimeline for child history; older hosts show
an update-host notice for that pane.
Older clients retain child names/status and receive an upgrade message when opening a child
conversation. This gate affects only the child transcript; it does not gate the connection,
main conversation, or current-context forks. A legacy canonical root request still receives
projected items.
Client replica lifetime
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline owner wraps cached preparation, network catch-up, accepted timeline application, and persistence behind one interface. React supplies transport and projection operations without selecting a cache path or issuing a separate persistence notification.
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh history response can arrive before the active-list response that omits it. Clearing history there would discard accepted rows while the viewed owner still considers them synchronized. Entity deletion clears the transcript; list membership changes do not.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together.
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
range describes those exact items: startSeq drives older pagination and endSeq drives forward
catch-up. The owner requests after endSeq, and requests before startSeq when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded tail bootstrap.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with the authoritative range through the existing forward-page path. The cache does not persist sync generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap replacement keeps only positioned rows newer than the page it installs, while unresolved local user presentations identify themselves by having client identity without a timeline position. This prevents a page from duplicating rows it already covers without coupling display continuity to the shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable replica cache.
Selective and legacy delivery
The app chooses one delivery policy from server_info.features.selectiveAgentTimeline:
- Selective daemons receive every open workspace chat plus any visible agent pane. Workspace layout owns open-chat lifetime, independently of mounted or retained React views. Switching workspaces, evicting a retained view, and app backgrounding preserve that demand; closing the chat releases it. Reconnect restores the open set and gives visible chats the first catch-up attempt. Hidden chats follow when those attempts settle, including failures, so a failed visible chat does not starve background recovery. Split panes catch up together. Hidden chats update the replica; on web their retained presentation stays suspended until revealed, on native it keeps rendering. Revealing a chat reads the current store and preserves its local UI state. There is no recent-agent limit.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing authoritative catch-up, but the app does not issue selective-subscription RPCs.
This policy is owned by viewed-timeline-sync.ts; downstream reducers do not branch on daemon
version.
Projected pages reconcile with live presentation
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
rows already received live—for example, a tool call retained at its original display position while
its completion advances seqEnd, followed by a merged assistant message. The app uses
sourceSeqRanges to replace overlapping assistant and reasoning projections before applying the
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the host runtime's automatic queue drain — goes through
dispatchComposerAgentMessage with a submission writer. There is no second transport for the same
product action: calling client.sendAgentMessage directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
A submitted prompt is one UserMessageItem row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape.
The transaction registry records two independent settlement facts: canonical acknowledgement and RPC
settlement. Canonical acknowledgement retires optimistic activity immediately. When both facts are
known, whichever arrives second deletes the record. A canonical acknowledgement that arrives first
prevents a later transport error from rolling back a prompt already observed.
The daemon's accepted response waits for the correlated run start and guarantees that the canonical submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the client applies authoritative activity before canonical acknowledgement retires optimistic activity. Timeline render batching does not delay lifecycle application. Directory status never settles a submission. Overlapping sends settle independently rather than collapsing to one newest pending message.
Daemons advertising server_info.features.canonicalSubmittedPrompts guarantee that every accepted
prompt carrying a client message id is recorded and streamed as a canonical user_message with that
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
row is recorded before handler output. The app tracks submission transactions only for hosts with this
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
rejection.
Turn activity has one client-side replica. Lifecycle events can attach turnId, and agent snapshots can
expose activeTurn: { turnId, startedAt } | null. An identified terminal cannot close a different
identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events,
cancellation requests, and every visible surface update the same per-agent record. The snapshot covers
both user-started foreground turns and autonomous provider turns;
foreground control ownership remains a separate daemon concern. Cancellation request identity is stored
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
the Paseo timeline, so one visible response can span several canonical turns without a user message
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
fork positions retain the canonical turnId boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a second running state. Disconnect preserves the last replicated turn until cache or network hydration advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn liveness, never from submission records or whichever timeline rows happen to be mounted.
The daemon records one canonical submitted user row at acceptance. Its wire messageId is the
submission's clientMessageId, so the row is born with its final identity and remains immutable on
the wire. A correlated provider echo records the provider's native identity internally without
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
the provider boundary. Daemon-handled prompts follow the same identity rule.
Content matching is limited to the dated compatibility path for daemon timelines created before that field existed. Canonical ingestion may match only an explicit unreconciled local candidate; the draft-create handoff is the one boundary that also permits the legacy canonical twin to have arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted rows because a provider may never echo them. A known epoch change or rewind replaces history and drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold reset without an existing epoch is destructive because the client has no continuity anchor.
Tail rows are positioned history, so an unmatched local presentation is appended after the canonical replacement rather than ordered by timestamps from different machines. The head is a live overlay: cursorless items stay there during continuity replacement until canonical positions arrive, while a destructive replacement retains only active submission transactions.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and payload while taking the canonical row's ordered position. If a live assistant head is the canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
Relevant code
- Server live stream forwarding:
packages/server/src/server/session.ts - App sync planning:
packages/app/src/timeline/timeline-sync-plan.ts - App viewed-agent synchronization:
packages/app/src/timeline/viewed-timeline-sync.ts - App stream/timeline reducer:
packages/app/src/timeline/session-stream-reducers.ts - Session wiring:
packages/app/src/contexts/session-context.tsx