From 286c020748eadabebb35a35c470ead20c71dd773 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 02:23:03 +0100 Subject: [PATCH] fix(ui): preserve autonomous tool failures (#100514) * fix(ui): preserve autonomous tool failures Co-authored-by: qingminlong * chore: defer release note to maintainer batch --------- Co-authored-by: qingminlong --- ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts | 72 +++++++++++++++++++ ui/src/pages/chat/chat-thread.test.ts | 61 ++++++++++++++++ ui/src/pages/chat/chat-thread.ts | 13 +++- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts diff --git a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts new file mode 100644 index 000000000000..efb5a8ac717e --- /dev/null +++ b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts @@ -0,0 +1,72 @@ +// Control UI E2E tests cover autonomous tool-turn outcome rendering. +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let server: ControlUiE2eServer; + +function failedTool(timestamp: number) { + return { + role: "toolResult", + toolName: "shell", + content: JSON.stringify({ status: "failed", exitCode: 1 }), + isError: true, + timestamp, + }; +} + +describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => { + beforeAll(async () => { + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("keeps an earlier autonomous failure visible after a later turn recovers", async () => { + const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); + const page = await context.newPage(); + await installMockGateway(page, { + historyMessages: [ + failedTool(1), + { + role: "assistant", + content: [{ type: "text", text: "Start the next autonomous task." }], + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + senderLabel: "Forwarded from main", + timestamp: 2, + }, + failedTool(3), + { + role: "assistant", + content: [{ type: "text", text: "Recovered on the next autonomous turn." }], + timestamp: 4, + }, + ], + }); + + await page.goto(`${server.baseUrl}chat`); + await page.getByText("Recovered on the next autonomous turn.", { exact: true }).waitFor(); + + expect(await page.locator(".chat-tool-msg-summary__label").allTextContents()).toEqual([ + "Tool error", + "Tool output", + ]); + await context.close(); + }); +}); diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index b6b0a0546e05..c13d39dc594a 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -1499,6 +1499,67 @@ describe("tool turn outcome annotation (#89683)", () => { expect(tools[0].turnSucceeded).toBe(false); }); + it("scopes adjacent autonomous turns at an empty forwarded boundary", () => { + const tools = toolGroups([ + failedTool(1), + { + role: "assistant", + content: [], + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + senderLabel: "Forwarded from main", + timestamp: 2, + }, + failedTool(3), + assistantReply("Recovered on the next autonomous turn.", 4), + ]); + expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]); + }); + + it("does not treat a forwarded message as the prior turn's reply", () => { + const tools = toolGroups([ + failedTool(1), + { + role: "assistant", + content: [{ type: "text", text: "Start the next autonomous task." }], + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + senderLabel: "Forwarded from main", + timestamp: 2, + }, + failedTool(3), + assistantReply("Recovered on the next autonomous turn.", 4), + ]); + expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]); + }); + + it("treats an ordinary labeled assistant message as a reply", () => { + const tools = toolGroups([ + userMsg("check the service", 1), + failedTool(2), + { + role: "assistant", + content: [{ type: "text", text: "Parzival recovered the service." }], + senderLabel: "Parzival", + timestamp: 3, + }, + ]); + expect(tools[0].turnSucceeded).toBe(true); + }); + + it("does not treat non-text assistant content as a turn boundary", () => { + const tools = toolGroups([ + userMsg("make a preview", 1), + failedTool(2), + { + role: "assistant", + content: [createAssistantCanvasBlock({ suffix: "tool_turn_outcome" })], + timestamp: 3, + }, + failedTool(4), + assistantReply("Done.", 5), + ]); + expect(tools.map((group) => group.turnSucceeded)).toEqual([true, true]); + }); + it("scopes the outcome per turn at user boundaries", () => { const tools = toolGroups([ userMsg("first", 1), diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 9b6dbeca69c0..353a643d3e3e 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -382,6 +382,13 @@ function assistantGroupHasReplyText(group: MessageGroup): boolean { return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim())); } +function assistantGroupIsForwardedBoundary(group: MessageGroup): boolean { + return group.messages.some(({ message }) => { + const provenance = asRecord(asRecord(message)?.provenance); + return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send"; + }); +} + function annotateToolTurnOutcome( items: Array, ): Array { @@ -395,7 +402,11 @@ function annotateToolTurnOutcome( if (role === "user") { sawAssistantReply = false; } else if (role === "assistant") { - if (assistantGroupHasReplyText(item)) { + if (assistantGroupIsForwardedBoundary(item)) { + // Gateway preserves sessions_send provenance when projecting inputs as assistant groups. + // Those groups start a new autonomous turn; they are not replies to an earlier tool. + sawAssistantReply = false; + } else if (assistantGroupHasReplyText(item)) { sawAssistantReply = true; } } else if (role === "tool") {