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:
parent
8923a9bbf9
commit
ed6eda7492
33 changed files with 1192 additions and 40 deletions
|
|
@ -41,6 +41,10 @@ vi.mock("@inspector/core/mcp/index.js", () => {
|
||||||
callTool = vi
|
callTool = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ success: true, result: { acts: [] } });
|
.mockResolvedValue({ success: true, result: { acts: [] } });
|
||||||
|
callToolStream = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ success: true, result: { acts: [] } });
|
||||||
|
cancelRequestorTask = vi.fn().mockResolvedValue(undefined);
|
||||||
getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
|
getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
|
||||||
readResource = vi
|
readResource = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|
@ -166,7 +170,11 @@ vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
||||||
useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [] })),
|
useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [] })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
|
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", () => ({
|
vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
|
||||||
useResourceSubscriptions: vi.fn(() => ({ subscriptions: [] })),
|
useResourceSubscriptions: vi.fn(() => ({ subscriptions: [] })),
|
||||||
|
|
@ -223,15 +231,26 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
filterText: string;
|
filterText: string;
|
||||||
visibleLevels: Record<string, boolean>;
|
visibleLevels: Record<string, boolean>;
|
||||||
}) => void;
|
}) => 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;
|
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
onReadResource: (uri: string) => void;
|
onReadResource: (uri: string) => void;
|
||||||
onSetLogLevel: (level: string) => void;
|
onSetLogLevel: (level: string) => void;
|
||||||
|
onCancelTask: (taskId: string) => void;
|
||||||
|
onClearCompletedTasks: () => void;
|
||||||
|
onRefreshTasks: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="tool-status">
|
<span data-testid="tool-status">
|
||||||
{props.toolCallState?.status ?? "none"}
|
{props.toolCallState?.status ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
|
<span data-testid="task-progress-keys">
|
||||||
|
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
||||||
|
</span>
|
||||||
<span data-testid="selected-tool">
|
<span data-testid="selected-tool">
|
||||||
{props.toolsUi?.selectedToolName ?? "none"}
|
{props.toolsUi?.selectedToolName ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -307,6 +326,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
set-log-filter
|
set-log-filter
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</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.onGetPrompt("greet", {})}>get-prompt</button>
|
||||||
<button onClick={() => props.onReadResource("res://x")}>
|
<button onClick={() => props.onReadResource("res://x")}>
|
||||||
read-resource
|
read-resource
|
||||||
|
|
@ -318,6 +345,21 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
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 = (
|
const clientInstances = (
|
||||||
McpIndex as unknown as { __clientInstances: EventTarget[] }
|
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",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
LoggingMessageNotification,
|
LoggingMessageNotification,
|
||||||
Progress,
|
Progress,
|
||||||
ProgressToken,
|
ProgressToken,
|
||||||
|
Task,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
|
@ -30,6 +31,7 @@ import type {
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
ServerType,
|
ServerType,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
|
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
|
||||||
import {
|
import {
|
||||||
API_SERVER_ENV_VARS,
|
API_SERVER_ENV_VARS,
|
||||||
INSPECTOR_API_TOKEN_GLOBAL,
|
INSPECTOR_API_TOKEN_GLOBAL,
|
||||||
|
|
@ -71,6 +73,7 @@ import type {
|
||||||
} from "./components/screens/ToolsScreen/ToolsScreen";
|
} from "./components/screens/ToolsScreen/ToolsScreen";
|
||||||
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
||||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||||
|
import type { TaskProgress } from "./components/groups/TaskCard/TaskCard";
|
||||||
import {
|
import {
|
||||||
EMPTY_TOOLS_UI,
|
EMPTY_TOOLS_UI,
|
||||||
EMPTY_PROMPTS_UI,
|
EMPTY_PROMPTS_UI,
|
||||||
|
|
@ -193,6 +196,7 @@ const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
|
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
|
||||||
|
|
@ -243,6 +247,52 @@ function formatProgressToastMessage(
|
||||||
return message ? `${message} — ${ratio}` : ratio;
|
return message ? `${message} — ${ratio}` : ratio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal task states — once a task reaches one of these it can't change, so
|
||||||
|
// its toast is dismissed and its per-task progress entry is pruned.
|
||||||
|
const TERMINAL_TASK_STATUSES: ReadonlySet<Task["status"]> = new Set([
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTerminalTaskStatus(status: Task["status"]): boolean {
|
||||||
|
return TERMINAL_TASK_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable toast id per task so live status updates replace one toast rather than
|
||||||
|
// stacking a fresh one per `notifications/tasks/status` tick.
|
||||||
|
function taskToastId(taskId: string): string {
|
||||||
|
return `task-${taskId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast color per task status — mirrors TaskStatusBadge's mapping so the toast
|
||||||
|
// and the Tasks-screen badge read consistently.
|
||||||
|
function taskToastColor(status: Task["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "green";
|
||||||
|
case "failed":
|
||||||
|
return "red";
|
||||||
|
case "cancelled":
|
||||||
|
return "gray";
|
||||||
|
case "input_required":
|
||||||
|
return "yellow";
|
||||||
|
default:
|
||||||
|
return "blue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The subset of Task fields the toast layer reads. Both task-event payloads —
|
||||||
|
// the server `taskStatusChange` (full Task) and the client-origin
|
||||||
|
// `requestorTaskUpdated` (Task with optional createdAt) — satisfy this.
|
||||||
|
type TaskToastInput = Pick<Task, "status"> & { statusMessage?: string };
|
||||||
|
|
||||||
|
// One-line toast body: the task's `statusMessage` when present, else a short
|
||||||
|
// fallback naming the status. The title carries the status itself.
|
||||||
|
function formatTaskToastMessage(task: TaskToastInput): string {
|
||||||
|
return task.statusMessage ?? `Task ${task.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Theme toggle plumbing (preserved from the pre-wire placeholder).
|
// Theme toggle plumbing (preserved from the pre-wire placeholder).
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
|
@ -396,6 +446,18 @@ function App() {
|
||||||
// because it's incidental bookkeeping that must not trigger re-renders.
|
// because it's incidental bookkeeping that must not trigger re-renders.
|
||||||
const progressToastIdsRef = useRef<Set<string>>(new Set());
|
const progressToastIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Same bookkeeping for live task-status toasts (one per taskId), so each
|
||||||
|
// `notifications/tasks/status` tick replaces the existing toast.
|
||||||
|
const taskToastIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Per-task progress, keyed by taskId. Sourced from the core `requestorTaskProgress`
|
||||||
|
// event (emitted by callToolStream, which owns the taskId), fed to the Tasks
|
||||||
|
// screen so `TaskCard`'s progress bar renders for active tasks. Pruned on
|
||||||
|
// terminal status (below) and reset on disconnect (`resetSessionScopedUiState`).
|
||||||
|
const [progressByTaskId, setProgressByTaskId] = useState<
|
||||||
|
Record<string, TaskProgress>
|
||||||
|
>({});
|
||||||
|
|
||||||
// Hook layer. Each hook subscribes to its respective event source and
|
// Hook layer. Each hook subscribes to its respective event source and
|
||||||
// re-renders the App on change. When `inspectorClient` / state managers
|
// re-renders the App on change. When `inspectorClient` / state managers
|
||||||
// are null, the hooks degrade to empty results.
|
// are null, the hooks degrade to empty results.
|
||||||
|
|
@ -422,10 +484,11 @@ function App() {
|
||||||
inspectorClient,
|
inspectorClient,
|
||||||
managedResourceTemplatesState,
|
managedResourceTemplatesState,
|
||||||
);
|
);
|
||||||
const { tasks, refresh: refreshTasks } = useManagedRequestorTasks(
|
const {
|
||||||
inspectorClient,
|
tasks,
|
||||||
managedRequestorTasksState,
|
refresh: refreshTasks,
|
||||||
);
|
clearCompleted: clearCompletedTasks,
|
||||||
|
} = useManagedRequestorTasks(inspectorClient, managedRequestorTasksState);
|
||||||
const { subscriptions } = useResourceSubscriptions(
|
const { subscriptions } = useResourceSubscriptions(
|
||||||
resourceSubscriptionsState,
|
resourceSubscriptionsState,
|
||||||
);
|
);
|
||||||
|
|
@ -489,6 +552,7 @@ function App() {
|
||||||
setLogsUi(EMPTY_LOGS_UI);
|
setLogsUi(EMPTY_LOGS_UI);
|
||||||
setHistoryUi(EMPTY_HISTORY_UI);
|
setHistoryUi(EMPTY_HISTORY_UI);
|
||||||
setNetworkUi(EMPTY_NETWORK_UI);
|
setNetworkUi(EMPTY_NETWORK_UI);
|
||||||
|
setProgressByTaskId({});
|
||||||
setCurrentLogLevel("info");
|
setCurrentLogLevel("info");
|
||||||
// Remembered scroll offsets are session-scoped too — drop them so the next
|
// Remembered scroll offsets are session-scoped too — drop them so the next
|
||||||
// session's screens start at the top (#1417).
|
// session's screens start at the top (#1417).
|
||||||
|
|
@ -570,6 +634,118 @@ function App() {
|
||||||
};
|
};
|
||||||
}, [inspectorClient]);
|
}, [inspectorClient]);
|
||||||
|
|
||||||
|
// Correlate task-call progress to the task it belongs to. `callToolStream`
|
||||||
|
// emits `requestorTaskProgress` tagged with the taskId it owns (the generic
|
||||||
|
// `progressNotification` above carries only the caller's progressToken), so we
|
||||||
|
// build a taskId → progress map the Tasks screen reads to render each active
|
||||||
|
// task's progress bar. Entries are pruned on terminal status (in the task-
|
||||||
|
// status effect below) and the whole map resets on disconnect.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inspectorClient) return;
|
||||||
|
const onTaskProgress = (
|
||||||
|
event: TypedEventGeneric<
|
||||||
|
InspectorClientEventMap,
|
||||||
|
"requestorTaskProgress"
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
const { taskId, progress } = event.detail;
|
||||||
|
setProgressByTaskId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: {
|
||||||
|
progress: progress.progress,
|
||||||
|
total: progress.total,
|
||||||
|
message: progress.message,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
inspectorClient.addEventListener("requestorTaskProgress", onTaskProgress);
|
||||||
|
return () => {
|
||||||
|
inspectorClient.removeEventListener(
|
||||||
|
"requestorTaskProgress",
|
||||||
|
onTaskProgress,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [inspectorClient]);
|
||||||
|
|
||||||
|
// Surface live task status as per-task toasts — the v2 replacement for v1/v1.5's
|
||||||
|
// inline "Task status: … Polling…" line under the Tool Result (#1422, consistent
|
||||||
|
// with #1414). Subscribes to `taskStatusChange` (server `notifications/tasks/status`)
|
||||||
|
// and `requestorTaskUpdated` (client-origin updates from `callToolStream`) — the
|
||||||
|
// same sources the managed task store consumes; `toolCallTaskUpdated` is redundant
|
||||||
|
// with `requestorTaskUpdated` so we skip it to avoid double-firing. One toast per
|
||||||
|
// taskId, replaced per tick, dismissed on terminal status (which also prunes the
|
||||||
|
// task's progress entry) and on client teardown. The full status history still
|
||||||
|
// lives in the History view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inspectorClient) return;
|
||||||
|
const liveToastIds = taskToastIdsRef.current;
|
||||||
|
const handleTaskUpdate = (taskId: string, task: TaskToastInput) => {
|
||||||
|
const id = taskToastId(taskId);
|
||||||
|
const terminal = isTerminalTaskStatus(task.status);
|
||||||
|
const title = `Task ${task.status}`;
|
||||||
|
const message = formatTaskToastMessage(task);
|
||||||
|
const color = taskToastColor(task.status);
|
||||||
|
if (terminal) {
|
||||||
|
// Drop the task's progress entry now that it can't change.
|
||||||
|
setProgressByTaskId((prev) => {
|
||||||
|
if (!(taskId in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[taskId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (liveToastIds.has(id)) {
|
||||||
|
notifications.update({ id, title, message, color });
|
||||||
|
if (terminal) {
|
||||||
|
notifications.hide(id);
|
||||||
|
liveToastIds.delete(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A first sighting that's already terminal needs no toast at all.
|
||||||
|
if (terminal) return;
|
||||||
|
liveToastIds.add(id);
|
||||||
|
notifications.show({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
autoClose: false,
|
||||||
|
onClose: () => liveToastIds.delete(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onTaskStatusChange = (
|
||||||
|
event: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
|
||||||
|
) => {
|
||||||
|
handleTaskUpdate(event.detail.taskId, event.detail.task);
|
||||||
|
};
|
||||||
|
const onRequestorTaskUpdated = (
|
||||||
|
event: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
|
||||||
|
) => {
|
||||||
|
handleTaskUpdate(event.detail.taskId, event.detail.task);
|
||||||
|
};
|
||||||
|
inspectorClient.addEventListener("taskStatusChange", onTaskStatusChange);
|
||||||
|
inspectorClient.addEventListener(
|
||||||
|
"requestorTaskUpdated",
|
||||||
|
onRequestorTaskUpdated,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
inspectorClient.removeEventListener(
|
||||||
|
"taskStatusChange",
|
||||||
|
onTaskStatusChange,
|
||||||
|
);
|
||||||
|
inspectorClient.removeEventListener(
|
||||||
|
"requestorTaskUpdated",
|
||||||
|
onRequestorTaskUpdated,
|
||||||
|
);
|
||||||
|
// Hide any still-visible task toasts on client swap so they don't linger
|
||||||
|
// into the next session, then drop the bookkeeping (mirrors the progress-
|
||||||
|
// toast teardown above).
|
||||||
|
liveToastIds.forEach((id) => notifications.hide(id));
|
||||||
|
liveToastIds.clear();
|
||||||
|
};
|
||||||
|
}, [inspectorClient]);
|
||||||
|
|
||||||
// Build the InitializeResult the connected ViewHeader expects from the
|
// Build the InitializeResult the connected ViewHeader expects from the
|
||||||
// hook's split fields. `protocolVersion` is hard-coded for now — the
|
// hook's split fields. `protocolVersion` is hard-coded for now — the
|
||||||
// useInspectorClient hook doesn't expose it. TODO(#1324): consume the
|
// useInspectorClient hook doesn't expose it. TODO(#1324): consume the
|
||||||
|
|
@ -1027,10 +1203,27 @@ function App() {
|
||||||
// --- Action handlers that route directly to the InspectorClient. ---
|
// --- Action handlers that route directly to the InspectorClient. ---
|
||||||
|
|
||||||
const onCallTool = useCallback(
|
const onCallTool = useCallback(
|
||||||
async (name: string, args: Record<string, unknown>) => {
|
async (
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
const tool = tools.find((t: Tool) => t.name === name);
|
const tool = tools.find((t: Tool) => t.name === name);
|
||||||
if (!tool) return;
|
if (!tool) return;
|
||||||
|
// Route through the task pipeline when the caller asked to (or the tool
|
||||||
|
// requires it) — but only if the server advertises task tool calls. Per
|
||||||
|
// spec a tool's `taskSupport` is considered only when the server declares
|
||||||
|
// `tasks.requests.tools.call`, so without it we never task-augment (even a
|
||||||
|
// "required" tool, which then surfaces callTool's "requires task support"
|
||||||
|
// error). The created task shows up on the Tasks screen via the
|
||||||
|
// `requestorTaskUpdated` events callToolStream dispatches, and its live
|
||||||
|
// status/progress surface as toasts + progress bar.
|
||||||
|
const serverSupportsTaskToolCalls =
|
||||||
|
!!capabilities?.tasks?.requests?.tools?.call;
|
||||||
|
const asTask =
|
||||||
|
serverSupportsTaskToolCalls &&
|
||||||
|
(runAsTask || tool.execution?.taskSupport === "required");
|
||||||
setToolCallState({ status: "pending" });
|
setToolCallState({ status: "pending" });
|
||||||
try {
|
try {
|
||||||
// ToolsScreen types the args as `Record<string, unknown>` (it accepts
|
// ToolsScreen types the args as `Record<string, unknown>` (it accepts
|
||||||
|
|
@ -1038,10 +1231,18 @@ function App() {
|
||||||
// `Record<string, JsonValue>` — narrow at the boundary instead of
|
// `Record<string, JsonValue>` — narrow at the boundary instead of
|
||||||
// claiming the object is empty (which the previous `as Record<string,
|
// claiming the object is empty (which the previous `as Record<string,
|
||||||
// never>` cast did, misleadingly).
|
// never>` cast did, misleadingly).
|
||||||
const invocation = await inspectorClient.callTool(
|
const invocation = asTask
|
||||||
tool,
|
? await inspectorClient.callToolStream(
|
||||||
args as Record<string, JsonValue>,
|
tool,
|
||||||
);
|
args as Record<string, JsonValue>,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ ttl: activeServer?.settings?.taskTtl || DEFAULT_TASK_TTL_MS },
|
||||||
|
)
|
||||||
|
: await inspectorClient.callTool(
|
||||||
|
tool,
|
||||||
|
args as Record<string, JsonValue>,
|
||||||
|
);
|
||||||
setToolCallState({
|
setToolCallState({
|
||||||
status: invocation.success ? "ok" : "error",
|
status: invocation.success ? "ok" : "error",
|
||||||
result: invocation.result ?? undefined,
|
result: invocation.result ?? undefined,
|
||||||
|
|
@ -1054,7 +1255,7 @@ function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[inspectorClient, tools],
|
[inspectorClient, tools, activeServer, capabilities],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClearToolResult = useCallback(() => {
|
const onClearToolResult = useCallback(() => {
|
||||||
|
|
@ -1246,13 +1447,28 @@ function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCancelTask = useCallback(
|
const onCancelTask = useCallback(
|
||||||
(taskId: string) => {
|
async (taskId: string) => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
void inspectorClient.cancelRequestorTask(taskId);
|
// The cancelled status is reflected by the managed store via the
|
||||||
|
// `taskCancelled` event, so no manual reload is needed — but a cancel
|
||||||
|
// *failure* would otherwise be swallowed, so surface it.
|
||||||
|
try {
|
||||||
|
await inspectorClient.cancelRequestorTask(taskId);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Failed to cancel task",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[inspectorClient],
|
[inspectorClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onClearCompletedTasks = useCallback(() => {
|
||||||
|
clearCompletedTasks();
|
||||||
|
}, [clearCompletedTasks]);
|
||||||
|
|
||||||
const onSetLogLevel = useCallback(
|
const onSetLogLevel = useCallback(
|
||||||
(level: LoggingLevel) => {
|
(level: LoggingLevel) => {
|
||||||
setCurrentLogLevel(level);
|
setCurrentLogLevel(level);
|
||||||
|
|
@ -1272,7 +1488,15 @@ function App() {
|
||||||
void refreshResources();
|
void refreshResources();
|
||||||
}, [refreshResources]);
|
}, [refreshResources]);
|
||||||
const onRefreshTasks = useCallback(() => {
|
const onRefreshTasks = useCallback(() => {
|
||||||
void refreshTasks();
|
// Surface list failures (e.g. the MAX_PAGES guard or a tasks/list error)
|
||||||
|
// instead of letting the rejected promise go unhandled.
|
||||||
|
refreshTasks().catch((err: unknown) => {
|
||||||
|
notifications.show({
|
||||||
|
title: "Failed to refresh tasks",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
}, [refreshTasks]);
|
}, [refreshTasks]);
|
||||||
|
|
||||||
const onClearLogs = useCallback(() => {
|
const onClearLogs = useCallback(() => {
|
||||||
|
|
@ -1568,6 +1792,7 @@ function App() {
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
logs={logs}
|
logs={logs}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
progressByTaskId={progressByTaskId}
|
||||||
history={messages}
|
history={messages}
|
||||||
network={fetchRequests}
|
network={fetchRequests}
|
||||||
toolCallState={toolCallState}
|
toolCallState={toolCallState}
|
||||||
|
|
@ -1604,9 +1829,12 @@ function App() {
|
||||||
const target = servers.find((s) => s.id === id);
|
const target = servers.find((s) => s.id === id);
|
||||||
if (target) setRemoveTarget(target);
|
if (target) setRemoveTarget(target);
|
||||||
}}
|
}}
|
||||||
|
serverSupportsTaskToolCalls={
|
||||||
|
!!capabilities?.tasks?.requests?.tools?.call
|
||||||
|
}
|
||||||
onToolsUiChange={onToolsUiChange}
|
onToolsUiChange={onToolsUiChange}
|
||||||
onCallTool={(name, args) => {
|
onCallTool={(name, args, runAsTask) => {
|
||||||
void onCallTool(name, args);
|
void onCallTool(name, args, runAsTask);
|
||||||
}}
|
}}
|
||||||
onClearToolResult={onClearToolResult}
|
onClearToolResult={onClearToolResult}
|
||||||
onRefreshTools={onRefreshTools}
|
onRefreshTools={onRefreshTools}
|
||||||
|
|
@ -1625,8 +1853,10 @@ function App() {
|
||||||
onCompleteArgument={onCompleteArgument}
|
onCompleteArgument={onCompleteArgument}
|
||||||
completionsSupported={capabilities?.completions !== undefined}
|
completionsSupported={capabilities?.completions !== undefined}
|
||||||
onTasksUiChange={setTasksUi}
|
onTasksUiChange={setTasksUi}
|
||||||
onCancelTask={onCancelTask}
|
onCancelTask={(taskId) => {
|
||||||
onClearCompletedTasks={todoNoop}
|
void onCancelTask(taskId);
|
||||||
|
}}
|
||||||
|
onClearCompletedTasks={onClearCompletedTasks}
|
||||||
onRefreshTasks={onRefreshTasks}
|
onRefreshTasks={onRefreshTasks}
|
||||||
onSetLogLevel={onSetLogLevel}
|
onSetLogLevel={onSetLogLevel}
|
||||||
onLogsUiChange={setLogsUi}
|
onLogsUiChange={setLogsUi}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const defaultSettings: InspectorServerSettings = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
};
|
};
|
||||||
|
|
||||||
function InteractiveRender(args: ServerSettingsFormProps) {
|
function InteractiveRender(args: ServerSettingsFormProps) {
|
||||||
|
|
@ -166,6 +167,7 @@ export const AllConfigured: Story = {
|
||||||
],
|
],
|
||||||
connectionTimeout: 15000,
|
connectionTimeout: 15000,
|
||||||
requestTimeout: 45000,
|
requestTimeout: 45000,
|
||||||
|
taskTtl: 45000,
|
||||||
oauthClientId: "my-client-id",
|
oauthClientId: "my-client-id",
|
||||||
oauthClientSecret: "super-secret-value",
|
oauthClientSecret: "super-secret-value",
|
||||||
oauthScopes: "read write",
|
oauthScopes: "read write",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const emptySettings: InspectorServerSettings = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const populatedSettings: InspectorServerSettings = {
|
const populatedSettings: InspectorServerSettings = {
|
||||||
|
|
@ -19,6 +20,7 @@ const populatedSettings: InspectorServerSettings = {
|
||||||
metadata: [{ key: "userId", value: "u-1" }],
|
metadata: [{ key: "userId", value: "u-1" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
oauthClientId: "cid",
|
oauthClientId: "cid",
|
||||||
oauthClientSecret: "secret",
|
oauthClientSecret: "secret",
|
||||||
oauthScopes: "read",
|
oauthScopes: "read",
|
||||||
|
|
@ -197,6 +199,36 @@ describe("ServerSettingsForm", () => {
|
||||||
expect(typeof call[1]).toBe("number");
|
expect(typeof call[1]).toBe("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("invokes onTimeoutChange with the taskTtl field when typing in Task TTL", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTimeoutChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
onTimeoutChange={onTimeoutChange}
|
||||||
|
settings={emptySettings}
|
||||||
|
expandedSections={["timeouts"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const ttlInput = screen.getByLabelText(/Task TTL/);
|
||||||
|
await user.type(ttlInput, "9");
|
||||||
|
expect(onTimeoutChange).toHaveBeenCalled();
|
||||||
|
const call = onTimeoutChange.mock.calls[0];
|
||||||
|
expect(call[0]).toBe("taskTtl");
|
||||||
|
expect(typeof call[1]).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Task TTL value from settings", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, taskTtl: 45000 }}
|
||||||
|
expandedSections={["timeouts"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(/Task TTL/)).toHaveValue("45000 ms");
|
||||||
|
});
|
||||||
|
|
||||||
it("invokes onOAuthChange when typing in client id", async () => {
|
it("invokes onOAuthChange when typing in client id", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export interface ServerSettingsFormProps {
|
||||||
onRemoveMetadata: (index: number) => void;
|
onRemoveMetadata: (index: number) => void;
|
||||||
onMetadataChange: (index: number, key: string, value: string) => void;
|
onMetadataChange: (index: number, key: string, value: string) => void;
|
||||||
onTimeoutChange: (
|
onTimeoutChange: (
|
||||||
field: "connectionTimeout" | "requestTimeout",
|
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
||||||
value: number,
|
value: number,
|
||||||
) => void;
|
) => void;
|
||||||
onOAuthChange: (oauth: OAuthSettings) => void;
|
onOAuthChange: (oauth: OAuthSettings) => void;
|
||||||
|
|
@ -105,7 +105,7 @@ export function ServerSettingsForm({
|
||||||
onOAuthChange,
|
onOAuthChange,
|
||||||
}: ServerSettingsFormProps) {
|
}: ServerSettingsFormProps) {
|
||||||
const handleTimeoutChange =
|
const handleTimeoutChange =
|
||||||
(field: "connectionTimeout" | "requestTimeout") =>
|
(field: "connectionTimeout" | "requestTimeout" | "taskTtl") =>
|
||||||
(value: number | string) => {
|
(value: number | string) => {
|
||||||
const numValue =
|
const numValue =
|
||||||
typeof value === "string" ? parseInt(value, 10) || 0 : value;
|
typeof value === "string" ? parseInt(value, 10) || 0 : value;
|
||||||
|
|
@ -193,6 +193,13 @@ export function ServerSettingsForm({
|
||||||
value={settings.requestTimeout}
|
value={settings.requestTimeout}
|
||||||
onChange={handleTimeoutChange("requestTimeout")}
|
onChange={handleTimeoutChange("requestTimeout")}
|
||||||
/>
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Task TTL"
|
||||||
|
suffix=" ms"
|
||||||
|
min={1}
|
||||||
|
value={settings.taskTtl}
|
||||||
|
onChange={handleTimeoutChange("taskTtl")}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const initialSettings: InspectorServerSettings = {
|
||||||
metadata: [{ key: "userId", value: "user-789" }],
|
metadata: [{ key: "userId", value: "user-789" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
oauthClientId: "my-client-id",
|
oauthClientId: "my-client-id",
|
||||||
oauthClientSecret: "super-secret-value",
|
oauthClientSecret: "super-secret-value",
|
||||||
oauthScopes: "read write",
|
oauthScopes: "read write",
|
||||||
|
|
@ -67,6 +68,7 @@ export const EmptySettings: Story = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const initialSettings: InspectorServerSettings = {
|
||||||
metadata: [{ key: "userId", value: "u-1" }],
|
metadata: [{ key: "userId", value: "u-1" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
oauthClientId: "cid",
|
oauthClientId: "cid",
|
||||||
oauthClientSecret: "secret",
|
oauthClientSecret: "secret",
|
||||||
oauthScopes: "read",
|
oauthScopes: "read",
|
||||||
|
|
@ -19,6 +20,7 @@ const emptySettings: InspectorServerSettings = {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
taskTtl: 60000,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ServerSettingsModal", () => {
|
describe("ServerSettingsModal", () => {
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function ServerSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTimeoutChange(
|
function handleTimeoutChange(
|
||||||
field: "connectionTimeout" | "requestTimeout",
|
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
||||||
value: number,
|
value: number,
|
||||||
) {
|
) {
|
||||||
onSettingsChange({ ...settings, [field]: value });
|
onSettingsChange({ ...settings, [field]: value });
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ const meta: Meta<typeof ToolDetailPanel> = {
|
||||||
onFormChange: fn(),
|
onFormChange: fn(),
|
||||||
onExecute: fn(),
|
onExecute: fn(),
|
||||||
onCancel: fn(),
|
onCancel: fn(),
|
||||||
|
onRunAsTaskChange: fn(),
|
||||||
formValues: {},
|
formValues: {},
|
||||||
isExecuting: false,
|
isExecuting: false,
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
|
runAsTask: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -142,3 +145,45 @@ export const WithProgress: Story = {
|
||||||
formValues: { batchSize: 100 },
|
formValues: { batchSize: 100 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const optionalTaskTool: Tool = {
|
||||||
|
name: "summarize_corpus",
|
||||||
|
description: "Summarizes a large corpus — can run as a background task",
|
||||||
|
execution: { taskSupport: "optional" },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
corpus: { type: "string", description: "Text to summarize" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredTaskTool: Tool = {
|
||||||
|
name: "train_model",
|
||||||
|
description: "Long-running training job — always runs as a task",
|
||||||
|
execution: { taskSupport: "required" },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
dataset: { type: "string", description: "Dataset id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server supports task tool calls + tool is `optional`: the toggle renders
|
||||||
|
// enabled and off by default.
|
||||||
|
export const RunAsTaskOptional: Story = {
|
||||||
|
args: {
|
||||||
|
tool: optionalTaskTool,
|
||||||
|
serverSupportsTaskToolCalls: true,
|
||||||
|
runAsTask: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tool is `required`: the toggle is forced on and disabled.
|
||||||
|
export const RunAsTaskRequired: Story = {
|
||||||
|
args: {
|
||||||
|
tool: requiredTaskTool,
|
||||||
|
serverSupportsTaskToolCalls: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ const annotatedTool: Tool = {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
formValues: {},
|
formValues: {},
|
||||||
isExecuting: false,
|
isExecuting: false,
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
|
runAsTask: false,
|
||||||
|
onRunAsTaskChange: vi.fn(),
|
||||||
onFormChange: vi.fn(),
|
onFormChange: vi.fn(),
|
||||||
onExecute: vi.fn(),
|
onExecute: vi.fn(),
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
|
|
@ -204,4 +207,144 @@ describe("ToolDetailPanel", () => {
|
||||||
await user.type(input, "hi");
|
await user.type(input, "hi");
|
||||||
expect(onFormChange).toHaveBeenCalled();
|
expect(onFormChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Run as task toggle", () => {
|
||||||
|
const toolWithSupport = (
|
||||||
|
taskSupport: "forbidden" | "optional" | "required",
|
||||||
|
): Tool => ({
|
||||||
|
name: "run_job",
|
||||||
|
execution: { taskSupport },
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the toggle when the server doesn't support task tool calls", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the toggle for a task-forbidden tool even when the server supports tasks", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("forbidden")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const toggle = screen.getByLabelText("Run as task");
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expect(toggle).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects the runAsTask prop for an optional tool", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Run as task")).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces the toggle on and disabled for a required tool", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("required")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const toggle = screen.getByLabelText("Run as task");
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expect(toggle).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onRunAsTaskChange when an optional toggle is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRunAsTaskChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={false}
|
||||||
|
onRunAsTaskChange={onRunAsTaskChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByLabelText("Run as task"));
|
||||||
|
expect(onRunAsTaskChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the effective run-as-task decision to onExecute", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onExecute = vi.fn();
|
||||||
|
// optional + runAsTask=true → effective true
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={true}
|
||||||
|
onExecute={onExecute}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
||||||
|
expect(onExecute).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes runAsTask=true to onExecute for a required tool regardless of the prop", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onExecute = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("required")}
|
||||||
|
serverSupportsTaskToolCalls={true}
|
||||||
|
runAsTask={false}
|
||||||
|
onExecute={onExecute}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
||||||
|
expect(onExecute).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates onExecute to false when the server lacks task-tool-call support, even with a stale runAsTask", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onExecute = vi.fn();
|
||||||
|
// The toggle is hidden (server doesn't support it), but a leftover
|
||||||
|
// runAsTask=true must not leak into the effective decision.
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={toolWithSupport("optional")}
|
||||||
|
serverSupportsTaskToolCalls={false}
|
||||||
|
runAsTask={true}
|
||||||
|
onExecute={onExecute}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Execute Tool" }));
|
||||||
|
expect(onExecute).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Image,
|
Image,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -27,8 +28,14 @@ export interface ToolDetailPanelProps {
|
||||||
formValues: Record<string, unknown>;
|
formValues: Record<string, unknown>;
|
||||||
isExecuting: boolean;
|
isExecuting: boolean;
|
||||||
progress?: ToolProgress;
|
progress?: ToolProgress;
|
||||||
|
/** Whether the connected server advertises task-augmented tool calls. */
|
||||||
|
serverSupportsTaskToolCalls: boolean;
|
||||||
|
/** User's "Run as task" preference (only meaningful for `optional` tools). */
|
||||||
|
runAsTask: boolean;
|
||||||
|
onRunAsTaskChange: (value: boolean) => void;
|
||||||
onFormChange: (values: Record<string, unknown>) => void;
|
onFormChange: (values: Record<string, unknown>) => void;
|
||||||
onExecute: () => void;
|
/** Receives the effective run-as-task decision for this execution. */
|
||||||
|
onExecute: (runAsTask: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +102,24 @@ const CancelButton = Button.withProps({
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Left-aligned row hosting the "Run as task" toggle, above the execute footer.
|
||||||
|
const TaskToggleRow = Group.withProps({
|
||||||
|
justify: "flex-start",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const RunAsTaskSwitch = Switch.withProps({
|
||||||
|
size: "sm",
|
||||||
|
label: "Run as task",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tool's per-tool task support, defaulting to "forbidden" (the SDK default
|
||||||
|
// when `execution` is absent) so tools that say nothing can't be run as tasks.
|
||||||
|
type TaskSupport = "forbidden" | "optional" | "required";
|
||||||
|
function getTaskSupport(tool: Tool): TaskSupport {
|
||||||
|
return tool.execution?.taskSupport ?? "forbidden";
|
||||||
|
}
|
||||||
|
|
||||||
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
|
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
annotations &&
|
annotations &&
|
||||||
|
|
@ -110,6 +135,9 @@ export function ToolDetailPanel({
|
||||||
formValues,
|
formValues,
|
||||||
isExecuting,
|
isExecuting,
|
||||||
progress,
|
progress,
|
||||||
|
serverSupportsTaskToolCalls,
|
||||||
|
runAsTask,
|
||||||
|
onRunAsTaskChange,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
onExecute,
|
onExecute,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
|
@ -117,6 +145,21 @@ export function ToolDetailPanel({
|
||||||
const { name, title, description, icons, annotations, inputSchema } = tool;
|
const { name, title, description, icons, annotations, inputSchema } = tool;
|
||||||
const iconSrc = icons?.[0]?.src;
|
const iconSrc = icons?.[0]?.src;
|
||||||
|
|
||||||
|
// Show the toggle only when the server supports task tool calls and the tool
|
||||||
|
// doesn't forbid them. `required` tools are forced on (checked + disabled);
|
||||||
|
// `optional` tools follow the user's `runAsTask` choice.
|
||||||
|
const taskSupport = getTaskSupport(tool);
|
||||||
|
const showRunAsTask =
|
||||||
|
serverSupportsTaskToolCalls && taskSupport !== "forbidden";
|
||||||
|
// Gate the effective decision on `showRunAsTask` (which includes
|
||||||
|
// `serverSupportsTaskToolCalls`): a stale `runAsTask`/`required` value must
|
||||||
|
// not route through callToolStream when the toggle is hidden because the
|
||||||
|
// server doesn't advertise task tool calls. Per spec, a tool's taskSupport is
|
||||||
|
// only considered when the server advertises `tasks.requests.tools.call`.
|
||||||
|
const effectiveRunAsTask =
|
||||||
|
showRunAsTask &&
|
||||||
|
(taskSupport === "required" || (taskSupport === "optional" && runAsTask));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelStack>
|
<PanelStack>
|
||||||
<PinnedHeader>
|
<PinnedHeader>
|
||||||
|
|
@ -159,11 +202,21 @@ export function ToolDetailPanel({
|
||||||
</BodyStack>
|
</BodyStack>
|
||||||
</BodyScroll>
|
</BodyScroll>
|
||||||
|
|
||||||
|
{showRunAsTask && (
|
||||||
|
<TaskToggleRow>
|
||||||
|
<RunAsTaskSwitch
|
||||||
|
checked={effectiveRunAsTask}
|
||||||
|
disabled={isExecuting || taskSupport === "required"}
|
||||||
|
onChange={(event) => onRunAsTaskChange(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</TaskToggleRow>
|
||||||
|
)}
|
||||||
|
|
||||||
<FooterRow>
|
<FooterRow>
|
||||||
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
|
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
onClick={onExecute}
|
onClick={() => onExecute(effectiveRunAsTask)}
|
||||||
disabled={isExecuting}
|
disabled={isExecuting}
|
||||||
loading={isExecuting}
|
loading={isExecuting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const meta: Meta<typeof ToolsScreen> = {
|
||||||
parameters: { layout: "fullscreen" },
|
parameters: { layout: "fullscreen" },
|
||||||
args: {
|
args: {
|
||||||
listChanged: false,
|
listChanged: false,
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
ui: EMPTY_TOOLS_UI,
|
ui: EMPTY_TOOLS_UI,
|
||||||
onUiChange: fn(),
|
onUiChange: fn(),
|
||||||
onRefreshList: fn(),
|
onRefreshList: fn(),
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const tools: Tool[] = [
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
tools,
|
tools,
|
||||||
listChanged: false,
|
listChanged: false,
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
ui: EMPTY_TOOLS_UI,
|
ui: EMPTY_TOOLS_UI,
|
||||||
onUiChange: vi.fn(),
|
onUiChange: vi.fn(),
|
||||||
onRefreshList: vi.fn(),
|
onRefreshList: vi.fn(),
|
||||||
|
|
@ -91,7 +92,7 @@ describe("ToolsScreen", () => {
|
||||||
await user.clear(field);
|
await user.clear(field);
|
||||||
await user.type(field, "slow");
|
await user.type(field, "slow");
|
||||||
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
||||||
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "slow" });
|
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "slow" }, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders selection and result from props (persisted across navigation)", () => {
|
it("renders selection and result from props (persisted across navigation)", () => {
|
||||||
|
|
@ -133,7 +134,7 @@ describe("ToolsScreen", () => {
|
||||||
renderWithMantine(<ControlledToolsScreen onCallTool={onCallTool} />);
|
renderWithMantine(<ControlledToolsScreen onCallTool={onCallTool} />);
|
||||||
await user.click(screen.getByText("alpha"));
|
await user.click(screen.getByText("alpha"));
|
||||||
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
||||||
expect(onCallTool).toHaveBeenCalledWith("alpha", {});
|
expect(onCallTool).toHaveBeenCalledWith("alpha", {}, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema defaults so untouched fields are sent on Execute", async () => {
|
it("seeds schema defaults so untouched fields are sent on Execute", async () => {
|
||||||
|
|
@ -143,7 +144,7 @@ describe("ToolsScreen", () => {
|
||||||
await user.click(screen.getByText("gamma"));
|
await user.click(screen.getByText("gamma"));
|
||||||
// Execute without editing the form: the default must still be sent.
|
// Execute without editing the form: the default must still be sent.
|
||||||
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
await user.click(screen.getByRole("button", { name: /Execute/ }));
|
||||||
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "fast" });
|
expect(onCallTool).toHaveBeenCalledWith("gamma", { mode: "fast" }, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onClearResult when Clear is clicked on the result panel", async () => {
|
it("invokes onClearResult when Clear is clicked on the result panel", async () => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ export interface ToolsUiState {
|
||||||
selectedToolName?: string;
|
selectedToolName?: string;
|
||||||
formValues: Record<string, unknown>;
|
formValues: Record<string, unknown>;
|
||||||
search: string;
|
search: string;
|
||||||
|
// Screen-level "Run as task" toggle, shared across tools (not per-tool):
|
||||||
|
// selecting a different tool keeps the current value. Persists across tab
|
||||||
|
// navigation like the rest of the UI state, and is only honored for the
|
||||||
|
// selected tool when its `execution.taskSupport` is "optional" (a "required"
|
||||||
|
// tool is always run as a task, "forbidden" never) — see ToolDetailPanel.
|
||||||
|
runAsTask: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolsScreenProps {
|
export interface ToolsScreenProps {
|
||||||
|
|
@ -29,9 +35,15 @@ export interface ToolsScreenProps {
|
||||||
callState?: ToolCallState;
|
callState?: ToolCallState;
|
||||||
ui: ToolsUiState;
|
ui: ToolsUiState;
|
||||||
listChanged: boolean;
|
listChanged: boolean;
|
||||||
|
/** Whether the connected server advertises task-augmented tool calls. */
|
||||||
|
serverSupportsTaskToolCalls: boolean;
|
||||||
onUiChange: (next: ToolsUiState) => void;
|
onUiChange: (next: ToolsUiState) => void;
|
||||||
onRefreshList: () => void;
|
onRefreshList: () => void;
|
||||||
onCallTool: (name: string, args: Record<string, unknown>) => void;
|
onCallTool: (
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => void;
|
||||||
onCancelCall?: () => void;
|
onCancelCall?: () => void;
|
||||||
onClearResult?: () => void;
|
onClearResult?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +104,7 @@ export function ToolsScreen({
|
||||||
callState,
|
callState,
|
||||||
ui,
|
ui,
|
||||||
listChanged,
|
listChanged,
|
||||||
|
serverSupportsTaskToolCalls,
|
||||||
onUiChange,
|
onUiChange,
|
||||||
onRefreshList,
|
onRefreshList,
|
||||||
onCallTool,
|
onCallTool,
|
||||||
|
|
@ -139,10 +152,17 @@ export function ToolsScreen({
|
||||||
formValues={formValues}
|
formValues={formValues}
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
progress={callState?.progress}
|
progress={callState?.progress}
|
||||||
|
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
||||||
|
runAsTask={ui.runAsTask}
|
||||||
|
onRunAsTaskChange={(value) =>
|
||||||
|
onUiChange({ ...ui, runAsTask: value })
|
||||||
|
}
|
||||||
onFormChange={(values) =>
|
onFormChange={(values) =>
|
||||||
onUiChange({ ...ui, formValues: values })
|
onUiChange({ ...ui, formValues: values })
|
||||||
}
|
}
|
||||||
onExecute={() => onCallTool(selectedTool.name, formValues)}
|
onExecute={(runAsTask) =>
|
||||||
|
onCallTool(selectedTool.name, formValues, runAsTask)
|
||||||
|
}
|
||||||
onCancel={() => onCancelCall?.()}
|
onCancel={() => onCancelCall?.()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const EMPTY_TOOLS_UI: ToolsUiState = {
|
||||||
selectedToolName: undefined,
|
selectedToolName: undefined,
|
||||||
formValues: {},
|
formValues: {},
|
||||||
search: "",
|
search: "",
|
||||||
|
runAsTask: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_PROMPTS_UI: PromptsUiState = {
|
export const EMPTY_PROMPTS_UI: PromptsUiState = {
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,7 @@ const meta: Meta<typeof InspectorView> = {
|
||||||
onServerEdit: fn(),
|
onServerEdit: fn(),
|
||||||
onServerClone: fn(),
|
onServerClone: fn(),
|
||||||
onServerRemove: fn(),
|
onServerRemove: fn(),
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
onToolsUiChange: fn(),
|
onToolsUiChange: fn(),
|
||||||
onCallTool: fn(),
|
onCallTool: fn(),
|
||||||
onRefreshTools: fn(),
|
onRefreshTools: fn(),
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ function makeProps(
|
||||||
onServerEdit: vi.fn(),
|
onServerEdit: vi.fn(),
|
||||||
onServerClone: vi.fn(),
|
onServerClone: vi.fn(),
|
||||||
onServerRemove: vi.fn(),
|
onServerRemove: vi.fn(),
|
||||||
|
serverSupportsTaskToolCalls: false,
|
||||||
onToolsUiChange: vi.fn(),
|
onToolsUiChange: vi.fn(),
|
||||||
onCallTool: vi.fn(),
|
onCallTool: vi.fn(),
|
||||||
onRefreshTools: vi.fn(),
|
onRefreshTools: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,14 @@ export interface InspectorViewProps {
|
||||||
|
|
||||||
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
|
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
|
||||||
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
|
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
|
||||||
|
/** Whether the connected server advertises task-augmented tool calls. */
|
||||||
|
serverSupportsTaskToolCalls: boolean;
|
||||||
onToolsUiChange: (next: ToolsUiState) => void;
|
onToolsUiChange: (next: ToolsUiState) => void;
|
||||||
onCallTool: (name: string, args: Record<string, unknown>) => void;
|
onCallTool: (
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => void;
|
||||||
onCancelToolCall?: () => void;
|
onCancelToolCall?: () => void;
|
||||||
onClearToolResult?: () => void;
|
onClearToolResult?: () => void;
|
||||||
onRefreshTools: () => void;
|
onRefreshTools: () => void;
|
||||||
|
|
@ -362,6 +368,7 @@ export function InspectorView({
|
||||||
onServerEdit,
|
onServerEdit,
|
||||||
onServerClone,
|
onServerClone,
|
||||||
onServerRemove,
|
onServerRemove,
|
||||||
|
serverSupportsTaskToolCalls,
|
||||||
onToolsUiChange,
|
onToolsUiChange,
|
||||||
onCallTool,
|
onCallTool,
|
||||||
onCancelToolCall,
|
onCancelToolCall,
|
||||||
|
|
@ -524,6 +531,7 @@ export function InspectorView({
|
||||||
callState={toolCallState}
|
callState={toolCallState}
|
||||||
ui={toolsUi}
|
ui={toolsUi}
|
||||||
listChanged={false}
|
listChanged={false}
|
||||||
|
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
||||||
onUiChange={onToolsUiChange}
|
onUiChange={onToolsUiChange}
|
||||||
onRefreshList={onRefreshTools}
|
onRefreshList={onRefreshTools}
|
||||||
onCallTool={onCallTool}
|
onCallTool={onCallTool}
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,7 @@ describe("RemoteClientTransport", () => {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
taskTtl: 0,
|
||||||
};
|
};
|
||||||
const transport = new RemoteClientTransport(
|
const transport = new RemoteClientTransport(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,8 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
// Absent taskTtl on disk → product default in memory (for the form)
|
||||||
|
taskTtl: 60000,
|
||||||
// Nested oauth on disk → flat oauthClientId in memory
|
// Nested oauth on disk → flat oauthClientId in memory
|
||||||
oauthClientId: "the-client",
|
oauthClientId: "the-client",
|
||||||
});
|
});
|
||||||
|
|
@ -272,6 +274,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
taskTtl: 0,
|
||||||
},
|
},
|
||||||
connection: { status: "disconnected" },
|
connection: { status: "disconnected" },
|
||||||
},
|
},
|
||||||
|
|
@ -295,6 +298,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
taskTtl: 0,
|
||||||
},
|
},
|
||||||
connection: { status: "disconnected" },
|
connection: { status: "disconnected" },
|
||||||
},
|
},
|
||||||
|
|
@ -302,6 +306,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||||
expect(stored).not.toHaveProperty("requestTimeout");
|
expect(stored).not.toHaveProperty("requestTimeout");
|
||||||
|
expect(stored).not.toHaveProperty("taskTtl");
|
||||||
expect(stored).not.toHaveProperty("oauth");
|
expect(stored).not.toHaveProperty("oauth");
|
||||||
expect(stored).not.toHaveProperty("headers");
|
expect(stored).not.toHaveProperty("headers");
|
||||||
expect(stored).not.toHaveProperty("metadata");
|
expect(stored).not.toHaveProperty("metadata");
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue