Skip to content

Commit b9ed990

Browse files
committed
fix(ui): classify status-only tool errors
1 parent 533f2f4 commit b9ed990

3 files changed

Lines changed: 95 additions & 2 deletions

File tree

ui/src/ui/chat/grouped-render.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,46 @@ describe("grouped chat rendering", () => {
11481148
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
11491149
});
11501150

1151+
it("marks status-only standalone tool-result summaries as errors", () => {
1152+
const container = document.createElement("div");
1153+
const groups = [
1154+
createMessageGroup(
1155+
{
1156+
id: "tool-status-error",
1157+
role: "toolResult",
1158+
toolCallId: "call-status-error",
1159+
toolName: "sessions_spawn",
1160+
content: JSON.stringify({ status: "error" }, null, 2),
1161+
timestamp: Date.now(),
1162+
},
1163+
"tool",
1164+
),
1165+
];
1166+
1167+
renderMessageGroups(container, groups, {
1168+
isToolMessageExpanded: () => false,
1169+
});
1170+
1171+
let summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
1172+
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
1173+
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
1174+
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
1175+
"sessions_spawn",
1176+
);
1177+
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
1178+
1179+
renderMessageGroups(container, groups, {
1180+
isToolMessageExpanded: () => true,
1181+
});
1182+
1183+
summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
1184+
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
1185+
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
1186+
expect(
1187+
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
1188+
).toEqual({ status: "error" });
1189+
});
1190+
11511191
it("collapses an inline tool call while keeping matching tool output visible", () => {
11521192
const container = document.createElement("div");
11531193
const groups = [

ui/src/ui/chat/tool-cards.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ describe("tool-cards", () => {
352352
expect(isToolErrorOutput("TOOL NOT FOUND")).toBe(true);
353353
});
354354

355+
it("flags JSON payloads with top-level failure statuses", () => {
356+
expect(isToolErrorOutput(JSON.stringify({ status: "error" }))).toBe(true);
357+
expect(isToolErrorOutput(JSON.stringify({ status: "failed" }))).toBe(true);
358+
expect(isToolErrorOutput(JSON.stringify({ status: "timeout" }))).toBe(true);
359+
expect(isToolErrorOutput(JSON.stringify({ status: "completed" }))).toBe(false);
360+
expect(isToolErrorOutput(JSON.stringify({ status: "ok" }))).toBe(false);
361+
});
362+
355363
it("does not flag successful payloads or strings without a tool error signal", () => {
356364
expect(isToolErrorOutput(undefined)).toBe(false);
357365
expect(isToolErrorOutput("")).toBe(false);
@@ -397,6 +405,26 @@ describe("tool-cards", () => {
397405
expect(container.querySelector(".chat-tool-card__status-badge")).not.toBeNull();
398406
});
399407

408+
it("renders a Tool error label when output has a status-only error payload", () => {
409+
const container = document.createElement("div");
410+
render(
411+
renderToolCard(
412+
{
413+
id: "msg:err:status-only",
414+
name: "sessions_spawn",
415+
outputText: JSON.stringify({ status: "error" }),
416+
},
417+
{ expanded: true, onToggleExpanded: vi.fn() },
418+
),
419+
container,
420+
);
421+
422+
expect(container.textContent).toContain("Tool error");
423+
expect(container.textContent).not.toMatch(/\bTool output\b/);
424+
expect(container.querySelector(".chat-tool-msg-summary--error")).not.toBeNull();
425+
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
426+
});
427+
400428
it("renders a Tool error label when output is the literal 'Tool not found'", () => {
401429
const container = document.createElement("div");
402430
render(
@@ -509,6 +537,27 @@ describe("tool-cards", () => {
509537
expect(action?.textContent).not.toContain("✓");
510538
});
511539

540+
it("marks status-only sidebar output as an error instead of View with a checkmark", () => {
541+
const container = document.createElement("div");
542+
render(
543+
renderToolCardSidebar(
544+
{
545+
id: "msg:err:sidebar-status",
546+
name: "sessions_wait",
547+
outputText: JSON.stringify({ status: "timeout" }),
548+
},
549+
vi.fn(),
550+
),
551+
container,
552+
);
553+
554+
const action = container.querySelector(".chat-tool-card__action");
555+
expect(container.querySelector(".chat-tool-card--error")).not.toBeNull();
556+
expect(action?.textContent).toContain("View error");
557+
expect(action?.textContent).toContain("✕");
558+
expect(action?.textContent).not.toContain("✓");
559+
});
560+
512561
it("keeps Tool output labelling for successful results", () => {
513562
const container = document.createElement("div");
514563
render(

ui/src/ui/chat/tool-cards.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ function readToolErrorFlag(value: Record<string, unknown>): boolean | undefined
7070

7171
const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i;
7272
const MAX_ERROR_DETECT_CHARS = 20_000;
73+
const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]);
74+
75+
function hasToolErrorStatus(value: unknown): boolean {
76+
return typeof value === "string" && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase());
77+
}
7378

7479
export function isToolErrorOutput(outputText: string | undefined): boolean {
7580
if (!outputText) {
@@ -113,9 +118,8 @@ export function isToolErrorOutput(outputText: string | undefined): boolean {
113118
if (value && typeof value === "object") {
114119
return true;
115120
}
116-
return false;
117121
}
118-
return false;
122+
return hasToolErrorStatus(obj.status);
119123
}
120124

121125
export function isToolCardError(card: ToolCard): boolean {

0 commit comments

Comments
 (0)