Skip to content

Commit 355c43f

Browse files
harjothkharaclaude
andauthored
fix(ui): collapse failed internal tool calls when the turn still replied (#89683) (#90122)
A non-zero internal tool exit (Codex marks any non-zero exit as failed, e.g. a no-match shell search) was promoted to a primary red "Tool error" banner in Control UI even when the turn produced a clean assistant reply. Compute a per-tool-group turnSucceeded signal at the chat projection boundary and de-promote such non-terminal failures to a collapsed tool summary; detail stays available on expand. Genuinely terminal tool failures still surface. Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent db2488b commit 355c43f

5 files changed

Lines changed: 200 additions & 3 deletions

File tree

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,3 +1138,60 @@ function createAssistantCanvasBlock(params: { suffix: string }) {
11381138
},
11391139
};
11401140
}
1141+
1142+
describe("tool turn outcome annotation (#89683)", () => {
1143+
function failedTool(timestamp: number) {
1144+
return {
1145+
role: "toolResult",
1146+
toolName: "shell",
1147+
content: JSON.stringify({ status: "failed", exitCode: 1 }),
1148+
isError: true,
1149+
timestamp,
1150+
};
1151+
}
1152+
function userMsg(text: string, timestamp: number) {
1153+
return { role: "user", content: text, timestamp };
1154+
}
1155+
function assistantReply(text: string, timestamp: number) {
1156+
return { role: "assistant", content: [{ type: "text", text }], timestamp };
1157+
}
1158+
function toolGroups(messages: unknown[]): MessageGroup[] {
1159+
return messageGroups({ messages }).filter((group) => group.role === "tool");
1160+
}
1161+
1162+
it("marks a failed tool followed by an assistant reply as turnSucceeded", () => {
1163+
const tools = toolGroups([
1164+
userMsg("search foo", 1),
1165+
failedTool(2),
1166+
assistantReply("No matches found.", 3),
1167+
]);
1168+
expect(tools).toHaveLength(1);
1169+
expect(tools[0].turnSucceeded).toBe(true);
1170+
});
1171+
1172+
it("leaves a terminal failed tool (no assistant reply) as not-succeeded", () => {
1173+
const tools = toolGroups([userMsg("search foo", 1), failedTool(2)]);
1174+
expect(tools).toHaveLength(1);
1175+
expect(tools[0].turnSucceeded).toBe(false);
1176+
});
1177+
1178+
it("does not count an assistant group without reply text as success", () => {
1179+
const tools = toolGroups([
1180+
userMsg("search foo", 1),
1181+
failedTool(2),
1182+
{ role: "assistant", content: [], timestamp: 3 },
1183+
]);
1184+
expect(tools[0].turnSucceeded).toBe(false);
1185+
});
1186+
1187+
it("scopes the outcome per turn at user boundaries", () => {
1188+
const tools = toolGroups([
1189+
userMsg("first", 1),
1190+
failedTool(2),
1191+
assistantReply("done", 3),
1192+
userMsg("second", 4),
1193+
failedTool(5),
1194+
]);
1195+
expect(tools.map((group) => group.turnSucceeded)).toEqual([true, false]);
1196+
});
1197+
});

ui/src/ui/chat/build-chat-items.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,41 @@ function isSameSourceRelayNativeDuplicate(previousMessage: unknown, nextMessage:
338338
);
339339
}
340340

341+
function assistantGroupHasReplyText(group: MessageGroup): boolean {
342+
// A real reply is assistant text; a tool-only assistant group does not count.
343+
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
344+
}
345+
346+
// Stamp each tool group with whether its turn ended in a successful assistant
347+
// reply. Codex marks any non-zero exec exit as failed, so a benign internal tool
348+
// failure (e.g. a no-match search) must not render as a primary error banner
349+
// once a clean reply exists. Backward pass: a user group ends the turn
350+
// downstream; an assistant reply marks success for earlier tool groups in the
351+
// same turn. turnSucceeded stays undefined for terminal or in-progress failures,
352+
// preserving the existing error banner.
353+
function annotateToolTurnOutcome(
354+
items: Array<ChatItem | MessageGroup>,
355+
): Array<ChatItem | MessageGroup> {
356+
let sawAssistantReply = false;
357+
for (let i = items.length - 1; i >= 0; i -= 1) {
358+
const item = items[i];
359+
if (item.kind !== "group") {
360+
continue;
361+
}
362+
const role = item.role.toLowerCase();
363+
if (role === "user") {
364+
sawAssistantReply = false;
365+
} else if (role === "assistant") {
366+
if (assistantGroupHasReplyText(item)) {
367+
sawAssistantReply = true;
368+
}
369+
} else if (role === "tool") {
370+
item.turnSucceeded = sawAssistantReply;
371+
}
372+
}
373+
return items;
374+
}
375+
341376
function collapseDuplicateDisplaySignature(message: unknown): string | null {
342377
if (isPendingSendMessage(message)) {
343378
return null;
@@ -817,7 +852,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
817852
}
818853
}
819854

820-
return groupMessages(collapseSequentialDuplicateMessages(sortChatItemsByVisibleTime(items)));
855+
return annotateToolTurnOutcome(
856+
groupMessages(collapseSequentialDuplicateMessages(sortChatItemsByVisibleTime(items))),
857+
);
821858
}
822859

823860
function messageKey(message: unknown, index: number): string {

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,98 @@ describe("grouped chat rendering", () => {
13751375
).toEqual({ status: "error" });
13761376
});
13771377

1378+
describe("non-terminal internal tool failure de-promotion (#89683)", () => {
1379+
function failedToolMessage(callId = "call-failed") {
1380+
return {
1381+
id: `tool-${callId}`,
1382+
role: "toolResult",
1383+
toolCallId: callId,
1384+
toolName: "shell",
1385+
content: JSON.stringify({ status: "failed", exitCode: 1, stdout: "" }, null, 2),
1386+
isError: true,
1387+
timestamp: Date.now(),
1388+
};
1389+
}
1390+
1391+
it("renders a failed tool collapsed (no error banner) when the turn succeeded", () => {
1392+
const container = document.createElement("div");
1393+
const group: MessageGroup = {
1394+
...createMessageGroup(failedToolMessage(), "tool"),
1395+
turnSucceeded: true,
1396+
};
1397+
renderMessageGroups(container, [group], { isToolMessageExpanded: () => false });
1398+
1399+
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
1400+
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
1401+
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).not.toBe(
1402+
"Tool error",
1403+
);
1404+
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
1405+
});
1406+
1407+
it("still surfaces a failed tool as an error when the turn did not produce a reply", () => {
1408+
const container = document.createElement("div");
1409+
// turnSucceeded undefined = terminal/in-progress failure; behavior unchanged.
1410+
const group = createMessageGroup(failedToolMessage(), "tool");
1411+
renderMessageGroups(container, [group], { isToolMessageExpanded: () => false });
1412+
1413+
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
1414+
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
1415+
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe(
1416+
"Tool error",
1417+
);
1418+
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull();
1419+
});
1420+
1421+
it("keeps the failed tool detail on expand even when de-promoted", () => {
1422+
const container = document.createElement("div");
1423+
const group: MessageGroup = {
1424+
...createMessageGroup(failedToolMessage(), "tool"),
1425+
turnSucceeded: true,
1426+
};
1427+
renderMessageGroups(container, [group], { isToolMessageExpanded: () => true });
1428+
// Info is not deleted: the expanded body still shows the failed tool output.
1429+
const body = container.querySelector(".chat-tool-msg-body");
1430+
expect(body).not.toBeNull();
1431+
expect(body?.textContent).toContain("failed");
1432+
});
1433+
1434+
it("de-promotes a multi-tool activity group when the turn succeeded", () => {
1435+
const container = document.createElement("div");
1436+
const group: MessageGroup = {
1437+
kind: "group",
1438+
key: "tool:multi",
1439+
role: "tool",
1440+
messages: [
1441+
{ key: "t1", message: failedToolMessage("call-1") },
1442+
{ key: "t2", message: failedToolMessage("call-2") },
1443+
],
1444+
timestamp: Date.now(),
1445+
isStreaming: false,
1446+
turnSucceeded: true,
1447+
};
1448+
renderMessageGroups(container, [group], { isToolMessageExpanded: () => false });
1449+
1450+
const summary = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement);
1451+
expect(summary.classList.contains("chat-activity-group__summary--error")).toBe(false);
1452+
expect(summary.querySelector(".chat-activity-group__badge")).toBeNull();
1453+
expect(summary.getAttribute("aria-expanded")).toBe("false");
1454+
});
1455+
1456+
it("keeps failed tools hidden when showToolCalls is false regardless of outcome", () => {
1457+
const container = document.createElement("div");
1458+
const group: MessageGroup = {
1459+
...createMessageGroup(failedToolMessage(), "tool"),
1460+
turnSucceeded: true,
1461+
};
1462+
renderMessageGroups(container, [group], {
1463+
showToolCalls: false,
1464+
isToolMessageExpanded: () => false,
1465+
});
1466+
expect(container.querySelector(".chat-tool-msg-summary")).toBeNull();
1467+
});
1468+
});
1469+
13781470
it("collapses an inline tool call while keeping matching tool output visible", () => {
13791471
const container = document.createElement("div");
13801472
const groups = [

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ function buildGroupedMessageRenderOptions(
451451
duplicateCount: item.duplicateCount ?? 1,
452452
showReasoning: opts.showReasoning,
453453
showToolCalls: opts.showToolCalls ?? true,
454+
turnSucceeded: group.turnSucceeded,
454455
autoExpandToolCalls: opts.autoExpandToolCalls ?? false,
455456
isToolMessageExpanded: opts.isToolMessageExpanded,
456457
onToggleToolMessageExpanded: opts.onToggleToolMessageExpanded,
@@ -520,7 +521,9 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
520521
: toolLabels.length <= 3
521522
? toolLabels.join(", ")
522523
: `${toolLabels.slice(0, 2).join(", ")} +${toolLabels.length - 2} more`;
523-
const hasError = cards.some(isToolCardError);
524+
// Non-terminal internal tool failures (turn produced a clean reply) stay
525+
// collapsed and unstyled; detail remains available on expand. #89683
526+
const hasError = cards.some(isToolCardError) && group.turnSucceeded !== true;
524527
const activityDisclosureId = `activity:${group.key}`;
525528
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError;
526529

@@ -1618,6 +1621,9 @@ function renderGroupedMessage(
16181621
duplicateCount?: number;
16191622
showReasoning: boolean;
16201623
showToolCalls?: boolean;
1624+
// True when the tool's turn still produced a clean assistant reply: a failed
1625+
// internal tool then renders collapsed, not as a primary error banner. #89683
1626+
turnSucceeded?: boolean;
16211627
autoExpandToolCalls?: boolean;
16221628
isToolMessageExpanded?: (messageId: string) => boolean | undefined;
16231629
onToggleToolMessageExpanded?: (messageId: string, expanded?: boolean) => void;
@@ -1730,7 +1736,7 @@ function renderGroupedMessage(
17301736
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
17311737
const toolNames = [...new Set(toolCards.map((c) => c.name))];
17321738
const singleToolCard = toolCards.length === 1 ? toolCards[0] : null;
1733-
const toolMessageHasError = toolCards.some(isToolCardError);
1739+
const toolMessageHasError = toolCards.some(isToolCardError) && opts.turnSucceeded !== true;
17341740
const singleToolDisplay = singleToolCard
17351741
? resolveToolDisplay({
17361742
name: singleToolCard.name,

ui/src/ui/types/chat-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export type MessageGroup = {
2525
messages: Array<{ message: unknown; key: string; duplicateCount?: number }>;
2626
timestamp: number;
2727
isStreaming: boolean;
28+
// Tool groups only: true when the turn still produced a successful assistant
29+
// reply, so a failed internal tool (Codex marks any non-zero exit as failed)
30+
// renders collapsed instead of as a primary red error banner. Undefined for
31+
// non-tool groups and for terminal/in-progress tool failures.
32+
turnSucceeded?: boolean;
2833
};
2934

3035
/** Content item types in a normalized message */

0 commit comments

Comments
 (0)