Lesson 05 — isStreaming stuck on abort during a tool call
Grounding: issue
#1614.
Code in
packages/ai-chat/src/react.tsx and
packages/ai-chat/src/ws-chat-transport.ts.
1. What stays broken after stop()
A user calls stop() from useAgentChat
while a tool call is running. Everything you'd expect to happen,
does: the WebSocket cancel frame goes out, the in-flight HTTP
stream is closed, the AI SDK updates its status. But one thing
doesn't happen: isStreaming stays true.
The reporter found this with MCP server tools, but the root cause
is actually on the client side. Understanding why requires tracing
exactly how isStreaming is constructed.
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.
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
U->>R: sendMessage("find me a restaurant")
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
R->>R: onToolCall fires async (still running)
U->>R: stop()
R->>S: CF_AGENT_CHAT_REQUEST_CANCEL
Note over R: AI SDK stop() called
Note over R: status="ready" (already was)
Note over R: isServerStreaming=false (already was)
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 transport stream is
closed, and the AI SDK has been stopped. But the tool part in the
last assistant message is still in input-available
state. hasPendingClientToolCalls reads that state on
the next render and returns true.
addToolOutput is the only thing that transitions the
tool part to output-available, and the user's async
onToolCall callback will never call it now — the user
chose to stop. The flag is permanently stuck.
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.
5. The server-side MCP case
The reporter's specific scenario is MCP tools, which execute server-side. With server-side tools the lifecycle is different: the server stream stays open while the tool runs, and the client sees a continuous stream of frames through the whole turn including tool execution.
In this case, aborting fires a different path. The client calls
cancelActiveServerTurn(), the server receives
CF_AGENT_CHAT_REQUEST_CANCEL, and the abort signal
registered in _streamSSEReply fires
reader.cancel() to unblock the loop. The server then
sends done: true.
When the client receives done: true, the
onAgentMessage handler sets
isServerStreaming = false and the AI SDK stream closes,
moving status to "ready". If all three
signals clear, isStreaming goes false.
So why does the reporter see it stuck? Two possible reasons:
-
If the user calls
abortAllRequests()via an RPC callable (server-side), the client never callscancelActiveServerTurn(). The transport still expects the server to close the stream via the normal done-frame path, which should eventually happen — but if the server is blocked on the MCP tool call and the abort signal doesn't propagate cleanly into the MCP client, thedone: trueframe may be delayed or never sent. -
The simpler explanation: the user is using
onToolCall(a client-side callback that dispatches to MCP) rather than having the server call MCP directly. In that case it's the same bug as section 4 — client-side,hasPendingClientToolCallsis stuck.
A reproduction test that actually fires MCP server-side tools through the abort path is needed to confirm which case the reporter is hitting.
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. The test gap
The issue #1365 test at
react-tests/use-agent-chat.test.tsx:4576 covers the
happy path: isStreaming stays true while
onToolCall is awaiting, then goes false when the tool
completes. It uses getInitialMessages to inject a tool
part in input-available state and a gated
onToolCall callback.
The missing test is the abort case: inject the same initial state,
call stop() while the tool is still running, and assert
that isStreaming goes to false immediately.
This test currently fails (demonstrating the bug) and should pass
after the fix.
it("isStreaming goes false when stop() is called during onToolCall (issue #1614)", async () => {
// inject a tool part in input-available state
// hold the onToolCall async so it never resolves on its own
// call stop()
// assert isStreaming === false
});
8. Self check
Q1
Why doesn't stop() set status to
something that clears isStreaming on its own?
Answer
By the time stop() is called during an
onToolCall gap, status is already
"ready". The server closed its stream when it
emitted the tool call. The AI SDK has nothing left to stop
on the request side, so status doesn't change.
isStreaming is already not relying on
status — it's relying on
hasPendingClientToolCalls.
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
Why is this bug most visible with MCP tools, even though the root cause is in the client-side React hook?
Answer
MCP tools tend to have long execution times — querying an
external service, fetching a file, etc. The window where
onToolCall is running and the user might want to
cancel is much wider than for fast local tool functions. Users
of MCP tools are more likely to notice the stop button not
working. The mechanism is the same regardless of tool type,
but MCP's latency makes the failure conspicuous.