Skip to content

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 …BeforeNow
execute(), resume() or submitToolResult() throws an unclassified error (a plain Error, a non-Error) (CP-03)200 SSE, data-resume-rejected internal-errorframework_internal_error: 503 before this request committed anything, else 500
executor has no submitToolResult200 SSE, data-resume-rejected500 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-call409 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-run409 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 + cause409 state_not_resumable, cause: 'run_stopped'
answers include a malformed approval id, and none applies200 SSE, data-resume-rejected malformed-approval-id400 validation_error
run committed, but its stream could not be opened (retries exhausted, or no stream) (CP-04)200 SSE, data-resume-rejected stream_unavailable500 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-callsubmitted: 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, liveBeforeNow
UI chunktool-output-available, output: { error, errorCode? }tool-output-error, errorText
part stateoutput-availableoutput-error (as after a reload)
errorCodein the part's outputnot 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 stock useChat: nothing to change. The failed request now ends the turn in an error (status: 'error'), where it used to end as an empty success; parseHelixChatError reads its code and cause. 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-rejected for these cases (a custom transport): handle the HTTP error instead. The rejection reasons map to cause (unknown_tool_call, stale_run, run_stopped) on a 409state_not_resumable.
  • A route that wrapped handleChatStream and inspected a 200 body for rejections: check the status instead.
  • A test that expected a 200 for an unclassified executor error: expect the typed error (503 before a commit, 500 after).
  • A UI that read a failed tool's output.error / output.errorCode: render the output-error state and its errorText instead, as you already must after a reload. A UI that needs the machine code reads the tool_end chunk's errorCode on the server (your hooks or stream observers).

Released under the MIT License.