mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
d6f0747f69
commit
286c020748
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user