Version: @convex-dev/agent@0.7.0 (same code on main today), ai@7.0.66, saveStreamDeltas: { chunking: "word", throttleMs: 50 }, gateway model openai/gpt-5.6-sol via @ai-sdk/gateway.
What happened
On a multi-step agent.streamText run, OpenAI emitted a mid-stream error part (sequence 227, right as the model finished reasoning and was about to call a tool):
onError { error: { type: 'error', sequence_number: 227,
error: { type: 'invalid_request_error', code: 'invalid_prompt',
message: 'Invalid prompt: your prompt was flagged as potentially violating our usage policy.' } } }
One second later our action died with an unrelated internal error that propagated out of the drain and ended up in the user's chat banner:
Error: Cannot create a stream after it has been aborted
at DeltaStreamer.getStreamId (src/vercel/client/streaming.ts:267)
and the failed assistant message row in the component's messages table has status: "failed", error: "[object Object]".
Two things I'd consider bugs
1. A late step on an aborted streamer throws instead of no-op'ing
Sequence, as far as I can trace it:
- AI SDK routes the provider error to the wrapper's
onError (src/vercel/client/streamText.ts:178-182) → call.fail(...), streamer.fail(...) → DeltaStreamer.#abort → abortController.abort().
- The AI SDK still proceeds to open the stream for the next step (or the deferred-final-step save path calls
streamer.getOrCreateStreamId()), which lands in getStreamId() while abortController.signal.aborted is true → throw new Error("Cannot create a stream after it has been aborted") (streaming.ts:264-268).
- That throw surfaces from
await result.consumeStream() / the caller's for await (const _ of result.textStream) and wins over the original provider error, which only ever existed inside onError. Any consumer that captures onError and rethrows a classified error after draining (the pattern the README suggests, since stream errors don't reject) gets pre-empted by this secondary throw.
Expected: once the streamer has been aborted by a stream error, a late getStreamId() / addParts() should be a no-op (like addParts already is on the aborted path at streaming.ts:279-281), or the wrapper should attach/prefer the original onError error when rethrowing. First error should win.
2. errorToString on the raw provider chunk → "[object Object]"
onError receives { error } where error is the raw provider chunk (a plain object, not an Error) for mid-stream failures. errorToString (src/vercel/client/utils.ts:49-54) does String(error) for non-Error values, so call.fail(...) / streamer.fail(...) persist error: "[object Object]" on the message and stream rows — the actual provider message/code is lost from the durable record.
Suggested: unwrap error.error?.message / error.message / error.code before falling back to String(...), or JSON.stringify plain objects (bounded).
Workaround we shipped
On our side we wrap the drain: once our onError has captured a stream error, a subsequent throw from the same streamText pass is swallowed and the captured error is rethrown (classified) instead. Works, but it's papering over (1), and (2) still leaves [object Object] in the component tables.
Happy to send a PR for either if you tell me which shape you'd prefer (no-op getStreamId after a stream-error abort vs. rethrow-the-original in the wrapper).
Version:
@convex-dev/agent@0.7.0(same code onmaintoday),ai@7.0.66,saveStreamDeltas: { chunking: "word", throttleMs: 50 }, gateway modelopenai/gpt-5.6-solvia@ai-sdk/gateway.What happened
On a multi-step
agent.streamTextrun, OpenAI emitted a mid-stream error part (sequence 227, right as the model finished reasoning and was about to call a tool):One second later our action died with an unrelated internal error that propagated out of the drain and ended up in the user's chat banner:
and the failed assistant message row in the component's
messagestable hasstatus: "failed", error: "[object Object]".Two things I'd consider bugs
1. A late step on an aborted streamer throws instead of no-op'ing
Sequence, as far as I can trace it:
onError(src/vercel/client/streamText.ts:178-182) →call.fail(...),streamer.fail(...)→DeltaStreamer.#abort→abortController.abort().streamer.getOrCreateStreamId()), which lands ingetStreamId()whileabortController.signal.abortedis true →throw new Error("Cannot create a stream after it has been aborted")(streaming.ts:264-268).await result.consumeStream()/ the caller'sfor await (const _ of result.textStream)and wins over the original provider error, which only ever existed insideonError. Any consumer that capturesonErrorand rethrows a classified error after draining (the pattern the README suggests, since stream errors don't reject) gets pre-empted by this secondary throw.Expected: once the streamer has been aborted by a stream error, a late
getStreamId()/addParts()should be a no-op (likeaddPartsalready is on the aborted path atstreaming.ts:279-281), or the wrapper should attach/prefer the originalonErrorerror when rethrowing. First error should win.2.
errorToStringon the raw provider chunk →"[object Object]"onErrorreceives{ error }whereerroris the raw provider chunk (a plain object, not anError) for mid-stream failures.errorToString(src/vercel/client/utils.ts:49-54) doesString(error)for non-Errorvalues, socall.fail(...)/streamer.fail(...)persisterror: "[object Object]"on the message and stream rows — the actual provider message/code is lost from the durable record.Suggested: unwrap
error.error?.message/error.message/error.codebefore falling back toString(...), orJSON.stringifyplain objects (bounded).Workaround we shipped
On our side we wrap the drain: once our
onErrorhas captured a stream error, a subsequent throw from the samestreamTextpass is swallowed and the captured error is rethrown (classified) instead. Works, but it's papering over (1), and (2) still leaves[object Object]in the component tables.Happy to send a PR for either if you tell me which shape you'd prefer (no-op
getStreamIdafter a stream-error abort vs. rethrow-the-original in the wrapper).