feat(web): render structured tool payloads
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user