fix: replay an in-flight turn in one frame instead of per event #277
No reviewers
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck!277
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/issue-272-batched-turn-replay"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 anaskdialog never emitturn_end, so they hold the whole turn in the buffer — exactly the worst case.What changed
session_eventsserver frame carrying the whole replay batch (packages/protocol/src/index.ts).appendTurnEvent()(exported fromapps/server/src/bridge/in-process.ts) replaces the rawpushinemit(). It drops an entry the next adjacent event fully supersedes — twomessage_updates, or twotool_execution_updates of the sametoolCallId. Both payloads are cumulative, so the earlier one is unobservable to a late subscriber. Adjacency is the predicate: a different assistant message requiresmessage_start/message_endin between, and a parallel tool's update carries a different id, so nothing merges across a run break.ws.tssends onesession_eventsframe instead of the per-event loop. The preceding UI/plan/idea replay loops and their ordering are unchanged.session_eventscase in the store folds the batch throughapplyEventin array order inside a singleset()— one render. Thesession_eventcase 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 ofmessage_updatecollapses to the latest; any other event between two of them breaks the run;tool_execution_updatecollapses only within onetoolCallId, 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 throughapplyEventlands on the sameSessionUi, because the reducer applies both event kinds as wholesale overwrites.bun test(1702 pass, 0 fail) andbun run typecheckare green. The manual end-to-end check from the issue (step 4/5 — attach to a session parked onaskand 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 (
handleFrameends indefault: return) and shows the in-flight turn from the snapshot only, recovering on the next live event or a reload. Grep confirmsapps/webis the only consumer ofsession_event.Can you fix the conflict?
3affd1f9c5to0ce71a9e8fAlready done — the branch was rebased onto
mainand force-pushed before your comment arrived; the PR now reportsmergeable: yesat head0ce71a9.The only conflict was in
CHANGELOG.mdand 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) andbun run typecheck(all packages clean). The commit has no status checks configured, so there is no CI run to wait on.