Skip to content

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

Closed
arul28 wants to merge 5 commits into
ade/t3-sync-compression-c8368d49from
ade/t3-replay-fold-c8368d49
Closed

Fold streaming text deltas at the chat replay boundary#1060
arul28 wants to merge 5 commits into
ade/t3-sync-compression-c8368d49from
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

Greptile Summary

The PR capability-gates replay folding so compatible clients receive adjacent clean-append text and reasoning deltas as single snapshot events while legacy clients retain unchanged behavior.

  • Adds conservative replay-folding logic and equivalence, ordering, seam, malformed-envelope, and sequence-regression tests.
  • Advertises foldedReplay from iOS and defines the shared sync capability.
  • Integrates folding only into full chat_subscribe snapshots while preserving pre-fold delivery bookkeeping and leaving replay-buffer resumes unfolded.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the reviewed follow-up scope.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/desktop/src/shared/chatReplayFold.ts Introduces a conservative adjacency-only fold that preserves client merge semantics, event ordering, and source delivery identities.
apps/ade-cli/src/services/sync/syncHostService.ts Capability-gates folding on full snapshots and marks every pre-fold envelope to prevent transcript-pump duplicates.
apps/desktop/src/shared/chatReplayFold.test.ts Covers fold equivalence, unsupported types, sequence behavior, malformed envelopes, cumulative streams, and page-seam composition.
apps/desktop/src/shared/types/sync.ts Adds the additive foldedReplay capability constant without changing existing wire shapes.
apps/ios/ADE/Services/SyncService.swift Advertises foldedReplay support while retaining compatibility with hosts that ignore unknown capabilities.
apps/ade-cli/src/services/sync/syncHostService.test.ts Verifies host wiring sends folded snapshots only to capable peers and leaves legacy snapshots unchanged.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Host as Sync host
  participant History as Transcript history
  Client->>Host: hello(capabilities)
  Client->>Host: chat_subscribe
  Host->>History: Read bounded snapshot
  History-->>Host: Events and byte cursor
  alt Client declares foldedReplay
    Host->>Host: Fold adjacent clean text/reasoning appends
    Host-->>Client: Folded chat_subscribe snapshot
    Host->>Host: Mark every original source event delivered
  else Legacy client
    Host-->>Client: Unchanged event-by-event snapshot
  end
  Note over Host,Client: Replay-buffer resume remains unfolded
Loading

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

Context used:

@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:06am

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dfab5220-dc63-4451-856d-3c5effca1a2b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 force-pushed the ade/t3-replay-fold-c8368d49 branch 3 times, most recently from 7129a3c to b3f70be Compare August 10, 2026 02:16
@arul28
arul28 force-pushed the ade/t3-sync-compression-c8368d49 branch from 4988d1e to c47aadb Compare August 10, 2026 04:50
@arul28
arul28 force-pushed the ade/t3-replay-fold-c8368d49 branch from 468bf50 to 94ebc46 Compare August 10, 2026 04:52
arul28 and others added 5 commits August 10, 2026 01:05
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>
@arul28
arul28 force-pushed the ade/t3-sync-compression-c8368d49 branch from c47aadb to c1867ce Compare August 10, 2026 05:05
@arul28
arul28 force-pushed the ade/t3-replay-fold-c8368d49 branch from 94ebc46 to 5abee10 Compare August 10, 2026 05:05
@arul28
arul28 deleted the branch ade/t3-sync-compression-c8368d49 August 10, 2026 05:19
@arul28 arul28 closed this Aug 10, 2026
@arul28

arul28 commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #1065. GitHub closed this irrecoverably when its stacked parent (#1057) was merged and its base branch deleted — the base could not be retargeted on a closed PR. #1065 is the same branch and commits, rebased onto main.

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