Skip to content

Mid-stream provider error → secondary "Cannot create a stream after it has been aborted" throw masks the real error; failed message stores error: "[object Object]" #320

Description

@AlexGodard

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:

  1. AI SDK routes the provider error to the wrapper's onError (src/vercel/client/streamText.ts:178-182) → call.fail(...), streamer.fail(...)DeltaStreamer.#abortabortController.abort().
  2. 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).
  3. 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).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions