feat: durable transcript projection — renderer renders state, not history-of-pushes - #422
Merged
dimavedenyapin merged 13 commits intoJul 27, 2026
Conversation
…tory-of-pushes
The transcript UI previously depended on the renderer catching every live
agent:output IPC event, compensated by dedup sets, session re-key redirect
maps, and pre-idle replay reconciliation. Output produced while no view was
bound — background wake-up turns, silently resumed sessions, app restarts —
could render late or not at all (e.g. a coordinator's wake-up ACK missing
from the transcript).
This makes the main process the authoritative owner of the transcript:
1. Durable projection (database.ts)
- New transcript_parts table: per-task monotonic seq, upsert by
(task_id, part_id) so streaming updates replace content in place.
- upsertTranscriptParts / getTranscriptParts(sinceSeq) / hasTranscriptParts /
getTranscriptMaxSeq / deleteTranscriptParts (wired into deleteTask).
2. Write-through at the emission chokepoint (agent-manager.ts)
- sendToRenderer persists every agent:output / agent:output-batch part
BEFORE any client sees it. Ephemeral part types (step-start/step-finish/
system-status) are skipped. History replays (session resume, mobile
catch-up) only seed an empty transcript — marked with replay: true.
- Resume now persists the session binding and emits task:updated with the
new session_id BEFORE any follow-up prompt, so a silent main-process
resume (event-driven wake-up) re-binds the renderer up front.
3. Snapshot hydration (renderer)
- New agentSession:getTranscriptSnapshot IPC (main -> preload -> types ->
ipc-client). agent-store.hydrateTranscript merges snapshots by part id:
snapshot is authoritative for content and order; in-memory extras the
projection skips are preserved.
- Hydration triggers: task view bind (useAgentSession mount) and every
idle transition — including idle events for sessions this window never
observed. Live events remain the fast path for streaming; hydration
guarantees the final state is always complete.
Tests: 8 new main-process tests (projection CRUD, write-through, replay
seeding, snapshot accessor), 3 new renderer tests (hydration merge), plus
mock updates. Full suites green: 936 main, 562 renderer. Typecheck clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntent collapse) Providers that re-list the whole thread on every idle/turn-completed (Codex app-server reconcileCompletedTurn) re-emitted the session's EARLIEST messages after going idle. Root cause: renderer dedup consulted only the module-level `seen` set, which is capped at 10k ids and evicts oldest-first — so on a long session the earliest ids fall out of `seen` and the re-listed parts re-append. Fix: add a non-evicting per-task rendered-id set that mirrors the messages array exactly, and dedup against it (id-only) in both output handlers, the streaming-update branches, and hydration. The set is rebuilt on hydrate and reset on init(new)/remove/clearMessageDedup. Dedup stays strictly id-based (the durable projection's stable part id), never content-based, so two legitimately-identical messages — e.g. the user sending the same text twice — are never collapsed (guards the "my message got replaced by my previous identical message" regression). O(1) per part; the earlier array-scan approach was O(n^2) and timed out on long sessions. Adds 3 renderer tests: evicted-id no-re-append, identical-messages-not-collapsed, streaming-update-after-eviction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sending a message after the renderer lost its sessionId falls back to
sendByTaskId → the backend resumes and replays the WHOLE thread. That replay,
merged into the accumulating in-memory list, reordered and duplicated the
transcript ("reloaded all messages, now broken").
Root cause was in hydrateTranscript: in-memory parts not in the snapshot were
appended at the END (losing chronological position) and a replayed part carrying
a different id than its live copy was kept as a duplicate.
Make the durable projection the single authoritative, ordered transcript:
- Render the snapshot verbatim in persisted seq order.
- Keep only genuinely-unpersisted in-memory parts: an extra is dropped if its id
is in the snapshot OR its content already appears in a snapshot part (collapses
the "same message under a different id" replay duplicate), and surviving extras
are re-inserted in TIMESTAMP order rather than appended at the end.
- After a recovery send (session recreated/resumed), the renderer hydrates so the
replayed thread is immediately reconciled to the authoritative projection.
Dedup between two DIFFERENT in-memory messages stays id-based (identical user
messages are never collapsed) — content is only compared against the snapshot.
Tests: hydration now asserts verbatim order, same-content-different-id dedup,
timestamp-ordered extras, and idempotent re-hydration. 566 renderer tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed chronology) The transcript_parts projection stamped created_at at WRITE time, so a bulk seed/replay (whole history written in one burst) gave every row a near-identical timestamp — collapsing the transcript to a single instant and scrambling order on reload (observed: all messages showing one clock time). Persist the part's original receivedAt as created_at when known (fall back to write-time otherwise), and preserve created_at across later reconcile upserts so a reconcile pass never rewrites a part's original time. Tests: bulk-seed keeps distinct chronology, write-time fallback, created_at preserved across reconcile. 939 main tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n history loadSessionHistory parsed the session JSONL but discarded each entry's timestamp, so replayed/rehydrated messages reached the renderer with no receivedAt — the UI then stamped them with new Date() (current time) and the durable projection stored write-time. Result: old messages (e.g. an auth-error from a day earlier) rendered as if they happened "just now" on every reload. Thread entry.timestamp → part.receivedAt for every part in a loaded message. Combined with the projection's created_at provenance, replayed history now carries its true time end to end (adapter → renderer → durable read model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ingle source Event-sourced step: make the durable projection COMPLETE for sessions that predate write-through, so the client can render from the projection alone (no adapter replay-push needed). getTranscriptSnapshot now, when the projection is empty, ingests the task's persisted session history ONCE and then serves the snapshot. Ingest is: - side-effect-free: reads the CLI's on-disk session (new adapter getPersistedMessages → Claude Code loadSessionHistory) with no CLI/SDK spawn and no live session required; - idempotent: upsert-by-part-id + an in-flight guard + hasTranscriptParts re-check mean a live event or a repeat call never duplicates; - faithful: carries each part's real receivedAt so backfilled history keeps true timestamps (not write-time); - best-effort: no-ops when unavailable (missing session_id/agent_id or an adapter without getPersistedMessages). This makes the projection authoritative and complete, killing the "blank/partial transcript on reload" at the data layer. Client rendering stays snapshot-based. Tests: ingest-once with real timestamps + ephemeral exclusion, no backfill when already populated, idempotent second call, safe no-op without adapter support. 943 main + 566 renderer tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pear + overlap) hydrateTranscript() on send replaced the whole messages array while the backend replay was still streaming — causing the transcript to blank then repopulate (disappear/reappear) and transiently overlap on relaunch+send. It fought the live render path. Load-time reconciliation (view-bind + idle hydration from the now-complete projection) already covers ordering; the send path no longer triggers a full-list replace. Removes the on-send hydrate in both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lizer overlap) The transcript is a dynamically-measured virtual list (@tanstack/react-virtual, absolutely-positioned rows via translateY + measureElement). The previous hydration replaced/reordered the whole messages array (verbatim snapshot + timestamp-sort), which invalidated the virtualizer's height cache and painted rows on top of each other — the "text overlap" in the UI. Hydration is now additive and never touches already-rendered rows: - Empty view (post-reload): render the full ordered projection once. - Live view: append only snapshot parts not already rendered, at the end; existing rows are left byte-for-byte stable so the virtualizer keeps its measurements. Missing is computed against actual rendered message ids, so a stale id-set can't cause a re-append. Tradeoff: a rare replay "same content under a different id" duplicate can remain (previously collapsed by a content check that also risked wrongly merging identical user messages). That is eliminated properly by the single-source step (remove the backend replay-push), not by mutating the rendered list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onger missing on reload) Two bugs made a reload show only the user's messages, in the wrong order: 1. Incompleteness: the backfill only ran when the projection was EMPTY. A projection that already held recent user echoes (write-through) but whose assistant replies predate write-through was treated as "populated" → backfill skipped → assistant messages never ingested → reload showed only user side. Fix: ingest the FULL persisted session history once per app run (idempotent, dedup by id AND by role+content so a live user echo and its persisted-session twin never double), regardless of whether the projection is partially filled. 2. Ordering: the projection was read in insertion (seq) order. Backfilled older history would then sort AFTER newer echoes. Fix: order snapshots by real event time (created_at) with seq as a stable tiebreaker, so ingest order is irrelevant to display order. Together the projection becomes the complete, correctly-ordered single source the renderer hydrates from. Tests updated: gap-fill with id+content dedup, per-run idempotency. 943 main + renderer suites pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Logs which hydration path runs (EMPTY vs ADDITIVE), snapshot/existing/missing counts, and when clearMessageDedup wipes the list — to pinpoint the reload race where projection-stored assistant messages don't render. No behavior change. To be reverted once the render bug is fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; drop temp diagnostics Root cause of "assistant messages missing after reload": on reload the durable projection is hydrated into the view on mount, but resume() then called clearMessageDedup() which wiped the message list — and the resume replay only partially repopulated it, so projection-stored assistant messages vanished from the render while live/most-recent ones remained. Fix: resume() no longer clears the list. The replay batch dedups against the hydrated messages by shared part id, so nothing is lost or duplicated by leaving the projection-hydrated history in place. Also removes the temporary [hydrate]/[clearMessageDedup] console diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the resume-clear fix: the canvas-store browser- and terminal- connection handlers also called clearMessageDedup before resuming, which wiped the hydrated durable transcript. Removed both, matching the use-agent-session resume fix. The resume replay dedups against the hydrated history by part id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate (single source) (#429) * feat(transcript): Phase 1 — rev cursor, delta query, transcript:changed push Event-sourced foundation so clients can subscribe to the projection: - database.ts: global monotonic `rev` column on transcript_parts (idempotent migration + backfill for existing DBs). upsertTranscriptParts bumps rev on insert AND content-update and returns {maxRev, changedPartIds}. getTranscriptDelta(taskId, sinceRev) -> {parts, maxRev}; getTranscriptMaxRev. Snapshot rows now carry rev. - agent-manager.ts: after write-through, emit `transcript:changed` delta directly to clients (bypasses persist to avoid recursion) — the single low-latency + durable push path. Public getTranscriptDelta (ensures backfill). - IPC (5 files): agentSession:getTranscriptDelta + onTranscriptChanged. Renderer still uses the old path; cutover is Phase 2/3. 947 main tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transcript): Phase 2/3 — renderer renders the projection as single source Rewrite the desktop agent-store transcript layer to the event-sourced model: - Per-task projection cache Map<partId, part>; `messages` is a DERIVED sorted list (createdAt, seq), never mutated by live events. One write path: applyParts(), fed by (a) full snapshot on bind (hydrateTranscript) and (b) idempotent `transcript:changed` deltas. - Deleted the competing writers: onAgentOutput/onAgentOutputBatch accumulation, flushPendingBatches, seenIds/renderedIds dedup sets, hasRecentDuplicateError, the additive/replace hydration body, and message-resetting on init/clear/rekey. clearMessageDedup is now a no-op (kept for API compat). - onAgentStatus handles session STATE only (status/pendingApproval/sessionId); at idle it runs a safety-net reconcileDelta to catch any missed delta. - Because parts are keyed by stable id and sorted deterministically, reorder, duplication, collapse, and the virtualizer-overlap are structurally impossible. Desktop no longer consumes agent:output at all; the backend replay-push remains only for mobile (unchanged). Tests rewritten for the projection model (delta apply, idempotency, streaming update in place, out-of-order convergence, snapshot bind). 549 renderer + 947 main pass; typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(transcript): explicit send-after-restart no-loss scenario Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Migrate mobile transcript to the event-sourced projection model Mobile previously rendered the transcript from an imperative replay push (POST /sessions/:id/sync → replaySessionMessages → agent:output-batch), a competing writer that reordered/duplicated messages on reconnect and send-after-idle — the same class of bug just fixed on desktop. Move mobile onto the identical single-source model as desktop: - Client renders a per-task projection cache keyed by stable part id; the message list is DERIVED (sorted by createdAt, seq), so reorder, duplication, and collapse are structurally impossible. - Bind loads a full durable snapshot (GET /tasks/:id/transcript) and applies idempotent `transcript:changed` deltas over WebSocket; idle reconciles via the rev-cursor delta endpoint. - syncActiveSessions registers active sessions (status only) and binds each transcript from the projection — no replay push. Remove the now-dead replay path end to end: - Delete AgentManager.replaySessionMessages and repoint the mobile /sync route to a status-only ping. - resumeAdapterSession no longer streams historical messages to clients; it only seeds the in-memory dedup state. The projection (snapshot + deltas) is the sole render source on both platforms. - Drop the dead `replay` write-through guard; live output is idempotent at the DB layer (upsert keyed by part id) and historical seeding is handled solely by the one-time projection backfill. Tests updated for the projection model on both platforms; full suite, typecheck, lint, and build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(transcript): mark mobile projection migration done Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): don't build the rev index before the rev column exists Startup crashed on a DB created before the `rev` column existed: SqliteError: no such column: rev at DatabaseManager.createTables createTables ran the `(task_id, rev)` index creation, but on a legacy DB the `CREATE TABLE IF NOT EXISTS transcript_parts` is a no-op (the table already exists without `rev`), and the ALTER TABLE migration that adds the column runs *after* createTables — so the index referenced a column that didn't exist yet and aborted the whole schema init. Move the rev index out of createTables and into ensureTranscriptRevColumn, which runs after the column is guaranteed present (declared in CREATE TABLE on a fresh DB, or added by ALTER on a legacy DB). The index creation now runs unconditionally there (idempotent via IF NOT EXISTS). Adds a regression test that drives createTables + ensureTranscriptRevColumn against a legacy transcript_parts table (no rev column): asserts no throw, the column is added, existing rows are backfilled with a monotonic rev, the index exists, delta queries work, and the migration is idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): backfill only seeds an EMPTY projection (no reader-triggered dupes) Connecting a mobile client repeated older messages on BOTH mobile and desktop. A mobile connect is meant to be a pure read, but the snapshot/delta REST routes call getTranscriptSnapshot/getTranscriptDelta, which run the one-time backfill. On a task already captured by live write-through, the backfill re-ingested the persisted session JSONL — whose part ids differ from the live-captured ids — and its content-based dedup missed tool / taskProgress / partially-streamed parts, so it re-inserted already-present messages under new ids. Every upsert broadcasts a `transcript:changed` delta to ALL clients, so those duplicates leaked to the desktop view too. Fix: backfill now seeds ONLY an empty projection. If the task already has parts, the live write-through capture is authoritative and is never re-ingested — so a reader (mobile or desktop) connecting to a populated task performs zero writes and emits zero deltas. Backfill remains a one-time seed for sessions that predate the store. The now-dead dedup-against-existing logic is removed (the projection is empty when we reach the ingest). Tests: replace the "fills gaps when partially populated" test (which asserted the dangerous re-ingest) with one proving a snapshot read on a populated projection writes nothing and doesn't even read history, plus an empty-projection seed test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): mobile transcript routes are pure DB reads Make the mobile REST transcript routes read the durable projection straight from the DB (db.getTranscriptParts / db.getTranscriptDelta) instead of going through AgentManager.getTranscriptSnapshot/getTranscriptDelta, which can trigger the one-time backfill/ingest. Mobile is a pure reader: connecting from a phone must never mutate the projection or broadcast `transcript:changed` deltas to other clients. The desktop (session owner) keeps ownership of the one-time seed for sessions that predate the store. This complements the empty-only backfill guard — mobile now cannot trigger ingest at all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): stop idle-replay from duplicating codex messages under mismatched ids Root cause of "old messages repeat" (survived the empty-only backfill guard — this is a different write path): replayMissedTranscriptPartsBeforeIdle runs on every session's idle transition, re-reads the full thread via adapter.getAllMessages, and re-emits any part not in seenPartIds. The codex app-server adapter assigns a message's LIVE streaming delta a different part id (agent-msg_<hash>) than its FINALIZED thread item (agent-item-N). seenPartIds holds the live ids, so every finalized item looked "new" and was re-emitted — persisting a duplicate of each assistant message under the agent-item-N id on every idle. Because each upsert broadcasts a transcript:changed delta, the dupes appeared on desktop and mobile alike, accumulating across all sessions. Fix: replayMissedTranscriptPartsBeforeIdle now dedups candidates against the durable projection BY CONTENT (normalized), not just by the (mismatched) part id. A part whose text is already persisted is skipped (its id is remembered so it isn't reconsidered), so the safety-net re-read only fills GENUINELY missing content. Tool parts use stable call ids (toolu_/call_) and don't collide, so they're untouched — avoiding the risk of collapsing legitimately repeated identical tool calls. Adds a regression test proving an already-persisted message arriving under a new (agent-item-N) id is not re-emitted while genuinely new content still is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(transcript): "Starting…" indicator + input lock while an idle session resumes Sending to an idle session triggers a slow backend resume (worktree + MCP + adapter.resumeSession); the send call blocks until it finishes, and resume even emits an interim `idle` status — so the UI sat on "Idle" with an open input and no feedback for seconds. Add a per-task `pendingSend` flag set the instant the user sends and cleared only by the first non-idle status (working/waiting/error) — interim idle events during resume don't clear it — plus a 120s safety timeout and an explicit clear on send failure. While pending, the status pill shows a spinner + "Starting…" and the composer is disabled (placeholder "Starting agent…") to prevent a confusing double-send. Wired on both platforms: desktop (agent-store + use-agent-session + AgentTranscriptPanel, threaded via TaskWorkspace / TranscriptPanelContent / OrchestratorPanel) and mobile (agent-store + ConversationPage). Store tests cover begin/clear semantics (idle does not clear, working/endSend do). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
dimavedenyapin
added a commit
that referenced
this pull request
Jul 27, 2026
…uple idle from termination (#421) * fix: stop terminating subagents when the coordinator goes quiet; decouple idle from termination Coordinator sessions that delegate work (subagent spawns, wait_for_subtasks) legitimately produce no output for minutes at a time. The stuck-tool watchdog (90s) and stuck-session watchdog (5min) treated that silence as a hang and aborted the prompt — and the abort cascades into the child work (server-side aborts kill child sessions; in-process subagents die with the parent query). From the user's perspective: subagents were terminated whenever the main agent went quiet/idle. Core changes: 1. Delegation-aware watchdogs - Tools that delegate to subagents or block on subtask progress (task/agent/subagent, wait_for_subtasks, start_task) are exempt from the 90s stuck-tool abort. - The 5min stuck-session watchdog stands down while a delegation tool is running or the task has subtasks still being worked on — child progress counts as parent activity. Truly silent sessions still get aborted. 2. Idle is a state flag, not termination - Going idle never destroys a session or any child work. Termination is decoupled and handled by a separate low-frequency inactivity reaper: sweeps every 5 minutes, releases only sessions idle for 30+ minutes, and skips active turns, coordinators with running subtasks, pseudo-task sessions, and sessions without a persisted resume anchor. - Released sessions are resumed transparently by sendMessage via the persisted task.session_id, so an idle agent costs ~nothing and can always be woken later. 3. Event-driven coordinator wake-up (no polling of idle agents) - When a subtask reaches ready_for_review/completed (via transitionToIdle or the task API), the idle parent coordinator is resumed with a summary prompt once ALL subtasks are terminal. Parents no longer need to stay resident blocking on wait_for_subtasks — they can go idle and exactly one session is touched when there is something to act on. Adds sessions' lastActivityAt tracking and 15 tests covering the watchdog exemptions, the reaper guards, and the wake-up conditions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: durable transcript projection — renderer renders state, not history-of-pushes (#422) * feat: durable transcript projection — renderer renders state, not history-of-pushes The transcript UI previously depended on the renderer catching every live agent:output IPC event, compensated by dedup sets, session re-key redirect maps, and pre-idle replay reconciliation. Output produced while no view was bound — background wake-up turns, silently resumed sessions, app restarts — could render late or not at all (e.g. a coordinator's wake-up ACK missing from the transcript). This makes the main process the authoritative owner of the transcript: 1. Durable projection (database.ts) - New transcript_parts table: per-task monotonic seq, upsert by (task_id, part_id) so streaming updates replace content in place. - upsertTranscriptParts / getTranscriptParts(sinceSeq) / hasTranscriptParts / getTranscriptMaxSeq / deleteTranscriptParts (wired into deleteTask). 2. Write-through at the emission chokepoint (agent-manager.ts) - sendToRenderer persists every agent:output / agent:output-batch part BEFORE any client sees it. Ephemeral part types (step-start/step-finish/ system-status) are skipped. History replays (session resume, mobile catch-up) only seed an empty transcript — marked with replay: true. - Resume now persists the session binding and emits task:updated with the new session_id BEFORE any follow-up prompt, so a silent main-process resume (event-driven wake-up) re-binds the renderer up front. 3. Snapshot hydration (renderer) - New agentSession:getTranscriptSnapshot IPC (main -> preload -> types -> ipc-client). agent-store.hydrateTranscript merges snapshots by part id: snapshot is authoritative for content and order; in-memory extras the projection skips are preserved. - Hydration triggers: task view bind (useAgentSession mount) and every idle transition — including idle events for sessions this window never observed. Live events remain the fast path for streaming; hydration guarantees the final state is always complete. Tests: 8 new main-process tests (projection CRUD, write-through, replay seeding, snapshot accessor), 3 new renderer tests (hydration merge), plus mock updates. Full suites green: 936 main, 562 renderer. Typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: prevent earlier messages repeating on idle; id-only dedup (no content collapse) Providers that re-list the whole thread on every idle/turn-completed (Codex app-server reconcileCompletedTurn) re-emitted the session's EARLIEST messages after going idle. Root cause: renderer dedup consulted only the module-level `seen` set, which is capped at 10k ids and evicts oldest-first — so on a long session the earliest ids fall out of `seen` and the re-listed parts re-append. Fix: add a non-evicting per-task rendered-id set that mirrors the messages array exactly, and dedup against it (id-only) in both output handlers, the streaming-update branches, and hydration. The set is rebuilt on hydrate and reset on init(new)/remove/clearMessageDedup. Dedup stays strictly id-based (the durable projection's stable part id), never content-based, so two legitimately-identical messages — e.g. the user sending the same text twice — are never collapsed (guards the "my message got replaced by my previous identical message" regression). O(1) per part; the earlier array-scan approach was O(n^2) and timed out on long sessions. Adds 3 renderer tests: evicted-id no-re-append, identical-messages-not-collapsed, streaming-update-after-eviction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: transcript reorder/duplication after resume-replay on send Sending a message after the renderer lost its sessionId falls back to sendByTaskId → the backend resumes and replays the WHOLE thread. That replay, merged into the accumulating in-memory list, reordered and duplicated the transcript ("reloaded all messages, now broken"). Root cause was in hydrateTranscript: in-memory parts not in the snapshot were appended at the END (losing chronological position) and a replayed part carrying a different id than its live copy was kept as a duplicate. Make the durable projection the single authoritative, ordered transcript: - Render the snapshot verbatim in persisted seq order. - Keep only genuinely-unpersisted in-memory parts: an extra is dropped if its id is in the snapshot OR its content already appears in a snapshot part (collapses the "same message under a different id" replay duplicate), and surviving extras are re-inserted in TIMESTAMP order rather than appended at the end. - After a recovery send (session recreated/resumed), the renderer hydrates so the replayed thread is immediately reconciled to the authoritative projection. Dedup between two DIFFERENT in-memory messages stays id-based (identical user messages are never collapsed) — content is only compared against the snapshot. Tests: hydration now asserts verbatim order, same-content-different-id dedup, timestamp-ordered extras, and idempotent re-hydration. 566 renderer tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: preserve original message time in transcript projection (bulk seed chronology) The transcript_parts projection stamped created_at at WRITE time, so a bulk seed/replay (whole history written in one burst) gave every row a near-identical timestamp — collapsing the transcript to a single instant and scrambling order on reload (observed: all messages showing one clock time). Persist the part's original receivedAt as created_at when known (fall back to write-time otherwise), and preserve created_at across later reconcile upserts so a reconcile pass never rewrites a part's original time. Tests: bulk-seed keeps distinct chronology, write-time fallback, created_at preserved across reconcile. 939 main tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: preserve real event timestamps when replaying Claude Code session history loadSessionHistory parsed the session JSONL but discarded each entry's timestamp, so replayed/rehydrated messages reached the renderer with no receivedAt — the UI then stamped them with new Date() (current time) and the durable projection stored write-time. Result: old messages (e.g. an auth-error from a day earlier) rendered as if they happened "just now" on every reload. Thread entry.timestamp → part.receivedAt for every part in a loaded message. Combined with the projection's created_at provenance, replayed history now carries its true time end to end (adapter → renderer → durable read model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: one-time projection backfill so the durable transcript is the single source Event-sourced step: make the durable projection COMPLETE for sessions that predate write-through, so the client can render from the projection alone (no adapter replay-push needed). getTranscriptSnapshot now, when the projection is empty, ingests the task's persisted session history ONCE and then serves the snapshot. Ingest is: - side-effect-free: reads the CLI's on-disk session (new adapter getPersistedMessages → Claude Code loadSessionHistory) with no CLI/SDK spawn and no live session required; - idempotent: upsert-by-part-id + an in-flight guard + hasTranscriptParts re-check mean a live event or a repeat call never duplicates; - faithful: carries each part's real receivedAt so backfilled history keeps true timestamps (not write-time); - best-effort: no-ops when unavailable (missing session_id/agent_id or an adapter without getPersistedMessages). This makes the projection authoritative and complete, killing the "blank/partial transcript on reload" at the data layer. Client rendering stays snapshot-based. Tests: ingest-once with real timestamps + ephemeral exclusion, no backfill when already populated, idempotent second call, safe no-op without adapter support. 943 main + 566 renderer tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: remove destructive re-hydrate on send (transcript disappear/reappear + overlap) hydrateTranscript() on send replaced the whole messages array while the backend replay was still streaming — causing the transcript to blank then repopulate (disappear/reappear) and transiently overlap on relaunch+send. It fought the live render path. Load-time reconciliation (view-bind + idle hydration from the now-complete projection) already covers ordering; the send path no longer triggers a full-list replace. Removes the on-send hydrate in both branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make transcript hydration additive/non-destructive (stops virtualizer overlap) The transcript is a dynamically-measured virtual list (@tanstack/react-virtual, absolutely-positioned rows via translateY + measureElement). The previous hydration replaced/reordered the whole messages array (verbatim snapshot + timestamp-sort), which invalidated the virtualizer's height cache and painted rows on top of each other — the "text overlap" in the UI. Hydration is now additive and never touches already-rendered rows: - Empty view (post-reload): render the full ordered projection once. - Live view: append only snapshot parts not already rendered, at the end; existing rows are left byte-for-byte stable so the virtualizer keeps its measurements. Missing is computed against actual rendered message ids, so a stale id-set can't cause a re-append. Tradeoff: a rare replay "same content under a different id" duplicate can remain (previously collapsed by a content check that also risked wrongly merging identical user messages). That is eliminated properly by the single-source step (remove the backend replay-push), not by mutating the rendered list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: complete + correctly-ordered projection (assistant messages no longer missing on reload) Two bugs made a reload show only the user's messages, in the wrong order: 1. Incompleteness: the backfill only ran when the projection was EMPTY. A projection that already held recent user echoes (write-through) but whose assistant replies predate write-through was treated as "populated" → backfill skipped → assistant messages never ingested → reload showed only user side. Fix: ingest the FULL persisted session history once per app run (idempotent, dedup by id AND by role+content so a live user echo and its persisted-session twin never double), regardless of whether the projection is partially filled. 2. Ordering: the projection was read in insertion (seq) order. Backfilled older history would then sort AFTER newer echoes. Fix: order snapshots by real event time (created_at) with seq as a stable tiebreaker, so ingest order is irrelevant to display order. Together the projection becomes the complete, correctly-ordered single source the renderer hydrates from. Tests updated: gap-fill with id+content dedup, per-run idempotency. 943 main + renderer suites pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: add transcript hydration/clear diagnostics (temporary) Logs which hydration path runs (EMPTY vs ADDITIVE), snapshot/existing/missing counts, and when clearMessageDedup wipes the list — to pinpoint the reload race where projection-stored assistant messages don't render. No behavior change. To be reverted once the render bug is fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: don't clear the transcript on resume (keeps hydrated projection); drop temp diagnostics Root cause of "assistant messages missing after reload": on reload the durable projection is hydrated into the view on mount, but resume() then called clearMessageDedup() which wiped the message list — and the resume replay only partially repopulated it, so projection-stored assistant messages vanished from the render while live/most-recent ones remained. Fix: resume() no longer clears the list. The replay batch dedups against the hydrated messages by shared part id, so nothing is lost or duplicated by leaving the projection-hydrated history in place. Also removes the temporary [hydrate]/[clearMessageDedup] console diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: don't clear transcript on browser/terminal-connection resume either Completes the resume-clear fix: the canvas-store browser- and terminal- connection handlers also called clearMessageDedup before resuming, which wiped the hydrated durable transcript. Removed both, matching the use-agent-session resume fix. The resume replay dedups against the hydrated history by part id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transcript): event-sourced projection — client renders server state (single source) (#429) * feat(transcript): Phase 1 — rev cursor, delta query, transcript:changed push Event-sourced foundation so clients can subscribe to the projection: - database.ts: global monotonic `rev` column on transcript_parts (idempotent migration + backfill for existing DBs). upsertTranscriptParts bumps rev on insert AND content-update and returns {maxRev, changedPartIds}. getTranscriptDelta(taskId, sinceRev) -> {parts, maxRev}; getTranscriptMaxRev. Snapshot rows now carry rev. - agent-manager.ts: after write-through, emit `transcript:changed` delta directly to clients (bypasses persist to avoid recursion) — the single low-latency + durable push path. Public getTranscriptDelta (ensures backfill). - IPC (5 files): agentSession:getTranscriptDelta + onTranscriptChanged. Renderer still uses the old path; cutover is Phase 2/3. 947 main tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transcript): Phase 2/3 — renderer renders the projection as single source Rewrite the desktop agent-store transcript layer to the event-sourced model: - Per-task projection cache Map<partId, part>; `messages` is a DERIVED sorted list (createdAt, seq), never mutated by live events. One write path: applyParts(), fed by (a) full snapshot on bind (hydrateTranscript) and (b) idempotent `transcript:changed` deltas. - Deleted the competing writers: onAgentOutput/onAgentOutputBatch accumulation, flushPendingBatches, seenIds/renderedIds dedup sets, hasRecentDuplicateError, the additive/replace hydration body, and message-resetting on init/clear/rekey. clearMessageDedup is now a no-op (kept for API compat). - onAgentStatus handles session STATE only (status/pendingApproval/sessionId); at idle it runs a safety-net reconcileDelta to catch any missed delta. - Because parts are keyed by stable id and sorted deterministically, reorder, duplication, collapse, and the virtualizer-overlap are structurally impossible. Desktop no longer consumes agent:output at all; the backend replay-push remains only for mobile (unchanged). Tests rewritten for the projection model (delta apply, idempotency, streaming update in place, out-of-order convergence, snapshot bind). 549 renderer + 947 main pass; typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(transcript): explicit send-after-restart no-loss scenario Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Migrate mobile transcript to the event-sourced projection model Mobile previously rendered the transcript from an imperative replay push (POST /sessions/:id/sync → replaySessionMessages → agent:output-batch), a competing writer that reordered/duplicated messages on reconnect and send-after-idle — the same class of bug just fixed on desktop. Move mobile onto the identical single-source model as desktop: - Client renders a per-task projection cache keyed by stable part id; the message list is DERIVED (sorted by createdAt, seq), so reorder, duplication, and collapse are structurally impossible. - Bind loads a full durable snapshot (GET /tasks/:id/transcript) and applies idempotent `transcript:changed` deltas over WebSocket; idle reconciles via the rev-cursor delta endpoint. - syncActiveSessions registers active sessions (status only) and binds each transcript from the projection — no replay push. Remove the now-dead replay path end to end: - Delete AgentManager.replaySessionMessages and repoint the mobile /sync route to a status-only ping. - resumeAdapterSession no longer streams historical messages to clients; it only seeds the in-memory dedup state. The projection (snapshot + deltas) is the sole render source on both platforms. - Drop the dead `replay` write-through guard; live output is idempotent at the DB layer (upsert keyed by part id) and historical seeding is handled solely by the one-time projection backfill. Tests updated for the projection model on both platforms; full suite, typecheck, lint, and build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(transcript): mark mobile projection migration done Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): don't build the rev index before the rev column exists Startup crashed on a DB created before the `rev` column existed: SqliteError: no such column: rev at DatabaseManager.createTables createTables ran the `(task_id, rev)` index creation, but on a legacy DB the `CREATE TABLE IF NOT EXISTS transcript_parts` is a no-op (the table already exists without `rev`), and the ALTER TABLE migration that adds the column runs *after* createTables — so the index referenced a column that didn't exist yet and aborted the whole schema init. Move the rev index out of createTables and into ensureTranscriptRevColumn, which runs after the column is guaranteed present (declared in CREATE TABLE on a fresh DB, or added by ALTER on a legacy DB). The index creation now runs unconditionally there (idempotent via IF NOT EXISTS). Adds a regression test that drives createTables + ensureTranscriptRevColumn against a legacy transcript_parts table (no rev column): asserts no throw, the column is added, existing rows are backfilled with a monotonic rev, the index exists, delta queries work, and the migration is idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): backfill only seeds an EMPTY projection (no reader-triggered dupes) Connecting a mobile client repeated older messages on BOTH mobile and desktop. A mobile connect is meant to be a pure read, but the snapshot/delta REST routes call getTranscriptSnapshot/getTranscriptDelta, which run the one-time backfill. On a task already captured by live write-through, the backfill re-ingested the persisted session JSONL — whose part ids differ from the live-captured ids — and its content-based dedup missed tool / taskProgress / partially-streamed parts, so it re-inserted already-present messages under new ids. Every upsert broadcasts a `transcript:changed` delta to ALL clients, so those duplicates leaked to the desktop view too. Fix: backfill now seeds ONLY an empty projection. If the task already has parts, the live write-through capture is authoritative and is never re-ingested — so a reader (mobile or desktop) connecting to a populated task performs zero writes and emits zero deltas. Backfill remains a one-time seed for sessions that predate the store. The now-dead dedup-against-existing logic is removed (the projection is empty when we reach the ingest). Tests: replace the "fills gaps when partially populated" test (which asserted the dangerous re-ingest) with one proving a snapshot read on a populated projection writes nothing and doesn't even read history, plus an empty-projection seed test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): mobile transcript routes are pure DB reads Make the mobile REST transcript routes read the durable projection straight from the DB (db.getTranscriptParts / db.getTranscriptDelta) instead of going through AgentManager.getTranscriptSnapshot/getTranscriptDelta, which can trigger the one-time backfill/ingest. Mobile is a pure reader: connecting from a phone must never mutate the projection or broadcast `transcript:changed` deltas to other clients. The desktop (session owner) keeps ownership of the one-time seed for sessions that predate the store. This complements the empty-only backfill guard — mobile now cannot trigger ingest at all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transcript): stop idle-replay from duplicating codex messages under mismatched ids Root cause of "old messages repeat" (survived the empty-only backfill guard — this is a different write path): replayMissedTranscriptPartsBeforeIdle runs on every session's idle transition, re-reads the full thread via adapter.getAllMessages, and re-emits any part not in seenPartIds. The codex app-server adapter assigns a message's LIVE streaming delta a different part id (agent-msg_<hash>) than its FINALIZED thread item (agent-item-N). seenPartIds holds the live ids, so every finalized item looked "new" and was re-emitted — persisting a duplicate of each assistant message under the agent-item-N id on every idle. Because each upsert broadcasts a transcript:changed delta, the dupes appeared on desktop and mobile alike, accumulating across all sessions. Fix: replayMissedTranscriptPartsBeforeIdle now dedups candidates against the durable projection BY CONTENT (normalized), not just by the (mismatched) part id. A part whose text is already persisted is skipped (its id is remembered so it isn't reconsidered), so the safety-net re-read only fills GENUINELY missing content. Tool parts use stable call ids (toolu_/call_) and don't collide, so they're untouched — avoiding the risk of collapsing legitimately repeated identical tool calls. Adds a regression test proving an already-persisted message arriving under a new (agent-item-N) id is not re-emitted while genuinely new content still is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(transcript): "Starting…" indicator + input lock while an idle session resumes Sending to an idle session triggers a slow backend resume (worktree + MCP + adapter.resumeSession); the send call blocks until it finishes, and resume even emits an interim `idle` status — so the UI sat on "Idle" with an open input and no feedback for seconds. Add a per-task `pendingSend` flag set the instant the user sends and cleared only by the first non-idle status (working/waiting/error) — interim idle events during resume don't clear it — plus a 120s safety timeout and an explicit clear on send failure. While pending, the status pill shows a spinner + "Starting…" and the composer is disabled (placeholder "Starting agent…") to prevent a confusing double-send. Wired on both platforms: desktop (agent-store + use-agent-session + AgentTranscriptPanel, threaded via TaskWorkspace / TranscriptPanelContent / OrchestratorPanel) and mobile (agent-store + ConversationPage). Store tests cover begin/clear semantics (idle does not clear, working/endSend do). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The transcript UI depends on the renderer catching every live
agent:outputIPC push, compensated by dedup sets, session re-key redirect maps, and pre-idle replay reconciliation. Any turn that runs while no view is bound — background wake-up turns, silently resumed sessions, app restarts — can render late or not at all. Observed live: a coordinator woken by subtask completion produced its ACK, but the transcript didn't show it until unrelatedtask_updatedevents flushed the view.Approach
Make the main process the authoritative owner of the transcript (a durable projection), and make the renderer hydrate state from snapshots instead of depending on having observed every event. Live events remain the fast path for streaming; snapshots guarantee completeness.
1. Durable projection —
database.tstranscript_partstable: per-task monotonicseq, upsert by(task_id, part_id)— streaming updates replace content in place, keeping position.upsertTranscriptParts/getTranscriptParts(sinceSeq?)/hasTranscriptParts/getTranscriptMaxSeq/deleteTranscriptParts(wired intodeleteTask).2. Write-through at the emission chokepoint —
agent-manager.tssendToRendererpersists everyagent:output/agent:output-batchpart before any client sees it (single chokepoint covers all 9 emission sites).step-start/step-finish/system-status) skipped.replay: true— they only seed an empty transcript (backfill), since original parts were persisted when first emitted.resumeAdapterSessionnow persiststask.session_idand emitstask:updatedbefore any follow-up prompt — a silent main-process resume (event-driven wake-up) re-binds the renderer up front instead of after the first output event.3. Snapshot hydration — renderer
agentSession:getTranscriptSnapshot(taskId, sinceSeq?)IPC (main → preload → types → ipc-client).agent-store.hydrateTranscript(taskId): merges by part id — snapshot authoritative for content/order; in-memory extras the projection intentionally skips are preserved; snapshot ids marked seen so live events dedupe.useAgentSessionmount) and every idle transition — including idle events for sessions this window never observed (the exact wake-up-ACK case).Testing
🤖 Generated with Claude Code