Skip to content

The chat submit response contract: a stock auto-send sends once (CP-180, CP-179, CP-171, CP-172, CP-182) ​

This guide covers the release in which every handleChatStream request that carries client-tool answers or approval responses (path 3) ends in one of three answers. Before it, two of those answers could make a stock useChat re-send the same answer forever, or hide an answer that was never stored. It is a major release of @helix-agents/ai-sdk: it changes wire answers and removes a type variant.

Every handleChatStream host is affected: agent-server POST /chat, the Express adapter, createCloudflareChatHandler (the CF Durable Object chat route) and any custom route.

The three answers ​

CaseWhenAnswer
Athe continuation is streamed into the client's trailing message (caller_resumes, a live new_run / deferred attach)unchanged: the run's content, which opens a new step
Bnothing continues for this client: the rest of the batch is answered elsewhere (awaiting_batch), an earlier continuation took the answer (consumed), or the attach re-anchorsa 200 that ends the client's tool step: start (the trailing id) → start-step → rejections → re-anchor triggers → finish-step → finish
Can answer for a call the server still awaits was not stored, while a sibling answer of the request was409 state_not_resumable, cause: 'answer_not_stored', toolCallIds naming the calls

B: why the step boundary ​

A stock useChat asks its sendAutomaticallyWhen predicate again after every non-error finish. Both of ai's stock predicates (lastAssistantMessageIsCompleteWithToolCalls, lastAssistantMessageIsCompleteWithApprovalResponses) read only the tool parts after the trailing message's LAST step-start, and the response of an auto-sent request is appended to that message. A 200 that added no step-start (an empty SSE, only data-resume-rejected events, or the re-anchor answer) left the answered step as it was, so the predicate stayed true and ai sent the same request again, without end. The new answer opens and closes an empty step: the predicates read it, find no tool part, and are false. The request is sent once (proven on the real AbstractChat of ai 6.0.0, 6.0.49, 6.0.91, 6.0.230 and 6.0.281, with each predicate).

What a stock app observes:

  • onFinish fires once for the answer, and the chat ends ready.
  • The trailing assistant message gains an empty step (a step-start part, and any data-* parts the answer carried). It lives only on the client's copy: the snapshot never has it, so a reload rebuilds the message without it, and a stock client does not auto-send on load.

The start-step / finish-step chunks are bare, as ai 6.0.0 declares them (the DI-20 schema contract checks the new answers on every pinned ai).

continuation (or decision)BeforeNow
awaiting_batch / consumedan empty SSE, or only the gathered data-resume-rejected eventsstart, start-step, [data-resume-rejected…], finish-step, finish
the attach re-anchors (another run took the answer, undecided)start, [data-resume-rejected…], data-tool-output-elsewhere…, finishthe same, inside start-step … finish-step

C: an answer that did not land is an error, never a 200 ​

An answer for a call that is still pending was not stored when:

  • the parser rejected it: it names another run (stale-run) or its approval id is malformed (CP-179);
  • its submit failed transiently (CP-171).

When a sibling answer of the same request was stored, a 200 would hide the failure: the client believes the call answered and waits forever. The request is now answered 409 { error, code: 'state_not_resumable', cause: 'answer_not_stored', toolCallIds }, non-retryable (the request is never repeated as sent: its stored answers are in). The calls still await their answers, so the client re-reads the snapshot and answers them again (useHelixChat().retry() does). A stock useChat ends the request with status 'error' and does not auto-send after an error.

Every answer is submitted first (CP-171). A failed submit used to end the request at once, so the request's later answers were never submitted. Now the handler submits each one, then answers:

  1. A submit refused as sent (a classified, non-retryable error: an invalid or oversized answer, a superseded run, …) is answered as itself, keeping its code (400 validation_error, 413 validation_payload_too_large, 413 state_size_exceeded, …): repeating the request cannot succeed, and the code says why. It wins over a transient failure.
  2. With nothing stored by this request, a transient failure keeps its retryable answer (503): a re-send is safe, since a stored sibling answers already_completed.
  3. Otherwise, case C.

The request whose answers were ALL rejected is answered as before: 409 state_not_resumable with causeunknown_tool_call, stale_run or run_stopped (400 validation_error for a malformed approval id).

A request with a stored answer and …BeforeNow
a stale-run answer for a still-pending call200, data-resume-rejected stale-run409 answer_not_stored
a malformed approval id for a still-pending approval200, data-resume-rejected409 answer_not_stored
a later answer whose submit failed transiently500 (inputCommitted)409 answer_not_stored, every other answer submitted
an earlier answer whose submit failed transiently503, the later answers never submitted409 answer_not_stored, every other answer submitted
any answer whose submit was refused as sentits typed answer, later answers droppedits typed answer, every other answer submitted

CP-182: a reloaded page auto-sends like the live page ​

A run waits on a client call and an approval-gated server call of one step, and the page reloads. The snapshot used to render the pending approval part with providerExecuted: true (from the persisted call), while the live stream flags a server part only once its output arrives. ai's stock predicates skip flagged parts, so after a reload:

  • lastAssistantMessageIsCompleteWithToolCalls sent the client's answer while the approval still waited (awaiting_batch), which on the old answers re-sent forever (CP-180's reload shape: 19 identical POSTs in 5 s on the CF DO route);
  • lastAssistantMessageIsCompleteWithApprovalResponses on ai 6.0.0 to 6.0.91 (6.0.230 dropped that filter) never sent the approval (CP-182).

The snapshot now renders an approval part (approval-requested, and a stored answer's approval-responded) without providerExecuted, as live; completed server parts keep the flag. After a reload, with a client call and a pending approval in one step:

PredicateAnswering the client callApproving
lastAssistantMessageIsCompleteWithApprovalResponsesnothing is sentboth answers are sent; the run continues
tool-calls OR approval predicatenothing is sentboth answers are sent; the run continues (then one consumed re-send, case B)
lastAssistantMessageIsCompleteWithToolCalls alonenothing is sentnothing is sent: it counts only output-* parts, live or reloaded

An app with approval-gated tools needs the approval predicate (alone, or OR-ed with the tool-calls predicate); useHelixChat's predicate covers both. Proven on the real AbstractChat of every pinned ai, seeded from buildSnapshot.

CP-172: a re-sent answer for a drained call ​

An answer whose call already drained (an earlier continuation consumed it; core keeps a completed marker, completedClientToolCalls, for completedTombstoneRetentionMs) used to be rejected unknown-tool-call by the handler's parser before the runtime saw it, so a re-POST (a lost 200, a second tab) was answered 409. It is now submitted: core's router answers already_completed / consumed from the marker, which is case B. Past the marker's retention the call is unknown again, answered as before. A consumed answer never replaces the continuation another answer of the same request decided (a trailing message that holds a drained answer and a new one resumes on the new one).

DI-63: a permanent store error is not retryable ​

A chat entry (send, resume or submit) that failed on a corrupt stored row (core isPermanentStoreError: CorruptMessageRowError, InvalidStoredStateError, UnknownCommitKindError) was answered 503, inviting a repeat that reads the same row again. It is now 500 framework_internal_error: the handler classifies a code-less error with core classifyEntryFailure, whose fatal row is isPermanentStoreError.

Rendering the answers in an app ​

typescript
import { parseHelixChatError } from '@helix-agents/ai-sdk';

const body = parseHelixChatError(chat.error);
if (body?.code === 'state_not_resumable' && body.cause === 'answer_not_stored') {
  // body.toolCallIds still await an answer: re-read the snapshot and answer them again.
  await retry(); // useHelixChat().retry()
}

API changes ​

  • @helix-agents/core: SUBMIT_REFUSAL_CAUSES / SubmitRefusalCause (unknown_tool_call, run_stopped, stale_run, answer_not_stored), the cause of a chat submit's 409 state_not_resumable.
  • @helix-agents/ai-sdk:
    • HelixChatErrorBody gains toolCallIds?: string[] (parseHelixChatError reads it).
    • ResumeIntentRejection: the malformed-approval-id variant gains toolCallId; the internal-error variant is removed (nothing produced it since a failed submit or resume is answered typed, DI-39 / CP-03). A switch with a case 'internal-error': no longer compiles: delete the case.
    • extractResumeIntent returns an intent for an answer whose call holds a completed marker (CP-172).
    • buildSnapshot renders an approval part (approval-requested, approval-responded) without providerExecuted (CP-182); the C-1 replay prefix replays it the same way.
  • Logging: each failed chat entry logs ONE line with its cause chain (error level; warn for a lost race, the S-B logging rule). A failed reconcileSession is logged as a reconcile, not a resume: handleChatStream.reconcileSession: reconcile failed (permanent) (DI-64). A submit whose answer was not stored (case C) logs handleResumePath: submit failed (its answer was not stored), once per such answer.

Rejected alternatives ​

  • Making a live approval-gated part carry providerExecuted from its first frame would make the live and reloaded views agree the other way: the stock tool-calls predicate would then skip the pending approval live too, and send the client's answer while the approval still waits. The reloaded part follows the live one instead (CP-182).
  • A 409 awaiting_client_action for an answer sent beside a call the client still holds unanswered: once the reloaded approval is unflagged, no stock predicate sends such a request, so it would only guard custom senders, and a stock chat would sit in 'error' until the user approved. Not built.

Released under the MIT License.