Repository navigation
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
Open
ankit-thesys wants to merge 8 commits into
ankit-thesys wants to merge 8 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
force-pushed
the
fix/react-headless-error-frames
branch
from
October 7, 2026 07:33
9569091 to
58909f9
Compare
- 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>
ankit-thesys
commented
Oct 7, 2026
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`. |
Contributor
Author
There was a problem hiding this comment.
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
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.
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:
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.openAIAdapter,openAIReadableStreamAdapter,agUIAdapter,openAIResponsesAdapter{"error":{…}}record under HTTP 200 (OpenAI SDK, OpenRouter, OpenUI Gateway)RUN_ERROR→ error callout; streamed text keptagUIAdapter,openAIAdapter,openAIResponsesAdapter,vercelAIAdapterapplication/json, HTTP 200)RUN_ERRORagUIAdapter,openAIAdapter,openAIResponsesAdapterdata:{…}without the optional space (Gofmt.Fprintf, several Python frameworks)openAIAdapter,openAIReadableStreamAdapterfinish_reason: length/content_filterRUN_ERROR(code= reason), text keptopenAIAdapter,openAIReadableStreamAdapterdelta.refusalopenAIAdapter,openAIReadableStreamAdapterstop(Gemini, OpenAI-compatible proxies)TOOL_CALL_END: the call never reaches "executing"TOOL_CALL_ENDon any non-truncating finish reason — the same lifecycle as atool_callsturnopenAIAdapter,openAIReadableStreamAdapteropenAIMessageFormat.fromApilangGraphAdaptertool_call_chunks: nullopenAIResponsesAdapterresponse.refusal.deltaopenAIResponsesAdapterresponse.incomplete(max_output_tokens,content_filter)RUN_ERRORopenAIResponsesAdapterfile_search_call,code_interpreter_call,image_generation_callisErrorwhen failed)openAIResponsesAdapterweb_search_callwithstatus: "failed"isErrorset, like the other server-run toolsvercelAIAdapterabortchunk;finishwithfinishReasonlength/content-filterRUN_ERRORlangGraphAdapterlangGraphAdaptertool_callswithtool_call_chunks: [](LangChain normalises a missing field to[])eveAdapteraction.resultwith a status other thancompletedisError/errorset, card shows the failureChanges
_shared/errorFrame.ts:errorFrameToRunError(record)maps a real error toRUN_ERROR. That means a non-empty stringerror, or anerrorobject with a message or a non-empty code. A chunk whoseerrorisfalse,"",0,{}or{code: 0}is left alone._shared/sseLines.ts:sseData(line)acceptsdata:with or without the optional space.sseDataPayloads(response)yields thedata:payloads. When a body has nodata: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._shared/chatCompletions.ts: the Chat Completions chunk mapping, previously duplicated inopenai-completions.tsandopenai-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 ascode.output_index. A done item without a matching added event still starts its call, and interleaved or id-less items cannot swap results.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.RUN_ERROR. The consumer already stopped there; this keeps the event stream valid for other AG-UI consumers.eveAdapterstill reads to the turn boundary soonEventsees the rest of the turn.adapters-and-formats.mdx: the callout and the per-adapter bullets describe the new behaviour.@openuidev/react-headless.Decisions worth a look
RUN_ERROR.codecarries the provider's reason (length,content_filter,max_output_tokens,content-filter) orabort, so an app can treat them differently.RUN_ERRORclears them.openAIConversationMessageFormat.fromApistill prefixes a stored refusal with[Refusal]:on reload; I left that alone.TEXT_MESSAGE_STARTnow has itsTEXT_MESSAGE_END. Completions tool turns end their message, and text after a finished step opens a new one. The bundled consumer ignores the event.createChatStoreclearsexecutingToolCallIdswhen a run finishes, so any call without aTOOL_CALL_RESULTgoes back to "streaming". That includes a normaltool_callsturn onmain, and a call cut off by truncation. A "finished arguments, no result" or "interrupted" state would need a newToolCallStatusvalue and react-ui rendering, so it isn't part of this change. Worth a decision.finish_reasonis left as it is rather than "closed", because it may have been cut off.image_generation_callreports{status, image: "generated"}as the tool result instead of copying the base64 image into the tool message.custom_tool_callandcomputer_call. They would needopenAIConversationMessageFormatto send their outputs back ascustom_tool_call_output/computer_call_outputfirst, so they're left for a follow-up.Tests
sseAdapters.test.ts, covering Completions, Responses and AG-UI. It includes the review edge cases:errorfield;chat.completionbody;length;finish_reason;nullrecord or a malformedtool_callsvalue is skipped and the answer after it still renders (both framings; these guard a regression and pass onmaintoo);RUN_ERROR.onEventafterturn.failedand LangGraph'serrorevent stopping the stream.errorFrames.test.ts: cases built from Gateway frames.streamTestHelpers.ts(flushPromises, a consumer runner).ci(lint + format),typecheckandbuild(tsdown) all clean.🤖 Generated with Claude Code