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.

SignalSourceMeaning
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:

  1. If the user calls abortAllRequests() via an RPC callable (server-side), the client never calls cancelActiveServerTurn(). 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, the done: true frame may be delayed or never sent.
  2. 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, hasPendingClientToolCalls is 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.