fix(agent): stop thinking timer on abort by emitting abort stream part#13568
fix(agent): stop thinking timer on abort by emitting abort stream part#13568
Conversation
The agent SSE stream's abort handler directly called controller.error()
without first enqueuing an 'abort' stream part. This meant
AiSdkToChunkAdapter never emitted the ERROR chunk, so callbacks.onError
was never called and the thinking block stayed in STREAMING status,
causing the thinking timer to run indefinitely after force-stopping.
Enqueue { type: 'abort' } before controller.error() to match AI SDK
stream behavior, ensuring the error callback fires and block statuses
are properly updated on abort.
Fixes #13566
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: suyao <sy20010504@gmail.com>
EurFelux
left a comment
There was a problem hiding this comment.
The bug diagnosis is correct — the thinking timer not stopping on abort is a real issue. However, the fix is applied at the wrong layer.
createSSEReadableStream is transport (SSE parsing), and shouldn't emit synthetic { type: 'abort' } parts to simulate AI SDK runtime behavior. In the normal chat pipeline, AI SDK's runtime handles abort signal → abort chunk translation. In the Agent pipeline, AI SDK's chunk types are used as a protocol but the runtime is absent — so nobody owns stream lifecycle management (abort, error, cleanup).
The proper fix is to introduce a middleware layer (between SSE stream and AiSdkToChunkAdapter) that takes over the stream lifecycle responsibilities that AI SDK normally handles. Without it, we'll continue patching AI SDK runtime behavior into unrelated layers.
The agent SSE stream's abort handler in createSSEReadableStream called
controller.error() without first emitting an 'abort' stream part. This
meant AiSdkToChunkAdapter never fired callbacks.onError, leaving the
thinking block in STREAMING status and the timer running indefinitely.
Instead of patching the transport layer (createSSEReadableStream) or
the protocol adapter (AiSdkToChunkAdapter), introduce a dedicated
stream lifecycle middleware — withAbortStreamPart — that sits between
them. It intercepts source stream errors on abort and emits the
{ type: 'abort' } stream part that the AI SDK runtime would normally
produce, keeping each layer's responsibilities clean.
Fixes #13566
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: suyao <sy20010504@gmail.com>
EurFelux
left a comment
There was a problem hiding this comment.
The layering is much better now — withAbortStreamPart as a dedicated lifecycle middleware is the right approach. One minor issue with silent error swallowing in the non-abort path, see inline comment.
| } catch { | ||
| // When the source errors due to abort, emit the abort stream part | ||
| // so downstream consumers (AiSdkToChunkAdapter) can fire onError. | ||
| if (signal.aborted) { | ||
| try { | ||
| controller.enqueue({ type: 'abort' } as TextStreamPart<Record<string, any>>) | ||
| } catch { | ||
| // Controller may already be closed | ||
| } | ||
| } | ||
| controller.close() |
There was a problem hiding this comment.
The middleware extraction looks great — clean separation of concerns 👍
One issue: the catch block silently swallows non-abort errors. If reader.read() throws for a reason other than abort (e.g. network failure, decoding error), the stream just closes normally via controller.close() and downstream has no idea an error occurred.
Consider propagating the error in the non-abort path:
} catch (error) {
if (signal.aborted) {
try {
controller.enqueue({ type: 'abort' } as TextStreamPart<Record<string, any>>)
} catch {
// Controller may already be closed
}
controller.close()
} else {
controller.error(error)
}
}This way abort gets the lifecycle treatment, and genuine errors still propagate.
There was a problem hiding this comment.
Good catch — fixed in 63c7612. Non-abort errors now propagate via controller.error(error), only abort gets the lifecycle treatment (enqueue abort part + close).
The catch block in withAbortStreamPart was silently closing the stream for all errors. Now only abort errors get the lifecycle treatment (enqueue abort part + close), while genuine errors (network failure, decoding error) propagate via controller.error() so downstream consumers are properly notified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: suyao <sy20010504@gmail.com>
What this PR does
Before this PR:
After force-stopping an Agent while it is thinking, the thinking time counter continues running indefinitely because
callbacks.onErroris never called for agent streams on abort.After this PR:
The thinking timer stops correctly when the user force-stops an Agent, because the agent SSE stream now emits an
'abort'stream part before erroring — matching AI SDK stream behavior — socallbacks.onErrorfires and updates block statuses.Fixes #13566
Why we need it and why it was done in this way
The root cause is in
createSSEReadableStream(used only for agent streams). Its abort handler calledcontroller.error()directly without first enqueuing an'abort'TextStreamPart. The standard AI SDK streams emit an'abort'part before erroring, whichAiSdkToChunkAdapter.convertAndEmitChunkconverts to aChunkType.ERRORchunk, triggeringcallbacks.onErrorto update block statuses (STREAMING → PAUSED). Without that stream part, the adapter'sprocessStreamcatch silently swallowed the AbortError, andonErrorwas never invoked.The following tradeoffs were made:
A try/catch wraps the
controller.enqueue()in case the controller is already closed. This is defensive but necessary since the abort handler can race with a natural stream close.The following alternatives were considered:
AiSdkToChunkAdapter.processStreamto emit the ERROR chunk in the catch block when no error chunk was emitted. Rejected because fixing the upstream source (createSSEReadableStream) is cleaner and makes agent streams consistent with AI SDK streams.Breaking changes
None.
Special notes for your reviewer
Single-line root cause:
createSSEReadableStream's abort handler →controller.error()without priorcontroller.enqueue({ type: 'abort' })→AiSdkToChunkAdapternever emits ERROR chunk →onErrornever called → thinking block stays STREAMING.Checklist
Release note