Skip to content

A chat turn should end in exactly one earned terminal #857

Description

@WaylandYang

The family

Four open PRs each close one hole, and they are the same hole:

PR What was reported as success
#845 The model's final text was tool-control markup, streamed and persisted as the answer
#850 A database failure during fallback retrieval became "no evidence found", then done
#852 The assistant message failed to save, and done was emitted anyway
#851 The stream ended without a terminal — or ended normally and fired onDone twice

Each fix is correct and each should land. But four guards written separately means the fifth leak is written the same way, by whoever next adds a path that can fail after the first byte is out.

The rule underneath

A chat turn ends in exactly one terminal, and the terminal is earned.

Today "ended" is inferred in several places from several things: the loop finished, the stream closed, the provider said stop, no error was raised. None of those is the same as the answer exists and was kept. The four fixes above are each a local repair of one inference.

What would make the fifth leak hard to write is a single representation the producer must return — a value that is either the answer, or a reason — with the SSE layer emitting exactly one terminal from it and the client accepting exactly one. Then "the loop ended" stops being a code path that can reach done at all, because done is not reachable without the value.

The client parser is a separate problem, and it should be a library

streamSse in web/src/api.ts hand-rolls SSE framing, and it is wrong in ways the spec is explicit about:

while ((idx = buf.indexOf("\n\n")) >= 0) {      // CRLF and bare CR are also frame separators
  ...
  else if (line.startsWith("data:")) data += line.slice(5).trim();   // multiple data: lines join with \n
}                                                                     // and trim() eats meaningful whitespace
  • Frames are split on \n\n only. The spec allows \r\n\r\n and \r\r.
  • Multiple data: lines in one event must be joined with \n. Here they are concatenated with nothing, so a multi-line payload is silently corrupted.
  • .trim() on each data line destroys leading and trailing whitespace that belongs to a delta.
  • Split UTF-8 across chunk boundaries is handled (decoder.decode(value, {stream: true})), which is the one part that is right.

The server side is already on a framework — axum::response::sse::{Event, KeepAlive, Sse} — so this is the one hand-written half. The browser's native EventSource is not a candidate because these streams are POSTs with a body, but the ecosystem has small focused parsers for exactly this shape (eventsource-parser is the common one; whoever picks should check its maintenance status rather than take this as a recommendation of a version). Feed it the chunks, keep our own event dispatch and reattachment policy on top.

web/package.json currently has no SSE dependency at all.

These two are complementary, not alternatives. A spec-correct parser fixes the framing bugs and none of the terminal-semantics bugs — onDone firing twice and an unterminated EOF reporting success are our logic, not the parser's. #851 is where that discipline belongs; the framing underneath it is what should stop being ours.

Suggested order

  1. Land Hand off exhausted tool runs to an evidence-only final answer #845, Keep fallback retrieval failures distinct from empty results #850, Preserve exact chat outcomes across stream closure and reattachment #851, Do not report fallback chat completion when saving fails #852 as they are. Each is a real fix with a regression behind it.
  2. Replace the framing in streamSse with a parser library, keeping the terminal handling Preserve exact chat outcomes across stream closure and reattachment #851 establishes.
  3. Then introduce the single producer terminal, and delete the guards that become unreachable.

Step 3 is the one that stops this list from growing, and it is only worth doing after 1 and 2 have settled what the terminal has to carry.

Background

Noticed while reviewing the batch on 2026-09-21. Related: #844 (the report behind #845), and the same shape appeared earlier this week on the export side — #821, #823, #824, #831 — where the store held something and the serializer dropped it without an error. Silent success is the recurring failure mode in both places.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions