fix(web): send a cancellation notification when cancelling an ordinary tool call (#1458) (#1463)

* fix(web): send a cancellation notification when cancelling an ordinary tool call (#1458)

The Tool detail panel's Cancel button was a silent no-op for ordinary
(non-task) tool calls: App.onCancelToolCall found no captured taskId and
did nothing, leaving the button looking actionable while the call ran on.

Wire request cancellation through the MCP cancellation flow. callTool now
tracks the in-flight call with an AbortController whose signal is threaded
into the SDK request options; aborting it makes the SDK send a
notifications/cancelled for that request and reject the pending call.
cancelToolCall() performs the abort. callTool surfaces the cancellation as
a ToolCallCancelledError (without recording a failed call), which App
clears as a clean cancel plus a short confirmation toast.

Task-augmented calls are unchanged: they have a server-side task and are
still cancelled via cancelRequestorTask (#1457).

Closes #1458

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): address review — distinguish disconnect from cancel; tidy callTool params

Review follow-ups on #1463:

- Disconnect no longer surfaces as "Tool call cancelled". The catch in
  callToolWithRetries now throws ToolCallCancelledError only when the abort
  reason matches the deliberate user-cancel reason (TOOL_CALL_CANCELLED_REASON);
  a disconnect aborts the same controller with a different reason and falls
  through to the normal error path, so no misleading toast.
- Note the activeToolCallAbortController slot is shared (last-writer-wins)
  across all callTool callers.
- Fold callToolWithRetries/attemptToolCall's long positional parameter list into
  a single ToolCallRequest object; move the URL-elicitation loop state into
  callToolWithRetries.

Adds an integration test that a disconnect mid-call rejects as an ordinary
error (not ToolCallCancelledError), using a slow send_progress call over HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-13 22:23:02 -04:00 • committed by GitHub
commit 3220d6239a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -27,6 +27,7 @@ import {
getUrlElicitationsFromError,
UrlElicitationLoopError,
} from "@inspector/core/mcp/urlElicitation.js";
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js";
import type {
InspectorServerSettings,
@ -1481,6 +1482,20 @@ function App() {
error: invocation.error,
});
} catch (err) {
// The user cancelled the in-flight call (Cancel button → cancelToolCall).
// The cancellation notification was already sent to the server, so just
// clear the executing state — surfacing it as an error would read as a
// failure rather than the deliberate cancel it was (#1458).
if (err instanceof ToolCallCancelledError) {
setToolCallState(undefined);
notifications.show({
title: "Tool call cancelled",
message: "A cancellation request was sent to the server.",
color: "gray",
autoClose: 3000,
});
return;
}
// The server kept asking for a URL the user already completed this call,
// so callTool aborted to avoid an endless re-prompt loop. Surface that
// explicitly rather than as a generic failure.
@ -1754,9 +1769,12 @@ function App() {
// Cancel the in-flight tool call. A task-augmented call (run-as-task) has a
// server-side task, so cancel that via the tasks API (#1455) — the cancelled
// status then flows back through the managed task store and toasts, the same
// as cancelling from the Tasks screen. An ordinary call has no task (and no
// request-abort channel yet), so there is nothing to cancel server-side.
// as cancelling from the Tasks screen. An ordinary call has no task, so abort
// its request: the SDK sends a `notifications/cancelled` to the server (the
// MCP cancellation flow) and the pending call rejects with a
// ToolCallCancelledError that `onCallTool` clears as a cancellation (#1458).
const onCancelToolCall = useCallback(() => {
if (!inspectorClient) return;
const taskId = activeToolCallTaskIdRef.current;
if (taskId) {
// Clear the ref before the call resolves so a rapid second Cancel click
@ -1764,8 +1782,10 @@ function App() {
// spurious "Failed to cancel task" toast).
activeToolCallTaskIdRef.current = undefined;
void onCancelTask(taskId);
return;
}
}, [onCancelTask]);
inspectorClient.cancelToolCall();
}, [inspectorClient, onCancelTask]);
const onClearCompletedTasks = useCallback(() => {
clearCompletedTasks();