Skip to content

Fold streaming text deltas at the chat replay boundary - #1065

Merged
arul28 merged 5 commits into
mainfrom
ade/t3-replay-fold-c8368d49
Aug 10, 2026
Merged

Fold streaming text deltas at the chat replay boundary#1065
arul28 merged 5 commits into
mainfrom
ade/t3-replay-fold-c8368d49

Conversation

@arul28

@arul28 arul28 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Item 3 of Tier 1C. Stacked on #1057 — review/merge that first.

The finding

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 other 98.7% is the identifier stack, re-sent per delta: a ~130-char composite messageId, a ~160-char provenance.messageId repeating it plus a UUID, then threadId/turnId/itemId/sessionId/timestamp/sequence.

The change

Full chat_subscribe snapshots collapse consecutive streaming deltas of the same message into one event. Gated on a new foldedReplay hello capability; a client that does not declare it receives every individual delta, unchanged.

Measurements — read the boundary, not the transcript

A chat_subscribe snapshot is bounded before the fold ever runs: CHAT_EVENT_REPLAY_MAX_EVENTS = 500 and maxBytes (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:

220 KB window sampled at before after smaller
25% through the thread 249 events / 213.9 KB 35 events / 44.3 KB 79.3%
75% 279 events / 214.8 KB 105 events / 87.4 KB 59.3%
tail 202 events / 214.5 KB 112 events / 187.4 KB 12.6%
50% 232 events / 214.5 KB 190 events / 201.9 KB 5.9%

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 cumulative startsWith branch, which is live on those types, so extending the fold to file_change or command must 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:

  • command merges its output and file_change merges its diff through the streaming merge (chatTranscriptRows.ts:959,984) — append-merges, so keep-last would drop output.
  • plan is a field-wise merge with fallbacks (mergePlanTranscriptEvent, :488): an event with empty steps preserves 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 eventSequence restarting per rehydration, producing sessionId:sequence collisions that silently dropped iOS AskUserQuestion cards. Three rules keep the fold clear of it:

  1. A folded run is emitted at the position of its first delta (where both clients place the message) and carries the last delta's sequence/timestamp, so no consumer can watermark inside a collapsed run.
  2. Delivery bookkeeping marks the pre-fold envelopes. A collapsed delta still has its own delivery key; leaving it unmarked lets the transcript pump re-send it as an event the client would render twice.
  3. 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.

Cursors are untouched: tailStartOffset, cursorKind and hasOlderHistory are computed before the fold and still address the same byte offsets. Contract sent to the t3-thread-reading lane, which owns page-cut placement.

Forward compatibility verified: syncHelloProtocol.ts:121 filters capabilities to non-empty strings with no allowlist, so an older host receiving foldedReplay ignores it rather than rejecting the connection.

Tests

24 tests in chatReplayFold.test.ts:

  • Fold equivalence per type — folded text equals what a client derives from the unfolded run, with the desktop merge reproduced verbatim; every folded pair is asserted to satisfy both it and iOS's pre-overlap guards.
  • Explicit non-foldingplan, command, file_change, tool_call, tool_result, context_usage, and an unknown future type pass through untouched.
  • Named sequence regression — two host epochs both numbering from 1 replayed into one snapshot: distinct messages stay distinct, no two emitted events share a delivery key, a folded run's sequence is >= every sequence it absorbed.
  • Ordering, interleaved messages, turn/message separation, deltas with no stable id, replay-shaped deltas ending a run.
  • Malformed envelopes — real transcripts contain lines with no event at 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)

  1. The fold shrinks the payload but does not deliver more history. The window is read byte-bounded before folding, so a phone gets the same conversation in fewer bytes rather than more conversation in the same bytes. Reading a larger window and folding down to the budget is the bigger prize — deliberately not done here because it moves what tailStartOffset points at, which the t3-thread-reading lane owns.
  2. A wire-side id dictionary would attack the 98.7% directly, and applies to live traffic too, not just replay.

🤖 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.

  • Adds a shared replay-folding implementation with client-equivalence, ordering, malformed-envelope, sequence, and page-seam coverage.
  • Integrates folding into full chat_subscribe hydration while retaining pre-fold delivery bookkeeping and leaving resume replay unchanged.
  • Advertises the capability from iOS and documents the updated synchronization behavior.

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

Filename Overview
apps/desktop/src/shared/chatReplayFold.ts Adds conservative, adjacency-preserving replay folding with stable grouping, source retention, and last-delta watermark metadata.
apps/desktop/src/shared/chatReplayFold.test.ts Thoroughly covers supported and unsupported event shapes, client-equivalent text behavior, ordering, sequence identity, and paging seams.
apps/ade-cli/src/services/sync/syncHostService.ts Capability-gates folding on full snapshots and marks original envelopes delivered while preserving the existing resume path.
apps/ade-cli/src/services/sync/syncHostService.test.ts Verifies negotiated peers receive folded snapshots while legacy peers receive every original delta.
apps/desktop/src/shared/types/sync.ts Defines the shared foldedReplay capability identifier.
apps/ios/ADE/Services/SyncService.swift Advertises iOS support for folded replay snapshots.
apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift Adds binary-frame and binary-chunk regression coverage; no issue was identified in these test-only additions.
docs/features/sync-and-multi-device/README.md Documents replay folding and the surrounding synchronization wire behavior.
docs/features/sync-and-multi-device/ios-companion.md Documents the iOS capability set and folded snapshot semantics.

Sequence Diagram

sequenceDiagram
  participant iOS
  participant Host as Sync host
  participant Fold as Replay folder
  participant Pump as Transcript pump
  iOS->>Host: hello(capabilities: foldedReplay)
  iOS->>Host: chat_subscribe(sessionId)
  Host->>Host: Read byte-bounded snapshot
  Host->>Fold: Fold adjacent clean text/reasoning appends
  Fold-->>Host: Folded events + original source envelopes
  Host-->>iOS: chat_subscribe snapshot
  Host->>Host: Mark every source envelope delivered
  Host->>Pump: Resume after snapshot offset
  Pump-->>iOS: Subsequent live events remain unfolded
Loading

Reviews (1): Last reviewed commit: "Pin that a folded snapshot half composes..." | Re-trigger Greptile

Context used:

arul28 and others added 5 commits August 10, 2026 01:19
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>
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Aug 10, 2026 5:20am

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@arul28, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ccde41af-043a-4c69-9ec8-25e975f30984

📥 Commits

Reviewing files that changed from the base of the PR and between 870d738 and 96e63b0.

⛔ Files ignored due to path filters (2)
  • docs/features/sync-and-multi-device/README.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/ios-companion.md is excluded by !docs/**
📒 Files selected for processing (7)
  • apps/ade-cli/src/services/sync/syncHostService.test.ts
  • apps/ade-cli/src/services/sync/syncHostService.ts
  • apps/desktop/src/shared/chatReplayFold.test.ts
  • apps/desktop/src/shared/chatReplayFold.ts
  • apps/desktop/src/shared/types/sync.ts
  • apps/ios/ADE/Services/SyncService.swift
  • apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@arul28
arul28 merged commit ee8ad78 into main Aug 10, 2026
37 checks passed
@arul28
arul28 deleted the ade/t3-replay-fold-c8368d49 branch August 10, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant