feat(web): wire up MCP task support — Tasks screen, Run-as-task, status toasts (#1422) (#1424)

Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
This commit is contained in:
Cliff Hall 2026-06-04 08:49:47 -04:00 • committed by GitHub
commit ed6eda7492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -41,6 +41,10 @@ vi.mock("@inspector/core/mcp/index.js", () => {
callTool = vi
.fn()
.mockResolvedValue({ success: true, result: { acts: [] } });
callToolStream = vi
.fn()
.mockResolvedValue({ success: true, result: { acts: [] } });
cancelRequestorTask = vi.fn().mockResolvedValue(undefined);
getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
readResource = vi
.fn()
@ -166,7 +170,11 @@ vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [] })),
}));
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
useManagedRequestorTasks: vi.fn(() => ({ tasks: [], refresh: vi.fn() })),
useManagedRequestorTasks: vi.fn(() => ({
tasks: [],
refresh: vi.fn().mockResolvedValue([]),
clearCompleted: vi.fn(),
})),
}));
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
useResourceSubscriptions: vi.fn(() => ({ subscriptions: [] })),
@ -223,15 +231,26 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
filterText: string;
visibleLevels: Record<string, boolean>;
}) => void;
onCallTool: (name: string, args: Record<string, unknown>) => void;
progressByTaskId?: Record<string, unknown>;
onCallTool: (
name: string,
args: Record<string, unknown>,
runAsTask?: boolean,
) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onReadResource: (uri: string) => void;
onSetLogLevel: (level: string) => void;
onCancelTask: (taskId: string) => void;
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
}) => (
<div>
<span data-testid="tool-status">
{props.toolCallState?.status ?? "none"}
</span>
<span data-testid="task-progress-keys">
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
</span>
<span data-testid="selected-tool">
{props.toolsUi?.selectedToolName ?? "none"}
</span>
@ -307,6 +326,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
set-log-filter
</button>
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
call-as-task
</button>
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
<button onClick={() => props.onClearCompletedTasks()}>
clear-completed
</button>
<button onClick={() => props.onRefreshTasks()}>refresh-tasks</button>
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
<button onClick={() => props.onReadResource("res://x")}>
read-resource
@ -318,6 +345,21 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
import App from "./App";
import * as McpIndex from "@inspector/core/mcp/index.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
// Default useInspectorClient return — capabilities empty (no task tool calls).
// Individual tests override via vi.mocked(...).mockReturnValue(...).
const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
status: "connected",
capabilities: {},
clientCapabilities: {},
serverInfo: undefined,
instructions: undefined,
appRendererClient: null,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
};
const clientInstances = (
McpIndex as unknown as { __clientInstances: EventTarget[] }
@ -587,3 +629,217 @@ describe("App pending server-initiated request modal", () => {
);
});
});
describe("App task wiring", () => {
beforeEach(() => {
clientInstances.length = 0;
notificationsMock.show.mockClear();
notificationsMock.update.mockClear();
notificationsMock.hide.mockClear();
// Restore the default task-hook return between tests that override it.
vi.mocked(useManagedRequestorTasks).mockReturnValue({
tasks: [],
refresh: vi.fn().mockResolvedValue([]),
clearCompleted: vi.fn(),
});
// Restore the default capabilities (no task tool calls) between tests.
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
});
it("routes a Run-as-task call through callToolStream with the server's TTL", async () => {
// onCallTool only task-augments when the server advertises task tool calls.
vi.mocked(useInspectorClient).mockReturnValue({
...DEFAULT_USE_INSPECTOR_CLIENT,
capabilities: { tasks: { requests: { tools: { call: {} } } } },
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("call-as-task"));
const client = clientInstances[0] as unknown as {
callToolStream: ReturnType<typeof vi.fn>;
callTool: ReturnType<typeof vi.fn>;
};
await waitFor(() => expect(client.callToolStream).toHaveBeenCalledTimes(1));
expect(client.callTool).not.toHaveBeenCalled();
// 5th arg is the task options; TTL falls back to the 60000 default since
// SERVER_A has no `settings.taskTtl`.
expect(client.callToolStream.mock.calls[0][4]).toEqual({ ttl: 60000 });
});
it("does NOT task-augment when the server lacks task-tool-call support", async () => {
// Default capabilities (no tasks.requests.tools.call). A stale run-as-task
// request must fall back to the normal callTool path, never callToolStream.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("call-as-task"));
const client = clientInstances[0] as unknown as {
callToolStream: ReturnType<typeof vi.fn>;
callTool: ReturnType<typeof vi.fn>;
};
await waitFor(() => expect(client.callTool).toHaveBeenCalledTimes(1));
expect(client.callToolStream).not.toHaveBeenCalled();
});
it("surfaces a cancel failure as a red toast", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
(
clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
}
).cancelRequestorTask.mockRejectedValueOnce(new Error("nope"));
await user.click(screen.getByText("cancel-task"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to cancel task",
color: "red",
}),
),
);
});
it("surfaces a refresh failure as a red toast", async () => {
vi.mocked(useManagedRequestorTasks).mockReturnValue({
tasks: [],
refresh: vi.fn().mockRejectedValue(new Error("list boom")),
clearCompleted: vi.fn(),
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("refresh-tasks"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to refresh tasks",
color: "red",
}),
),
);
});
it("clear-completed calls through to the hook's clearCompleted", async () => {
const clearCompleted = vi.fn();
vi.mocked(useManagedRequestorTasks).mockReturnValue({
tasks: [],
refresh: vi.fn().mockResolvedValue([]),
clearCompleted,
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("clear-completed"));
expect(clearCompleted).toHaveBeenCalledTimes(1);
});
it("shows a task-status toast, updates it, and hides it on terminal status", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// First status → a fresh toast keyed by the task id.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskStatusChange", {
detail: {
taskId: "task-1",
task: { status: "working", statusMessage: "Interpreting" },
},
}),
);
});
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
const shown = notificationsMock.show.mock.calls[0][0];
expect(shown.title).toBe("Task working");
expect(shown.message).toBe("Interpreting");
// Next status on the same task → the existing toast is updated, not stacked.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskStatusChange", {
detail: {
taskId: "task-1",
task: { status: "working", statusMessage: "Still going" },
},
}),
);
});
expect(notificationsMock.show).toHaveBeenCalledTimes(1);
expect(notificationsMock.update).toHaveBeenCalledTimes(1);
// Terminal status → the toast is hidden.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("taskStatusChange", {
detail: {
taskId: "task-1",
task: { status: "completed", statusMessage: "Done" },
},
}),
);
});
expect(notificationsMock.hide).toHaveBeenCalledWith(shown.id);
});
it("builds progressByTaskId from requestorTaskProgress and prunes it on terminal status", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(screen.getByTestId("task-progress-keys")).toHaveTextContent("none");
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("requestorTaskProgress", {
detail: {
taskId: "task-1",
progress: { progress: 2, total: 5, message: "Halfway" },
},
}),
);
});
await waitFor(() =>
expect(screen.getByTestId("task-progress-keys")).toHaveTextContent(
"task-1",
),
);
// A terminal task update prunes the entry.
act(() => {
clientInstances[0].dispatchEvent(
new CustomEvent("requestorTaskUpdated", {
detail: {
taskId: "task-1",
task: { status: "completed", statusMessage: "Done" },
},
}),
);
});
await waitFor(() =>
expect(screen.getByTestId("task-progress-keys")).toHaveTextContent(
"none",
),
);
});
});