fix(ui): preserve agent file lifecycle outcomes (#124140)

This commit is contained in:
Peter Steinberger
2026-08-15 02:57:37 -07:00
committed by GitHub
parent 20e81a724d
commit 5b96cbc52d
4 changed files with 423 additions and 7 deletions
+303
View File
@@ -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();
});
});
+4 -5
View File
@@ -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);
}
});
+5 -2
View File
@@ -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;
}