fix(ui): preserve autonomous tool failures (#100514)

* fix(ui): preserve autonomous tool failures

Co-authored-by: qingminlong <qing.minlong@xydigit.com>

* chore: defer release note to maintainer batch

---------

Co-authored-by: qingminlong <qing.minlong@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-06 02:23:03 +01:00
committed by GitHub
parent d6f0747f69
commit 286c020748
3 changed files with 145 additions and 1 deletions
@@ -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();
});
});
+61
View File
@@ -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),
+12 -1
View File
@@ -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<ChatItem | MessageGroup>,
): Array<ChatItem | MessageGroup> {
@@ -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") {