Lesson 05 — isStreaming stuck on abort during a tool call
Grounding: issue
#1614.
Client code in packages/ai-chat/src/react.tsx; server
abort path in packages/ai-chat/src/index.ts
(_streamSSEReply); MCP tool wrapper in
packages/agents/src/mcp/client.ts.
1. What stays broken after stop()
A user calls stop() from useAgentChat
(or a callable that calls abortAllRequests()) while a
tool call is running. Everything you'd expect to happen, does: the
WebSocket cancel frame goes out, the in-flight stream is closed, the
AI SDK updates its status. But one thing doesn't happen:
isStreaming stays true.
The reporter's tools come from an MCP server, which
matters: MCP tools execute server-side, inside the
chat turn's streamText loop, not via the client
onToolCall callback. The obvious hypothesis is that the
server, blocked awaiting a slow MCP tool, never signals that the
stream ended — so the client never learns the turn is over.
That hypothesis is wrong, and this lesson's main job is to show why it's wrong and where the bug actually lives. The discipline here is: when a symptom points at one layer (server), prove or disprove that layer before fixing anything. We disprove it with a reproduction, then follow the evidence to the client.
2. How isStreaming is built
isStreaming is not stored state. It is derived on
every render from three independent signals.
| Signal | Source | Meaning |
|---|---|---|
status === "streaming" |
AI SDK useChat |
A user-initiated request stream is in flight. |
isServerStreaming |
useState in useAgentChat |
Server has broadcast a streaming response the client is receiving over WebSocket. |
hasPendingClientToolCalls |
Derived from message state | The last assistant message has a tool part in input-available state that onToolCall or a tool execute function will handle. |
The derivation, from
packages/ai-chat/src/react.tsx:
const hasPendingClientToolCalls = (() => {
const hasOnToolCall = !!onToolCall;
if (!hasOnToolCall && !tools) return false;
if (!lastAssistantMessage) return false;
for (const part of lastAssistantMessage.parts) {
if (!isToolUIPart(part)) continue;
if (part.state !== "input-available") continue;
...
if (hasOnToolCall || tools?.[toolName]?.execute) return true;
}
return false;
})();
const effectiveIsServerStreaming = isServerStreaming || hasPendingClientToolCalls;
const isStreaming = status === "streaming" || effectiveIsServerStreaming;
hasPendingClientToolCalls was added by issue #1365 to
plug a gap: when a server-side turn emits a tool call and then
closes its stream, status drops to "ready"
even though the async onToolCall is still working.
Without this flag, there would be no way to show a loading indicator
during that gap. The fix is sound for the normal completion case.
It has a hole in the abort case.
Why the flag is derived, not stored
Tool part state lives in the AI SDK's message list, which is the canonical UI state. Deriving from it means the flag is always consistent with what the UI would render — no setter to keep in sync. This is the right model. The bug is not in the choice to derive, but in a missing short-circuit.
The over-count at line 2217
Look closely at the last line of the loop:
if (hasOnToolCall || tools?.[toolName]?.execute) return
true;. With onToolCall defined, this returns
true for any tool part in
input-available — without checking that
onToolCall actually owns that tool. A server-side MCP
tool the client never handles still trips it. This is the seam the
MCP case falls through: the client cannot distinguish a server-run
tool from a client-run one, and assumes any pending tool is its own
work.
3. The happy path
Here is what happens when a tool call completes normally:
sequenceDiagram
participant U as User
participant R as React
participant S as Server
U->>R: sendMessage("find me a restaurant")
Note over R: status="streaming", isStreaming=true
S-->>R: tool-input-start, tool-input-delta
S-->>R: tool-input-available (done: false)
S-->>R: done: true
Note over R: status="ready"
Note over R: hasPendingClientToolCalls=true
Note over R: isStreaming=true ← correct, tool still running
R->>R: onToolCall fires async
R->>R: addToolOutput called
Note over R: tool part → output-available
Note over R: hasPendingClientToolCalls=false
Note over R: isStreaming=false ← clean
The tool part transitions from input-available to
output-available when addToolOutput is
called. That transition is what makes hasPendingClientToolCalls
drop to false.
4. Where abort breaks the state machine
Now consider what happens when stop() is called while
the tool is still running:
sequenceDiagram
participant U as User
participant R as React
participant S as Server (MCP tool)
U->>R: sendMessage("look up the order")
S-->>R: tool-input-available (searchDatabase, done: false)
Note over R: status="streaming", isStreaming=true
Note over S: MCP tool running server-side…
U->>R: stop()
R->>S: CF_AGENT_CHAT_REQUEST_CANCEL
S-->>R: done: true (abort path)
Note over R: status="ready"
Note over R: isServerStreaming=false
Note over R: searchDatabase part still input-available
Note over R: hasPendingClientToolCalls=true ← STUCK
Note over R,S: isStreaming stays true forever
The call to stop() goes through
stopWithToolContinuationAbort:
const stopWithToolContinuationAbort: typeof stop = useCallback(async () => {
try {
customTransport.cancelActiveServerTurn(); // sends cancel frame
await stop(); // AI SDK stop
} finally {
customTransport.abortActiveToolContinuation(); // abort continuation stream
}
}, [stop, customTransport]);
After this runs: the cancel frame is sent, the server replies with
done: true, status returns to
"ready", and isServerStreaming clears. But
the searchDatabase tool part is still in
input-available — the server never sent
tool-output-available because the turn was aborted
before the tool finished. hasPendingClientToolCalls
reads that state on the next render and returns true.
The only things that transition a tool part out of
input-available are a tool-output-available
from the server, or a client addToolOutput. Neither
fires on abort. For an MCP tool the client has no
addToolOutput to call anyway — it was the server's tool.
The part is frozen, and the flag is pinned.
The missing state transition
stop() creates a new terminal state (aborted) but
the flag hasPendingClientToolCalls has no branch for
it. It was designed to go false on tool completion; it has no path
for tool cancellation — and via the line 2217 over-count it counts
tools that were never the client's to complete.
5. Disproving the server hypothesis
MCP tools execute server-side. While the tool runs,
streamText is suspended and the server's read loop in
_streamSSEReply (index.ts:3858) is parked
on await reader.read() (index.ts:3893). The
worry is that abort can't break that suspension, so
done: true never goes out.
But the abort signal is wired in two independent places. The read
loop checks abortSignal?.aborted each iteration, AND an
abort listener cancels the reader directly:
// index.ts:3879
if (abortSignal && !abortSignal.aborted) {
abortSignal.addEventListener("abort", () => {
reader.cancel().catch(() => {}); // unblocks the parked read()
}, { once: true });
}
Per the WHATWG Streams spec, reader.cancel() resolves a
pending read() with { done: true } — it
does not wait for the suspended source (the tool) to produce
anything. The loop sees done, sees
abortSignal.aborted, breaks, and the post-loop branch
broadcasts the terminal frame:
// index.ts:4239 — runs after the loop breaks on abort
if (!streamCompleted.value) {
this._completeStream(streamId);
streamCompleted.value = true;
this._broadcastChatMessage({ body: "", done: true, id, type: ... });
return { status: "aborted" };
}
Proven, not assumed
A reproduction in
tests/cancel-request.test.ts ("Cancel during
server-side tool execution") connects a test agent that emits a
tool call then leaves the stream open — modelling a suspended MCP
tool. Cancelling mid-tool produces a done: true frame,
with no tool-output-available ever emitted. The test
passes. The server terminates the stream correctly.
One detail that makes the safety net necessary: the MCP tool
wrapper's execute ignores the AI SDK
abortSignal (mcp/client.ts:1499), so the
in-flight MCP request is not cancelled — it runs to completion and
its result is discarded. Stream termination does not depend on the
tool, precisely because reader.cancel() is independent
of it.
So the server emits done: true, the client's
status returns to "ready", and
isServerStreaming clears. Two of the three signals are
correctly false. The stuck flag is the third one —
hasPendingClientToolCalls — which the next section
showed never resets. The MCP tool part is frozen in
input-available, and line 2217 counts it whenever the
app also defines onToolCall or tools.
Caveat: pure MCP with no client tools does not reproduce
If the app defines neither onToolCall nor
tools on the client, the derivation returns
false at the early guard
(react.tsx:2208), and isStreaming clears
correctly. So this bug needs the app to also define a client tool
handler. If the reporter has neither and still hangs, there is a
second, provider-specific server path not reproduced here — worth
asking them for their useAgentChat config before
committing to a fix.
6. Design options for the fix
Option A: stopped ref in useAgentChat
Add a userStoppedRef = useRef(false). Set it to
true in stopWithToolContinuationAbort.
Clear it at the start of the next sendMessage. In the
hasPendingClientToolCalls derivation, add a short-circuit:
if (userStoppedRef.current) return false;
Minimal. No message state changes. The tool part stays in
input-available in the message list, which may or may
not be desirable for UIs that render tool state.
Option B: call addToolResult with a sentinel error
In stopWithToolContinuationAbort, after stopping, find
every input-available tool part in the last assistant
message and call addToolResult with an error sentinel:
for (const part of lastMessage.parts) {
if (isToolUIPart(part) && part.state === "input-available") {
addToolResult({
toolCallId: part.toolCallId,
result: { error: "Aborted" }
});
}
}
Self-heals the message state. Any UI rendering tool output will see a clean terminal state. More invasive — changes what the user's message history looks like after a stop.
Option C: check AI SDK abort state
If the AI SDK exposes a stable way to check that a stop has been
called (e.g., via the status field or a dedicated
flag), use it in the derivation. Worth checking whether
status === "submitted" or any short-lived
intermediate state after stop() could be used, but
this requires inspecting the AI SDK internals and is likely fragile.
Lean
Option A is the minimal fix with no message-state side-effects. Option B is cleaner for consumers that render tool state. Confirm the desired UX with the team before choosing — the decision is about what the message history should look like after a stop, not just about the flag.
7. Two reproductions, two layers
The investigation produced two tests, one per layer. The pairing is the point: the passing server test rules out the server, and the failing client test localises the defect.
Server (passes): the server is not the bug
tests/cancel-request.test.ts → "Cancel during
server-side tool execution". A new test agent emits a tool call then
holds the stream open (a suspended MCP tool). Cancelling mid-tool
yields a done: true frame and no
tool-output-available. It passes today — evidence the
server terminates correctly.
Client (fails): the MCP over-count
react-tests/use-agent-chat.test.tsx → "isStreaming on
abort during a server-side (MCP) tool call". It seeds a
searchDatabase tool part (server-run) in
input-available, defines onToolCall for a
different client tool (getLocation), and asserts
isStreaming goes false after stop(). It
fails: the MCP part is counted by line 2217 even though
onToolCall never handles it.
// onToolCall owns getLocation, NOT searchDatabase (server's job)
// seed searchDatabase as input-available
// isStreaming === true ← correct while server runs it
// await stop()
// isStreaming === false ← currently FAILS
The issue #1365 test at
use-agent-chat.test.tsx:4576 remains the guard for the
happy path (flag stays true while a tool genuinely runs), so any fix
must keep it green.
8. Self check
Q1
Why doesn't stop() set status to
something that clears isStreaming on its own?
Answer
status does clear: the server's abort-path
done: true closes the transport stream and moves
status to "ready". The problem is that
isStreaming is an OR of three signals, and the third
one (hasPendingClientToolCalls) is still true. So
clearing status isn't enough — the stuck tool part
pins the composite flag regardless.
Q2
Why was hasPendingClientToolCalls added in #1365,
and what problem would recur if it were simply removed?
Answer
Issue #1365 found that status drops to
"ready" as soon as the server stream closes,
even while an async onToolCall is still running.
Without hasPendingClientToolCalls, there is a
"dark gap" where isStreaming === false but the UI
should still be showing a loading indicator. Consumers that use
isStreaming to gate a spinner would flash it off
mid-tool. Removing the flag would reintroduce that UX regression.
Q3
If the fix is Option A (stopped ref), what edge case must the ref clear logic handle to avoid a different bug?
Answer
The ref must be cleared before the next sendMessage
is processed, not after. If it's cleared in the response handler,
there is a brief window where a new message is submitted but the
ref still says stopped — which would incorrectly suppress
hasPendingClientToolCalls on the first tool call of
the new turn. Clearing it at the start of the
sendMessage call (or equivalently, at the point
where the transport opens a new server turn) is the safe spot.
Q4
The report blamed an MCP (server-side) tool. How do you rule out the server being at fault before touching client code?
Answer
Write a server-side reproduction: an agent that emits a tool
call then holds the stream open (a suspended tool), cancel it
mid-tool, and inspect the frames the client receives. If a
done: true frame arrives, the server is signalling
stream end correctly and the fault is downstream. That test
passes here, which is what licenses focusing on the client's
derived isStreaming. Proving the layer, rather than
reasoning about it, is what stops you fixing the wrong file.
Q5
A pure-MCP app that passes neither onToolCall nor
tools to useAgentChat does not reproduce
this bug. Why, and what does that imply for triage?
Answer
The derivation returns false at the early guard
(react.tsx:2208) when both are absent, so
hasPendingClientToolCalls can't be the culprit and
isStreaming clears on done: true. The
bug needs a client tool handler to be defined too. So before
committing a fix, confirm the reporter's useAgentChat
config. If they truly have neither and still hang, there's a
separate, provider-specific server path to chase instead.