Skip to content

Fix assistant text cut mid-word and repeated in mobile chat#905

Merged
arul28 merged 8 commits into
mainfrom
ade/start-skill-screenshot-ade-mobile-340acbb2
Jul 26, 2026
Merged

Fix assistant text cut mid-word and repeated in mobile chat#905
arul28 merged 8 commits into
mainfrom
ade/start-skill-screenshot-ade-mobile-340acbb2

Conversation

@arul28

@arul28 arul28 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Reported as a mobile rendering bug: assistant text in an ADE Work chat was cut off mid-word and then repeated in the next bubble. Mobile is only where it was visible.

Root cause

chat.getTranscript rebuilds an assistant message from its streamed text fragments. It treated two fragments as contiguous only when the immediately preceding envelope was the same message, and glued them with "\n\n" otherwise. Ephemeral chrome that no renderer draws — activity hints, subagent progress, token usage — lands between deltas constantly, so the separator landed inside words. Straight from the reported session's transcript:

seq 913  text  len=100  msg_011CdQ6hQAzFEoQpQAkq7UFK  "…measurements, no new mod"
seq 914  activity
seq 915  text  len=19   msg_011CdQ6hQAzFEoQpQAkq7UFK  "ifier chain needed:"

→ canonical text became '…no new mod\n\nifier chain needed:'.

Desktop renders the envelope stream directly and concatenates adjacent text rows, skipping invisible events, so it never saw the corrupted string. iOS holds both the live fragments and this canonical text and reconciles them; because the two no longer matched, its overlap heuristics fell through to existing + incoming and printed the message twice. Every previous attempt at this bug went into those client heuristics, which is why it kept coming back.

A second defect surfaced while testing: whitespace-only fragments were dropped outright, so "chain" + " " + "needed" rebuilt as "chainneeded", and markdown hard breaks (" \n") vanished.

Measured over the last 60 production transcripts: 1,126 mid-word splices and 4,181 dropped whitespace fragments.

The fix

Identity is now read at two granularities, and that split is the whole design:

  • key groups fragments into an entrymessageId when present, else the turn. It deliberately matches the identity clients key bubbles on (iOS collapses a text event onto its messageId). Grouping more finely than a client does makes the client silently concatenate two entries.
  • block decides verbatim continuation — the messageId/itemId pair, tracked per entry. Runtimes disagree about which field is finer-grained (Claude sets only messageId; Codex sets messageId to a per-turn UUID and itemId to the provider message id), so neither alone can be believed. While the pair holds, fragments concatenate verbatim whatever interleaves; when the provider advances its finer id, the entry takes a paragraph break.

isTranscriptContentEvent is an allowlist of content types on purpose: a future event type defaults to "does not break the run", which at worst drops a paragraph break, whereas the inverse default corrupts words.

On the client, mergeWorkAssistantText holds the matching rule — a whole-message rendition may replace the accumulation or be dropped, but is never concatenated onto it, and two renditions differing only in whitespace never duplicate.

The logic moved out of agentChatService.ts into chatTranscriptEntries.ts so it is unit-testable.

Verification

  • 5,796 identified streams across 60 production transcripts rebuild verbatim — zero mismatches
  • Entry grouping is identical to main (5591 → 5594; all three extra entries are the intended clear-on-user-message hardening, matching exactly three recurring turn-keyed streams)
  • Mid-word splices 1,126 → 0 (the 3 the detector flags are real boundaries whose next word starts lowercase, e.g. "· ACTIVE\n\nclean")
  • Desktop chat suite 40 files / 1,133 tests · shard 1/8 · iOS target 894 tests · typecheck, lint, build clean
  • A property test splits a message at random points, interleaves chrome, and asserts byte-exact rebuild

No data migration: the stored JSONL fragments were never corrupted, only the rebuild, so the fix applies retroactively to every existing transcript on read.

Follow-ups (deliberately not in this PR)

  • Emit a per-block itemId on Claude text events (the thinking emitter already does) — removes the last case where two text blocks in one message lose their paragraph break
  • iOS: several repair heuristics are now provably dead, workStreamingTextByCollapsingRepeatedTailReplay first — it deletes legitimately repeated text on every fragment
  • onEntrySourceOffset builds a parallel array whose alignment is contractual but untyped

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved chat transcript rendering to prevent duplicate assistant text when live fragments overlap with finalized content.
    • Refined transcript assembly to preserve whitespace, markdown hard breaks, and correct paragraph/run boundaries.
    • Avoided cross-contamination by respecting session scope and preserving message/item/turn identity during reconstruction.
  • Tests
    • Added coverage for transcript entry construction across boundary, interleaving, unknown-event, and whitespace/merge invariants.
    • Added iOS tests validating assistant merge behavior and single-rendition output for mixed canonical/live envelopes.

Greptile Summary

The PR changes canonical transcript reconstruction and adds focused regression coverage.

  • Extracts transcript-entry construction into a dedicated module.
  • Rebuilds identified assistant streams verbatim across interleaved events while preserving whitespace-only deltas.
  • Adds desktop transcript reconstruction tests and an iOS canonical/live reconciliation test.
  • Documents canonical transcript identity and merge behavior.

Confidence Score: 4/5

The PR is not yet safe to merge because iOS can still discard or replace legitimately repeated sequenced live deltas.

The host-side reconstruction fixes canonical/live disagreement, but iOS continues applying canonical-replay heuristics to live provider fragments, leaving the previously reported repeated-output loss reachable.

Files Needing Attention: apps/ios/ADETests/ADETests.swift and apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex attempted to validate a focused XCTest fixture that constructs two sequenced live assistant envelopes for the same turn and item to reproduce a legitimately repeated delta, but the test could not start because xcodebuild is missing on the Linux host.
  • T-Rex attempted to run the direct Swift harness for the same fixture, but the swift command is unavailable on this host.
  • A command-availability check showed that xcodebuild and swift are both absent on the environment.
  • T-Rex ran the desktop test command for chatTranscriptEntries with verbose output; the command exited with code 0 and Vitest reported 308 ms.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
apps/desktop/src/main/services/chat/chatTranscriptEntries.ts Extracts canonical transcript assembly and preserves identified text fragments verbatim across interleaved envelopes.
apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts Adds broad regression coverage for stream identity, boundaries, interleaving, whitespace, and byte-exact reconstruction.
apps/desktop/src/main/services/chat/agentChatService.ts Replaces the inline transcript builder with the extracted implementation.
apps/ios/ADETests/ADETests.swift Adds canonical/live suffix reconciliation coverage, but does not cover the previously reported repeated-live-delta path that remains subject to replay heuristics.
docs/features/chat/transcript-and-turns.md Documents canonical transcript reconstruction and its renderer-agreement invariant.

Sequence Diagram

sequenceDiagram
  participant Provider
  participant Brain as Desktop brain
  participant Canonical as chat.getTranscript
  participant iOS
  Provider->>Brain: Sequenced assistant text fragments
  Brain->>Canonical: Rebuild identified stream verbatim
  Canonical->>iOS: Sequence-less canonical rendition
  Brain->>iOS: Sequenced live fragments
  iOS->>iOS: Reconcile canonical and live text
  Note over iOS: Live fragments still pass through replay heuristics
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/ios/ADETests/ADETests.swift:12963-12968
**Live replay still drops deltas**

When a sequenced live assistant delta legitimately repeats an earlier prefix or sufficiently long substring of the accumulated message, the existing replay heuristics still transform or discard that delta because the new test covers only canonical-plus-suffix reconciliation, causing repeated provider output to remain missing from the iOS message.

Reviews (5): Last reviewed commit: "Delete the client-side rendition guard" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

arul28 and others added 4 commits July 26, 2026 01:56
`chat.getTranscript` rebuilds an assistant message from its streamed `text`
fragments. It decided two fragments were contiguous only when the immediately
preceding envelope was the same message, and joined them with "\n\n" otherwise.
Ephemeral chrome the renderers never draw — activity hints, subagent progress,
token usage — sat between deltas constantly, so the separator landed inside
words: "no new mod\n\nifier chain needed:".

Desktop never showed it because it renders the envelope stream directly and
concatenates adjacent text rows. iOS holds both the live fragments and this
canonical text and reconciles them, so the mismatch defeated its overlap
heuristics and it fell back to concatenation — rendering the message twice.

Across the last 60 production transcripts this produced 1101 mid-word splices,
and a second bug dropped 4181 whitespace-only fragments (word gaps and markdown
hard breaks), gluing words together.

The rebuild now guarantees one invariant: canonical text is the verbatim
concatenation of a message's fragments.

- Fragments sharing a provider messageId always concatenate. The event carries
  no block-level identity, so ADE cannot tell "new paragraph" from "next delta"
  and must not guess.
- Only rendered content ends an assistant run. `isTranscriptContentEvent` is an
  allowlist of content types, so a future event type defaults to not breaking a
  run — losing a paragraph break rather than corrupting a word.
- A whitespace-only fragment is a real delta and is dropped only when there is
  no run for it to continue.

Extracted to chatTranscriptEntries.ts with unit tests, including a property test
that splits a message at random points and asserts byte-exact rebuild. Verified
against 4668 message-keyed entries in 60 production transcripts: zero mismatches.

On iOS, `mergeWorkAssistantText` adds the matching guard — an envelope with no
sequence came from chat.getTranscript and is a complete message, so it may
replace the accumulated text but is never concatenated onto it.

Co-Authored-By: Claude <noreply@anthropic.com>
The first pass keyed assistant streams on messageId alone and treated every
message-keyed fragment as verbatim. Both were wrong:

- Codex inverts the id fields — messageId is a per-turn UUID and itemId is the
  provider message id — so 17 messageIds in the last 60 transcripts spanned two
  itemIds and would have concatenated two distinct messages. Identity is now the
  messageId/itemId pair, joined with a NUL so no id content can forge a
  collision.
- The content allowlist called todo_update, the subagent_* cards and done
  "invisible chrome". They render their own transcript rows, so a turn-keyed run
  resuming after one would have run on from the previous message with no break.
  They are now content; delegation_state and transcript_retraction are not,
  since neither draws a row.
- A user message now clears the open drafts, so a recurring key cannot glue text
  across a user turn and file it at the pre-user-message position.

The paragraph break between two text blocks of one message is still lost, since
no runtime marks content blocks. Measured over the same 60 transcripts that
costs ~0 real breaks — the model's own deltas already carry the newlines —
against 300 joins that would otherwise split mid-word, so verbatim stays the
safe side. Noted in the module with the per-block itemId that would remove the
trade-off.

Also from review: collapse the paired contiguity state into one openStreamKey,
return identity as data instead of re-parsing the key prefix, drop the boolean
parameter that switched appendFragment's meaning, import the shared
AgentChatTranscriptEntry instead of redeclaring it, and keep test timestamps
valid past the hundredth envelope.

A literal NUL had been written into the source rather than an escape, which made
git treat the module as binary and left it with no reviewable diff. It is now
written as an escape, with a test pinning the separator.

Verified: 5796 identified streams across 60 production transcripts rebuild
verbatim, zero mismatches; 39 chat test files, 1133 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
The user-message case asserted the same role ordering as the recurring-key test
while covering strictly less, so the stronger one stands alone.

Co-Authored-By: Claude <noreply@anthropic.com>
Keying entries on the messageId/itemId pair split Codex messages into separate
transcript entries, but iOS collapses a text event onto its messageId
(workAssistantMessageStableId) — so on history load it keyed both entries the
same and concatenated them, gluing two messages into one paragraph.

Identity is now read at two granularities. `key` groups fragments into an entry
and matches what clients key bubbles on: messageId when present, else the turn.
`block` — the messageId/itemId pair — decides only whether text continues
verbatim. While the pair holds, fragments are one message's deltas and join with
no separator whatever interleaves; when the provider advances its finer id, the
entry takes a paragraph break. The block is tracked per entry so a concurrent
message's fragments landing in between cannot fake a boundary.

Measured against main over the last 60 production transcripts: entry grouping is
identical (5591 vs 5594 entries, and all three extra entries are the intended
clear-on-user-message hardening — exactly three turn-keyed streams recur across
a user turn in one session), while mid-word splices go 1126 to 0. The three
flagged by the detector are real message boundaries whose next word starts
lowercase ("- ACTIVE\n\nclean").

Because the canonical text now takes that paragraph break where the live stream
does not, the two renditions of a multi-item message differ by exactly that
whitespace and neither is a delta of the other. mergeWorkAssistantText gained
the matching rule: when a merge falls through to blind concatenation and the two
sides are the same text once whitespace is ignored, keep the longer one in
either direction. Without it iOS rendered such messages twice — the same failure
this lane set out to fix, reintroduced from the other side.

Verified: desktop chat suite 40 files / 1133 tests, iOS target 894 tests, both
green; typecheck and lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 26, 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 Jul 26, 2026 7:27pm

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Transcript assembly and rendering

Layer / File(s) Summary
Desktop transcript module and service wiring
apps/desktop/src/main/services/chat/chatTranscriptEntries.ts, apps/desktop/src/main/services/chat/agentChatService.ts
Moves transcript entry construction into a dedicated module with identity-aware buffering, content boundaries, whitespace handling, and source-offset support.
Desktop transcript behavior coverage
apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts
Adds coverage for interleaved events, identity boundaries, concurrent messages, whitespace, hard breaks, session filtering, and exact text reconstruction.
iOS assistant text reconciliation
apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift, apps/ios/ADETests/ADETests.swift
Adds live-fragment-aware merging to avoid duplicate canonical assistant text while preserving valid streaming deltas, with XCTest coverage for reconciliation cases.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • arul28/ADE#80: Changes the same desktop assistant transcript assembly path and text-event processing flow.

Suggested labels: desktop, ios, docs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main fix for assistant text being truncated and duplicated in mobile chat.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ade/start-skill-screenshot-ade-mobile-340acbb2

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 changed the title mobile-chat-text-cut-and-repeated -> Primary Fix assistant text cut mid-word and repeated in mobile chat Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts (2)

227-231: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

LCG multiply overflows float64 precision, degrading split variety.

seed * 1103515245 reaches ~2^61 for a 31-bit seed, so the low bits are lost to float rounding before & 0x7fffffff truncates. The generator stays deterministic, but the split widths it produces are far less varied than intended — which is the entire point of the 200 trials. Math.imul keeps exact 32-bit semantics.

♻️ Proposed fix
     const nextInt = (bound: number): number => {
-      seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+      seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
       return seed % bound;
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts` around
lines 227 - 231, Update the nextInt linear-congruential generator to use
Math.imul for the seed multiplication, preserving exact 32-bit integer semantics
before applying the existing mask and modulo operation. Keep the deterministic
seed, constants, and bound behavior unchanged.

213-252: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

No coverage for the TranscriptEntriesOptions callbacks.

agentChatService.ts (lines 8357-8364) relies on onEntrySourceOffset firing exactly once per emitted entry, in order, so convertedEntryOffsets stays index-aligned with converted. That contract is subtle — the callback fires inside the final flatMap and is skipped for entries dropped as empty — and nothing here exercises it. A test covering a stream with a dropped empty entry plus a flushed anonymous draft would lock the alignment down.

Want me to draft it?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts` around
lines 213 - 252, Extend the transcript entries tests to cover the
TranscriptEntriesOptions onEntrySourceOffset callback contract: use a stream
containing a dropped empty entry and a flushed anonymous draft, then assert the
callback fires exactly once for each emitted entry, in emitted order, excluding
the dropped entry. Anchor the test around transcriptEntriesFromEnvelopes and
verify the reported offsets remain aligned with the resulting entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts`:
- Around line 227-231: Update the nextInt linear-congruential generator to use
Math.imul for the seed multiplication, preserving exact 32-bit integer semantics
before applying the existing mask and modulo operation. Keep the deterministic
seed, constants, and bound behavior unchanged.
- Around line 213-252: Extend the transcript entries tests to cover the
TranscriptEntriesOptions onEntrySourceOffset callback contract: use a stream
containing a dropped empty entry and a flushed anonymous draft, then assert the
callback fires exactly once for each emitted entry, in emitted order, excluding
the dropped entry. Anchor the test around transcriptEntriesFromEnvelopes and
verify the reported offsets remain aligned with the resulting entries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e3c665f-dd7b-42c3-a316-4616d69b596a

📥 Commits

Reviewing files that changed from the base of the PR and between d27a227 and 6bb7257.

⛔ Files ignored due to path filters (3)
  • docs/features/chat/README.md is excluded by !docs/**
  • docs/features/chat/transcript-and-turns.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/ios-companion.md is excluded by !docs/**
📒 Files selected for processing (5)
  • apps/desktop/src/main/services/chat/agentChatService.ts
  • apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts
  • apps/desktop/src/main/services/chat/chatTranscriptEntries.ts
  • apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
  • apps/ios/ADETests/ADETests.swift

Comment thread apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift Outdated
Greptile flagged that testing containment anywhere lets a live delta which
repeats a long phrase from earlier in the same message look like a second
rendition of the whole thing, so it would be discarded.

Two renditions of one message always share a beginning, whereas a repeated
phrase matches somewhere in the middle, so the check is now a prefix test in
either direction. That keeps the canonical-versus-live case it exists for —
including a canonical row reconciled against a partial live accumulation — while
a mid-message repeat falls through and appends.

Worth recording for anyone reading the thread: the discard Greptile describes is
real but predates this PR. mergeWorkStreamingReplayText already drops a delta
that replays 24 or more characters of earlier text, which is what a test written
against that scenario actually proves. This change narrows the newly added path
rather than introducing the behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
@arul28

arul28 commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Addressed the Greptile P1 on workAssistantTextRenditionsAgreeIgnoringWhitespace.

Change: the whitespace-insensitive rendition check is now anchored at the start of both texts (hasPrefix in either direction) instead of matching containment anywhere. Two renditions of one message always share a beginning; a live delta that merely repeats a phrase from earlier in the message matches mid-text, so it now falls through and appends.

One correction for the record. I tried to pin the reported behavior with a test and it failed — revealing the discard is real but predates this PR. mergeWorkStreamingReplayText already drops a delta that replays ≥24 characters of earlier text, so a test written directly against that scenario proves the pre-existing heuristic, not the new code. This change narrows the newly added path rather than introducing the behavior. The follow-up lane noted in the PR description (retiring the now-dead replay heuristics) is where that older discard should be revisited.

Verified: the five mergeWorkAssistantText / canonical-vs-live tests pass; full iOS target was green at 894 tests before this narrowing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83b82cff32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +52
"subagent_progress",
"subagent_result",
"subagent_started",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat dotted subagent lifecycle events as content boundaries

When turn-keyed text lacks both messageId and itemId, a canonical subagent.started, subagent.progress, or subagent.completed event between fragments leaves openStreamKey unchanged, so the fragments are concatenated as one uninterrupted message. These canonical dotted events are normalized into visible subagent cards by appendCollapsedChatTranscriptEvent in chatTranscriptRows.ts, meaning the live renderer keeps the surrounding text runs separate while chat.getTranscript now joins them; this can recreate a canonical/live rendition mismatch for providers that omit message identity. Add the dotted lifecycle variants to this content-boundary allowlist alongside the legacy subagent_* variants.

Useful? React with 👍 / 👎.

Comment thread apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift Outdated
Greptile flagged twice that the whitespace-insensitive rendition check discards
legitimate live deltas — first for containment anywhere, then, after narrowing
it to a shared opening, for a delta repeating the message's opening. Both are
right, and a third narrowing would have been the wrong answer: the check only
existed to paper over a divergence this branch introduced.

Canonical text had started taking a paragraph break where the provider advances
its finer id inside one messageId. No renderer does that. Desktop's
shouldMergeTextRows compares messageId, ignores itemId, and joins rows with a
bare `${previous}${next}`; iOS collapses a text event onto its messageId. So
canonical became the only surface that separated there, the two renditions
stopped being deltas of each other, and iOS concatenated them — the same
duplicate render this branch set out to fix.

Identified fragments now concatenate verbatim, exactly as the renderer joins
them, and the invariant is stated as the stronger property it always should have
been: canonical text is byte-identical to what a renderer draws from the same
envelopes. Verified across 4838 messages in the last 60 production transcripts —
zero mismatches against a renderer-faithful reconstruction.

That removes the reason for the client guard, so mergeWorkAssistantText loses
the whitespace branch and both its helpers, and the tests that constrained them
go with it. A heuristic deleted rather than a fourth one added.

Also from review (Codex P2): the canonical dotted subagent lifecycle events —
subagent.started, subagent.progress, subagent.completed — render the same
visible cards as the underscore variants and now sit alongside them in the
content allowlist, so a turn-keyed run split by one keeps its paragraph break.

Verified: desktop chat 39 files / 1132 tests, iOS target 893 tests, typecheck
and lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@arul28

arul28 commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Both Greptile P1s and the Codex P2 are addressed — but the first two by removing the code they pointed at rather than narrowing it a third time.

The check they flagged only existed to compensate for a divergence this branch had introduced. Canonical text had started taking a paragraph break where the provider advances its finer id inside one messageId. No renderer does that: shouldMergeTextRows compares messageId, ignores itemId, and joins rows with a bare ${previous}${next}; iOS collapses a text event onto its messageId. Canonical became the only surface separating there, so the two renditions stopped being deltas of each other and the client concatenated them — the exact duplicate render this PR set out to fix, reintroduced from the other side.

Fixed at the source: identified fragments concatenate verbatim, matching the renderer. The invariant is now stated as the stronger property — canonical text is byte-identical to what a renderer draws from the same envelopes — and checked against 4,838 messages across 60 production transcripts, zero mismatches.

Consequently mergeWorkAssistantText loses the whitespace-insensitive branch and both helpers, plus the tests that constrained them. Net: one heuristic deleted, not a fourth added, which is the right direction for that file.

Codex P2 appliedsubagent.started / .progress / .completed render the same cards as the underscore variants and are now in the content allowlist beside them.

Verified: desktop chat 39 files / 1,132 tests · iOS target 893 tests · typecheck + lint clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84ded4dfc0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +94 to +96
const key = messageId ? `message:${messageId}` : turnId ? `turn:${turnId}` : null;
if (!key) return null;
return { key, identified: Boolean(messageId || itemId) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Key item-only streams by item identity

When a provider omits messageId but supplies itemId, two different items in the same turn receive the same turn: key while identified remains true. If rendered content occurs between them, contiguous is therefore forced true and canonical text concatenates the separate messages without a boundary. Desktop's turnAndItemMatch rejects differing item IDs, and iOS also uses itemId as its fallback stable identity, so providers using this supported fallback can again receive canonical text that disagrees with both renderers.

Useful? React with 👍 / 👎.

* live emission granularity. It intentionally defaults the other way (unknown
* types flush) because a wrong guess there only costs batching latency.
*/
const TRANSCRIPT_CONTENT_EVENT_TYPES: ReadonlySet<string> = new Set<AgentChatEvent["type"]>([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add every rendered event to the content allowlist

The allowlist omits known events that create transcript rows, including turn_diagnostics, turn_recovery, codex_turn_recovery, and turn_health (see appendCollapsedChatTranscriptEvent in chatTranscriptRows.ts). For turn-keyed text without message/item identity on either side of one of these events, isTranscriptContentEvent returns false, leaves openStreamKey active, and concatenates the text as one uninterrupted run even though the renderer inserts a row and separates the text. This breaks the canonical/rendered reconciliation invariant for those transcripts.

Useful? React with 👍 / 👎.

Comment on lines +337 to +342
let merged = mergeWorkStreamingText(existing, incoming)
guard !existing.isEmpty,
!incoming.isEmpty,
merged == existing + incoming
else { return merged }
guard !incomingIsLiveFragment else { return merged }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Live replay heuristics discard deltas

When a sequenced assistant delta repeats an earlier opening or sufficiently long substring, mergeWorkStreamingText applies its prefix and replay-collapse heuristics before incomingIsLiveFragment is checked. Those branches return transformed text rather than existing + incoming, so the guard never preserves the live delta, causing legitimately repeated output to disappear or the accumulated iOS message to be truncated.

Artifacts

Repro: faithful executable port of the targeted Swift merge branches

  • Evidence file captured while the check ran.

Repro: verbose trace showing both live deltas discarded and native tool availability

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
Line: 337-342

Comment:
**Live replay heuristics discard deltas**

When a sequenced assistant delta repeats an earlier opening or sufficiently long substring, `mergeWorkStreamingText` applies its prefix and replay-collapse heuristics before `incomingIsLiveFragment` is checked. Those branches return transformed text rather than `existing + incoming`, so the guard never preserves the live delta, causing legitimately repeated output to disappear or the accumulated iOS message to be truncated.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Two Codex P2s, both real violations of the invariant this branch just committed
to.

A provider that omits messageId but supplies itemId was landing every item in a
turn under one `turn:` key, with `identified` true, so rendered content between
two items could not break them apart and canonical concatenated two separate
messages. Both renderers disagree: iOS keys the bubble on itemId when messageId
is absent (workAssistantMessageStableId) and desktop's turnAndItemMatch refuses
to merge rows whose item ids differ. Item-only streams now key on itemId, which
is exactly what those clients do.

The content allowlist was also missing turn_diagnostics, turn_health,
turn_recovery, and codex_turn_recovery. All four append a visible row in
appendCollapsedChatTranscriptEvent, so turn-keyed text on either side of one has
to keep its paragraph break.

Re-verified against the last 60 production transcripts, now reconstructing the
renderer identity including the itemId fallback: 5912 messages, zero mismatches.
Desktop chat 39 files / 1133 tests; no Swift touched this round.

Co-Authored-By: Claude <noreply@anthropic.com>
@arul28

arul28 commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Both P2s applied — each was a real violation of the invariant this PR commits to.

Item-only streams now key on itemId. You're right that turn: keying forced identified true across two separate items; iOS keys the bubble on itemId when messageId is absent (workAssistantMessageStableId) and desktop's turnAndItemMatch refuses to merge rows with differing item ids, so canonical was disagreeing with both. There are 1,061 such events in the last 60 transcripts, so this was live.

Allowlist gained turn_diagnostics, turn_health, turn_recovery, codex_turn_recovery — all four append a row via appendCollapsedChatTranscriptEvent.

Re-verified with the renderer reconstruction extended to the itemId fallback: 5,912 messages, zero mismatches (up from 4,838 now that item-only streams are covered).


@greptile-apps on the P1 at WorkErrorAndMessageHelpers.swift:342 — this one I'm deliberately not fixing here, and I want to be precise about why rather than wave it off.

You're describing real behavior: mergeWorkStreamingText's prefix and replay-collapse branches run before incomingIsLiveFragment is consulted, so a delta replaying earlier text is transformed rather than appended. But that is pre-existingmergeWorkStreamingText is untouched by this PR, and I confirmed the mechanism empirically last iteration: a test written against exactly that scenario failed because mergeWorkStreamingReplayText discards the delta, entirely independent of anything here.

That function is one of seven repair heuristics iOS accumulated to compensate for the host bug this PR fixes. Several are now provably dead, and workStreamingTextByCollapsingRepeatedTailReplay — applied unconditionally to every fragment — is the most aggressive. Retiring them needs its own lane with its own verification, which the PR description already scopes. Changing their ordering here, in a PR whose point is that canonical and rendered text must agree, would be the same scope creep that produced the pile.

CI is green at 34/34.

@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

The scoping argument holds. mergeWorkStreamingText and its replay-collapse heuristics are entirely pre-existing — nothing in this diff touches them — and their reason for existing (compensating for the corrupted canonical text) is now addressed at the root. The ordering concern is real but it's a concern about code that predates this PR.

What this PR adds on the iOS side is the !incomingIsLiveFragment guard in mergeWorkAssistantText, which is the correct backstop for the canonical-row-vs-live-fragment case. That guard does exactly what it says: a whole rendition never concatenates onto a live accumulation. The pre-existing replay heuristics operate within mergeWorkStreamingText, which is called first, but their trigger condition — a live delta that replays a long enough prefix of existing text — will be significantly rarer now that canonical text is the verbatim concatenation of the actual fragments. In practice, the ordering only matters when a live delta happens to repeat ≥24 characters of the existing accumulation, which is a legitimate output pattern, not a corruption artifact.

Accepted. The one thing worth doing before merge: make sure workStreamingTextByCollapsingRepeatedTailReplay and the other provably-dead heuristics are tracked somewhere (an issue or a // TODO(follow-up): comment pointing at the follow-up lane) so they don't quietly outlast the memory of why they existed. The PR description mentions it but a living pointer in the code would survive a git blame lookup in six months.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0319cc39d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +342 to +343
guard !incomingIsLiveFragment else { return merged }
return incoming.count >= existing.count ? incoming : existing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve canonical chunks across transcript page boundaries

When one identified assistant message spans two byte-cursor transcript pages, both page entries have sequence == nil but neither is a whole message: getChatTranscriptPage can stop at an envelope boundary and iOS later prepends the older chunk. This branch therefore keeps only the longer chunk instead of concatenating the disjoint older and newer chunks, silently dropping part of the assistant response when canonical paging is used (for example with a host lacking chat.getChatEventHistoryPage or when that action fails). The merge needs to distinguish separate canonical page chunks from overlapping whole-message renditions rather than treating every sequence-less envelope as complete.

Useful? React with 👍 / 👎.

* live emission granularity. It intentionally defaults the other way (unknown
* types flush) because a wrong guess there only costs batching latency.
*/
const TRANSCRIPT_CONTENT_EVENT_TYPES: ReadonlySet<string> = new Set<AgentChatEvent["type"]>([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat visible compaction events as transcript boundaries

For existing transcripts containing turn-keyed text around a context_compact event, this allowlist now treats the compaction event as invisible chrome because neither context_compact nor codex_context_compaction is included. Desktop's appendCollapsedChatTranscriptEvent instead routes those events through isContextCompactionChatEvent and appends a visible row, so openStreamKey must be cleared; otherwise the canonical reconstruction concatenates the text on both sides verbatim rather than preserving the separate rendered messages. This regresses the prior behavior, where every non-text event ended the run.

Useful? React with 👍 / 👎.

Comment on lines +337 to +342
let merged = mergeWorkStreamingText(existing, incoming)
guard !existing.isEmpty,
!incoming.isEmpty,
merged == existing + incoming
else { return merged }
guard !incomingIsLiveFragment else { return merged }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Live replay heuristics discard deltas

When a sequenced live assistant delta repeats an earlier prefix, suffix, or sufficiently long substring of the accumulated message, mergeWorkStreamingText transforms or discards it before incomingIsLiveFragment is checked, causing legitimate repeated assistant output to remain missing on iOS.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
Line: 337-342

Comment:
**Live replay heuristics discard deltas**

When a sequenced live assistant delta repeats an earlier prefix, suffix, or sufficiently long substring of the accumulated message, `mergeWorkStreamingText` transforms or discards it before `incomingIsLiveFragment` is checked, causing legitimate repeated assistant output to remain missing on iOS.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Codex found that it loses data. When one assistant message spans two byte-cursor
transcript pages, both chunks arrive with sequence == nil and neither is whole,
so treating every sequence-less envelope as a complete message kept the longer
chunk and silently dropped the rest of the answer.

The guard existed to reconcile canonical text against the live stream when the
two disagreed. They no longer disagree: canonical text is byte-identical to what
a renderer draws from the same envelopes, verified across 5912 messages. So iOS
merges every assistant fragment through mergeWorkStreamingText again, exactly as
it did before this branch, and the tests that only constrained the guard go with
it.

testCanonicalAndLiveRowsForOneMessageRenderItOnce still passes without it, which
is the point: the host fix alone is what stops the message rendering twice.

That also answers the Greptile P1 about replay heuristics running ahead of the
live-fragment check — it described mergeWorkStreamingText, which this branch no
longer wraps or modifies. Retiring those heuristics stays scoped to its own lane.

Also from review (Codex P2): context_compact and codex_context_compaction append
a visible row through isContextCompactionChatEvent, so they belong in the content
allowlist; turn-keyed text on either side of a compaction keeps its break.

Verified: desktop chat 39 files / 1133 tests, typecheck clean. iOS 890 tests with
one pre-existing flake in unrelated lane-deletion ordering, which passes 3/3 in
isolation. The ade-cli CI failure on the previous commit was likewise a flake —
remoteLauncher asserts a spawn count under an 80ms wall-clock budget — and the
job passed on re-run.

Co-Authored-By: Claude <noreply@anthropic.com>
@arul28

arul28 commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

P1 (paged canonical chunks) — fixed by deleting the guard entirely. You're right that it loses data: two chunks of one message from getChatTranscriptPage both arrive with sequence == nil and neither is whole, so "keep the longer" drops the rest of the answer.

Rather than teach it to distinguish page chunks from whole messages, I removed it. It only ever existed to reconcile canonical text against the live stream when the two disagreed, and after the previous commit they don't — canonical is byte-identical to what a renderer draws, checked across 5,912 messages. iOS merges through mergeWorkStreamingText again, exactly as before this branch.

The evidence that this is right: testCanonicalAndLiveRowsForOneMessageRenderItOnce still passes without the guard. The host fix alone is what stops the message rendering twice.

P2 (compaction events) — applied. context_compact and codex_context_compaction both append a row via isContextCompactionChatEvent, so they're in the allowlist now.

@greptile-apps this also resolves your P1 at :342 — that comment describes mergeWorkStreamingText's internal ordering, which this branch no longer wraps or modifies. Retiring those replay heuristics remains its own lane, scoped in the PR description.


Two flakes seen, neither related to this diff:

  • test-ade-cliremoteLauncher asserts a spawn count under an 80ms wall-clock budget; passes locally and passed on re-run (34/34 green).
  • iOS testLaneDeleteBatchRunnerStartsTwoDeletesAtOnceAndPreservesOrder — lane-deletion ordering, untouched here; passes 3/3 in isolation.

Net for this PR the iOS side now has less code than it started with.

@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

The guard is gone — incomingIsLiveFragment no longer exists anywhere in the repo. That addresses the concern cleanly: rather than making the client guess whether a sequence-less envelope is a complete message or a paged chunk, you fixed the invariant at the host so the client never needs to distinguish the two.

The reasoning is sound:

  • The original guard failed P1 because getChatTranscriptPage splits messages across page boundaries, delivering two sequence-less chunks neither of which is "whole."
  • Removing the guard and letting both live and canonical fragments flow through mergeWorkStreamingText unchanged is safe because host canonical text is now byte-identical to the rendered output — there's nothing to reconcile.
  • testCanonicalAndLiveRowsForOneMessageRenderItOnce passing without the guard confirms the host fix alone stops the duplication.

P2 (compaction events in the allowlist) is straightforward and correct.

The approach is right. Good to go from my side.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fd778e759

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

| `apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts` | Unified project-wide slash command discovery. Merges commands from Claude, Codex, and Cursor discovery modules into a single deduplicated list, filtering `/login`. Used by the ADE Code TUI's `discoverProjectSlashCommands` so all providers see the same cross-provider command catalog. |
| `apps/desktop/src/main/services/chat/slashCommandPromptExpansion.ts` | Provider-routed slash command prompt expansion. Given a provider, cwd, and slash command input, resolves the command's markdown body into prompt text through the appropriate provider-specific resolution path (Claude, Codex, or Cursor). Built-in and runtime-registered commands are skipped so the SDK handles them natively. Consumed by `agentChatService` to expand slash commands before dispatch. |
| `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. |
| `apps/desktop/src/main/services/chat/chatTranscriptEntries.ts` | Flattens an envelope stream into the canonical role-tagged `AgentChatTranscriptEntry[]` behind `chat.getTranscript`, the cursor-paged chat-history reader, and the internal auto-title/handoff transcript reads. Its invariant is that a message's canonical text is the **verbatim** concatenation of its `text` fragments: fragments sharing provider identity (the `messageId`/`itemId` *pair* — Claude sets only `messageId`, Codex sets `messageId` per turn and `itemId` per message, so either alone merges distinct messages) concatenate untouched no matter what interleaves, and only `isTranscriptContentEvent` rows (a deliberate *allowlist* of rendered types) end a run, and only for fragments ADE cannot tie to a provider message. Whitespace-only fragments are real deltas and are dropped only when no run is open to continue. Inventing a `"\n\n"` here spliced separators mid-word and made clients that reconcile canonical text against the live stream (iOS, web) render a message twice. See [Transcript and turns](transcript-and-turns.md#canonical-assistant-text-fragile--read-before-editing). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align the documented stream identity with the implementation

This entry says continuation is keyed by the messageId/itemId pair and that either ID alone merges distinct messages, but assistantStream actually selects messageId alone whenever present, and the new tests intentionally concatenate fragments whose itemIds differ under one messageId. The more detailed transcript guide also documents message-level grouping. Because this README is presented as the invariant to preserve, it could lead a future maintainer to reintroduce pair-based grouping and the duplicate-render regression; describe the actual messageId, then itemId, then turn fallback policy instead.

Useful? React with 👍 / 👎.

Comment on lines +12963 to +12968
timestamp: "2026-07-26T01:00:01.000Z",
sequence: 42,
event: .assistantText(text: "ifier chain needed:", turnId: "turn-1", itemId: "msg-a")
),
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Live replay still drops deltas

When a sequenced live assistant delta legitimately repeats an earlier prefix or sufficiently long substring of the accumulated message, the existing replay heuristics still transform or discard that delta because the new test covers only canonical-plus-suffix reconciliation, causing repeated provider output to remain missing from the iOS message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/ADETests/ADETests.swift
Line: 12963-12968

Comment:
**Live replay still drops deltas**

When a sequenced live assistant delta legitimately repeats an earlier prefix or sufficiently long substring of the accumulated message, the existing replay heuristics still transform or discard that delta because the new test covers only canonical-plus-suffix reconciliation, causing repeated provider output to remain missing from the iOS message.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

@arul28
arul28 merged commit 554c499 into main Jul 26, 2026
34 checks passed
@arul28
arul28 deleted the ade/start-skill-screenshot-ade-mobile-340acbb2 branch July 26, 2026 19:35
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