fix: replay an in-flight turn in one frame instead of per event #277

Merged
lucas merged 1 commit from agent/issue-272-batched-turn-replay into main 2026-11-13 15:20:57 +01:00
Collaborator

Closes: #272

Switching to a background session re-rendered its already-produced assistant text as if it were being typed. The replay path sent the in-flight turn's buffered events as one WebSocket frame each; the client applies one set() per frame, so React painted between them. Sessions parked on an ask dialog never emit turn_end, so they hold the whole turn in the buffer — exactly the worst case.

What changed

  • Protocol: new session_events server frame carrying the whole replay batch (packages/protocol/src/index.ts).
  • Server buffer: appendTurnEvent() (exported from apps/server/src/bridge/in-process.ts) replaces the raw push in emit(). It drops an entry the next adjacent event fully supersedes — two message_updates, or two tool_execution_updates of the same toolCallId. Both payloads are cumulative, so the earlier one is unobservable to a late subscriber. Adjacency is the predicate: a different assistant message requires message_start/message_end in between, and a parallel tool's update carries a different id, so nothing merges across a run break.
  • Attach: ws.ts sends one session_events frame instead of the per-event loop. The preceding UI/plan/idea replay loops and their ordering are unchanged.
  • Client: a session_events case in the store folds the batch through applyEvent in array order inside a single set() — one render. The session_event case is untouched, so live streaming is unaffected: subscribers still get every event, one frame each.

This also removes the O(N²) bytes a long turn used to replay on every attach, reconnect and extra browser tab, and bounds buffer growth while a turn stays open.

Tests

  • apps/server/src/bridge/turn-replay.test.ts — pins the coalescing predicate: a run of message_update collapses to the latest; any other event between two of them breaks the run; tool_execution_update collapses only within one toolCallId, so interleaved parallel tools keep their own payloads.
  • apps/web/src/lib/reducer-replay.test.ts — pins the invariant that makes the coalescing lossless from the client side: folding the full stream and the coalesced stream through applyEvent lands on the same SessionUi, because the reducer applies both event kinds as wholesale overwrites.

bun test (1702 pass, 0 fail) and bun run typecheck are green. The manual end-to-end check from the issue (step 4/5 — attach to a session parked on ask and confirm a single paint, plus live token streaming still working) has not been run here; this session has no browser.

Compatibility

A browser tab loaded before this change ignores the unknown frame (handleFrame ends in default: return) and shows the in-flight turn from the snapshot only, recovering on the next live event or a reload. Grep confirms apps/web is the only consumer of session_event.

Closes: #272 Switching to a background session re-rendered its already-produced assistant text as if it were being typed. The replay path sent the in-flight turn's buffered events as one WebSocket frame each; the client applies one `set()` per frame, so React painted between them. Sessions parked on an `ask` dialog never emit `turn_end`, so they hold the whole turn in the buffer — exactly the worst case. ## What changed - **Protocol**: new `session_events` server frame carrying the whole replay batch (`packages/protocol/src/index.ts`). - **Server buffer**: `appendTurnEvent()` (exported from `apps/server/src/bridge/in-process.ts`) replaces the raw `push` in `emit()`. It drops an entry the next *adjacent* event fully supersedes — two `message_update`s, or two `tool_execution_update`s of the same `toolCallId`. Both payloads are cumulative, so the earlier one is unobservable to a late subscriber. Adjacency is the predicate: a different assistant message requires `message_start`/`message_end` in between, and a parallel tool's update carries a different id, so nothing merges across a run break. - **Attach**: `ws.ts` sends one `session_events` frame instead of the per-event loop. The preceding UI/plan/idea replay loops and their ordering are unchanged. - **Client**: a `session_events` case in the store folds the batch through `applyEvent` in array order inside a single `set()` — one render. The `session_event` case is untouched, so **live streaming is unaffected**: subscribers still get every event, one frame each. This also removes the O(N²) bytes a long turn used to replay on every attach, reconnect and extra browser tab, and bounds buffer growth while a turn stays open. ## Tests - `apps/server/src/bridge/turn-replay.test.ts` — pins the coalescing predicate: a run of `message_update` collapses to the latest; any other event between two of them breaks the run; `tool_execution_update` collapses only within one `toolCallId`, so interleaved parallel tools keep their own payloads. - `apps/web/src/lib/reducer-replay.test.ts` — pins the invariant that makes the coalescing lossless from the client side: folding the full stream and the coalesced stream through `applyEvent` lands on the same `SessionUi`, because the reducer applies both event kinds as wholesale overwrites. `bun test` (1702 pass, 0 fail) and `bun run typecheck` are green. The manual end-to-end check from the issue (step 4/5 — attach to a session parked on `ask` and confirm a single paint, plus live token streaming still working) has **not** been run here; this session has no browser. ## Compatibility A browser tab loaded before this change ignores the unknown frame (`handleFrame` ends in `default: return`) and shows the in-flight turn from the snapshot only, recovering on the next live event or a reload. Grep confirms `apps/web` is the only consumer of `session_event`.
Attaching to a session whose turn is still open replayed the buffered
events one WebSocket frame at a time. The client applies one store
update per frame, so React painted between them and the already
finished assistant text appeared to type itself out — most visibly on
sessions parked on an `ask` dialog, whose turn never ends and whose
buffer therefore holds everything.

The replay now travels as a single `session_events` frame that the
client folds through the reducer in one update, so the turn renders in
one paint. The buffer also drops a cumulative event that the next
adjacent one fully supersedes (`message_update`, and
`tool_execution_update` of the same tool call), which removes the
quadratic bytes a long turn used to replay on every attach, reconnect
and extra browser tab, and bounds its growth while the turn stays open.

Live streaming is untouched: subscribers still receive every event as
its own frame.

Co-Authored-By: Claude Opus 5 via omp-deck
Owner

Can you fix the conflict?

Can you fix the conflict?
omp-deck force-pushed agent/issue-272-batched-turn-replay from 3affd1f9c5 to 0ce71a9e8f 2026-11-13 15:20:07 +01:00 Compare
Author
Collaborator

Already done — the branch was rebased onto main and force-pushed before your comment arrived; the PR now reports mergeable: yes at head 0ce71a9.

The only conflict was in CHANGELOG.md and it was purely additive: main's plan-issue-label entry and this PR's replay entry both wanted the first slot in ### Fixed, so both are kept, upstream's first. No other file conflicted and no code changed in the rebase.

Re-verified afterwards: bun test apps/server/src/bridge/turn-replay.test.ts apps/web/src/lib/reducer-replay.test.ts (9 pass, 0 fail) and bun run typecheck (all packages clean). The commit has no status checks configured, so there is no CI run to wait on.

Already done — the branch was rebased onto `main` and force-pushed before your comment arrived; the PR now reports `mergeable: yes` at head `0ce71a9`. The only conflict was in `CHANGELOG.md` and it was purely additive: `main`'s plan-issue-label entry and this PR's replay entry both wanted the first slot in `### Fixed`, so both are kept, upstream's first. No other file conflicted and no code changed in the rebase. Re-verified afterwards: `bun test apps/server/src/bridge/turn-replay.test.ts apps/web/src/lib/reducer-replay.test.ts` (9 pass, 0 fail) and `bun run typecheck` (all packages clean). The commit has no status checks configured, so there is no CI run to wait on.
lucas approved these changes 2026-11-13 15:20:53 +01:00
lucas merged commit 25098ec7a8 into main 2026-11-13 15:20:57 +01:00
lucas deleted branch agent/issue-272-batched-turn-replay 2026-11-13 15:20:57 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck!277
No description provided.