Fold streaming text deltas at the chat replay boundary - #1065
Conversation
A 30-second reply is persisted as hundreds of individual `text` delta rows, and replay re-sends every one for the client to re-fold. Measured on a real 87.85 MiB thread from this machine, 68,081 `text` events averaged 798 bytes each while carrying 10.1 bytes of text — a 1.27% payload. One measured event spent 833 bytes to deliver the word " and". The rest is the identifier stack, re-sent per delta. Full chat_subscribe snapshots now collapse consecutive streaming deltas of the same message into one event. On that thread: 91,090 -> 29,345 events, 87.85 -> 41.30 MiB (53.0% smaller), in 30 ms. Gated on a new `foldedReplay` hello capability; a client that does not declare it receives every delta unchanged. The scope is deliberately narrow, and the reason is not caution for its own sake. Desktop's `mergeStreamingText` (chatTranscriptRows.ts:634) is five lines: prefix check, else concatenate. iOS's `mergeWorkStreamingText` (WorkErrorAndMessageHelpers.swift:310) is ~50 lines of replay-shape detection, trimmed prefix/suffix checks and an overlap scan. For deltas that overlap or repeat, those two already render DIFFERENT text today. No server-side fold can be byte-identical to both, so folding those cases would silently pick a winner. This therefore folds only runs where every implementation provably agrees: clean appends, where neither side is a prefix of the other, they are not equal, and there is no boundary overlap. In that case desktop concatenates and iOS falls through its heuristics to the same concatenation. Anything else ends the run and is emitted unfolded for the client to merge exactly as it does now. Other types stay unfolded for the same reason rather than for lack of value: `command` merges its `output` and `file_change` merges its `diff` through the streaming merge (chatTranscriptRows.ts:959,984), so keep-last would drop output; `plan` is a field-wise merge with fallbacks (`mergePlanTranscriptEvent`, :488) where an event with empty `steps` preserves the previous steps. Each is foldable under its own predicate; that is follow-up work, not a guess to make here. An unrecognized event type is never folded. Two ordering rules make this safe against the sequence-collision class that silently dropped iOS question cards. A folded run is emitted at the position of its FIRST delta, which is where both clients place the message, and carries the LAST delta's sequence and timestamp so no consumer can watermark inside a collapsed run. Delivery bookkeeping marks the PRE-fold envelopes, because a collapsed delta still has its own delivery key and leaving it unmarked lets the transcript pump re-send it as an event the client would render twice. The replay-buffer resume path is not folded. Its per-event `seq` monotonicity is load-bearing for the client's `seq <= lastSeq` drop rule, and it carries only a small recent gap; the snapshot is what carries the history. Cursors are untouched: `tailStartOffset`, `cursorKind` and `hasOlderHistory` are computed before the fold and still address the same byte offsets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…docs The unit tests pinned the fold and the container in isolation; nothing pinned what actually ships. Adds a host-level test that the `foldedReplay` capability gate gates — two peers on one snapshot, one declaring it and one not — and asserts the folded event carries the last delta's sequence and exactly the text the ungated peer derives by concatenating. iOS gets the Swift half of the container contract, extending the existing assembler tests rather than adding a sibling file: header/body round-trip, a JSON frame delivered as data staying on the text path, truncated and over-cap header lengths, binary chunk parts reassembling to Data, and decoding from a sliced Data buffer — URLSession hands back slices with a non-zero startIndex, which is exactly how a container decoder silently reads garbage. Docs record the binary frame, the permessage-deflate interaction, and the fold's scope and cursor rules where the sync transport and iOS companion are described. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review from the lane that owns page-cut placement flagged that desktop mergeStreamingText is five lines but TWO semantics: `incoming.startsWith( existing)` REPLACES, everything else CONCATENATES. A runtime emitting cumulative deltas — each carrying the whole message so far — folded as if it were incremental would duplicate its text quadratically, and the client could not detect it: after the fold there is one event and nothing to compare against. `isCleanTextAppend` already rejected that shape, but it was argued rather than pinned. Fixtures now assert it directly: a fully cumulative run folds nothing and renders once, an 8-delta growing reply renders at exactly its own length, and a mixed incremental-then-cumulative run renders identically folded and unfolded. A ninth fixture asserts fold-then-client-merge equals client-merge of the raw stream across every shape — incremental, cumulative, duplicate delta, repeated tail, boundary overlap, empty and whitespace-only deltas. Measured while checking whether the branch is live: across the 40 largest transcripts on this machine, 316,506 text delta pairs, zero cumulative. The renderer branch exists though, and unobserved is not impossible, so the guard is pinned rather than assumed away. Also records the invariant the same review asked for. Event identity is content-derived (`agentChatEventIdentityKey` is timestamp#type#JSON), so a folded run and the deltas it replaces have different identities and will not dedupe against each other. That is safe only because the snapshot span [tailStartOffset, EOF) is disjoint from byte-paged history strictly below tailStartOffset. The module has exactly one call site and must keep it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A correction from the paging lane sent me back to read the desktop text path properly, and it turned up a real bug plus a documentation error of mine. THE BUG. Desktop merges a text delta into `rows[rows.length - 1]`, and only when that row is itself a text row. A tool call landing between two deltas of one message therefore ends the text row and starts a second one. This module folded by message id across intervening events, so [text "A", tool_call, text "B"] folded to [text "AB", tool_call] — moving the tool call after the whole message instead of leaving it between the halves. Folding is now adjacency-only: any non-foldable event closes the open run. That is exactly what desktop produces, and no more than iOS already does (iOS finds its merge target by searching for the item id, so it merges across gaps regardless). Two existing tests encoded the wrong assumption and now assert the true contract: interleaved messages do not fold at all, because iOS would merge them by item id and desktop would keep four rows — they diverge, so nothing folds. THE ATTRIBUTION ERROR. I described `mergeStreamingText` (chatTranscriptRows.ts :634) as the desktop text merge. It is not. Its only two call sites are :959 (file_change diffs) and :984 (command/tool output); it never sees a text event. The real text merge is inline in the row reducer and concatenates unconditionally — no prefix branch on the text path at all. That makes the earlier cumulative-delta work land differently than reported: the guard is stricter than desktop needs for text, so it only folds less, and the previous "316,506 delta pairs, zero cumulative" sweep measured text deltas, which never reach that function — it could not have observed the branch either way. The branch is live, just on diffs and command output. Recorded in the module header, because that is precisely where a future extension of the fold to `file_change` or `command` would be caught out: a runtime emitting a growing diff per event is the cumulative shape, and every fixture here is text-shaped. Re-measured on the same real 87.85 MiB thread. Adjacency costs about 1.6 points: 53.0% -> 51.4% over the whole transcript, and per 220 KB snapshot window 5.9%-79.3% (was 8.7%-79.3%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The paging lane's trace of prefetch vs the adjacency rule came with a precision worth acting on: turn-anchored paging guarantees the loaded span CONTAINS a user_message, not that it STARTS on one. The window top is still a byte cut, so one message's deltas can straddle it — the older half arriving as individual deltas through byte-paged history while the newer half arrives inside a folded snapshot. That is the one place a folded event meets an unfolded one, and nothing here covered it. It works because concatenation is associative — folding (C+D) and appending it to A+B is the same string as folding nothing — but that was an argument, not a test. Two now: an explicit straddle, and a sweep asserting the composition holds with the seam at every possible delta boundary. No production change; the invariant already held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Warning Review limit reached
Next review available in: 39 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (7)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Item 3 of Tier 1C. Stacked on #1057 — review/merge that first.
The finding
A 30-second reply is persisted as hundreds of individual
textdelta rows, and replay re-sends every one for the client to re-fold. Measured on a real 87.85 MiB thread from this machine:68,081
textevents averaged 798 bytes each while carrying 10.1 bytes of text — a 1.27% payload. One measured event spent 833 bytes to deliver the word" and".The other 98.7% is the identifier stack, re-sent per delta: a ~130-char composite
messageId, a ~160-charprovenance.messageIdrepeating it plus a UUID, thenthreadId/turnId/itemId/sessionId/timestamp/sequence.The change
Full
chat_subscribesnapshots collapse consecutive streaming deltas of the same message into one event. Gated on a newfoldedReplayhello capability; a client that does not declare it receives every individual delta, unchanged.Measurements — read the boundary, not the transcript
A
chat_subscribesnapshot is bounded before the fold ever runs:CHAT_EVENT_REPLAY_MAX_EVENTS = 500andmaxBytes(220 KB default, 2 MB ceiling). No snapshot carries the whole transcript, so the effect per hydration is what matters, and it varies with how much of the window is streaming text:So: 5.9%–79.3% per hydration, with event count down 18–86%. A window landing inside a long streamed reply is where the fold pays; a window of mixed tool/command traffic is where it barely does.
For reference, folding the entire 87.85 MiB transcript gives 91,090 → 31,703 events and 42.72 MiB (51.4%) in 30 ms — but that is the fold's effect on history, not on any single hydration, and no code path sends it. An earlier revision of this PR led with that number; it was correct arithmetic against the wrong boundary.
The event-count reduction is worth as much as the bytes here: every snapshot event is decoded on iOS, and #1056 moved that decode off the main actor precisely because it was expensive.
Why the scope is this narrow
The two clients do not fold text the same way, in two independent respects, and the fold obeys both.
Merge semantics. Desktop concatenates text deltas unconditionally (inline in the row reducer). iOS's
mergeWorkStreamingText(WorkErrorAndMessageHelpers.swift:310) is ~50 lines of replay-shape detection, trimmed prefix/suffix checks, and an overlap scan. For deltas that overlap or repeat those two already render differently today, so only clean appends fold — neither side a prefix of the other, not equal, no boundary overlap. For text this is stricter than desktop needs; it simply folds less.Adjacency. Desktop merges into
rows[rows.length - 1]and only when that row is a text row, so a tool call between two deltas ends the row. iOS finds its target by searching for the item id and merges across gaps. Folding is therefore adjacency-only — any intervening event closes the run. Folding across it would move the tool call after the whole message.Note for anyone extending this:
mergeStreamingText(chatTranscriptRows.ts:634) is not the text merge — its only call sites are :959 (diffs) and :984 (command output). It does have a cumulativestartsWithbranch, which is live on those types, so extending the fold tofile_changeorcommandmust handle a growing-diff-per-event runtime. Every fixture here is text-shaped and would not catch it.Other types stay unfolded for the same reason, not for lack of value:
commandmerges itsoutputandfile_changemerges itsdiffthrough the streaming merge (chatTranscriptRows.ts:959,984) — append-merges, so keep-last would drop output.planis a field-wise merge with fallbacks (mergePlanTranscriptEvent,:488): an event with emptystepspreserves the previous steps, so keep-last loses them.Each is foldable under its own provable-agreement predicate — follow-up work, not a guess to make here. An unrecognized event type is never folded.
Sequence and cursor safety
The documented failure class here is host
eventSequencerestarting per rehydration, producingsessionId:sequencecollisions that silently dropped iOS AskUserQuestion cards. Three rules keep the fold clear of it:sequence/timestamp, so no consumer can watermark inside a collapsed run.seqmonotonicity is load-bearing for the client'sseq <= lastSeqdrop rule, and it carries only a small recent gap.Cursors are untouched:
tailStartOffset,cursorKindandhasOlderHistoryare computed before the fold and still address the same byte offsets. Contract sent to thet3-thread-readinglane, which owns page-cut placement.Forward compatibility verified:
syncHelloProtocol.ts:121filters capabilities to non-empty strings with no allowlist, so an older host receivingfoldedReplayignores it rather than rejecting the connection.Tests
24 tests in
chatReplayFold.test.ts:plan,command,file_change,tool_call,tool_result,context_usage, and an unknown future type pass through untouched.eventat all. This crashed the first implementation; caught by running the real transcript through the module, not by the synthetic tests.567 sync tests pass.
Follow-up (not built here)
tailStartOffsetpoints at, which thet3-thread-readinglane owns.🤖 Generated with Claude Code
Supersedes #1060, which GitHub closed irrecoverably when its stacked parent branch (#1057) was deleted on merge. Same branch, same commits, rebased onto main.
Greptile Summary
The PR capability-gates replay folding so supported clients receive adjacent, clean-append text and reasoning deltas as compact snapshot events while legacy peers retain the original stream.
chat_subscribehydration while retaining pre-fold delivery bookkeeping and leaving resume replay unchanged.Confidence Score: 5/5
The PR appears safe to merge, with no actionable correctness or security defects identified in the changed replay path.
Folding is explicitly negotiated, restricted to adjacent clean appends, preserves the final sequence and timestamp, retains every original delivery key, and leaves legacy and resume-replay behavior unchanged.
Important Files Changed
foldedReplaycapability identifier.Sequence Diagram
Reviews (1): Last reviewed commit: "Pin that a folded snapshot half composes..." | Re-trigger Greptile
Context used:
ade codeTUI