fix(chat): SSE parser drops large events when chunk boundary lands between header and data#56
Merged
Merged
Conversation
…tween header and data
The runtime adapter's SSE parser declared `eventType` *inside* the
`while (true) { const { value } = await reader.read(); ... }` loop, so
the variable reset every time a new TCP chunk arrived. When a single
SSE event straddled two chunks — `event: <type>\n` in chunk N and the
huge `data: <json>\n\n` line in chunk N+1 — the new iteration started
with `eventType = null` and the `if (eventType && data)` guard silently
dropped the event.
In production this surfaced as a stuck "Running…" tool-call card. We
hit it on a parallel run of three `gds/content-read` calls where the
middle call's `tool_result` payload was ~100 KB (vs ~30 KB and ~60 KB
for the siblings). The audit log + persisted conversation both held
the correct result; the SSE event for it never made it past the parser.
## Fix
Lifted `eventType` to the same scope as `buffer` so it persists across
`reader.read()` iterations. The only places that reset it are the two
legitimate end-of-event signals: a yielded `data:` line, and a blank
line (SSE-spec separator). Both pre-existed and stay.
## Refactor
Extracted `parseSSE` from `use-runtime-adapter.ts` to its own
`hooks/parse-sse.ts` so a Jest test can exercise it without dragging
the whole runtime adapter's `@wordpress/element` imports into the
sandbox. The new file is a verbatim copy of the (now-correct) parser
plus the doc-comment explaining the bug.
## Regression test
`hooks/__tests__/parseSSE.test.ts` builds a `ReadableStream` from a
hand-chopped chunk array and feeds it to `parseSSE`. Six cases:
- Single complete event in one chunk (sanity).
- `event:` line in chunk 1, `data:` in chunk 2 (the primary bug).
- Mid-`data:`-line split across three chunks (large payloads).
- Three back-to-back `tool_result`s where the middle one straddles
chunks — mirrors the exact production scenario.
- Blank line resets `eventType` so a stray `data:` doesn't inherit it.
- Malformed JSON is skipped, not thrown.
Verified the test catches the regression: temporarily restoring the
broken `let eventType: string | null = null;` inside the loop fails
exactly 3 of the 6 tests (the chunk-straddling cases) before passing
again with the fix.
`@types/node` added to devDeps so the test can import
`node:stream/web` / `node:util` for `ReadableStream` / `TextEncoder` /
`TextDecoder` polyfills under wp-scripts' jest config.
## Verification
- `npm run typecheck` — clean
- `npm run lint:js` — 0 errors, 0 warnings
- `npm run test:unit` — 33/33 (6 new + 27 carried)
- `npm run build` — succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Real bug surfaced by @oskaresh testing #55 locally: in a parallel run of three same-shape
gds/content-readtool calls, the middle card got stuck onRunning…while the other two flipped toDone. Audit log and persisted conversation both held the correct 100 KB result for the middle call; the UI just never saw it.Root cause
parseSSEinuse-runtime-adapter.tsdeclaredeventTypeinside the network-chunk loop:When the server's SSE frame is bigger than one TCP chunk, the
event:line lands in chunk N and the matchingdata: <huge json>\n\nline lands in chunk N+1. The new iteration starts witheventType = null, theif (eventType && data)guard fails, and the event is silently dropped. Small results (the two siblings: ~30 KB and ~60 KB) fit in one chunk so they always work; the bug only fires above ~64 KB.The middle call's
tool_resultpayload was 100 169 chars. That's exactly large enough to straddle a frame.Fix
Lifted
eventTypeto the same scope asbufferso it persists acrossreader.read()iterations. The only writes that reset it are the legitimate end-of-event signals (a yieldeddata:line, a blank line per SSE spec) — both pre-existed and stay.Refactor
Extracted
parseSSEto its ownhooks/parse-sse.tsso the regression test can import it without dragging@wordpress/elementinto the Jest sandbox. No behaviour change beyond the fix.Regression test
hooks/__tests__/parseSSE.test.tsfeedsparseSSEa hand-choppedReadableStream<Uint8Array>to assert the right events come through:event:in chunk 1,data:in chunk 2data:-line split across three chunkstool_results with middle straddling chunkseventTypeTest catches the regression: temporarily restoring the inner
let eventTypefails exactly the 3 chunk-straddling cases (Tests: 3 failed, 30 passed), passes 33/33 with the fix.@types/nodeadded to devDeps so the test can importnode:stream/web/node:utilforReadableStream/TextEncoder/TextDecoderpolyfills under wp-scripts' Jest config.Verification
npm run typecheck— cleannpm run lint:js— 0 errors, 0 warningsnpm run test:unit— 33/33npm run build— succeeds🤖 Generated with Claude Code