Skip to content

QA chat SSE: no cross-chunk buffer silently drops the metadata frame (sources + confidence) #228

Description

@NathanTarbert

Origin: deferred bucket (d) from the Tier-3 cr-loop review of PR #167 (merged as 55744ca). Flagged independently by four reviewers. Separate subject from that PR, which only swapped this file's fetch for apiFetch.

apps/web/src/hooks/use-qa-chat.ts consumes the /api/qa SSE stream. Four defects, in severity order.

1. No cross-chunk buffer — frames are silently dropped

:99 does chunk.split('\n') per read, with no carry buffer for a partial frame. A ReadableStream read boundary can fall anywhere, so any SSE event split across two reads is parsed as two invalid halves. Both halves land in the empty catch at :127 and vanish.

The event most likely to be split is the largest one — the metadata frame carrying sources and confidence. So the visible symptom is an answer that renders fine but loses its citations, intermittently, with nothing logged. That is the worst possible shape for a bug: it looks like the model just didn't cite anything.

Fix: keep a buffer across reads, split on \n\n, and retain the trailing partial for the next iteration.

2. [DONE] never matches under CRLF

:100-109 compares against [DONE] exactly. With CRLF line endings the token arrives as [DONE]\r, falls through to JSON.parse, and throws — again into the empty catch. Normalise line endings before comparing.

3. The empty catch is what makes 1 and 2 invisible

:127 swallows every parse failure with no logging. Both defects above would be obvious in a single session with any logging at all. Related: #197 — nothing in apps/web uses the structured logger that exists.

4. Concurrent sends corrupt shared state

:68 assigns a new AbortController over the in-flight one without aborting it, so a second send leaves the first stream running and both write the same state. There is also no abort on unmount. Separately, :61 reads conversationHistory from a stale closure, so rapid sends replay incomplete history to the model — and :152 strands the user's message on abort while the error placeholder gets replayed as assistant history.

Test gaps

No coverage for a frame split across chunk boundaries, no CRLF case, no concurrent-send case. A regression test for #1 needs a mocked stream that deliberately splits a frame mid-JSON — worth writing first, since it fails today.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: aiAI response pipeline, groundedness, confidenceroadmapTracked on the Outpost roadmaproadmap: nextRoadmap horizon: after launch path clears

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions