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.

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.

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.