diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts index 727fe5a..fcfc49a 100644 --- a/e2e/tools.spec.ts +++ b/e2e/tools.spec.ts @@ -317,9 +317,19 @@ test.describe("Tools page", () => { body: JSON.stringify({ target: { kind: "local" }, resolved_arguments: { query: "cloudflare", limit: 5 }, + content: [ + { type: "text", text: "Found 2 matching issues", mimeType: "text/plain" }, + { type: "text", text: '{"total":2}', mimeType: "application/json" }, + { + type: "image", + text: '', + mimeType: "image/svg+xml", + }, + ], + structured_output: { total: 2, query: "cloudflare" }, annotations: { readOnlyHint: true }, pre_hooks_run: [], - warnings: [], + warnings: [{ code: "elicitation_skipped", hooks: ["approval_hook"] }], }), }); }); @@ -346,6 +356,14 @@ test.describe("Tools page", () => { await previewButton.click(); await expect(panel.getByText("Preview 200")).toBeVisible(); + await expect(panel.getByText("Warnings", { exact: true }).first()).toBeVisible(); + await expect( + panel.getByText("Live invocation may request user input; preview skipped approval_hook."), + ).toBeVisible(); + await expect(panel.getByText("Tool result")).toBeVisible(); + await expect(panel.getByText("Found 2 matching issues").first()).toBeVisible(); + await expect(panel.getByRole("img", { name: "Tool result image 3" })).toBeVisible(); + await expect(panel.getByText("Structured output")).toBeVisible(); await expect(panel.getByText("Resolved arguments")).toBeVisible(); expect(previewBody).toEqual({ arguments: { query: "cloudflare", limit: 5 } }); expect(previewHeaders["x-tenant-id"]).toBe("team-a"); diff --git a/src/api/tools.ts b/src/api/tools.ts index 5beb407..d8ac29a 100644 --- a/src/api/tools.ts +++ b/src/api/tools.ts @@ -51,8 +51,35 @@ export interface ToolPreviewTarget { [key: string]: unknown; } +export interface ToolResultResource { + uri?: string; + mimeType?: string; + mime_type?: string; + text?: string; + blob?: string; + data?: string; + [key: string]: unknown; +} + +export interface ToolResultContentBlock { + type?: string; + text?: string; + data?: string; + blob?: string; + mimeType?: string; + mime_type?: string; + uri?: string; + resource?: ToolResultResource; + [key: string]: unknown; +} + export interface ToolPreviewResponse { + content?: ToolResultContentBlock[]; resolved_arguments?: Record; + structured_output?: unknown; + structuredOutput?: unknown; + isError?: boolean; + is_error?: boolean; target?: "local" | "federated" | ToolPreviewTarget | null; annotations?: Record | null; pre_hooks_run?: unknown[] | number | null; diff --git a/src/components/tools/ToolPreviewResult.test.tsx b/src/components/tools/ToolPreviewResult.test.tsx index 6ca9e87..1743bff 100644 --- a/src/components/tools/ToolPreviewResult.test.tsx +++ b/src/components/tools/ToolPreviewResult.test.tsx @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/test/test-utils"; import { ToolPreviewResult } from "./ToolPreviewResult"; import type { ToolPreviewState } from "@/hooks/useToolPreview"; +import type { ToolPreviewResponse } from "@/api/tools"; +import { TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES } from "./toolResultContent"; function previewProps( overrides: Partial>, @@ -23,7 +26,7 @@ describe("ToolPreviewResult", () => { }); it("renders status, warnings, resolved arguments, and raw response for success", () => { - render( + const { container } = render( { preview: { target: { kind: "federated", gateway_name: "github" }, resolved_arguments: { query: "cloudflare" }, + content: [{ type: "text", text: "found issue", mimeType: "text/plain" }], warnings: [{ code: "elicitation_skipped", message: "approval skipped" }], }, }, @@ -45,8 +49,12 @@ describe("ToolPreviewResult", () => { expect(screen.getByText("federated: github")).toBeInTheDocument(); expect(screen.getByText("Warnings")).toBeInTheDocument(); expect(screen.getByText("approval skipped")).toBeInTheDocument(); + expect(screen.getByText("Tool result")).toBeInTheDocument(); + expect(screen.getByText("found issue")).toBeInTheDocument(); expect(screen.getByText("Resolved arguments")).toBeInTheDocument(); expect(screen.getByText("Raw preview response")).toBeInTheDocument(); + expect(screen.getByLabelText("Copy raw preview response")).toBeVisible(); + expect(container.textContent).toContain('"resolved_arguments"'); }); it("renders API failures", () => { @@ -86,6 +94,100 @@ describe("ToolPreviewResult", () => { expect(screen.queryByText("Resolved arguments")).not.toBeInTheDocument(); }); + it("renders elicitation skipped warnings without backend messages", () => { + render( + , + ); + + expect( + screen.getByText("Live invocation may request user input; preview skipped approval_hook."), + ).toBeInTheDocument(); + }); + + it("renders localized fallback hook labels for elicitation warnings", () => { + render( + , + ); + + expect( + screen.getByText( + "Live invocation may request user input; preview skipped one or more hooks.", + ), + ).toBeInTheDocument(); + }); + + it("keeps large raw responses collapsed until requested", async () => { + const user = userEvent.setup(); + const marker = "hidden raw marker"; + const preview = { + debug: `${"x".repeat(TOOL_RESULT_STRUCTURED_OUTPUT_SIZE_LIMIT_BYTES)} ${marker}`, + } as unknown as ToolPreviewResponse; + const { container } = render( + , + ); + + expect(screen.getByText(/Large content hidden/)).toBeInTheDocument(); + expect(container.textContent).not.toContain(marker); + + await user.click(screen.getByRole("button", { name: "View all" })); + + expect(container.textContent).toContain(marker); + }); + + it("renders tool error results without treating the HTTP request as failed", () => { + render( + , + ); + + expect(screen.getByText("Preview 200")).toBeInTheDocument(); + expect(screen.getByText("Error response")).toBeInTheDocument(); + expect(screen.getByText("tool failed")).toBeInTheDocument(); + }); + it("renders generic failures without an HTTP status", () => { render( ; @@ -18,7 +33,10 @@ export function ToolPreviewResult({ preview }: ToolPreviewResultProps) { if (!hasRun) return null; const renderTimeMs = result?.renderTimeMs ?? error?.renderTimeMs ?? 0; + const response = result?.preview; + const toolResultIsError = response ? getToolResultIsError(response) : false; const succeeded = result !== null; + const statusOk = succeeded && !toolResultIsError; const statusCode = result?.status ?? error?.status ?? null; const statusLabel = succeeded ? intl.formatMessage({ id: "tools.details.preview.statusOk" }, { status: statusCode ?? 200 }) @@ -29,7 +47,6 @@ export function ToolPreviewResult({ preview }: ToolPreviewResultProps) { ) : intl.formatMessage({ id: "tools.details.preview.statusError" }); - const response = result?.preview; const target = response ? formatTarget(response.target) : null; const warnings = response?.warnings ?? []; @@ -40,12 +57,12 @@ export function ToolPreviewResult({ preview }: ToolPreviewResultProps) { aria-live="polite" className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px]" > - {succeeded ? ( + {statusOk ? ( ) : ( )} - + {statusLabel}