feat(web): render structured tool payloads

This commit is contained in:
Miguel Palhas
2026-09-07 07:51:42 +01:00
parent 8c151a5fb2
commit 4a2f7e98b4
4 changed files with 425 additions and 45 deletions
+94 -6
View File
@@ -171,19 +171,107 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("false");
});
it("shows raw command, input, and output as separately copyable details", () => {
it("renders command fields and keeps the exact JSON available", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
rawInput: { command: "printf ok", timeout_ms: 1000 },
rawOutput: { stdout: "ok", exit_code: 0 },
rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 },
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [...el.querySelectorAll(".st-tool-detail-label")].map((node) => node.textContent);
expect(labels).toEqual(["Command", "Input", "Output"]);
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelectorAll(".st-tool-detail pre[role=button]")).toHaveLength(3);
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
expect(raw.open).toBe(false);
expect(raw.textContent).toContain('"timeout_ms": 1000');
expect(raw.textContent).toContain('"exit_code": 0');
});
it("renders MCP arguments and results without hiding the source payload", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "mcp.github.search",
rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } },
rawOutput: { result: { count: 2 }, error: null },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [
...el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
expect(labels).toEqual(["Arguments", "Result"]);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
});
it("renders a recognized web search query", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Search the web",
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
});
it("falls back to raw JSON for an unknown provider shape", () => {
const el = buildTranscriptRow(
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(
el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
).toHaveLength(0);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
});
it("renders standardized content before provider-specific and raw data", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Bash(test)",
content: [
{ type: "content", content: { type: "text", text: "typed result" } },
{ type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } },
{ type: "terminal", terminalId: "term-1" },
],
rawInput: { command: "test" },
rawOutput: { formatted_output: "typed result", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
expect([...inner.children].map((node) => node.className)).toEqual([
"st-tool-text st-copyable",
"st-tool-resource",
"st-tool-terminal",
"st-tool-detail",
"st-tool-fact",
"st-tool-raw",
]);
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
});
it("renders safe typed image and audio blocks as media controls", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Media",
content: [
{ type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } },
{ type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } },
],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
});
});
+208 -33
View File
@@ -246,16 +246,18 @@ function printable(value: unknown): string | null {
}
}
function toolInput(tool: AcpToolCallLike): { command: string | null; input: string | null } {
const raw = tool.rawInput;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { command: null, input: printable(raw) };
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
const record = raw as Record<string, unknown>;
const command = typeof record.command === "string" ? record.command : null;
if (!command) return { command: null, input: printable(raw) };
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== "command"));
return { command, input: Object.keys(rest).length > 0 ? printable(rest) : null };
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function appendToolDetail(container: HTMLElement, label: string, text: string): void {
@@ -269,6 +271,200 @@ function appendToolDetail(container: HTMLElement, label: string, text: string):
container.appendChild(detail);
}
function appendToolValue(container: HTMLElement, label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) appendToolDetail(container, label, text);
}
function appendExitCode(container: HTMLElement, value: unknown): void {
if (typeof value !== "number") return;
const line = el("div", "st-tool-fact");
const label = el("span", "st-tool-fact-label");
label.textContent = "Exit code";
const code = el("code", value === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed");
code.textContent = String(value);
line.append(label, code);
container.appendChild(line);
}
function appendCommandPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.command !== "string") return false;
appendToolDetail(container, "Command", input.command);
appendToolValue(container, "Working directory", input.cwd);
const result = record(output);
if (result) {
appendToolValue(container, "Output", result.formatted_output ?? result.stdout ?? result.output, contentText);
appendExitCode(container, result.exit_code);
}
return true;
}
function appendMcpPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (typeof input.server !== "string" || typeof input.tool !== "string" || !("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) {
appendToolValue(container, "Result", result.result, contentText);
appendToolValue(container, "Error", result.error);
}
return true;
}
function appendSearchPayload(
container: HTMLElement,
input: Record<string, unknown>,
tool: AcpToolCallLike,
): boolean {
if (typeof input.query !== "string" || (tool.kind !== "search" && input.type !== "webSearch")) return false;
appendToolDetail(container, "Query", input.query);
appendToolValue(container, "Action", input.action);
return true;
}
function appendFunctionPayload(
container: HTMLElement,
input: Record<string, unknown>,
output: unknown,
contentText: Set<string>,
): boolean {
if (!("arguments" in input)) return false;
appendToolValue(container, "Arguments", input.arguments);
const result = record(output);
if (result) appendToolValue(container, "Result", result.output, contentText);
return true;
}
function appendKnownToolPayload(
container: HTMLElement,
tool: AcpToolCallLike,
content: AcpToolCallContent[],
): void {
const input = record(tool.rawInput);
if (!input) return;
const contentText = new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
if (appendCommandPayload(container, input, tool.rawOutput, contentText)) return;
if (appendMcpPayload(container, input, tool.rawOutput, contentText)) return;
if (appendSearchPayload(container, input, tool)) return;
appendFunctionPayload(container, input, tool.rawOutput, contentText);
}
function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
function appendContentBlock(container: HTMLElement, block: AcpContentBlock): void {
if (block.type === "text") {
if (!block.text) return;
const text = el("div", "st-tool-text");
text.textContent = block.text;
makeCopyable(text, block.text, "output");
container.appendChild(text);
return;
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) {
const image = document.createElement("img");
image.className = "st-tool-image";
image.src = source;
image.alt = "Tool output";
image.loading = "lazy";
container.appendChild(image);
return;
}
appendToolValue(container, "Image", block.uri ?? block.mimeType ?? "Unavailable");
return;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) {
const audio = document.createElement("audio");
audio.className = "st-tool-audio";
audio.controls = true;
audio.src = source;
container.appendChild(audio);
return;
}
appendToolValue(container, "Audio", block.mimeType ?? "Unavailable");
return;
}
if (block.type === "resource_link") {
const resource = el("div", "st-tool-resource");
const name = el("span", "st-tool-resource-name");
name.textContent = block.title ?? block.name ?? "Resource";
resource.appendChild(name);
if (block.uri) {
const uri = el("code", "st-tool-resource-uri");
uri.textContent = block.uri;
makeCopyable(uri, block.uri, "resource URI");
resource.appendChild(uri);
}
if (block.description) {
const description = el("span", "st-tool-resource-description");
description.textContent = block.description;
resource.appendChild(description);
}
container.appendChild(resource);
return;
}
const resource = block.resource;
if (typeof resource?.text === "string") {
appendToolDetail(container, resource.uri ? `Resource · ${resource.uri}` : "Resource", resource.text);
} else {
appendToolValue(container, "Resource", resource);
}
}
function appendStandardContent(container: HTMLElement, content: AcpToolCallContent[]): void {
for (const block of content) {
if (block.type === "content") appendContentBlock(container, block.content);
if (block.type === "diff") container.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-terminal");
note.textContent = block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal";
container.appendChild(note);
}
}
}
function appendRawPayload(container: HTMLElement, tool: AcpToolCallLike): void {
const input = jsonPrintable(tool.rawInput);
const output = jsonPrintable(tool.rawOutput);
if (input === null && output === null) return;
const disclosure = document.createElement("details");
disclosure.className = "st-tool-raw";
const summary = document.createElement("summary");
summary.textContent = "Raw JSON";
disclosure.appendChild(summary);
const body = el("div", "st-tool-raw-body");
if (input !== null) appendToolDetail(body, "Input", input);
if (output !== null) appendToolDetail(body, "Output", output);
disclosure.appendChild(body);
container.appendChild(disclosure);
}
function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void {
element.hidden = false;
if (animate && expanded && !element.classList.contains("is-expanded")) {
@@ -354,30 +550,9 @@ function buildToolRow(
const buildOut = () => {
if (outBuilt) return;
outBuilt = true;
const { command, input } = toolInput(tool);
if (command) appendToolDetail(outInner, "Command", command);
if (input !== null) appendToolDetail(outInner, "Input", input);
const rawOutput = printable(tool.rawOutput);
if (rawOutput !== null) appendToolDetail(outInner, "Output", rawOutput);
const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content))
.filter(Boolean);
const contentText = texts.join("\n");
if (contentText && contentText !== rawOutput) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = contentText;
makeCopyable(textPanel, contentText, "output");
outInner.appendChild(textPanel);
}
for (const block of content) {
if (block.type === "diff") outInner.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]";
outInner.appendChild(note);
}
}
appendStandardContent(outInner, content);
appendKnownToolPayload(outInner, tool, content);
appendRawPayload(outInner, tool);
};
card.appendChild(out);
+103
View File
@@ -2725,6 +2725,109 @@ input.leaf-rename:focus {
overflow-x: auto;
}
.st-tool-fact {
display: flex;
align-items: baseline;
gap: var(--sp-2);
color: var(--faint);
font-size: var(--fs-11);
}
.st-tool-fact code {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.st-tool-exit-ok {
color: var(--live);
}
.st-tool-exit-failed {
color: var(--danger);
}
.st-tool-image {
display: block;
width: auto;
max-width: min(100%, 720px);
max-height: 480px;
border: 1px solid var(--border);
border-radius: var(--r-1);
object-fit: contain;
}
.st-tool-audio {
display: block;
width: min(100%, 480px);
min-height: var(--control-h);
}
.st-tool-resource {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: baseline;
gap: var(--sp-1) var(--sp-2);
min-width: 0;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--bg);
font-size: var(--fs-12);
}
.st-tool-resource-name {
color: var(--ink);
font-weight: 500;
}
.st-tool-resource-uri {
min-width: 0;
overflow-wrap: anywhere;
color: var(--muted);
}
.st-tool-resource-description {
grid-column: 1 / -1;
color: var(--muted);
}
.st-tool-terminal {
color: var(--muted);
font-family: var(--font-mono);
font-size: var(--fs-12);
}
.st-tool-raw {
color: var(--faint);
font-size: var(--fs-11);
}
.st-tool-raw summary {
width: fit-content;
padding-block: var(--sp-1);
cursor: pointer;
user-select: none;
transition: color var(--dur-fast) var(--ease-out);
}
.st-tool-raw summary:hover,
.st-tool-raw summary:focus-visible {
color: var(--ink);
}
.st-tool-raw summary:focus-visible {
border-radius: var(--r-1);
outline: 2px solid var(--accent-ring);
outline-offset: 2px;
}
.st-tool-raw-body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding-top: var(--sp-1);
}
.st-tool-group {
min-width: 0;
border: 1px solid var(--border);
+20 -6
View File
@@ -485,14 +485,28 @@ export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "d
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
* generic placeholder rather than being dropped silently. */
export interface AcpEmbeddedResource {
uri?: string;
mimeType?: string;
text?: string;
blob?: string;
}
/** `ContentBlock`, including the fields needed to render typed tool output. */
export type AcpContentBlock =
| { type: "text"; text: string }
| { type: "image"; mimeType?: string }
| { type: "audio"; mimeType?: string }
| { type: "resource_link"; uri?: string; name?: string }
| { type: "resource"; resource?: unknown };
| { type: "image"; data?: string; mimeType?: string; uri?: string | null }
| { type: "audio"; data?: string; mimeType?: string }
| {
type: "resource_link";
uri?: string;
name?: string;
title?: string | null;
description?: string | null;
mimeType?: string | null;
size?: number | null;
}
| { type: "resource"; resource?: AcpEmbeddedResource };
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
export interface AcpContentChunk {