inspector/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx
cliffhall 6f47eaa143 fix: render a tool result's structuredContent in the Tools screen (#1908)
A tool declaring an `outputSchema` returns its real payload in
`structuredContent`; the `content[]` blocks usually only summarize it. v1
rendered that payload as its own inspectable JSON section — v2's
ToolResultPanel only ever walked `result.content`, so the payload was
dropped with no label, section, or hint that it existed.

Adds a `StructuredOutputPanel` group: a collapsible "Structured Output"
box (bordered like the existing "Resource Links" box) rendering the
payload as pretty-printed, syntax-highlighted, copyable JSON, bounded so
a large payload scrolls within itself. ToolResultPanel renders it below
the content blocks — and also when `content` is empty (where "No results
yet" was simply wrong) and below the alert on an error result, so it is
never silently dropped.

Adds a `list_items` test-server preset (nested payload, the issue's repro
shape) and a `structured-output-http.json` showcase config, documented in
the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-11 09:23:43 -04:00

223 lines
7.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { CallToolResult } from "@modelcontextprotocol/client";
import {
renderWithMantine,
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { ToolResultPanel } from "./ToolResultPanel";
import { resultHasResourceLinks } from "./toolResultUtils";
const okResult: CallToolResult = {
content: [{ type: "text", text: "ok" }],
isError: false,
};
const errorResult: CallToolResult = {
content: [{ type: "text", text: "boom" }],
isError: true,
};
const emptyResult: CallToolResult = { content: [] };
describe("ToolResultPanel", () => {
it("renders text content blocks", () => {
renderWithMantine(<ToolResultPanel result={okResult} onClear={() => {}} />);
expect(screen.getByText("ok")).toBeInTheDocument();
});
it("renders an error alert when isError is true", () => {
renderWithMantine(
<ToolResultPanel result={errorResult} onClear={() => {}} />,
);
expect(screen.getByText("Tool Error")).toBeInTheDocument();
expect(screen.getByText("boom")).toBeInTheDocument();
});
it("renders the empty state when content is empty", () => {
renderWithMantine(
<ToolResultPanel result={emptyResult} onClear={() => {}} />,
);
expect(screen.getByText("No results yet")).toBeInTheDocument();
});
it("groups resource_link blocks in a scrollable Resource Links box", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn().mockResolvedValue({
contents: [{ uri: "demo://r/1", text: "linked body" }],
});
const result: CallToolResult = {
content: [
{ type: "text", text: "ok" },
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
],
};
renderWithMantine(
<ToolResultPanel
result={result}
onClear={() => {}}
onReadResource={onReadResource}
/>,
);
expect(screen.getByText("ok")).toBeInTheDocument();
// The link sits inside a grouped, labeled box.
expect(
screen.getByRole("heading", { name: "Resource Links" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Expand resource demo://r/1" }),
);
expect(onReadResource).toHaveBeenCalledWith("demo://r/1");
await waitFor(() =>
expect(screen.getByText(/"linked body"/)).toBeInTheDocument(),
);
});
it("collapses consecutive resource_link blocks into a single box", () => {
const result: CallToolResult = {
content: [
{ type: "text", text: "intro" },
{ type: "resource_link", uri: "demo://r/1", name: "One" },
{ type: "resource_link", uri: "demo://r/2", name: "Two" },
{ type: "resource_link", uri: "demo://r/3", name: "Three" },
],
};
renderWithMantine(<ToolResultPanel result={result} onClear={() => {}} />);
// One shared "Resource Links" heading for the whole run of links.
expect(
screen.getAllByRole("heading", { name: "Resource Links" }),
).toHaveLength(1);
expect(screen.getByText("One")).toBeInTheDocument();
expect(screen.getByText("Two")).toBeInTheDocument();
expect(screen.getByText("Three")).toBeInTheDocument();
});
it("renders a separate Resource Links box per non-adjacent run", () => {
const result: CallToolResult = {
content: [
{ type: "resource_link", uri: "demo://r/1", name: "One" },
{ type: "text", text: "divider" },
{ type: "resource_link", uri: "demo://r/2", name: "Two" },
],
};
renderWithMantine(<ToolResultPanel result={result} onClear={() => {}} />);
// The text block between the two links splits them into two boxes.
expect(
screen.getAllByRole("heading", { name: "Resource Links" }),
).toHaveLength(2);
expect(screen.getByText("divider")).toBeInTheDocument();
});
describe("structuredContent (#1908)", () => {
const structured = { items: [{ id: 1, name: "Item A" }], total: 1 };
it("renders a Structured Output section alongside the content blocks", () => {
renderWithMantine(
<ToolResultPanel
result={{ ...okResult, structuredContent: structured }}
onClear={() => {}}
/>,
);
expect(screen.getByText("ok")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Structured Output" }),
).toBeInTheDocument();
});
it("omits the section when the result has no structuredContent", () => {
renderWithMantine(
<ToolResultPanel result={okResult} onClear={() => {}} />,
);
expect(
screen.queryByRole("heading", { name: "Structured Output" }),
).not.toBeInTheDocument();
});
it("renders the section instead of the empty state when content is empty", () => {
renderWithMantine(
<ToolResultPanel
result={{ content: [], structuredContent: structured }}
onClear={() => {}}
/>,
);
expect(screen.queryByText("No results yet")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Structured Output" }),
).toBeInTheDocument();
});
it("renders the section below the alert on an error result", () => {
renderWithMantine(
<ToolResultPanel
result={{ ...errorResult, structuredContent: structured }}
onClear={() => {}}
/>,
);
expect(screen.getByText("Tool Error")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Structured Output" }),
).toBeInTheDocument();
});
it("renders the section alongside a Resource Links box", () => {
renderWithMantine(
<ToolResultPanel
result={{
content: [
{ type: "resource_link", uri: "demo://r/1", name: "One" },
],
structuredContent: structured,
}}
onClear={() => {}}
/>,
);
expect(
screen.getByRole("heading", { name: "Resource Links" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Structured Output" }),
).toBeInTheDocument();
});
});
it("invokes onClear when the close button is clicked", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(<ToolResultPanel result={okResult} onClear={onClear} />);
await user.click(screen.getByRole("button", { name: "Close results" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
describe("resultHasResourceLinks", () => {
it("is true only for a non-error result containing a resource_link", () => {
expect(
resultHasResourceLinks({
content: [
{ type: "text", text: "ok" },
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
],
}),
).toBe(true);
});
it("is false for a text-only result", () => {
expect(resultHasResourceLinks(okResult)).toBe(false);
});
it("is false for an empty result", () => {
expect(resultHasResourceLinks(emptyResult)).toBe(false);
});
it("is false when the result is an error, even with a resource_link", () => {
expect(
resultHasResourceLinks({
isError: true,
content: [
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
],
}),
).toBe(false);
});
});
});