Skip to content

fix(langgraph): stop streamed chunks being dropped by prefix-based dedupe#751

Merged
blove merged 9 commits into
mainfrom
fix/langgraph-delta-merge-identity
Jul 6, 2026
Merged

fix(langgraph): stop streamed chunks being dropped by prefix-based dedupe#751
blove merged 9 commits into
mainfrom
fix/langgraph-delta-merge-identity

Conversation

@blove

@blove blove commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Problem

Streaming an assistant message over the LangGraph transport intermittently lost characters mid-stream. Most visible with markdown tables: the wire delivered |--------------|--------|-------------| (48 pipes across the message) but the accumulated content rendered -----------------------------------| (35 pipes) — the table collapsed to raw text until the final message healed it.

Root cause (proven)

accumulateContent merged every incoming messages-tuple chunk with string-prefix heuristics. The guard existingText.startsWith(incomingText) → keep existing (a stale-duplicate guess) drops any delta that coincidentally prefixes the accumulated message. A table message starts with |, so every bare "|" token the model streams is silently swallowed. Proof: replaying a corrupted run's captured wire events through this exact algorithm reproduced the observed accumulation byte-identically (13 dropped chunks, every one the single character |); the same replay without the guard yields wire-exact text. Intermittency = tokenization luck (|--- fused tokens don't trip it; bare | tokens do).

Fix — reconcile by identity and declared event kind, never by comparing text to text

  • Tuple events (event.messageMetadata) are deltas → append unconditionally. Both prefix guards removed from the delta path (the other guard, incoming.startsWith(existing) → replace, is the same bug class: a "| Gem" delta onto "|" must give "|| Gem").
  • messages/partial + values-sync keep snapshot semantics (mutual-prefix reconcile — correct for message-so-far payloads, including lagging mid-run values state).
  • canonicalMessageIds backstop: the final-canonical reasoning+text replacement marks its message id; late deltas for a canonical id are ignored by id — the old guard's real purpose, now exact. Cleared per run and on thread switch.

Verification

  • TDD: 6 new bridge tests (2 red on the old code — pipe preservation + prefix-delta append — now green; snapshot + lagging-values regressions pinned). One pre-existing fixture ('hel''hello' cumulative resend) was corrected to a genuine delta — history shows it was synthetic from birth and encoded the buggy guard, not wire behavior.
  • Suites: langgraph 227/227, chat 916/916, ag-ui green; builds green; lint 0 errors.
  • Live gate (Chrome MCP, 3-layer capture: SDK events / accumulation signal / DOM): 6/6 table runs PASS — wire pipes == accumulated pipes on every run (48/48, 70/70, 48/48, 56/56, 35/35, 56/56), runs 3-6 byte-identical (accumulation === reconstructed wire text), zero table-collapse frames, zero divergences. Pre-fix baseline on the same harness: 48 vs 35.

Closes out the streaming-table arc (partial-markdown 0.5.2/0.5.3, #743/#744): this was the last remaining cause of mid-stream table corruption.

🤖 Generated with Claude Code

blove and others added 4 commits July 6, 2026 07:55
Root cause of intermittent mid-stream character loss (tables collapsing to
raw pipes): accumulateContent's string-prefix dedupe guard drops any delta
that coincidentally prefixes the accumulated message — every bare "|" token
in a table. Proven by byte-identical replay of captured wire events. Fix:
merge by declared event kind (tuple=delta → unconditional append;
messages/partial=snapshot → prefix reconcile) with an identity-based
canonical-id backstop replacing the text heuristic. TDD via the existing
bridge spec harness + a live 3-layer Chrome MCP gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 tasks: red tests via the existing bridge spec harness, mode-aware merge
(delta=append, snapshot=prefix reconcile) + canonical-id backstop, cross-
suite gates, a REQUIRED live Chrome MCP gate (wire==accumulation across ≥6
table runs, temp recorder reverted), PR + merge. Spec refined: values-sync
keeps snapshot semantics (lagging mid-run state must not rewind or mark
canonical); finality is marked only by the canonical reasoning+text shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing chunks

mergeMessages/accumulateContent in stream-manager.bridge.ts merges
messages-tuple chunks via string-prefix heuristics. The guard
`existingText.startsWith(incomingText) -> keep existing` silently
drops any delta that coincidentally prefixes the accumulated message,
which corrupts streamed markdown tables (every bare "|" token vanishes).

Pins the correct identity-based behavior with 6 cases: every delta
chunk must be preserved (including prefix-coinciding ones), canonical
reasoning+text arrays still replace + block stragglers, a new run's
messages accumulate independently, and messages/partial + values-sync
snapshot semantics are unaffected (regression baselines).
…t prefixes

Tuple events are deltas: append unconditionally. The old prefix-based dedupe
guard dropped any delta that coincidentally prefixed the accumulated message —
every bare pipe token in a streamed markdown table — silently corrupting
content mid-stream (wire had 48 pipes, accumulation 35; replaying captured
events through the guard reproduced the corruption byte-identically).
Snapshot payloads (messages/partial, values-sync) keep prefix reconciliation.
Stale post-final stragglers are now suppressed by canonical message id, not
by comparing text to text.

Also fixes a pre-existing bridge spec fixture ("merges message tuple events
into the existing transcript") that modeled tuple/messages events resending
the whole message-so-far ('hel' then 'hello') rather than genuine deltas —
that shape only made sense under the old prefix-reconciliation guard the
design explicitly removes from the delta path. Updated the fixture to emit
real incremental deltas ('hel' then 'lo'), preserving the test's original
intent (merge-by-id into one transcript entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment Jul 6, 2026 6:40pm

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 4m 2s —— View job


Code Review

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read the implementation diff (stream-manager.bridge.ts)
  • Read the test diff (stream-manager.bridge.spec.ts)
  • Analyze correctness, type safety, edge cases
  • Post inline comments and top-level summary

The fix is sound and the root cause analysis is correct. The prefix-comparison heuristic in accumulateContent was a correctness hazard from the start — using text shape to infer event semantics is fragile when any legitimate token can coincide with the accumulated prefix. Replacing it with declared-event-kind semantics is the right architecture.

No blocking issues. A few minor things below.

Orphaned JSDoc block

accumulateContent's JSDoc (lines 1155–1179) doesn't directly precede the function it documents — isFinalCanonicalReasoningContent is sandwiched between the doc and accumulateContent. IDE tooling won't associate the doc with the function. Fix this →

Double invocation of isFinalCanonicalReasoningContent

Inside the merge loop, isFinalCanonicalReasoningContent(incomingRaw['content']) is called twice: once inside accumulateContent (to choose the return value) and once back in mergeMessages to add to canonicalMessageIds. The second call could use the return value from accumulateContent as a signal, or accumulateContent could return a tagged result. Not harmful, but the duplicate work is mildly fragile if the heuristic ever changes. Fix this →

Test helper uses as any instead of satisfies

The tupleEvent helper in the new test suite casts with as any:

} as any;

The existing test suite prefers satisfies StreamEvent for type-checked fixtures. Not a correctness bug, but diverges from the established pattern.

Test timing

All 6 new tests use await new Promise(r => setTimeout(r, 10)) to let async work settle. This is pre-existing in the test file and works in CI, but it's an inherently racy pattern. Not a regression — just worth noting if these tests flake on a slow runner.

What's right

  • The invariant is clear and correct: event kind governs merge semantics, text never does. Delta path appends unconditionally; snapshot path uses prefix comparison; canonical detection fires in both modes and installs an identity block for future stragglers.
  • canonicalMessageIds is cleared in both resetThreadState() (thread switch) and at the top of runStream() (new run) — no leaked state across runs or threads.
  • The corrected 'hel'/'lo' fixture is the right call — 'hel' + 'hello' (cumulative resend) was encoding the buggy guard, not real wire behavior.
  • The 6-test suite (2 red on old code, regression pins for snapshot + lagging-values, canonical blocking, cross-run reset) gives good coverage of the boundary conditions.

incomingRaw['content'],
mode,
);
if (targetId && isFinalCanonicalReasoningContent(incomingRaw['content'])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isFinalCanonicalReasoningContent(incomingRaw['content']) is evaluated here and also inside accumulateContent on line ~1213. Both calls parse the same array. Minor DRY concern — if the heuristic ever becomes more expensive or its definition changes, one of the two call sites is easy to miss. Consider computing it once before the accumulateContent call and threading the result in (e.g. a dedicated flag parameter or a small return-tagged struct).

return {
type: 'messages',
data: [{ id, type: 'ai', content }, META],
messageMetadata: META,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

function tupleEvent(id: string, content: unknown) {
  return {
    type: 'messages',
    data: [{ id, type: 'ai', content }, META],
    messageMetadata: META,
  } as any;
}

The rest of the test file uses satisfies StreamEvent for typed fixtures. as any works here (and normalizeMessages handles the data array path correctly), but switching to satisfies would keep the pattern consistent and catch future StreamEvent shape changes at compile time.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 3m 40s —— View job


Code Review

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read the implementation diff (stream-manager.bridge.ts)
  • Read the test diff (stream-manager.bridge.spec.ts)
  • Analyze correctness, type safety, edge cases
  • Post inline comments and top-level summary

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 3m 55s —— View job


Code Review

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read the implementation diff (stream-manager.bridge.ts)
  • Read the test diff (stream-manager.bridge.spec.ts)
  • Analyze correctness, type safety, edge cases
  • Post inline comments and top-level summary

The fix is correct. Replacing text-comparison heuristics with declared-event-kind semantics is the right call — using string shapes to infer event semantics is fundamentally fragile, and this PR proves it with a byte-identical reproduction. The MergeMode split is clean and the invariant is easy to reason about going forward.

No blocking issues. Three minor things below (two already flagged by the prior review, still present).

Orphaned JSDoc (minor)

accumulateContent's JSDoc no longer immediately precedes accumulateContent — the newly added isFinalCanonicalReasoningContent (with its own JSDoc) was inserted between them. IDE tooling attaches a JSDoc to the next declaration it precedes, so the accumulateContent doc currently documents isFinalCanonicalReasoningContent instead. See inline comment on line 1201.

Double isFinalCanonicalReasoningContent call (minor)

isFinalCanonicalReasoningContent(incomingRaw['content']) is evaluated twice per merge: once inside accumulateContent to pick the return value, and once back in mergeMessages (line 1104) to decide whether to mark the id canonical. Both traverse the same array. Not harmful now, but two call sites diverging when the heuristic evolves is a real maintenance risk. See inline comment on line 1104.

tupleEvent uses as any (minor)

The new test helper casts with as any while the rest of the suite uses satisfies StreamEvent. Worth noting: tupleEvent uses the SDK's data: [msg, META] wire format while the existing fixtures use the already-processed messages: [...] property — both are handled by normalizeMessages, so test coverage is equivalent, but the as any means the compiler won't catch if either format diverges from StreamEvent's shape. See inline comment on line 1479 of the spec.

What's right

  • The invariant is clear: event kind governs merge semantics, text never does. Delta path appends unconditionally; snapshot path uses prefix comparison; canonical detection fires in both modes and installs an identity block for future stragglers.
  • canonicalMessageIds cleared in both resetThreadState() and at the top of runStream() — no leaked state across runs or threads.
  • The MarkdownTableRowComponent change (direct NgComponentOutlet instead of <chat-md-children>) is correct — it fixes invalid DOM structure (<div> wrapper inside <tr>) that was collapsing table column alignment.
  • The corrected 'hel'/'lo' fixture (genuine delta, not cumulative resend) is the right call — it was encoding the buggy guard, not real wire behavior.
  • Test suite gives good coverage of the boundary conditions (pipe preservation, prefix-delta append, snapshot regression, canonical blocking, cross-run reset).

incomingRaw['content'],
mode,
);
if (targetId && isFinalCanonicalReasoningContent(incomingRaw['content'])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isFinalCanonicalReasoningContent(incomingRaw['content']) is computed twice: here (to decide whether to register the id) and again inside accumulateContent at line ~1213 (to decide whether to replace). Both traverse the same array. Not a correctness issue now, but if the heuristic ever gets more expensive or changes definition, having two call sites is fragile. Consider extracting the result once before the accumulateContent call and passing it in as a flag — or having accumulateContent return a tagged result that surfaces the detection. Fix this →

return {
type: 'messages',
data: [{ id, type: 'ai', content }, META],
messageMetadata: META,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The rest of the test file uses satisfies StreamEvent for typed fixtures. as any here bypasses structural type-checking — if StreamEvent's shape changes (e.g., messageMetadata is renamed), this helper silently stays wrong while the other fixtures fail at compile time. Also worth noting: the helper uses data: [msg, META] (the SDK wire format) while the existing fixtures above use messages: [...] — both are handled by normalizeMessages, but the inconsistency is non-obvious. Switching to satisfies StreamEvent would surface if either format stops matching the type. Fix this →

}

function accumulateContent(existing: unknown, incoming: unknown): string {
function accumulateContent(existing: unknown, incoming: unknown, mode: MergeMode = 'snapshot'): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The JSDoc block for this function (above) is orphaned — isFinalCanonicalReasoningContent with its own JSDoc was inserted between the accumulateContent doc and this function definition. IDE tooling attaches a JSDoc to the immediately following declaration, so the accumulateContent doc currently documents isFinalCanonicalReasoningContent instead. Move the accumulateContent JSDoc to immediately precede this line. Fix this →

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 11s —— View job


Code Review

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read the implementation diff (stream-manager.bridge.ts)
  • Read the test diff (stream-manager.bridge.spec.ts)
  • Analyze correctness, type safety, edge cases
  • Post inline comments and top-level summary

The fix is correct and the latest two commits (93df438, b53a93d) are just auto-generated doc regeneration — no new correctness issues introduced. The core implementation is sound.

Three minor issues from previous reviews are still open (inline comments posted):

  1. Orphaned JSDoc (stream-manager.bridge.ts:1201) — accumulateContent's JSDoc at lines 1155–1179 is orphaned because isFinalCanonicalReasoningContent (with its own JSDoc) was inserted between it and the function definition. IDE tooling won't associate the doc with the right function.

  2. Double isFinalCanonicalReasoningContent call (stream-manager.bridge.ts:1104) — the heuristic is evaluated twice per delta merge: once inside accumulateContent (line 1213) and once back in mergeMessages (line 1104) to register the canonical id. Not a bug now, but fragile if the detection logic evolves. Extracting the result once and passing it as a flag would close this.

  3. tupleEvent uses as any (stream-manager.bridge.spec.ts:1480) — the rest of the suite uses satisfies StreamEvent. as any means shape drift in StreamEvent won't be caught at compile time for this helper.

What's right

  • The invariant is clean and provably correct: event kind governs merge semantics, text never does. Delta path appends unconditionally; snapshot path uses prefix comparison; canonical detection fires in both modes and installs an identity block for late stragglers.
  • canonicalMessageIds is cleared in both resetThreadState() (thread switch) and at the top of runStream() (new run) — no leaked state across runs or threads.
  • The MarkdownTableRowComponent rewrite (NgComponentOutlet replacing <chat-md-children>) removes the <div> wrapper that was collapsing table column alignment. The cell components now land directly under <tr> as <chat-md-table-cell> hosts, which is valid enough for browsers and confirmed by the E2E column-alignment gate.
  • The corrected 'hel'/'lo' fixture is the right call — it was encoding the buggy guard, not real wire behavior.
  • The 6-test suite (2 red on old code, regression pins for snapshot + lagging-values, canonical blocking, cross-run reset) gives good coverage of all boundary conditions.

}

function accumulateContent(existing: unknown, incoming: unknown): string {
function accumulateContent(existing: unknown, incoming: unknown, mode: MergeMode = 'snapshot'): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The JSDoc block for accumulateContent (lines 1155–1179) is still orphaned — isFinalCanonicalReasoningContent with its own JSDoc was inserted between it and this function definition. IDE tooling attaches a JSDoc to the immediately following declaration, so the accumulateContent doc currently documents isFinalCanonicalReasoningContent instead. Move the accumulateContent JSDoc to immediately precede this line. Fix this →

incomingRaw['content'],
mode,
);
if (targetId && isFinalCanonicalReasoningContent(incomingRaw['content'])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isFinalCanonicalReasoningContent(incomingRaw['content']) is evaluated here and also inside accumulateContent at line 1213 — same data, two traversals. Not harmful, but if the heuristic changes (e.g. adds a third block type to detect) one call site is easy to miss. Consider computing it once before accumulateContent and passing the result in as a boolean flag:

Suggested change
if (targetId && isFinalCanonicalReasoningContent(incomingRaw['content'])) {
const isCanonical = isFinalCanonicalReasoningContent(incomingRaw['content']);
const accumulatedContent = accumulateContent(
existing.content as unknown,
incomingRaw['content'],
mode,
isCanonical,
);
if (targetId && isCanonical) {

Fix this →

type: 'messages',
data: [{ id, type: 'ai', content }, META],
messageMetadata: META,
} as any;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The rest of the test file uses satisfies StreamEvent for typed fixtures. as any here bypasses structural type-checking — if StreamEvent's shape changes (e.g. messageMetadata is renamed), this helper stays wrong silently while typed fixtures fail at compile time. Also note: this helper uses the data: [msg, META] wire format while the existing fixtures use the already-processed messages: [...] property — both paths are covered by normalizeMessages, but the as any means the compiler won't catch if either format drifts from StreamEvent.

Suggested change
} as any;
} satisfies StreamEvent;

Fix this →

@blove blove enabled auto-merge (squash) July 6, 2026 18:31
@blove blove closed this Jul 6, 2026
auto-merge was automatically disabled July 6, 2026 18:33

Pull request was closed

@blove blove reopened this Jul 6, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2m 41s —— View job


Code Review

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read the implementation diff
  • Read the test diff
  • Analyze correctness, type safety, edge cases
  • Apply fixes for the 3 open review issues
    • Fix orphaned JSDoc in stream-manager.bridge.ts
    • Deduplicate isFinalCanonicalReasoningContent call
    • Change as any to satisfies StreamEvent in test helper
  • Commit and push

@blove blove closed this Jul 6, 2026
@blove blove reopened this Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1m 48s —— View job


Code Review Fixes

  • Load tools
  • Read CLAUDE.md and relevant files
  • Apply 3 open review fixes:
    • Fix orphaned JSDoc in stream-manager.bridge.ts
    • Deduplicate isFinalCanonicalReasoningContent call
    • Change as any to satisfies StreamEvent in test helper
  • Commit and push

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2m 5s —— View job


Code Review & Fix

  • Load tools
  • Read CLAUDE.md and repo guidelines
  • Read current state of implementation files
  • Apply 3 open fixes from prior reviews
    • Fix orphaned JSDoc in stream-manager.bridge.ts
    • Deduplicate isFinalCanonicalReasoningContent call
    • Change as any to satisfies StreamEvent in test helper
  • Commit and push

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1m 53s —— View job


Code Review Fixes

  • Load tools
  • Read CLAUDE.md and relevant files
  • Apply 3 open review fixes:
    • Fix orphaned JSDoc in stream-manager.bridge.ts
    • Deduplicate isFinalCanonicalReasoningContent call
    • Change as any to satisfies StreamEvent in test helper
  • Commit and push

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2m 27s —— View job


Code Review Fixes

  • Load tools
  • Read CLAUDE.md and relevant files
  • Apply 3 open fixes from prior reviews:
    • Fix orphaned JSDoc in stream-manager.bridge.ts
    • Deduplicate isFinalCanonicalReasoningContent call
    • Change as any to satisfies StreamEvent in test helper
  • Commit and push

@blove blove merged commit 9ec0cd8 into main Jul 6, 2026
64 of 68 checks passed
@blove blove deleted the fix/langgraph-delta-merge-identity branch July 6, 2026 19:01
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