Skip to content

Commit 1bff2b0

Browse files
fix(ui): preserve autonomous tool failures
Co-authored-by: qingminlong <[email protected]>
1 parent 5a31666 commit 1bff2b0

4 files changed

Lines changed: 146 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525
- **Managed browser cookie persistence:** initialize new isolated macOS headless profiles with a non-interactive encryption key while preserving existing profile keys, and close Chromium through CDP before bounded signal fallback so persistent logins survive graceful browser and Gateway restarts. (#96704, #98284) Thanks @TurboTheTurtle.
2626
- **MCP OAuth response bounds:** reject body-less foreign error bodies without calling their inherently unbounded `text()` fallback, while preserving HTTP status and headers for safe SDK diagnostics. (#98143) Thanks @Pick-cat.
2727
- **Control UI approval prompts:** keep stale resolve failures and busy-state cleanup from leaking across newer approvals or Gateway reconnects. (#98394) Thanks @haruaiclone-droid.
28+
- **Control UI autonomous tool failures:** keep an earlier failed turn's Tool error visible when a later autonomous turn recovers without an intervening user message. (#98888) Thanks @qingminglong.
2829
- **Agent empty replies:** surface a visible failure when a completed interactive turn has no deliverable reply, including queued follow-ups, while preserving explicit silence, pending continuations, and committed side effects, honoring queued send policies, and treating compaction notices as progress. (#100456) Thanks @mushuiyu886.
2930
- **Child process output safety:** prevent stdout/stderr pipe failures from crashing agent exec sessions, local TUI shell commands, and bounded process execution. (#100407, #100406, #100410) Thanks @cxbAsDev.
3031
- **Background refresh isolation:** keep remote skill-bin refreshes running when one node fails, and contain periodic subagent-sweeper failures without hiding errors from direct callers. (#100393, #100390) Thanks @cxbAsDev.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Control UI E2E tests cover autonomous tool-turn outcome rendering.
2+
import { chromium, type Browser } from "playwright";
3+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
4+
import {
5+
canRunPlaywrightChromium,
6+
installMockGateway,
7+
resolvePlaywrightChromiumExecutablePath,
8+
startControlUiE2eServer,
9+
type ControlUiE2eServer,
10+
} from "../test-helpers/control-ui-e2e.ts";
11+
12+
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
13+
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
14+
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
15+
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
16+
17+
let browser: Browser;
18+
let server: ControlUiE2eServer;
19+
20+
function failedTool(timestamp: number) {
21+
return {
22+
role: "toolResult",
23+
toolName: "shell",
24+
content: JSON.stringify({ status: "failed", exitCode: 1 }),
25+
isError: true,
26+
timestamp,
27+
};
28+
}
29+
30+
describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => {
31+
beforeAll(async () => {
32+
server = await startControlUiE2eServer();
33+
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
34+
});
35+
36+
afterAll(async () => {
37+
await browser?.close();
38+
await server?.close();
39+
});
40+
41+
it("keeps an earlier autonomous failure visible after a later turn recovers", async () => {
42+
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
43+
const page = await context.newPage();
44+
await installMockGateway(page, {
45+
historyMessages: [
46+
failedTool(1),
47+
{
48+
role: "assistant",
49+
content: [{ type: "text", text: "Start the next autonomous task." }],
50+
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
51+
senderLabel: "Forwarded from main",
52+
timestamp: 2,
53+
},
54+
failedTool(3),
55+
{
56+
role: "assistant",
57+
content: [{ type: "text", text: "Recovered on the next autonomous turn." }],
58+
timestamp: 4,
59+
},
60+
],
61+
});
62+
63+
await page.goto(`${server.baseUrl}chat`);
64+
await page.getByText("Recovered on the next autonomous turn.", { exact: true }).waitFor();
65+
66+
expect(await page.locator(".chat-tool-msg-summary__label").allTextContents()).toEqual([
67+
"Tool error",
68+
"Tool output",
69+
]);
70+
await context.close();
71+
});
72+
});

ui/src/pages/chat/chat-thread.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,67 @@ describe("tool turn outcome annotation (#89683)", () => {
14991499
expect(tools[0].turnSucceeded).toBe(false);
15001500
});
15011501

1502+
it("scopes adjacent autonomous turns at an empty forwarded boundary", () => {
1503+
const tools = toolGroups([
1504+
failedTool(1),
1505+
{
1506+
role: "assistant",
1507+
content: [],
1508+
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
1509+
senderLabel: "Forwarded from main",
1510+
timestamp: 2,
1511+
},
1512+
failedTool(3),
1513+
assistantReply("Recovered on the next autonomous turn.", 4),
1514+
]);
1515+
expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]);
1516+
});
1517+
1518+
it("does not treat a forwarded message as the prior turn's reply", () => {
1519+
const tools = toolGroups([
1520+
failedTool(1),
1521+
{
1522+
role: "assistant",
1523+
content: [{ type: "text", text: "Start the next autonomous task." }],
1524+
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
1525+
senderLabel: "Forwarded from main",
1526+
timestamp: 2,
1527+
},
1528+
failedTool(3),
1529+
assistantReply("Recovered on the next autonomous turn.", 4),
1530+
]);
1531+
expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]);
1532+
});
1533+
1534+
it("treats an ordinary labeled assistant message as a reply", () => {
1535+
const tools = toolGroups([
1536+
userMsg("check the service", 1),
1537+
failedTool(2),
1538+
{
1539+
role: "assistant",
1540+
content: [{ type: "text", text: "Parzival recovered the service." }],
1541+
senderLabel: "Parzival",
1542+
timestamp: 3,
1543+
},
1544+
]);
1545+
expect(tools[0].turnSucceeded).toBe(true);
1546+
});
1547+
1548+
it("does not treat non-text assistant content as a turn boundary", () => {
1549+
const tools = toolGroups([
1550+
userMsg("make a preview", 1),
1551+
failedTool(2),
1552+
{
1553+
role: "assistant",
1554+
content: [createAssistantCanvasBlock({ suffix: "tool_turn_outcome" })],
1555+
timestamp: 3,
1556+
},
1557+
failedTool(4),
1558+
assistantReply("Done.", 5),
1559+
]);
1560+
expect(tools.map((group) => group.turnSucceeded)).toEqual([true, true]);
1561+
});
1562+
15021563
it("scopes the outcome per turn at user boundaries", () => {
15031564
const tools = toolGroups([
15041565
userMsg("first", 1),

ui/src/pages/chat/chat-thread.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,13 @@ function assistantGroupHasReplyText(group: MessageGroup): boolean {
382382
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
383383
}
384384

385+
function assistantGroupIsForwardedBoundary(group: MessageGroup): boolean {
386+
return group.messages.some(({ message }) => {
387+
const provenance = asRecord(asRecord(message)?.provenance);
388+
return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send";
389+
});
390+
}
391+
385392
function annotateToolTurnOutcome(
386393
items: Array<ChatItem | MessageGroup>,
387394
): Array<ChatItem | MessageGroup> {
@@ -395,7 +402,11 @@ function annotateToolTurnOutcome(
395402
if (role === "user") {
396403
sawAssistantReply = false;
397404
} else if (role === "assistant") {
398-
if (assistantGroupHasReplyText(item)) {
405+
if (assistantGroupIsForwardedBoundary(item)) {
406+
// Gateway preserves sessions_send provenance when projecting inputs as assistant groups.
407+
// Those groups start a new autonomous turn; they are not replies to an earlier tool.
408+
sawAssistantReply = false;
409+
} else if (assistantGroupHasReplyText(item)) {
399410
sawAssistantReply = true;
400411
}
401412
} else if (role === "tool") {

0 commit comments

Comments
 (0)