fix(web): cancel the underlying task when cancelling a task tool call (#1455) (#1457)

* fix(web): cancel the underlying task when cancelling a task tool call (#1455)

The ToolDetailPanel Cancel button fired onCancel, but App never supplied
an onCancelToolCall handler, so clicking Cancel did nothing — a tool
running as a task kept running on the server.

App now captures the in-flight task-augmented call's taskId from the
`toolCallTaskUpdated` event callToolStream dispatches, and the new
onCancelToolCall handler cancels that task via the existing tasks API
(reusing onCancelTask). The id is reset at the start of every call, so an
ordinary (non-task) call's Cancel stays a no-op (no task to cancel, no
request-abort channel yet) — preserving prior behavior.

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

* feat(web): show a toast when a task is cancelled

Cancellation goes out as a `taskCancelled` event (not a status
notification), so a running task's open 'Task <status>' toast would
otherwise hang with no confirmation. Subscribe to `taskCancelled` and
replace that toast (or show a fresh one) with a short auto-closing
'Task cancelled' toast, pruning the dead progress entry. Covers both
cancel paths — the Tasks screen and the Tool detail panel.

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

* fix(core): report a cancelled task as cancelled, not failed

When a task is cancelled, the in-flight callToolStream rejects with a
generic -32603 error, whose error path reported the task as "failed" —
so the Tasks screen flashed FAILED until a manual refresh fetched the
server's true "cancelled" state. InspectorClient now records cancelled
task ids (set in cancelRequestorTask before the await, since cancelling
unblocks the stream) and the stream's error path labels the terminal
task "cancelled" with a matching status message. Cleared on disconnect.

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

* refactor: address review nits on task-cancel wiring

- Prune the consumed taskId from cancelledTaskIds in the stream error path
  so the set can't grow across a long session (disconnect-clear remains the
  backstop for cancels whose task completed before the cancel landed).
- Clear activeToolCallTaskIdRef before the cancel resolves so a rapid second
  Cancel click doesn't re-cancel the terminating task (which would surface a
  spurious 'Failed to cancel task' toast). Add a test.

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 18:00:48 -04:00 • committed by GitHub
commit 6bb49e55ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -263,6 +263,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onReadResource: (uri: string) => void;
onSetLogLevel: (level: string) => void;
onCancelTask: (taskId: string) => void;
onCancelToolCall: () => void;
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
onServerSettings: (id: string) => void;
@ -357,6 +358,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
call-as-task
</button>
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
<button onClick={() => props.onClearCompletedTasks()}>
clear-completed
</button>
@ -756,6 +758,87 @@ describe("App task wiring", () => {
);
});
it("cancels the underlying task when a task-augmented tool call is cancelled", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
// callToolStream surfaces the created task's id via `toolCallTaskUpdated`
// mid-call; App stashes it so Cancel knows which task to cancel (#1455).
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("toolCallTaskUpdated", {
detail: { taskId: "task-42", task: { taskId: "task-42" } },
}),
);
});
await user.click(screen.getByText("cancel-tool-call"));
expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-42");
});
it("does not re-cancel on a rapid second Cancel click", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("toolCallTaskUpdated", {
detail: { taskId: "task-42", task: { taskId: "task-42" } },
}),
);
});
// Two clicks before the call resolves must cancel only once — the second
// finds the ref already cleared, avoiding a spurious cancel of a terminal
// task.
await user.click(screen.getByText("cancel-tool-call"));
await user.click(screen.getByText("cancel-tool-call"));
expect(client.cancelRequestorTask).toHaveBeenCalledTimes(1);
});
it("does not cancel a task when an ordinary tool call is cancelled", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
// A stale task id from an earlier task call...
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("toolCallTaskUpdated", {
detail: { taskId: "old-task", task: { taskId: "old-task" } },
}),
);
});
// ...is cleared when a new ordinary call starts, so its Cancel is a no-op.
await user.click(screen.getByText("call"));
await waitFor(() =>
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
);
await user.click(screen.getByText("cancel-tool-call"));
expect(client.cancelRequestorTask).not.toHaveBeenCalled();
});
it("shows a URL-elicitation toast when a tool call fails with a no-list -32042", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
@ -941,6 +1024,57 @@ describe("App task wiring", () => {
),
);
});
it("shows a 'Task cancelled' toast when a task is cancelled", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// No live status toast for this task — cancellation shows a fresh one.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskCancelled", { detail: { taskId: "task-1" } }),
);
});
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: "Task cancelled", color: "gray" }),
);
});
it("converts a running task's live toast into the cancellation toast", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// A running task has an open "Task working" toast...
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskStatusChange", {
detail: {
taskId: "task-1",
task: { status: "working", statusMessage: "Interpreting" },
},
}),
);
});
const liveId = notificationsMock.show.mock.calls[0][0].id;
notificationsMock.show.mockClear();
// ...which the cancel replaces in place (update), not a stacked toast.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskCancelled", { detail: { taskId: "task-1" } }),
);
});
expect(notificationsMock.show).not.toHaveBeenCalled();
expect(notificationsMock.update).toHaveBeenCalledWith(
expect.objectContaining({ id: liveId, title: "Task cancelled" }),
);
});
});
// Live-apply roots on settings-dialog close: the App diffs the final draft