Stop replayed turn events from re-typing themselves on session switch #272
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#272
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Problem
Switching to a session that was running in the background re-renders its
already-produced assistant text character by character, as if it were being
typed live. This is most visible on sessions that are parked on an
askdialog: the model finished writing long ago, yet attaching to the session
replays the whole turn at reading speed.
There is no typewriter animation in the client — nothing in
apps/web/srcschedules characters. The cause is the replay path:
InProcessSessionHandle.emit()(apps/server/src/bridge/in-process.ts:1422-1443)buffers every raw SDK event of the in-flight turn into
currentTurnBuffer,from
turn_startuntilturn_end/agent_end, whether or not a client is attached.askhas not emittedturn_end— the agent is suspendedinside the tool call — so its buffer holds the entire turn indefinitely. These are
exactly the sessions with the largest buffers.
subscribe,WsHub.handleSubscribesends the snapshot and then loops thebuffer out as one WebSocket frame per event
(
apps/server/src/ws.ts:283-285).set()per frame (apps/web/src/lib/store.ts:759-766),so React paints between frames. That sequence of paints is the "typing".
It is also expensive.
message_updatecarries the cumulative message, not adelta (
MessageUpdateEvent.message: AgentMessage,node_modules/@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:1203-1207),and the reducer replaces the assistant blocks wholesale from it
(
apps/web/src/lib/reducer.ts:149-152→updateAssistantMessage,reducer.ts:593-625). A turn with N text deltas therefore buffers and replays Ncopies of a growing message: O(N²) bytes across the socket on every attach,
every reconnect, and every additional browser tab — plus unbounded buffer growth
in the server while the turn stays open.
Live streaming is correct and must stay exactly as it is; only the retroactive
replay is wrong.
Plan
1. Add a batched replay frame to the protocol
In
packages/protocol/src/index.ts, add to theServerFrameunion (next to theexisting
session_eventmember at line 1140):AgentSessionEventJson(index.ts:853-855) is already an untyped passthrough;no new event typing is needed.
Old browser tabs loaded before this change ignore unknown frames —
handleFrameends in
case "pong": default: return;(apps/web/src/lib/store.ts:982-984) —so a stale tab shows the in-flight turn from the snapshot only, and recovers on
the next live event or a reload. No compatibility shim; this is a loopback tool
that serves its own bundle.
2. Coalesce superseded cumulative events as they enter the buffer
In
apps/server/src/bridge/in-process.ts, add an exported pure helper above theInProcessSessionHandleclass (the class begins around line 1340;currentTurnBufferis declared at line 1347):
Implementation:
const prev = buffer[buffer.length - 1];— if absent,buffer.push(event)and return.const type = (event as { type?: string }).type;and the same forprev."message_update":buffer[buffer.length - 1] = event;and return."tool_execution_update"andString((prev as { toolCallId?: unknown }).toolCallId ?? "") === String((event as { toolCallId?: unknown }).toolCallId ?? ""):replace in place and return.
buffer.push(event).Then in
emit()(in-process.ts:1429-1431) replacewith
if (this.turnBufferActive) appendTurnEvent(this.currentTurnBuffer, event);.Nothing else in
emit()changes:turn_startstill resets the buffer,turn_end/agent_endstill clear it and dropturnBufferActive, and thelistener fan-out at
in-process.ts:1436-1442is untouched — live subscriberskeep receiving every single event. Only the replay buffer is condensed.
getCurrentTurnEvents()(in-process.ts:1445-1447) andgetCurrentTurnReplayEvents()(in-process.ts:1283-1287) keep their signatures.3. Send the replay as one frame
In
apps/server/src/ws.ts, replace the per-event loop at lines 283-285:Leave the three preceding replay loops (
getUiReplayFrames,getPlanModeReplayFrames,getIdeaModeReplayFrames, lines 274-282) and theirordering alone — they emit a handful of frames and carry no streaming content.
The live listener registered at
ws.ts:220-222still sends individualsession_eventframes, and becausesendis synchronous and the buffer iscopied before the first
await-free send, an event arriving during attach isdelivered after the batch, preserving order.
The early-return branch for an already-subscribed connection (
ws.ts:189-195)re-sends only the snapshot today; leave it unchanged.
4. Fold the batch in a single client store update
In
apps/web/src/lib/store.ts, add a case next tosession_event(lines 759-767).
applyEventis already imported and is pure:One
set()for the whole replay means one render: the turn appears complete,instantly. Do not touch the
session_eventcase — live events must keeprendering per frame.
Critical files & anchors
apps/server/src/bridge/in-process.ts:1422-1447—emit(),currentTurnBuffer,getCurrentTurnEvents(); the only place the buffer is written.apps/server/src/ws.ts:262-286— ordered attach sequence; the comment block at268-273 explains why
subscribedmust precede replay. Keep that ordering.apps/web/src/lib/store.ts:759-767—session_eventhandler to mirror.apps/web/src/lib/reducer.ts:149-152,593-625— proof thatmessage_updateisapplied as a wholesale overwrite, which is what makes coalescing lossless.
apps/server/src/bridge/in-process-queue.test.ts:1-60— the established patternfor exercising
InProcessSessionHandleagainst a duck-typed stub sessionwithout pulling in the SDK.
Verification
Run from the repo root.
apps/server/src/bridge/turn-replay.test.tsdriving
appendTurnEventdirectly (pure function, no session needed):turn_start,message_start, thenmessage_updatewithmessage.content = [{type:"text",text:"a"}],"ab","abc"yields a bufferof 3 entries whose last is the
"abc"update — the two intermediates are gone.message_update("a"),tool_execution_start,message_update("ab")keeps all three: the tool event breaks the run.tool_execution_update{toolCallId:"t1"},tool_execution_update{toolCallId:"t2"},tool_execution_update{toolCallId:"t1"}keeps all three (parallel tools mustnot collapse into each other), while two consecutive
t1updates collapse to one.Run:
bun test apps/server/src/bridge/turn-replay.test.ts.apps/web/src/lib/reducer.test.ts(or a sibling test if that file is strictly queue-scoped) asserting that folding
[message_start(assistant), message_update("abc")]throughapplyEventinsequence produces the same
SessionUias the current per-event path: oneassistant message,
blockstext"abc",isStreaming: true. This pins thebatched fold as equivalent to the frame-by-frame fold.
Run:
bun test apps/web/src/lib.bun test apps/server/src/bridgeandbun run typecheck(the latter covers the newServerFramemember reachingboth server and web).
bun run dev, open the web UI, start asession whose prompt makes the agent produce a long answer and then call
ask(e.g. a planning prompt). Once the ask dialog is pending, navigate to another
session, then back. Expected: the full assistant text and tool cards appear in
a single paint, with the ask dialog restored; no progressive reveal. Compare
against
git stashof the change to confirm the before/after difference.While still attached and generating, confirm live text still streams token by
token — that path must be unaffected.
renders exactly one assistant bubble for the in-flight message. Whether the
SDK's
session.messagesused bysnapshot()(in-process.ts:1509) alreadycontains the in-flight partial assistant message is
unverified; this changedoes not alter which distinct events reach the reducer, so the rendering must
match pre-change behaviour either way. If a duplicate appears only after the
change, the fold order in step 4 is wrong — re-check that events are applied
in array order.
Assumptions & contingencies
event.messagefrommessage_updateand onlyevent.partialResultfromtool_execution_update; it ignoresassistantMessageEvent(the per-token delta).If a future consumer needs the individual deltas of a past turn, it must read
them from the live stream, not the replay buffer — the buffer is explicitly a
"state to catch up to", not a log.
apps/webconsumessession_event. Verified by grep: the server emitsit in
ws.tsand the web store handles it; nothing else parses it. If anexternal WebSocket consumer turns out to exist, it will ignore the unknown
session_eventsframe and simply miss the in-flight turn until the next liveevent — acceptable, do not add a duplicate per-event replay for it.
tool calls with large partial results), do not reintroduce per-event sending:
cap the batch by dropping the oldest
tool_execution_updateentries whose toolalready has a
tool_execution_endlater in the buffer, which are equallysuperseded.