Skip to content

Commit 4711495

Browse files
committed
fix(chat): show tool call errors instead of eternal "Running" badge
Failed tool calls (AI SDK state "output-error") were normalized to "input-available", so the widget showed a pulsing "Running" badge forever and the errorText was dropped (hard-coded undefined). - normalizeToolPart now maps output-error / errorText to an error state with a generic fallback message - ToolPartView passes errorText through to ToolOutput (red error panel) - error widgets render expanded by default so the message is visible without a click
1 parent 36967d7 commit 4711495

2 files changed

Lines changed: 61 additions & 8 deletions

File tree

src/features/chat-page/__tests__/tool-part-view.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,48 @@ describe("normalizeToolPart", () => {
7373
);
7474
expect(out.state).toBe("output-available");
7575
});
76+
77+
it("maps SDK output-error state to output-error, not Running", () => {
78+
const out = normalizeToolPart(
79+
{
80+
type: "tool-comos_search",
81+
state: "output-error",
82+
input: { query: "x" },
83+
errorText: 'Extension "comos_search" failed with status 403',
84+
} as never,
85+
0,
86+
);
87+
expect(out.state).toBe("output-error");
88+
expect(out.errorText).toBe(
89+
'Extension "comos_search" failed with status 403',
90+
);
91+
});
92+
93+
it("treats a part with errorText but no state as output-error", () => {
94+
const out = normalizeToolPart(
95+
{ type: "tool-call", input: {}, errorText: "boom" } as never,
96+
0,
97+
);
98+
expect(out.state).toBe("output-error");
99+
expect(out.errorText).toBe("boom");
100+
});
101+
102+
it("falls back to a generic error message when output-error has no errorText", () => {
103+
const out = normalizeToolPart(
104+
{ type: "tool-call", state: "output-error", input: {} } as never,
105+
0,
106+
);
107+
expect(out.state).toBe("output-error");
108+
expect(out.errorText).toBe("Tool call failed");
109+
});
110+
111+
it("leaves errorText undefined for successful parts", () => {
112+
const out = normalizeToolPart(
113+
{ type: "tool-call", output: { hits: [] } } as never,
114+
0,
115+
);
116+
expect(out.errorText).toBeUndefined();
117+
});
76118
});
77119

78120
describe("renderToolOutput", () => {

src/features/chat-page/tool-part-view.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ interface NormalizedToolPart {
5151
/**
5252
* The Tool component's state input. Mapped from AI SDK part type +
5353
* presence of `output` so we never show a "Running" widget for a part
54-
* that already has a result.
54+
* that already has a result — including failed parts, which carry
55+
* `errorText` but no `output` and must surface as "Error", not an
56+
* eternally-running widget.
5557
*/
56-
state: "input-available" | "output-available";
58+
state: "input-available" | "output-available" | "output-error";
59+
/** Error message when the tool call failed (AI SDK `output-error` parts). */
60+
errorText: string | undefined;
5761
}
5862

5963
/**
@@ -86,6 +90,7 @@ export function normalizeToolPart(
8690
input?: unknown;
8791
output?: unknown;
8892
state?: string;
93+
errorText?: string;
8994
};
9095

9196
// For `tool-<name>` parts the toolName isn't a separate field — derive
@@ -97,18 +102,22 @@ export function normalizeToolPart(
97102
: "tool");
98103

99104
const hasOutput = p.output !== undefined && p.output !== null;
100-
const state: NormalizedToolPart["state"] = hasOutput
101-
? "output-available"
102-
: p.state === "output-available"
105+
const hasError = p.state === "output-error" || p.errorText !== undefined;
106+
const state: NormalizedToolPart["state"] = hasError
107+
? "output-error"
108+
: hasOutput
103109
? "output-available"
104-
: "input-available";
110+
: p.state === "output-available"
111+
? "output-available"
112+
: "input-available";
105113

106114
return {
107115
id: p.toolCallId ?? `idx-${index}`,
108116
toolName,
109117
input: p.input,
110118
output: p.output ?? null,
111119
state,
120+
errorText: hasError ? (p.errorText ?? "Tool call failed") : undefined,
112121
};
113122
}
114123

@@ -254,7 +263,9 @@ export function renderToolOutput(output: unknown, toolName?: string): ReactNode
254263
export function ToolPartView({ part, index }: { part: UIMessagePart; index: number }) {
255264
const normalized = normalizeToolPart(part, index);
256265
return (
257-
<Tool>
266+
// Failed calls open expanded so the error message is visible without a
267+
// click; successful calls stay collapsed as before.
268+
<Tool defaultOpen={normalized.state === "output-error"}>
258269
<ToolHeader
259270
type={`tool-${normalized.toolName}` as `tool-${string}`}
260271
state={normalized.state}
@@ -263,7 +274,7 @@ export function ToolPartView({ part, index }: { part: UIMessagePart; index: numb
263274
<ToolInput input={normalized.input} />
264275
<ToolOutput
265276
output={renderToolOutput(normalized.output, normalized.toolName)}
266-
errorText={undefined}
277+
errorText={normalized.errorText}
267278
/>
268279
</ToolContent>
269280
</Tool>

0 commit comments

Comments
 (0)