mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(ui): preserve agent file lifecycle outcomes (#124140)
This commit is contained in:
committed by
GitHub
parent
20e81a724d
commit
5b96cbc52d
@@ -0,0 +1,303 @@
|
||||
// Control UI E2E tests cover visible agent-file save outcomes and agent ownership.
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { createOpenClawTestState } from "../../../src/test-utils/openclaw-test-state.ts";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI agent file lifecycle",
|
||||
startServerBeforeBrowser: true,
|
||||
unavailableMessage: (executablePath) =>
|
||||
`Playwright Chromium is not available at ${executablePath}`,
|
||||
});
|
||||
|
||||
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "agent-file-lifecycle");
|
||||
|
||||
async function capture(page: Page, name: string) {
|
||||
if (!captureUiProof) {
|
||||
return;
|
||||
}
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, name),
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAgent(page: Page, name: string) {
|
||||
const select = page.locator(".agents-control-select openclaw-agent-select");
|
||||
await select.locator(".agent-select__trigger").click();
|
||||
await select.locator("wa-dropdown-item[data-agent-option]").filter({ hasText: name }).click();
|
||||
await expect
|
||||
.poll(async () => (await select.locator(".agent-select__label").textContent())?.trim())
|
||||
.toBe(name);
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("failed to allocate a Gateway port");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return address.port;
|
||||
}
|
||||
|
||||
function fileList(agentId: string) {
|
||||
return {
|
||||
agentId,
|
||||
workspace: `/tmp/openclaw-e2e/workspace-${agentId}`,
|
||||
files: [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: `/tmp/openclaw-e2e/workspace-${agentId}/AGENTS.md`,
|
||||
missing: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fileGet(agentId: string, content: string) {
|
||||
return {
|
||||
...fileList(agentId),
|
||||
file: { ...fileList(agentId).files[0], content },
|
||||
};
|
||||
}
|
||||
|
||||
function requestAgentId(request: { params?: unknown }) {
|
||||
return (request.params as { agentId?: unknown } | undefined)?.agentId;
|
||||
}
|
||||
|
||||
const fileListResponses = {
|
||||
cases: [
|
||||
{ match: { agentId: "main" }, response: fileList("main") },
|
||||
{ match: { agentId: "writer" }, response: fileList("writer") },
|
||||
{ response: fileList("main") },
|
||||
],
|
||||
};
|
||||
|
||||
function fileGetResponses(mainContent: string) {
|
||||
return {
|
||||
cases: [
|
||||
{
|
||||
match: { agentId: "main", name: "AGENTS.md" },
|
||||
response: fileGet("main", mainContent),
|
||||
},
|
||||
{
|
||||
match: { agentId: "writer", name: "AGENTS.md" },
|
||||
response: fileGet("writer", "# Writer instructions\n"),
|
||||
},
|
||||
{ response: fileGet("main", mainContent) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps save errors visible, retries, and rejects stale cross-agent reads", async () => {
|
||||
await suite.withPage(
|
||||
{
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
...(captureUiProof
|
||||
? { recordVideo: { dir: proofDir, size: { height: 900, width: 1440 } } }
|
||||
: {}),
|
||||
},
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: [
|
||||
"agents.files.get",
|
||||
"agents.files.list",
|
||||
"agents.files.set",
|
||||
"agents.list",
|
||||
],
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "agent",
|
||||
agents: [
|
||||
{ id: "main", name: "Main" },
|
||||
{ id: "writer", name: "Writer" },
|
||||
],
|
||||
},
|
||||
"agents.files.get": fileGetResponses("# Main instructions\n"),
|
||||
"agents.files.list": fileListResponses,
|
||||
"agents.files.set": {
|
||||
__mockError: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "workspace write failed; retry Save",
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
operatorScopes: ["operator.admin", "operator.read", "operator.write"],
|
||||
});
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}settings/agents/main/files`);
|
||||
const editor = page.locator(".agent-file-textarea");
|
||||
const fileActions = page.locator(".agent-file-actions");
|
||||
const reset = fileActions.getByRole("button", { name: "Reset" });
|
||||
const save = fileActions.getByRole("button", { name: "Save" });
|
||||
const initialRead = await gateway.waitForRequest("agents.files.get");
|
||||
expect(initialRead.params).toMatchObject({ agentId: "main", name: "AGENTS.md" });
|
||||
await expect.poll(() => editor.inputValue()).toBe("# Main instructions\n");
|
||||
|
||||
await editor.fill("temporary draft");
|
||||
await reset.click();
|
||||
await expect.poll(() => editor.inputValue()).toBe("# Main instructions\n");
|
||||
|
||||
await editor.fill("Updated main instructions");
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("agents.files.set")).length)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() => page.getByText(/workspace write failed; retry Save/).isVisible())
|
||||
.toBe(true);
|
||||
expect(await gateway.getRequests("agents.files.list")).toHaveLength(1);
|
||||
await capture(page, "01-save-error-visible.png");
|
||||
|
||||
await gateway.setMethodResponse("agents.files.set", {
|
||||
ok: true,
|
||||
...fileGet("main", "Updated main instructions"),
|
||||
});
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("agents.files.set")).length)
|
||||
.toBe(2);
|
||||
await expect
|
||||
.poll(() => page.getByText(/workspace write failed; retry Save/).count())
|
||||
.toBe(0);
|
||||
await expect.poll(() => save.isDisabled()).toBe(true);
|
||||
await capture(page, "02-save-retry-succeeded.png");
|
||||
|
||||
await gateway.setMethodResponse(
|
||||
"agents.files.get",
|
||||
fileGetResponses("Updated main instructions"),
|
||||
);
|
||||
await gateway.deferNext("agents.files.get", { agentId: "writer", name: "AGENTS.md" });
|
||||
await selectAgent(page, "Writer");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await gateway.getRequests("agents.files.get")).some(
|
||||
(request) => requestAgentId(request) === "writer",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await selectAgent(page, "Main");
|
||||
await expect.poll(() => editor.inputValue()).toBe("Updated main instructions");
|
||||
await gateway.resolveDeferred("agents.files.get", fileGet("writer", "stale writer"));
|
||||
await expect.poll(() => editor.inputValue()).toBe("Updated main instructions");
|
||||
|
||||
await selectAgent(page, "Writer");
|
||||
await expect.poll(() => editor.inputValue()).toBe("# Writer instructions\n");
|
||||
const writes = await gateway.getRequests("agents.files.set");
|
||||
expect(writes.every((request) => requestAgentId(request) === "main")).toBe(true);
|
||||
await capture(page, "03-writer-owned-file.png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reads and saves the selected agent workspace through an isolated Gateway", async () => {
|
||||
const port = await getFreePort();
|
||||
const state = await createOpenClawTestState({
|
||||
label: "control-ui-agent-files",
|
||||
layout: "home",
|
||||
env: {
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
||||
OPENCLAW_SKIP_CHANNELS: "1",
|
||||
OPENCLAW_SKIP_CRON: "1",
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
OPENCLAW_SKIP_PROVIDERS: "1",
|
||||
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
|
||||
VITEST: "1",
|
||||
},
|
||||
});
|
||||
const mainWorkspace = state.path("workspace-main");
|
||||
const writerWorkspace = state.path("workspace-writer");
|
||||
await Promise.all([
|
||||
mkdir(mainWorkspace, { recursive: true }),
|
||||
mkdir(writerWorkspace, { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(mainWorkspace, "AGENTS.md"), "# Real main instructions\n", "utf8"),
|
||||
writeFile(path.join(writerWorkspace, "AGENTS.md"), "# Real writer instructions\n", "utf8"),
|
||||
]);
|
||||
await state.writeConfig({
|
||||
agents: {
|
||||
defaults: { workspace: mainWorkspace },
|
||||
entries: {
|
||||
main: { default: true, workspace: mainWorkspace },
|
||||
writer: { workspace: writerWorkspace },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
auth: { mode: "none" },
|
||||
controlUi: {
|
||||
allowedOrigins: [new URL(suite.server.baseUrl).origin],
|
||||
enabled: false,
|
||||
},
|
||||
port,
|
||||
},
|
||||
});
|
||||
state.applyEnv();
|
||||
const { startGatewayServer } = await import("../../../src/gateway/server.js");
|
||||
const gateway = await startGatewayServer(port, {
|
||||
auth: { mode: "none" },
|
||||
bind: "loopback",
|
||||
controlUiEnabled: false,
|
||||
sidecarStartup: "defer",
|
||||
});
|
||||
|
||||
try {
|
||||
await suite.withPage(
|
||||
{
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
},
|
||||
async ({ page }) => {
|
||||
const url = new URL("settings/agents/main/files", suite.server.baseUrl);
|
||||
url.searchParams.set("gatewayUrl", `ws://127.0.0.1:${port}`);
|
||||
await page.goto(url.toString());
|
||||
const confirmation = page.locator("openclaw-gateway-url-confirmation");
|
||||
await confirmation.waitFor();
|
||||
await confirmation.getByRole("button", { name: "Confirm", exact: true }).click();
|
||||
const editor = page.locator(".agent-file-textarea");
|
||||
await expect.poll(() => editor.inputValue()).toBe("# Real main instructions\n");
|
||||
|
||||
await selectAgent(page, "writer");
|
||||
await expect.poll(() => editor.inputValue()).toBe("# Real writer instructions\n");
|
||||
|
||||
await selectAgent(page, "main");
|
||||
await editor.fill("# Saved through real Gateway\n");
|
||||
const save = page.locator(".agent-file-actions").getByRole("button", { name: "Save" });
|
||||
await save.click();
|
||||
await expect.poll(() => save.isDisabled()).toBe(true);
|
||||
await expect
|
||||
.poll(() => readFile(path.join(mainWorkspace, "AGENTS.md"), "utf8"))
|
||||
.toBe("# Saved through real Gateway\n");
|
||||
await capture(page, "04-real-gateway-main-save.png");
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await gateway.close({ reason: "agent file lifecycle e2e cleanup" });
|
||||
await state.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { AgentsFilesListResult } from "../../api/types.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import type { AgentsRouteData } from "./route.ts";
|
||||
import "./agents-page.ts";
|
||||
|
||||
type TestAgentsPage = HTMLElement & {
|
||||
context: ApplicationContext;
|
||||
routeData?: AgentsRouteData;
|
||||
agentsSelectedId: string | null;
|
||||
agentFilesLoading: boolean;
|
||||
agentFilesError: string | null;
|
||||
agentFileActive: string | null;
|
||||
agentFileContents: Record<string, string>;
|
||||
gateway: {
|
||||
applySnapshot: (
|
||||
snapshot: ApplicationGatewaySnapshot,
|
||||
binding: { initial: boolean; sourceChanged: boolean },
|
||||
) => void;
|
||||
};
|
||||
selectDefaultAgentFile: (agentId: string) => Promise<void>;
|
||||
syncCurrentAgentFiles: (agents?: ApplicationContext["agents"]) => void;
|
||||
saveSelectedAgentFile: (agentId: string, name: string, content: string) => void;
|
||||
};
|
||||
|
||||
function snapshot(client: GatewayBrowserClient): ApplicationGatewaySnapshot {
|
||||
return {
|
||||
client,
|
||||
phase: "connected",
|
||||
offlineStable: false,
|
||||
canvasPluginSurfaceUrl: null,
|
||||
hello: null,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(current: ApplicationGatewaySnapshot): ApplicationContext["gateway"] {
|
||||
return {
|
||||
snapshot: current,
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
} as unknown as ApplicationContext["gateway"];
|
||||
}
|
||||
|
||||
function setPageGateway(page: TestAgentsPage, client: GatewayBrowserClient) {
|
||||
page.gateway.applySnapshot(snapshot(client), { initial: false, sourceChanged: false });
|
||||
}
|
||||
|
||||
function fileList(): AgentsFilesListResult {
|
||||
return {
|
||||
agentId: "main",
|
||||
workspace: "/tmp/workspace",
|
||||
files: [{ name: "AGENTS.md", path: "/tmp/workspace/AGENTS.md", missing: false }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent file lifecycle", () => {
|
||||
it("hydrates a file selected by an early list publication after list loading settles", async () => {
|
||||
const list = fileList();
|
||||
const request = vi.fn(async () => ({
|
||||
...list,
|
||||
file: { ...list.files[0], content: "# Instructions" },
|
||||
}));
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const agents = {
|
||||
files: () => ({ list, loading: false, error: null }),
|
||||
} as unknown as ApplicationContext["agents"];
|
||||
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
|
||||
page.context = { gateway: gateway(snapshot(client)), agents } as unknown as ApplicationContext;
|
||||
setPageGateway(page, client);
|
||||
page.agentsSelectedId = "main";
|
||||
page.routeData = { panel: "files" } as AgentsRouteData;
|
||||
page.agentFilesLoading = true;
|
||||
|
||||
page.syncCurrentAgentFiles(agents);
|
||||
expect(page.agentFileActive).toBe("AGENTS.md");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
|
||||
page.agentFilesLoading = false;
|
||||
await page.selectDefaultAgentFile("main");
|
||||
|
||||
expect(page.agentFileContents["AGENTS.md"]).toBe("# Instructions");
|
||||
});
|
||||
|
||||
it("keeps a rejected save visible without refreshing it away", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("workspace write failed");
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const refreshFiles = vi.fn(async () => fileList());
|
||||
const agents = {
|
||||
files: () => ({ list: null, loading: false, error: null }),
|
||||
refreshFiles,
|
||||
} as unknown as ApplicationContext["agents"];
|
||||
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
|
||||
page.context = { gateway: gateway(snapshot(client)), agents } as unknown as ApplicationContext;
|
||||
setPageGateway(page, client);
|
||||
page.agentsSelectedId = "main";
|
||||
|
||||
page.saveSelectedAgentFile("main", "AGENTS.md", "updated");
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(page.agentFilesError).toBe("Error: workspace write failed"));
|
||||
expect(refreshFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -331,10 +331,9 @@ class AgentsPage
|
||||
|
||||
private async selectDefaultAgentFile(agentId: string) {
|
||||
const files = this.agentFilesList?.files ?? [];
|
||||
if (this.agentFileActive && files.some((file) => file.name === this.agentFileActive)) {
|
||||
return;
|
||||
if (!this.agentFileActive || !files.some((file) => file.name === this.agentFileActive)) {
|
||||
this.agentFileActive = files.find((file) => file.name === "AGENTS.md")?.name ?? null;
|
||||
}
|
||||
this.agentFileActive = files.find((file) => file.name === "AGENTS.md")?.name ?? null;
|
||||
if (this.agentFileActive) {
|
||||
await loadAgentFileContent(this, agentId, this.agentFileActive);
|
||||
}
|
||||
@@ -830,8 +829,8 @@ class AgentsPage
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void saveAgentFile(this, agentId, name, content).then(() => {
|
||||
if (this.isCurrentRequest(client, generation, agentId, { agents })) {
|
||||
void saveAgentFile(this, agentId, name, content).then((saved) => {
|
||||
if (saved && this.isCurrentRequest(client, generation, agentId, { agents })) {
|
||||
void this.loadAgentFiles(agentId, true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,10 +91,10 @@ export async function saveAgentFile(
|
||||
agentId: string,
|
||||
name: string,
|
||||
content: string,
|
||||
) {
|
||||
): Promise<boolean> {
|
||||
const client = state.client;
|
||||
if (!client || !state.connected || state.agentFileSaving) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const generation = state.requestGeneration;
|
||||
const isCurrent = () =>
|
||||
@@ -115,14 +115,17 @@ export async function saveAgentFile(
|
||||
if (!Object.hasOwn(state.agentFileDrafts, name) || state.agentFileDrafts[name] === content) {
|
||||
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isCurrent()) {
|
||||
state.agentFilesError = String(err);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
state.agentFileSaving = false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user