Skip to content

fix(react-headless): stream adapters surface errors, truncation and refusals instead of ending the turn silently - #1286

Open
ankit-thesys wants to merge 8 commits into
mainfrom
fix/react-headless-error-frames
Open

ankit-thesys wants to merge 8 commits into
mainfrom
fix/react-headless-error-frames

Conversation

@ankit-thesys

@ankit-thesys ankit-thesys commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1312.

Replaces #1276, which was closed unmerged by accident. Rebased on current main (react-headless 0.17.0). The adapters are unchanged since 0.16.3, so the original adapter cases reproduce on the published 0.17.0. The follow-up also fixes a null-chunk regression introduced on this branch and stored refusal conversion.

Problem

Several wire shapes made a stream adapter do one of three things:

  • end the turn silently (no message, no error);
  • render a failed or cut-off answer as if it succeeded;
  • drop a tool call.

The original adapter cases were reproduced against the published package through AgentInterface. The follow-up regressions have targeted tests; the LangGraph null-chunk crash was introduced on this branch, while repeated tool IDs and stored refusals also affect main.

adapter case before after
openAIAdapter, openAIReadableStreamAdapter, agUIAdapter, openAIResponsesAdapter in-stream {"error":{…}} record under HTTP 200 (OpenAI SDK, OpenRouter, OpenUI Gateway) blank turn / answer with no error RUN_ERROR → error callout; streamed text kept
agUIAdapter, openAIAdapter, openAIResponsesAdapter, vercelAIAdapter route answers a failure as a plain JSON error body (application/json, HTTP 200) blank turn RUN_ERROR
agUIAdapter, openAIAdapter, openAIResponsesAdapter data:{…} without the optional space (Go fmt.Fprintf, several Python frameworks) blank turn parsed (the SSE spec makes the space optional)
openAIAdapter, openAIReadableStreamAdapter finish_reason: length / content_filter truncated answer looks complete RUN_ERROR (code = reason), text kept
openAIAdapter, openAIReadableStreamAdapter delta.refusal empty turn refusal rendered as text
openAIAdapter, openAIReadableStreamAdapter tool-call turn finished with stop (Gemini, OpenAI-compatible proxies) no TOOL_CALL_END: the call never reaches "executing" TOOL_CALL_END on any non-truncating finish reason — the same lifecycle as a tool_calls turn
openAIAdapter, openAIReadableStreamAdapter repeated tool-call id on argument chunks call restarts with an empty name one named call, arguments accumulated; parallel calls stay separate
openAIMessageFormat.fromApi stored assistant refusal with null content empty bubble after reload refusal restored as text; combined with content when both exist
langGraphAdapter tool_call_chunks: null crash on this PR branch null-safe handling; subsequent text and complete tool calls preserved
openAIResponsesAdapter response.refusal.delta empty turn rendered as text
openAIResponsesAdapter response.incomplete (max_output_tokens, content_filter) truncated answer looks complete RUN_ERROR
openAIResponsesAdapter file_search_call, code_interpreter_call, image_generation_call no activity at all tool card with args + result (isError when failed)
openAIResponsesAdapter web_search_call with status: "failed" card shows a successful search isError set, like the other server-run tools
vercelAIAdapter abort chunk; finish with finishReason length / content-filter partial answer looks complete RUN_ERROR
langGraphAdapter CRLF line endings (valid SSE; some proxies and Windows hosts) zero events, blank turn parsed, including a CRLF split across two reads
langGraphAdapter complete tool_calls with tool_call_chunks: [] (LangChain normalises a missing field to []) no tool call; result orphaned tool call announced
eveAdapter action.result with a status other than completed card shows success, result text is an error object isError / error set, card shows the failure

Changes

  • _shared/errorFrame.ts: errorFrameToRunError(record) maps a real error to RUN_ERROR. That means a non-empty string error, or an error object with a message or a non-empty code. A chunk whose error is false, "", 0, {} or {code: 0} is left alone.
  • _shared/sseLines.ts:
    • sseData(line) accepts data: with or without the optional space.
    • sseDataPayloads(response) yields the data: payloads. When a body has no data: line at all and is a JSON error object of at most 64 KB, it yields that body once. Any other non-SSE body is ignored, as before.
    • Used by the AG-UI, Completions and Responses adapters, so the Responses adapter now shares the same line buffering instead of its own copy.
  • _shared/chatCompletions.ts: the Chat Completions chunk mapping, previously duplicated in openai-completions.ts and openai-readable-stream.ts, now lives in one place. Both adapters now differ only in framing. Repeated tool IDs start one call and continue appending arguments without resetting its name.
  • _shared/truncation.ts:
    • isTruncationReason() covers the Completions, Responses and AI SDK spellings.
    • truncatedRunError(reason) builds one message per reason family, with the provider's reason as code.
  • Responses:
    • Hosted tool calls use their own item id. When a backend omits it, a unique stand-in id bridges the added and done events by output_index. A done item without a matching added event still starts its call, and interleaved or id-less items cannot swap results.
    • Refusal deltas share the output-text case.
  • vercel-ai-sdk.ts, langgraph.ts, eve.ts: the cases in the table. LangGraph handles null or empty tool-call chunks without crashing.
  • openai-message-format.ts: stored refusals survive reload and combine with text consistently with the streaming adapter.
  • Every adapter stops emitting at its first RUN_ERROR. The consumer already stopped there; this keeps the event stream valid for other AG-UI consumers. eveAdapter still reads to the turn boundary so onEvent sees the rest of the turn.
  • Docs and changeset:
    • adapters-and-formats.mdx: the callout and the per-adapter bullets describe the new behaviour.
    • Changeset: patch for @openuidev/react-headless.

Decisions worth a look

  • Truncation and abort are RUN_ERROR.
    • AG-UI has no warning event, so this is the only signal the UI can show.
    • The consumer keeps the text that already streamed and shows the callout under it.
    • code carries the provider's reason (length, content_filter, max_output_tokens, content-filter) or abort, so an app can treat them differently.
    • Tool calls left open by a truncation are not ended, because their arguments may be cut off; the RUN_ERROR clears them.
    • If you'd rather surface these another way, it is one helper to change.
  • Patch or minor? A truncated answer used to end a turn normally and now ends it with an error callout. I marked this as a patch because it fixes a silent failure; say if you'd rather call it minor.
  • Refusals render as plain text, matching what the model returned. openAIConversationMessageFormat.fromApi still prefixes a stored refusal with [Refusal]: on reload; I left that alone.
  • Every TEXT_MESSAGE_START now has its TEXT_MESSAGE_END. Completions tool turns end their message, and text after a finished step opens a new one. The bundled consumer ignores the event.
  • A tool call with no result shows "streaming" once the run ends. createChatStore clears executingToolCallIds when a run finishes, so any call without a TOOL_CALL_RESULT goes back to "streaming". That includes a normal tool_calls turn on main, and a call cut off by truncation. A "finished arguments, no result" or "interrupted" state would need a new ToolCallStatus value and react-ui rendering, so it isn't part of this change. Worth a decision.
  • No closing at stream end. A stream that stops without any finish_reason is left as it is rather than "closed", because it may have been cut off.
  • image_generation_call reports {status, image: "generated"} as the tool result instead of copying the base64 image into the tool message.
  • Not in this PR: custom_tool_call and computer_call. They would need openAIConversationMessageFormat to send their outputs back as custom_tool_call_output / computer_call_output first, so they're left for a follow-up.

Tests

  • sseAdapters.test.ts, covering Completions, Responses and AG-UI. It includes the review edge cases:
    • a falsy error field;
    • a non-streamed chat.completion body;
    • error bodies over 64 KB;
    • a half-written tool call cut off by length;
    • a stream with no finish_reason;
    • hosted-tool id fallbacks and interleaved ids;
    • text after a finished step;
    • a null record or a malformed tool_calls value is skipped and the answer after it still renders (both framings; these guard a regression and pass on main too);
    • repeated tool IDs through SSE and NDJSON, including consumer state and parallel calls;
    • a terminal RUN_ERROR.
  • The Eve, LangGraph and Vercel cases sit in each adapter's existing test file, including Eve's onEvent after turn.failed and LangGraph's error event stopping the stream.
  • errorFrames.test.ts: cases built from Gateway frames.
  • Shared helpers are in streamTestHelpers.ts (flushPromises, a consumer runner).
  • Follow-up regression tests: null LangGraph chunks with following text and complete tool calls; six stored-message conversion cases, including refusal round trips. The new bug cases failed before these fixes.
  • Package: 304 tests passing in 25 files, ci (lint + format), typecheck and build (tsdown) all clean.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
openui-docs Ready Ready Preview Oct 7, 2026 12:24pm UTC

Request Review

ankit-thesys and others added 3 commits October 7, 2026 13:02
An OpenAI-style error object delivered inside an HTTP 200 stream —
`{"error":{"message":…,"type":…,"code":…}}`, as the OpenAI SDK, OpenRouter
and the OpenUI Gateway emit it for a rejected request, an upstream provider
failure or a post-stream error — has no `choices`, so `openAIAdapter` and
`openAIReadableStreamAdapter` skipped it as an empty chunk. `agUIAdapter`
yielded it untyped and `openAIResponsesAdapter` dropped it in `default`.
In every case AgentInterface ended the turn with no message and no error.

Map the record to an AG-UI `RUN_ERROR` (message + code) in all four
adapters via a shared helper. The consumer already turns RUN_ERROR into
`threadError`, so the UI now shows the error callout; text streamed before
a post-stream failure is kept. `agUIAdapter` only treats records with no
`type` this way, so typed events that carry an `error` field (tool-result
failures) are untouched.

Tests use the frames captured from the Gateway on 2026-09-30.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The adapters reference listed four adapters as surfacing provider failures.
With the error-frame mapping, the AG-UI, OpenAI Completions and OpenAI
readable-stream adapters do too, and the Responses adapter also accepts the
untyped record. Updates the callout and the per-adapter bullets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ol edge cases and non-SSE errors

Follows the error-frame fix: the remaining ways an adapter could end a
turn silently or render it wrong.

- Chat Completions (shared by openAIAdapter / openAIReadableStreamAdapter,
  now one mapper): any finish_reason closes the step's tool calls and the
  message, so a tool turn finished with `stop` (Gemini, OpenAI-compatible
  proxies) no longer stays "streaming"; `length` / `content_filter` end
  with RUN_ERROR; `delta.refusal` renders as text; an error record is
  terminal.
- openAIResponsesAdapter: `response.refusal.*` as text;
  `response.incomplete` → RUN_ERROR; file_search_call,
  code_interpreter_call and image_generation_call as tool calls with
  results (isError when failed; the base64 image is not copied);
  custom_tool_call and computer_call as client tool calls.
- SSE adapters (agUI, Completions, Responses) accept `data:` without the
  optional space, and map a body with no SSE framing that is a JSON
  `{"error":…}` object (HTTP 200 + application/json) to RUN_ERROR.
- vercelAIAdapter: `abort` and `finish` with length / content-filter end
  with RUN_ERROR; JSON error bodies too.
- langGraphAdapter: CRLF line endings (including a CRLF split across
  reads); complete tool_calls are announced when tool_call_chunks is [].
- eveAdapter: a failed action.result carries isError / error.

Tests: adapterEdgeCases.test.ts (34 cases, 30 fail on the previous
adapters) and sseData / sseDataPayloads unit tests. Docs and changeset
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ankit-thesys
ankit-thesys force-pushed the fix/react-headless-error-frames branch from 9569091 to 58909f9 Compare October 7, 2026 07:33
@ankit-thesys ankit-thesys changed the title fix(react-headless): surface in-stream error frames as RUN_ERROR fix(react-headless): stream adapters surface errors, truncation and refusals instead of ending the turn silently Oct 7, 2026
- errorFrameToRunError only maps a real error: a non-empty string, or an
  object with a message or a code. A chunk with `error: false | "" | 0 |
  {}` keeps its content instead of becoming "Stream error".
- The plain-body fallback in sseDataPayloads yields only an error object,
  so a non-streamed chat.completion is no longer read as a truncated
  stream; the 64 KB cap now applies to the whole body, however it is laid
  out.
- Chat Completions: on length / content_filter, open tool calls are left
  unended (their arguments may be cut off) and the RUN_ERROR clears them;
  a stream that stops without a finish_reason is no longer "closed" as if
  it had finished. The mapper is `push()` / `terminated`.
- Every adapter stops reading at its first RUN_ERROR (Responses, AG-UI,
  LangGraph and Eve now match Completions and Vercel).
- Responses: hosted tool calls keep one id per output_index, so a done
  item without a matching added event is still started and two id-less
  items cannot collide; refusal deltas share the output-text case;
  custom_tool_call and computer_call are dropped from this change until
  openAIConversationMessageFormat can round-trip them.
- isTruncationReason() in _shared/truncation.ts is the one place that
  classifies finish reasons (Completions, Vercel).
- Tests: per-adapter cases moved into eve / langgraph / vercel-ai-sdk
  test files; Completions, Responses and AG-UI cases in
  sseAdapters.test.ts; shared helpers in streamTestHelpers.ts (with
  flushPromises). New cases for each item above.
- Docs and changeset describe the narrower truncation scope, the
  terminal RUN_ERROR and the AG-UI untyped-record handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- eveAdapter: after turn.failed nothing more is emitted, but the stream is
  still read to the turn boundary so `onEvent` sees the rest of the turn
  (the previous early return cut it off).
- openAIResponsesAdapter: a hosted tool's own item id always wins; the
  output_index map only bridges an added event that had no id, and
  stand-in ids are unique per call. Interleaved items without
  output_index no longer swap results.
- Chat Completions: text after a finished step opens a new message, so
  every TEXT_MESSAGE_START has its TEXT_MESSAGE_END.
- errorFrameToRunError: a numeric code 0 is not an error code.
- Docs / changeset: the plain JSON error body is accepted by the SSE
  adapters and Vercel (not the NDJSON adapter); adapters stop emitting at
  the first RUN_ERROR.
- Tests: multi-step pairing, interleaved hosted ids, code 0, LangGraph
  `error` stopping the stream, Eve onEvent after turn.failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver-run tools

The adapters reference says server-run tools carry `isError` when the item
failed. file_search, code_interpreter, image_generation and MCP did;
web_search_call did not, so a search with `status: "failed"` showed as a
successful call. It now uses the same failed/incomplete check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ibe the stop-turn fix precisely

- openAIAdapter / openAIReadableStreamAdapter: moving the chunk mapping
  into the shared mapper took it out of the per-record try/catch, so a
  `null` record or a malformed `tool_calls` value threw and aborted the
  stream, dropping the answer that followed (main skipped such a record).
  JSON.parse and the mapping are back inside one try/catch, and the mapper
  tolerates a null record, a non-array `tool_calls` and null entries.
  Regression tests cover all three on both framings.
- Docs / changeset: a tool turn that finishes with `stop` now ends its
  calls exactly like a `tool_calls` turn. After the run ends the store
  resets any tool call without a result to "streaming" for both, which is
  store behaviour and unchanged here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +361 to +371
The line-oriented adapters log and skip malformed lines, and accept `data:` with or without the
optional space after the colon. `vercelAIAdapter()` delegates validation to the Vercel AI SDK and
rejects invalid UIMessage chunks. Provider-level failures are surfaced as a `RUN_ERROR` event by
all seven adapters: each maps its protocol's error events, and the AG-UI, OpenAI Completions,
OpenAI readable-stream, and OpenAI Responses adapters also map the untyped `{"error":{…}}` record
that OpenAI-compatible servers send inside an HTTP 200 stream; the SSE adapters (AG-UI, OpenAI
Completions, OpenAI Responses) and the Vercel AI SDK adapter also accept it as a plain JSON error
body under HTTP 200. The Chat Completions, Responses, and Vercel AI SDK adapters also end a response
the provider cut short — a token limit or a content filter — with a `RUN_ERROR` whose `code` is the
provider's reason (and a Vercel `abort` chunk with `code: "abort"`); text that already streamed is
kept. Every adapter stops emitting at its first `RUN_ERROR`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Basically, talking about how errors are handled in our adapters, if we dont need it we can skip this part

This branch was successfully deployed

1 active deployment
Preview — 2cfab5d2 Deployed Oct 7, 2026 by vercel[bot]
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.

react-headless: stream adapters end turns silently on in-stream errors, truncation, refusals and some wire variants

1 participant