A failed chat request is a typed HTTP error; a failed tool renders the same live and on reload (CP-03, CP-04, CP-10)
This guide covers the release in which handleChatStream stops answering a failed request with a 200 SSE response that carries only data-resume-rejected events. No client reads that event: a stock useChat (and useHelixChat) saw a successful, empty turn, so the user's answer, message or tool result was lost without a word. Every such failure is now a typed HTTP error, { error, code } with core typedErrorResponse's status, which the client surfaces as an error and recovers from (useHelixChat().retry(), or a reload that re-anchors through the snapshot). It is a major release of @helix-agents/ai-sdk (38.0.0): it changes wire answers.
The earlier guides covered the failures core can classify: a refused send or resume and every HelixError the handler met. This release covers what was left: the failures with no code, the answers that apply to nothing, and a run whose stream could not be opened.
What changed
Every handleChatStream host is affected: agent-server POST /chat, the Express adapter, createCloudflareChatHandler (the CF Durable Object chat route) and any custom route.
| A request whose … | Before | Now |
|---|---|---|
execute(), resume() or submitToolResult() throws an unclassified error (a plain Error, a non-Error) (CP-03) | 200 SSE, data-resume-rejected internal-error | framework_internal_error: 503 before this request committed anything, else 500 |
executor has no submitToolResult | 200 SSE, data-resume-rejected | 500 framework_not_supported |
answers all name calls pending nowhere (the parser found none, or the runtime answered unknown_tool_call to each) | 200 SSE, data-resume-rejected unknown-tool-call | 409 state_not_resumable, cause: 'unknown_tool_call' |
| answers all belong to a run that is not current (path 7, "stale run") | 200 SSE, data-resume-rejected stale-run | 409 state_not_resumable, cause: 'stale_run' |
answers all belong to a run that was stopped (unknown_tool_call / run_stopped) | 200 SSE, data-resume-rejected unknown-tool-call + cause | 409 state_not_resumable, cause: 'run_stopped' |
| answers include a malformed approval id, and none applies | 200 SSE, data-resume-rejected malformed-approval-id | 400 validation_error |
| run committed, but its stream could not be opened (retries exhausted, or no stream) (CP-04) | 200 SSE, data-resume-rejected stream_unavailable | 500 transport_stream_drop (never 503: the input is committed) |
answer is for a call a sub-agent owns (its root's clientToolCallOwnership) (CP-10) | 200 SSE, data-resume-rejected unknown-tool-call | submitted: core's submit router finds the owner, and the request continues as any other one |
The body is exactly { error, code }, the message scrubbed (core sanitizeErrorMessage), plus cause when the row names one, and the X-Session-Id header. A framework_internal_error is logged at error level with the original error, which the client never sees.
Never 503 once something is committed. When the request's own input reached a committed run (a send's execute() returned its handle) or a submitted answer was stored, a retryable error is answered with its non-retryable row (500, or 409 for state_*): a re-sent request would append the message, or submit the answer, twice. The client re-reads the snapshot instead (useHelixChat().retry() does).
A snapshot reconcile that throws unclassified (executor.reconcileSession, for a live current run) is a 500framework_internal_error, never 503: the request has nothing of its own to re-send.
What still answers 200 with data-resume-rejected
A request that succeeded in part: at least one answer was stored, and others were rejected (a stale answer beside a current one, one unknown call in a batch). The stored answers continue the turn (or wait for the rest of their batch), and the rejected ones ride the response as data-resume-rejected events, as before.
A failed tool renders the same live and after a reload
A failed tool call rendered differently depending on how the client saw it. Live, the transformer turned a failed tool_end into tool-output-available with an object output { error, errorCode? }, so the part was output-available. After a reload, the snapshot rendered the stored failed result as output-error with errorText. Every failed tool changed state on reload (FU-AISDK-FAILED-TOOL-LIVE-RELOAD-STATE).
Now one classification drives both views (toolOutcomeOfEnd live, toolOutcomeOfResult on reload). A failed tool is ai's own tool-output-error { toolCallId, errorText, providerExecuted?, dynamic } live, so the part is output-error with the same sanitized errorText the reload shows. A stock useChat renders the standard output-error state.
| A failed tool, live | Before | Now |
|---|---|---|
| UI chunk | tool-output-available, output: { error, errorCode? } | tool-output-error, errorText |
| part state | output-available | output-error (as after a reload) |
errorCode | in the part's output | not in the UI part (see below) |
errorCode stays on the core tool_end chunk, for aggregation and observability. The UI part has no field for it: ai's tool-output-error has none, and a reload never carried it. A successful tool is unchanged (tool-output-available with its result).
Migration
- A client on
useHelixChat/HelixChatTransport/ a stockuseChat: nothing to change. The failed request now ends the turn in an error (status: 'error'), where it used to end as an empty success;parseHelixChatErrorreads itscodeandcause.useHelixChat().retry()re-anchors through the snapshot, and re-sends a failed send only when the snapshot does not hold the turn yet. - A client that read
data-resume-rejectedfor these cases (a custom transport): handle the HTTP error instead. The rejection reasons map tocause(unknown_tool_call,stale_run,run_stopped) on a409state_not_resumable. - A route that wrapped
handleChatStreamand inspected a200body for rejections: check the status instead. - A test that expected a
200for an unclassified executor error: expect the typed error (503before a commit,500after). - A UI that read a failed tool's
output.error/output.errorCode: render theoutput-errorstate and itserrorTextinstead, as you already must after a reload. A UI that needs the machine code reads thetool_endchunk'serrorCodeon the server (your hooks or stream observers).