Skip to content

fix(protocol): emit one complete response.completed per responses stream - #206

Merged
argszero merged 1 commit into
mainfrom
fix/responses-stream-terminal-event
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/responses-stream-terminal-event

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

openai_sse_to_openai_responses emitted event: response.completed with data: {} on the upstream [DONE] line — a terminal event with neither type nor response. On a normal upstream the terminal therefore arrived twice (counted by the SSE event: name): once well-formed, once payload-less; on an upstream that only sent [DONE], the payload-less one arrived alone. A Responses client that reads the documented event.response got nothing, while the whole-body translator for the same protocol pair always returns a complete object. The streamed terminal response was also an empty shell (output: [], no usage).

openai_sse_to_openai_responses converts every Responses-protocol client on a plan without a responses endpoint (11 of the 12 plans in config/config.toml), and serves the anthropic → responses chain too, so this is the path a Responses client actually gets.

Related Issue

None — found by an in-repo sweep of the streamed vs whole-body shape fields of the five protocol pairs. (No issue exists to link; needs:issue stays unpassable by design.)

Changes

  • The terminal object is built once from the accumulated stream state (upstream_id, model, text deltas, tool calls with their concatenated arguments, upstream usage chunk) and emitted at most once per stream — whichever branch reaches the end first (finish_reason or [DONE]). The payload-less data: {} emission is gone.
  • The terminal shape now has a single source shared with the whole-body path instead of a second inline copy: new protocol::openai_chat_message_to_responses_output(message, message_item_id) and protocol::openai_usage_to_responses_usage(usage); openai_chat_to_openai_responses_resp was refactored onto them with unchanged behaviour (same message item id, same zeroed-usage default).
  • The message item id stays msg_… in the terminal object, matching the item_id the incremental events already sent; usage follows the whole-body rule (mapped from the upstream chunk, zeroed when absent, never omitted).
  • Not touched (recorded divergences): the streamed resp_/msg_ id prefixes (a client only ever sees one path) and status — the whole-body object has none, and status/truncation semantics are a separate decision, so a stream that ends without [DONE] still emits no terminal event.
  • No config/data-structure change, no schema change, no ui/, no i18n keys, no release.

Tests

  • cargo test all pass — 196 passed (was 192), cargo fmt --check clean, clippy --all-targets -- -D warnings clean.
  • Four new tests: the terminal event is asserted exactly once per stream (counted by the SSE event: name, not the payload type — an empty payload carries no type); its response object is checked field by field against what the stream carried (accumulated text, concatenated tool-call arguments, mapped usage); a [DONE]-only stream with and without a usage chunk is covered; and a cross-path parity test compares the streamed terminal with the whole-body translator over four upstream shapes, asserting the two recorded divergences as exact relations (resp_{whole.id}, msg_{whole.id}) rather than ignoring the fields.
  • The parity comparator carries its own positive control — it must reject seven injected shape deviations (wrong object/model/usage/status, wrong output length, tampered content, broken id-prefix relation) and must accept the honest pair, so "consistent" is not a permanent exemption.
  • A/B red-before / green-after, 3/3 (each mutation injected alone, then reverted): reinstating the payload-less [DONE] branch → the three stream-driven tests red; dropping the once-only guard → the duplicate case and the parity case red (the no-finish_reason case cannot go red — that stream never takes the finish_reason path); reverting the terminal object to the empty shell → the three stream-driven tests red. All green again after each revert.

Checklist

  • Branch name follows the convention (fix/)
  • Commit message uses Conventional Commits (fix(protocol): …)
  • Single responsibility, minimal change — the protocol.rs part is a behaviour-preserving extraction of the shape the two paths now share

openai_sse_to_openai_responses emitted `event: response.completed` with
`data: {}` on the upstream `[DONE]` line: a terminal event with neither
`type` nor `response`. Because the finish_reason branch also emits the
terminal, a normal upstream produced the terminal twice (counted by the
SSE `event:` name) — once well-formed, once payload-less; an upstream
that only sent `[DONE]` produced the payload-less one alone. A Responses
client dispatching on `event.response` therefore had no response object
at all, while the whole-body translator always returns a complete one.

The terminal object is now built once from the accumulated stream state
(text, tool calls, usage) and emitted at most once per stream, whichever
branch reaches the end first, and its shape is shared with the whole-body
path instead of being a second inline copy:

- new `protocol::openai_chat_message_to_responses_output` and
  `protocol::openai_usage_to_responses_usage` are the single source for
  the Responses `output` / `usage` shape; the whole-body translator
  `openai_chat_to_openai_responses_resp` was refactored onto them with
  unchanged behaviour (same message item id, same zeroed-usage default);
- `ResponsesStreamState` accumulates the upstream `id`/`model`, the text
  deltas, the tool-call arguments (concatenated per call id) and the
  upstream usage chunk; `terminal_response()` derives the object from it,
  so the message item id stays `msg_…` and matches the `item_id` the
  incremental events already sent;
- `completed_event()` guarantees a single emission; the payload-less
  `data: {}` is gone.

Recorded divergences that stay untouched: the streamed `resp_`/`msg_`
id prefixes (a client only ever sees one path) and `status` — the
whole-body object has none, and the status/truncation semantics belong to
the host-adjudication family, so a stream that ends without `[DONE]`
still emits no terminal event.

Tests: 4 new (196 total, was 192). The terminal event is asserted once
per stream, counted by the SSE `event:` name; its `response` object is
checked field by field against what the stream carried (accumulated text,
concatenated tool-call arguments, mapped usage); a `[DONE]`-only stream
with and without a usage chunk is covered; and a cross-path parity test
compares the streamed terminal with the whole-body translator for four
upstream shapes, asserting the two recorded divergences as exact
relations rather than ignoring them. The comparator has its own positive
control (it must reject seven injected shape deviations).

A/B red-before / green-after, 3/3: reinstating the payload-less `[DONE]`
branch, dropping the once-only guard, and reverting the terminal object to
the empty shell each redden exactly the stream-driven tests (the duplicate
guard cannot redden the no-finish_reason case — that stream never takes
the finish_reason path), and all are green again after the revert.

No billing impact: streamed usage is recorded by the same slot as before.
@argszero
argszero merged commit b46f113 into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/responses-stream-terminal-event branch September 13, 2026 11:34
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