Skip to content

Commit 868e83a

Browse files
committed
fix(ui): scope tool-error success to empty agent turns
1 parent 8e95e56 commit 868e83a

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,31 @@ describe("tool turn outcome annotation (#89683)", () => {
12431243
expect(tools[0].turnSucceeded).toBe(false);
12441244
});
12451245

1246+
it("keeps adjacent agent-initiated failed turns scoped without a user boundary", () => {
1247+
const tools = toolGroups([
1248+
failedTool(1),
1249+
{ role: "assistant", content: [], senderLabel: "cron-turn-1", timestamp: 2 },
1250+
failedTool(3),
1251+
assistantReply("Recovered on the next autonomous turn.", 4),
1252+
]);
1253+
expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]);
1254+
});
1255+
1256+
it("does not treat non-text assistant content as a turn boundary", () => {
1257+
const tools = toolGroups([
1258+
userMsg("make a preview", 1),
1259+
failedTool(2),
1260+
{
1261+
role: "assistant",
1262+
content: [createAssistantCanvasBlock({ suffix: "tool_turn_outcome" })],
1263+
timestamp: 3,
1264+
},
1265+
failedTool(4),
1266+
assistantReply("Done.", 5),
1267+
]);
1268+
expect(tools.map((group) => group.turnSucceeded)).toEqual([true, true]);
1269+
});
1270+
12461271
it("scopes the outcome per turn at user boundaries", () => {
12471272
const tools = toolGroups([
12481273
userMsg("first", 1),

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,13 +348,25 @@ function assistantGroupHasReplyText(group: MessageGroup): boolean {
348348
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
349349
}
350350

351+
function assistantGroupIsEmptyBoundary(group: MessageGroup): boolean {
352+
if (!group.senderLabel?.trim()) {
353+
return false;
354+
}
355+
return group.messages.every(({ message }) => {
356+
const normalized = safeNormalizeMessage(message);
357+
return Boolean(normalized && normalized.content.length === 0 && !normalized.replyTarget);
358+
});
359+
}
360+
351361
// Stamp each tool group with whether its turn ended in a successful assistant
352362
// reply. Codex marks any non-zero exec exit as failed, so a benign internal tool
353363
// failure (e.g. a no-match search) must not render as a primary error banner
354364
// once a clean reply exists. Backward pass: a user group ends the turn
355365
// downstream; an assistant reply marks success for earlier tool groups in the
356-
// same turn. turnSucceeded stays undefined for terminal or in-progress failures,
357-
// preserving the existing error banner.
366+
// same turn. Empty retained agent groups also end the scan so adjacent
367+
// autonomous turns cannot inherit a later turn's reply. turnSucceeded stays
368+
// undefined for terminal or in-progress failures, preserving the existing error
369+
// banner.
358370
function annotateToolTurnOutcome(
359371
items: Array<ChatItem | MessageGroup>,
360372
): Array<ChatItem | MessageGroup> {
@@ -370,6 +382,8 @@ function annotateToolTurnOutcome(
370382
} else if (role === "assistant") {
371383
if (assistantGroupHasReplyText(item)) {
372384
sawAssistantReply = true;
385+
} else if (assistantGroupIsEmptyBoundary(item)) {
386+
sawAssistantReply = false;
373387
}
374388
} else if (role === "tool") {
375389
item.turnSucceeded = sawAssistantReply;

0 commit comments

Comments
 (0)