fix(ui): show failed tool results as errors (#85786)

Merged via squash.

Prepared head SHA: c0c4fb5917
Co-authored-by: chengjiew <75600865+chengjiew@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Chengjie Wang
2026-05-27 05:27:57 +08:00
committed by GitHub
parent 1d972af69d
commit 950007dd9c
8 changed files with 761 additions and 32 deletions

View File

@@ -19,7 +19,7 @@
.chat-tool-card--expanded {
max-height: none;
overflow: visible;
overflow: hidden;
}
.chat-tool-card:hover {
@@ -63,10 +63,13 @@
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
font-weight: 600;
font-size: var(--control-ui-text-md);
line-height: 1.2;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.chat-tool-card__icon {
@@ -95,7 +98,9 @@
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
font-size: 11px;
white-space: nowrap;
color: var(--muted);
opacity: 1;
transition:
@@ -161,6 +166,98 @@
margin-top: 10px;
}
/* Error state: tool returned an error payload or "Tool not found" */
.chat-tool-card__status--error,
.chat-tool-card__action--error,
.chat-tool-card__status-text--error {
color: var(--destructive, var(--danger, #c0392b));
}
.chat-tool-card__status-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
margin-left: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 15%, transparent);
color: var(--destructive, var(--danger, #c0392b));
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.4;
}
.chat-tool-card__status-badge svg {
width: 10px;
height: 10px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card--error {
border-color: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 35%, var(--border));
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 8%, transparent),
transparent 36%
),
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
}
.chat-tool-card--error:hover {
border-color: color-mix(
in srgb,
var(--destructive, var(--danger, #c0392b)) 44%,
var(--border-strong)
);
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 11%, transparent),
transparent 38%
),
color-mix(in srgb, var(--card) 78%, var(--bg-hover) 22%);
}
.chat-tool-card--error .chat-tool-card__icon,
.chat-tool-msg-summary.chat-tool-msg-summary--error .chat-tool-msg-summary__icon {
color: var(--destructive, var(--danger, #c0392b));
opacity: 0.9;
}
.chat-tool-msg-summary__error-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
margin-left: auto;
border-radius: 999px;
background: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 15%, transparent);
color: var(--destructive, var(--danger, #c0392b));
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.4;
flex-shrink: 0;
}
.chat-tool-msg-summary__error-badge svg {
width: 10px;
height: 10px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__detail {
font-size: var(--control-ui-text-sm);
color: var(--muted);
@@ -365,6 +462,12 @@
max-height: min(520px, 60vh);
}
.chat-tool-card__block-content code {
white-space: inherit;
overflow-wrap: anywhere;
word-break: inherit;
}
.chat-tool-card__inline {
margin-top: 10px;
white-space: pre-wrap;
@@ -585,6 +688,17 @@
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
}
.chat-tool-msg-summary.chat-tool-msg-summary--error {
border-color: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 30%, var(--border));
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 11%, transparent),
transparent 36%
),
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
}
.chat-tool-msg-summary::-webkit-details-marker {
display: none;
}
@@ -618,6 +732,21 @@
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
}
.chat-tool-msg-summary.chat-tool-msg-summary--error:hover {
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 14%, transparent),
transparent 38%
),
color-mix(in srgb, var(--card) 78%, var(--bg-hover) 22%);
border-color: color-mix(
in srgb,
var(--destructive, var(--danger, #c0392b)) 38%,
var(--border-strong)
);
}
.chat-tool-msg-collapse--manual.is-open > .chat-tool-msg-summary,
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary {
border-bottom-right-radius: 0;
@@ -790,6 +919,10 @@
max-height: 100px;
}
.chat-tool-card--expanded {
max-height: none;
}
.chat-tool-card__title {
font-size: 12px;
}
@@ -826,6 +959,10 @@
max-height: 80px;
}
.chat-tool-card--expanded {
max-height: none;
}
.chat-tool-card__preview {
padding: 4px 6px;
max-height: 28px;

View File

@@ -20,4 +20,14 @@ describe("chat tool card styles", () => {
expect(css).toContain("white-space: normal;");
expect(css).not.toContain("max-width: 42%;");
});
it("keeps expanded tool cards and actions usable on narrow screens", () => {
const css = readToolCardsCss();
expect(css).toContain(".chat-tool-card--expanded {");
expect(css).toContain("max-height: none;");
expect(css).toContain("overflow: hidden;");
expect(css).toContain("white-space: nowrap;");
expect(css).toContain(".chat-tool-card__block-content code");
});
});

View File

@@ -44,7 +44,7 @@ function requireFirstMockArg(
if (!arg || typeof arg !== "object" || Array.isArray(arg)) {
throw new Error(`expected ${label} payload`);
}
return arg as Record<string, unknown>;
return arg;
}
vi.mock("../views/agents-utils.ts", () => {
@@ -1068,7 +1068,7 @@ describe("grouped chat rendering", () => {
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
expect(
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
).toEqual(["Tool input", "Tool output"]);
).toEqual(["Tool input", "Tool error"]);
expect(blocks[0]?.querySelector("code")?.textContent).toBe(
'{\n "mode": "session",\n "thread": true\n}',
);
@@ -1079,6 +1079,115 @@ describe("grouped chat rendering", () => {
});
});
it("respects explicit success on collapsed standalone tool-result summaries", () => {
const container = document.createElement("div");
renderMessageGroups(
container,
[
createMessageGroup(
{
id: "tool-error-collapsed",
role: "toolResult",
toolCallId: "call-error-collapsed",
toolName: "web_search",
isError: false,
content: JSON.stringify({
error: "missing_brave_api_key",
message: "BRAVE_API_KEY is not configured",
}),
timestamp: Date.now(),
},
"tool",
),
],
{
isToolMessageExpanded: () => false,
},
);
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe("web_search");
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
});
it("respects explicit success on MCP-style standalone tool-result summaries", () => {
const container = document.createElement("div");
renderMessageGroups(
container,
[
createMessageGroup(
{
id: "tool-error-collapsed-mcp",
role: "toolResult",
toolCallId: "call-error-collapsed-mcp",
toolName: "memory_forget",
isError: false,
content: JSON.stringify({
isError: true,
content: [{ type: "text", text: "Tool error: boom" }],
}),
timestamp: Date.now(),
},
"tool",
),
],
{
isToolMessageExpanded: () => false,
},
);
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
"memory_forget",
);
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
});
it("marks status-only standalone tool-result summaries as errors", () => {
const container = document.createElement("div");
const groups = [
createMessageGroup(
{
id: "tool-status-error",
role: "toolResult",
toolCallId: "call-status-error",
toolName: "sessions_spawn",
content: JSON.stringify({ status: "error" }, null, 2),
timestamp: Date.now(),
},
"tool",
),
];
renderMessageGroups(container, groups, {
isToolMessageExpanded: () => false,
});
let summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
"sessions_spawn",
);
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
renderMessageGroups(container, groups, {
isToolMessageExpanded: () => true,
});
summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
expect(
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
).toEqual({ status: "error" });
});
it("collapses an inline tool call while keeping matching tool output visible", () => {
const container = document.createElement("div");
const groups = [

View File

@@ -27,6 +27,7 @@ import {
extractToolCards,
formatCollapsedToolPreviewText,
formatCollapsedToolSummaryText,
isToolCardError,
renderExpandedToolCardContent,
renderRawOutputToggle,
renderToolCard,
@@ -1534,6 +1535,7 @@ function renderGroupedMessage(
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const singleToolCard = toolCards.length === 1 ? toolCards[0] : null;
const toolMessageHasError = toolCards.some(isToolCardError);
const singleToolDisplay = singleToolCard
? resolveToolDisplay({
name: singleToolCard.name,
@@ -1542,21 +1544,28 @@ function renderGroupedMessage(
})
: null;
const singleToolDisplayDetail =
singleToolCard && singleToolDisplay
!toolMessageHasError && singleToolCard && singleToolDisplay
? resolveCollapsedToolDetail(singleToolCard, singleToolDisplay.detail)
: undefined;
const toolSummaryLabelRaw = singleToolDisplayDetail
? singleToolCard?.outputText?.trim()
? "output"
: undefined
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolSummaryLabelRaw = toolMessageHasError
? singleToolDisplay
? singleToolDisplay.label
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`
: singleToolDisplayDetail
? singleToolCard?.outputText?.trim()
? "output"
: undefined
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolSummaryLabel = formatCollapsedToolSummaryText(toolSummaryLabelRaw);
const toolPreview =
markdown && !toolSummaryLabel ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
const toolMessageLabelRaw =
singleToolDisplayDetail && !markdown && !hasImages
const toolMessageLabelRaw = toolMessageHasError
? "Tool error"
: singleToolDisplayDetail && !markdown && !hasImages
? singleToolDisplayDetail
: singleToolDisplay && !markdown && !hasImages
? singleToolDisplay.label
@@ -1584,7 +1593,9 @@ function renderGroupedMessage(
: ""}"
>
<button
class="chat-tool-msg-summary"
class="chat-tool-msg-summary ${toolMessageHasError
? "chat-tool-msg-summary--error"
: ""}"
type="button"
aria-expanded=${String(toolMessageExpanded)}
@click=${() => opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId)}
@@ -1596,6 +1607,13 @@ function renderGroupedMessage(
: toolPreview
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
: nothing}
${toolMessageHasError
? html`<span
class="chat-tool-msg-summary__error-badge"
aria-label="Tool returned an error"
>${icons.x}<span>Error</span></span
>`
: nothing}
</button>
${toolMessageExpanded
? html`

View File

@@ -163,6 +163,61 @@ describe("tool-card extraction", () => {
expect(cards[0]?.outputText).toBe("# Heading\nfile body");
});
it("preserves explicit tool error flags from tool result items and messages", () => {
const pairedCards = extractToolCards(
{
role: "assistant",
content: [
{
type: "toolcall",
id: "call-error",
name: "lookup",
},
{
type: "tool_result",
id: "call-error",
name: "lookup",
text: "lookup failed",
isError: true,
},
],
},
"msg:error-item",
);
expect(pairedCards[0]?.isError).toBe(true);
const messageFlagCards = extractToolCards(
{
role: "toolResult",
isError: true,
content: [
{
type: "tool_result",
id: "call-message-error",
name: "lookup",
text: "lookup failed",
},
],
},
"msg:error-message-flag",
);
expect(messageFlagCards[0]?.isError).toBe(true);
const standaloneCards = extractToolCards(
{
role: "tool",
toolName: "lookup",
content: "lookup failed",
isError: true,
},
"msg:error-message",
);
expect(standaloneCards[0]?.isError).toBe(true);
});
it("builds sidebar content with input and empty output status", () => {
const [card] = extractToolCards(
{
@@ -193,6 +248,21 @@ with Example Deck
*No output — tool completed successfully.*`);
});
it("builds sidebar content with a failed empty-output status for explicit errors", () => {
const sidebar = buildToolCardSidebarContent({
id: "msg:error-empty",
name: "lookup",
isError: true,
});
expect(sidebar).toBe(`## Lookup
**Tool:** \`lookup\`
### Tool error
*No output — tool failed.*`);
});
it("extracts canvas handle payloads into canvas previews", () => {
const [card] = extractToolCards(
{

View File

@@ -5,11 +5,19 @@ import { describe, expect, it, vi } from "vitest";
import {
formatCollapsedToolPreviewText,
formatCollapsedToolSummaryText,
isToolErrorOutput,
renderToolCard,
renderToolCardSidebar,
} from "./tool-cards.ts";
vi.mock("../icons.ts", () => ({
icons: {},
icons: {
check: "✓",
chevronDown: "",
panelRightOpen: "",
x: "✕",
zap: "",
},
}));
vi.mock("../tool-display.ts", () => ({
@@ -40,7 +48,7 @@ function requireFirstMockArg(
if (!arg || typeof arg !== "object" || Array.isArray(arg)) {
throw new Error(`expected ${label} payload`);
}
return arg as Record<string, unknown>;
return arg;
}
describe("tool-cards", () => {
@@ -305,4 +313,268 @@ describe("tool-cards", () => {
expect(sidebar.docId).toBe("cv_sidebar");
expect(sidebar.entryUrl).toBe("/__openclaw__/canvas/documents/cv_sidebar/index.html");
});
describe("isToolErrorOutput", () => {
it("flags JSON payloads that carry a top-level error string", () => {
expect(
isToolErrorOutput(
JSON.stringify({
error: "missing_brave_api_key",
message: "BRAVE_API_KEY is not configured",
provider: "brave",
}),
),
).toBe(true);
});
it("flags JSON payloads that carry a top-level isError flag", () => {
expect(
isToolErrorOutput(
JSON.stringify({
isError: true,
content: [{ type: "text", text: "Tool error: boom" }],
}),
),
).toBe(true);
expect(
isToolErrorOutput(
JSON.stringify({
is_error: true,
content: [{ type: "text", text: "Tool error: boom" }],
}),
),
).toBe(true);
});
it("flags 'Tool not found' bodies regardless of trailing punctuation or case", () => {
expect(isToolErrorOutput("Tool not found")).toBe(true);
expect(isToolErrorOutput(" tool not found. ")).toBe(true);
expect(isToolErrorOutput("TOOL NOT FOUND")).toBe(true);
});
it("flags JSON payloads with top-level failure statuses", () => {
expect(isToolErrorOutput(JSON.stringify({ status: "error" }))).toBe(true);
expect(isToolErrorOutput(JSON.stringify({ status: "failed" }))).toBe(true);
expect(isToolErrorOutput(JSON.stringify({ status: "timeout" }))).toBe(true);
expect(isToolErrorOutput(JSON.stringify({ status: "completed" }))).toBe(false);
expect(isToolErrorOutput(JSON.stringify({ status: "ok" }))).toBe(false);
});
it("does not flag successful payloads or strings without a tool error signal", () => {
expect(isToolErrorOutput(undefined)).toBe(false);
expect(isToolErrorOutput("")).toBe(false);
expect(isToolErrorOutput("Opened page")).toBe(false);
expect(
isToolErrorOutput(
JSON.stringify({ isError: false, result: "ok", error: "no validation errors" }),
),
).toBe(false);
expect(isToolErrorOutput(JSON.stringify({ result: "ok", error: null }))).toBe(false);
expect(isToolErrorOutput(JSON.stringify({ result: "ok", error: "" }))).toBe(false);
expect(isToolErrorOutput(JSON.stringify({ result: "ok" }))).toBe(false);
expect(isToolErrorOutput("{ not really json }")).toBe(false);
});
});
it("renders a Tool error label and Error badge when output is an error JSON", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:err:1",
name: "web_search",
args: { query: "python stable version" },
inputText: '{\n "query": "python stable version"\n}',
outputText: JSON.stringify({
error: "missing_brave_api_key",
message: "BRAVE_API_KEY is not configured",
}),
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool error");
expect(container.textContent).not.toMatch(/\bTool output\b/);
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
const expandedCard = container.querySelector(".chat-tool-card--expanded");
expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true);
expect(container.querySelector(".chat-tool-card__status-badge")).not.toBeNull();
});
it("renders a Tool error label when output has a status-only error payload", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:err:status-only",
name: "sessions_spawn",
outputText: JSON.stringify({ status: "error" }),
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool error");
expect(container.textContent).not.toMatch(/\bTool output\b/);
expect(container.querySelector(".chat-tool-msg-summary--error")).not.toBeNull();
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
});
it("renders a Tool error label when output is the literal 'Tool not found'", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:err:2",
name: "Unknown",
outputText: "Tool not found",
},
{ expanded: false, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool error");
expect(container.textContent).not.toMatch(/\bTool output\b/);
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(true);
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
});
it("renders a Tool error label when the tool card has an explicit error flag", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:err:explicit",
name: "lookup",
outputText: "lookup failed",
isError: true,
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool error");
expect(container.textContent).not.toMatch(/\bTool output\b/);
expect(container.querySelector(".chat-tool-msg-summary--error")).not.toBeNull();
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
});
it("respects an explicit success flag even when the payload looks like an error", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:err:status-false",
name: "web_search",
outputText: JSON.stringify({
error: "missing_brave_api_key",
}),
isError: false,
},
{ expanded: false, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Web Search");
expect(container.textContent).not.toContain("Tool error");
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
});
it("does not render View with a checkmark for sidebar cards whose output is an error JSON", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar",
name: "web_search",
outputText: JSON.stringify({
error: "missing_brave_api_key",
message: "BRAVE_API_KEY is not configured",
}),
},
vi.fn(),
),
container,
);
const card = container.querySelector(".chat-tool-card");
const action = container.querySelector(".chat-tool-card__action");
expect(card?.classList.contains("chat-tool-card--error")).toBe(true);
expect(action?.classList.contains("chat-tool-card__action--error")).toBe(true);
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});
it("marks Tool not found sidebar output as an error instead of View with a checkmark", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar-tool-not-found",
name: "Unknown",
outputText: "Tool not found",
},
vi.fn(),
),
container,
);
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});
it("marks status-only sidebar output as an error instead of View with a checkmark", () => {
const container = document.createElement("div");
render(
renderToolCardSidebar(
{
id: "msg:err:sidebar-status",
name: "sessions_wait",
outputText: JSON.stringify({ status: "timeout" }),
},
vi.fn(),
),
container,
);
const action = container.querySelector(".chat-tool-card__action");
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
expect(action?.textContent).toContain("View error");
expect(action?.textContent).toContain("✕");
expect(action?.textContent).not.toContain("✓");
});
it("keeps Tool output labelling for successful results", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:ok:1",
name: "browser.open",
outputText: "Opened page",
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool output");
expect(container.textContent).not.toContain("Tool error");
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull();
});
});

View File

@@ -20,7 +20,9 @@ function normalizeContent(content: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(content)) {
return [];
}
return content.filter(Boolean) as Array<Record<string, unknown>>;
return content.filter(
(entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object",
);
}
function coerceArgs(value: unknown): unknown {
@@ -63,6 +65,72 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
return undefined;
}
function readToolErrorFlag(value: Record<string, unknown>): boolean | undefined {
const raw = value.isError ?? value.is_error;
return typeof raw === "boolean" ? raw : undefined;
}
const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i;
const MAX_ERROR_DETECT_CHARS = 20_000;
const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]);
function hasToolErrorStatus(value: unknown): boolean {
return typeof value === "string" && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase());
}
export function isToolErrorOutput(outputText: string | undefined): boolean {
if (!outputText) {
return false;
}
const trimmed = outputText.trim();
if (!trimmed) {
return false;
}
if (TOOL_NOT_FOUND_PATTERN.test(trimmed)) {
return true;
}
if (trimmed.length > MAX_ERROR_DETECT_CHARS) {
return false;
}
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return false;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return false;
}
const obj = parsed as Record<string, unknown>;
const explicitErrorFlag = readToolErrorFlag(obj);
if (explicitErrorFlag !== undefined) {
return explicitErrorFlag;
}
if ("error" in obj) {
const value = obj.error;
if (typeof value === "string") {
return value.trim().length > 0;
}
if (typeof value === "boolean") {
return value;
}
if (value && typeof value === "object") {
return true;
}
}
return hasToolErrorStatus(obj.status);
}
export function isToolCardError(card: ToolCard): boolean {
if (card.isError !== undefined) {
return card.isError;
}
return isToolErrorOutput(card.outputText);
}
export function extractToolPreview(
outputText: string | undefined,
toolName: string | undefined,
@@ -169,6 +237,7 @@ function findLatestCard(cards: ToolCard[], id: string, name: string): ToolCard |
export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const messageIsError = readToolErrorFlag(m);
const cards: ToolCard[] = [];
for (let index = 0; index < content.length; index++) {
@@ -182,7 +251,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
const args = coerceArgs(item.arguments ?? item.args ?? item.input);
cards.push({
id: resolveToolCardId(item, m, index, prefix),
name: (item.name as string) ?? "tool",
name: typeof item.name === "string" ? item.name : "tool",
args,
inputText: serializeToolInput(args),
});
@@ -195,15 +264,20 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
const existing = findLatestCard(cards, cardId, name);
const text = extractToolText(item);
const preview = extractToolPreview(text, name);
const isError = readToolErrorFlag(item) ?? messageIsError;
if (existing) {
existing.outputText = text;
existing.preview = preview;
if (isError !== undefined) {
existing.isError = isError;
}
continue;
}
cards.push({
id: cardId,
name,
outputText: text,
...(isError !== undefined ? { isError } : {}),
preview,
});
}
@@ -227,6 +301,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
id: resolveToolCardId({}, m, 0, prefix),
name,
outputText: text,
...(messageIsError !== undefined ? { isError: messageIsError } : {}),
preview: extractToolPreview(text, name),
});
}
@@ -237,6 +312,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
export function buildToolCardSidebarContent(card: ToolCard): string {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const isError = isToolCardError(card);
const sections = [`## ${display.label}`, `**Tool:** \`${display.name}\``];
if (detail) {
@@ -251,9 +327,15 @@ export function buildToolCardSidebarContent(card: ToolCard): string {
}
if (card.outputText?.trim()) {
sections.push(`### Tool output\n${formatToolOutputForSidebar(card.outputText)}`);
sections.push(
`### ${isError ? "Tool error" : "Tool output"}\n${formatToolOutputForSidebar(card.outputText)}`,
);
} else {
sections.push(`### Tool output\n*No output — tool completed successfully.*`);
sections.push(
isError
? "### Tool error\n*No output — tool failed.*"
: "### Tool output\n*No output — tool completed successfully.*",
);
}
return sections.join("\n\n");
@@ -412,14 +494,15 @@ function renderCollapsedToolSummary(params: {
icon: ReturnType<typeof html> | undefined;
name?: string;
expanded: boolean;
isError?: boolean;
onToggleExpanded: () => void;
}) {
const { label, icon, name, expanded, onToggleExpanded } = params;
const { label, icon, name, expanded, isError, onToggleExpanded } = params;
const displayLabel = formatCollapsedToolSummaryText(label) ?? label;
const displayName = formatCollapsedToolSummaryText(name);
return html`
<button
class="chat-tool-msg-summary"
class="chat-tool-msg-summary ${isError ? "chat-tool-msg-summary--error" : ""}"
type="button"
aria-expanded=${String(expanded)}
@click=${() => onToggleExpanded()}
@@ -429,6 +512,11 @@ function renderCollapsedToolSummary(params: {
${displayName
? html`<span class="chat-tool-msg-summary__names">${displayName}</span>`
: nothing}
${isError
? html`<span class="chat-tool-msg-summary__error-badge" aria-label="Tool returned an error"
>${icons.x}<span>Error</span></span
>`
: nothing}
</button>
`;
}
@@ -458,9 +546,10 @@ export function renderToolCard(
) {
const hasOutput = Boolean(card.outputText?.trim());
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const collapsedDetail = resolveCollapsedToolDetail(card, display.detail);
const previewLabel = collapsedDetail ?? display.label;
const previewName = collapsedDetail && hasOutput ? "output" : undefined;
const isError = isToolCardError(card);
const collapsedDetail = isError ? undefined : resolveCollapsedToolDetail(card, display.detail);
const previewLabel = isError ? "Tool error" : (collapsedDetail ?? display.label);
const previewName = isError ? display.label : collapsedDetail && hasOutput ? "output" : undefined;
return html`
<div
@@ -473,6 +562,7 @@ export function renderToolCard(
icon: icons[display.icon],
name: previewName,
expanded: opts.expanded,
isError,
onToggleExpanded: () => opts.onToggleExpanded(card.id),
})}
${opts.expanded
@@ -503,6 +593,7 @@ export function renderExpandedToolCardContent(
const detail = formatToolDetail(display);
const hasOutput = Boolean(card.outputText?.trim());
const hasInput = Boolean(card.inputText?.trim());
const isError = isToolCardError(card);
const canOpenSidebar = Boolean(onOpenSidebar);
const previewSidebarContent =
card.preview?.kind === "canvas"
@@ -521,11 +612,16 @@ export function renderExpandedToolCardContent(
: nothing;
return html`
<div class="chat-tool-card chat-tool-card--expanded">
<div class="chat-tool-card chat-tool-card--expanded ${isError ? "chat-tool-card--error" : ""}">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
${isError
? html`<span class="chat-tool-card__status-badge" role="status"
>${icons.x}<span>Error</span></span
>`
: nothing}
</div>
${canOpenSidebar
? html`
@@ -555,7 +651,7 @@ export function renderExpandedToolCardContent(
? card.preview
? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}`
: renderToolDataBlock({
label: "Tool output",
label: isError ? "Tool error" : "Tool output",
text: card.outputText!,
expanded: true,
})
@@ -575,6 +671,7 @@ export function renderToolCardSidebar(
const preview = card.preview;
const hasText = Boolean(card.outputText?.trim());
const hasPreview = Boolean(preview);
const isError = isToolCardError(card);
const sidebarContent =
preview?.kind === "canvas"
? buildPreviewSidebarContent(preview, card.outputText)
@@ -586,10 +683,13 @@ export function renderToolCardSidebar(
const showCollapsed = hasText && !hasPreview && !isShort;
const showInline = hasText && !hasPreview && isShort;
const isEmpty = !hasText && !hasPreview;
const statusIcon = isError ? icons.x : icons.check;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${isError
? "chat-tool-card--error"
: ""}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@@ -609,16 +709,28 @@ export function renderToolCardSidebar(
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action"
>${hasText || hasPreview ? "View" : ""} ${icons.check}</span
? html`<span
class="chat-tool-card__action ${isError ? "chat-tool-card__action--error" : ""}"
>${isError ? "View error" : hasText || hasPreview ? "View" : ""} ${statusIcon}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span class="chat-tool-card__status">${icons.check}</span>`
? html`<span
class="chat-tool-card__status ${isError ? "chat-tool-card__status--error" : ""}"
>${statusIcon}</span
>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${isEmpty ? html`<div class="chat-tool-card__status-text muted">Completed</div>` : nothing}
${isEmpty
? html`<div
class="chat-tool-card__status-text ${isError
? "chat-tool-card__status-text--error"
: "muted"}"
>
${isError ? "Failed" : "Completed"}
</div>`
: nothing}
${preview
? html`${renderToolPreview(preview, "chat_tool", {
onOpenSidebar,

View File

@@ -77,6 +77,7 @@ export type ToolCard = {
args?: unknown;
inputText?: string;
outputText?: string;
isError?: boolean;
preview?: {
kind: "canvas";
surface: "assistant_message";