From 950007dd9ccfca9258b99d702b654f2eafdac8ff Mon Sep 17 00:00:00 2001 From: Chengjie Wang <75600865+chengjiew@users.noreply.github.com> Date: Wed, 27 May 2026 05:27:57 +0800 Subject: [PATCH] fix(ui): show failed tool results as errors (#85786) Merged via squash. Prepared head SHA: c0c4fb59175fc2314a194f3b17e8a5ee81381cf1 Co-authored-by: chengjiew <75600865+chengjiew@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- ui/src/styles/chat/tool-cards.css | 139 ++++++++++++- ui/src/styles/chat/tool-cards.test.ts | 10 + ui/src/ui/chat/grouped-render.test.ts | 113 +++++++++- ui/src/ui/chat/grouped-render.ts | 40 +++- ui/src/ui/chat/tool-cards.node.test.ts | 70 +++++++ ui/src/ui/chat/tool-cards.test.ts | 276 ++++++++++++++++++++++++- ui/src/ui/chat/tool-cards.ts | 144 +++++++++++-- ui/src/ui/types/chat-types.ts | 1 + 8 files changed, 761 insertions(+), 32 deletions(-) diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 2e360f131c8..83cb6e2c7ca 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -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; diff --git a/ui/src/styles/chat/tool-cards.test.ts b/ui/src/styles/chat/tool-cards.test.ts index 0d419a92432..bfe9dccc5e7 100644 --- a/ui/src/styles/chat/tool-cards.test.ts +++ b/ui/src/styles/chat/tool-cards.test.ts @@ -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"); + }); }); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 7fb6c2c6744..2db22b7d659 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -44,7 +44,7 @@ function requireFirstMockArg( if (!arg || typeof arg !== "object" || Array.isArray(arg)) { throw new Error(`expected ${label} payload`); } - return arg as Record; + 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 = [ diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 341e2fcddaa..ae977bd2bd7 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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( : ""}" > ${toolMessageExpanded ? html` diff --git a/ui/src/ui/chat/tool-cards.node.test.ts b/ui/src/ui/chat/tool-cards.node.test.ts index 8925faab70c..435e6781a65 100644 --- a/ui/src/ui/chat/tool-cards.node.test.ts +++ b/ui/src/ui/chat/tool-cards.node.test.ts @@ -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( { diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index f0f29a3fd3a..fb66dfc658a 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -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; + 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(); + }); }); diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index ec884b01b5d..a686ad1bc55 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -20,7 +20,9 @@ function normalizeContent(content: unknown): Array> { if (!Array.isArray(content)) { return []; } - return content.filter(Boolean) as Array>; + return content.filter( + (entry): entry is Record => Boolean(entry) && typeof entry === "object", + ); } function coerceArgs(value: unknown): unknown { @@ -63,6 +65,72 @@ function extractToolText(item: Record): string | undefined { return undefined; } +function readToolErrorFlag(value: Record): 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; + 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; 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 | 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` `; } @@ -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`
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` -
+
${icons[display.icon]} ${display.label} + ${isError + ? html`${icons.x}Error` + : nothing}
${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`
${display.label}
${canClick - ? html`${hasText || hasPreview ? "View" : ""} ${icons.check}${isError ? "View error" : hasText || hasPreview ? "View" : ""} ${statusIcon}` : nothing} ${isEmpty && !canClick - ? html`${icons.check}` + ? html`${statusIcon}` : nothing}
${detail ? html`
${detail}
` : nothing} - ${isEmpty ? html`
Completed
` : nothing} + ${isEmpty + ? html`
+ ${isError ? "Failed" : "Completed"} +
` + : nothing} ${preview ? html`${renderToolPreview(preview, "chat_tool", { onOpenSidebar, diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index eb937635cf4..5b1e9c0c4cf 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -77,6 +77,7 @@ export type ToolCard = { args?: unknown; inputText?: string; outputText?: string; + isError?: boolean; preview?: { kind: "canvas"; surface: "assistant_message";