Files
paseo/packages/app/e2e/support/helpers/codex-workspace-restore.ts
T
0009a7e717 feat(workspaces): add background visibility (#5039)
* feat(server): let clients create internal agents

The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.

Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.

Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): keep internal agents whole through failure, archive, and wait

Review follow-ups for internal agents:

- A create that fails after the runtime is registered now closes the
  internal agent before rethrowing, so a hidden runtime cannot outlive
  the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
  parent. cascadeArchiveChildren takes the parent record instead of
  reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
  readable by exact id for ten minutes, so create-then-waitForFinish
  returns the finished turn whether the wait lands before or after an
  auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
  create_agent_request lifecycle suite rather than a new file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes

Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.

* feat(cli,mcp): expose internal agents and document --no-wait

paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.

* test: follow the --no-wait rename and stub getRetiredInternalAgent

* feat: list internal agents on request and add internal workspaces

Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.

Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.

Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workspaces): add background visibility with inherited creation defaults

* fix(cli): resolve workspace callers on the selected daemon

* test(workspaces): keep caller integration coverage in server suite

* test(cli): exercise workspace callers through CLI integration harness

* test(mcp): remove obsolete execution flags from browser fixtures

---------

Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-09 09:31:50 +01:00

277 lines
11 KiB
TypeScript

import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import type { TestInfo } from "@playwright/test";
import { test as base, expect, type Page } from "../fixtures";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { openSessions } from "./archive-tab";
import { getE2EDaemonPort } from "./daemon-port";
import { assertComposerIdle } from "./rewind-flow";
import { getServerId } from "./server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
import type { SeedDaemonClient } from "./seed-client";
const REPLY = "CODEX_WORKTREE_RESTORE_READY";
const workspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
isolation: z.literal("worktree"),
});
async function callPaseoTool(
mcp: Client,
name: string,
args: Record<string, unknown>,
): Promise<unknown> {
const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 });
expect(result).not.toMatchObject({ isError: true });
return result.structuredContent;
}
async function defaultCodexModel(mcp: Client): Promise<string> {
const result = z
.object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) })
.parse(await callPaseoTool(mcp, "list_models", { provider: "codex" }));
const model = result.models.find((entry) => entry.isDefault);
if (!model) throw new Error("Codex did not advertise a default model");
return model.id;
}
function recordTimelineWire(page: Page): string[] {
const wire: string[] = [];
page.on("websocket", (socket) => {
const recordFrame = (direction: string, message: string) => {
if (/agent.*timeline|fetch_agents|agent_update/.test(message)) {
wire.push(JSON.stringify({ at: Date.now(), direction, message }));
}
};
socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString()));
socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString()));
});
return wire;
}
async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) {
const repo = await createTempGitRepo("codex-worktree-restore-");
const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" });
const wire = recordTimelineWire(page);
let workspace: z.infer<typeof workspaceSchema> | undefined;
let agentId: string | undefined;
function created() {
if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it");
return { ...workspace, agentId };
}
async function expectSelected() {
await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
return {
async createWorktreeWithCodex() {
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`),
),
);
workspace = workspaceSchema.parse(
await callPaseoTool(mcp, "create_workspace", {
isolation: "worktree",
path: repo.path,
baseBranch: "main",
title: "Codex worktree restore reproduction",
}),
);
const agent = z.object({ agentId: z.string() }).parse(
await callPaseoTool(mcp, "create_agent", {
workspaceId: workspace.workspaceId,
provider: `codex/${await defaultCodexModel(mcp)}`,
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
}),
);
agentId = agent.agentId;
},
async waitForCompletedReply() {
const target = created();
await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId));
await waitForSidebarHydration(page);
await expect(page.getByTestId("assistant-message")).toContainText(REPLY, {
timeout: 240_000,
});
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { status: "idle", archivedAt: null },
});
await assertComposerIdle({ page });
},
async archiveBeforeBrowserConnects() {
const target = created();
await client.waitForFinish(target.agentId, 240_000);
expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull();
await expect.poll(() => existsSync(target.cwd)).toBe(false);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
},
async connectFreshBrowser() {
await page.goto("/");
},
async archiveWorkspaceFromSidebar() {
await archiveWorkspaceFromSidebar(page, created().workspaceId);
},
async expectWorkspaceAndAgentArchived() {
const target = created();
await expectWorkspaceAbsentFromSidebar(page, target.workspaceId);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false);
await expect
.poll(() => client.fetchWorkspaces())
.not.toMatchObject({
entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]),
});
},
async openArchivedWorkspaceFromHistory() {
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`);
await expect(row).toContainText("Archived");
await row.click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
},
async restoreWorkspace() {
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectWorktreeRestoredWithArchivedAgentSelected() {
await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true);
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await info.attach("after-workspace-restore", {
body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2),
contentType: "application/json",
});
},
async expectArchivedAgentSelectedWithHistory() {
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toContainText(REPLY);
},
async reloadWithoutAgentCache() {
const url = page.url();
const cdp = await page.context().newCDPSession(page);
// Unmount first so an open connection cannot repopulate the cache while it is cleared.
await page.goto("about:blank");
await cdp.send("Storage.clearDataForOrigin", {
origin: new URL(url).origin,
storageTypes: "indexeddb,cache_storage",
});
await cdp.send("Network.clearBrowserCache");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url);
await cdp.detach();
},
async waitForWorkspaceHydration() {
await waitForSidebarHydration(page);
},
async unarchiveAgent() {
await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectIdleAgentWithVisibleComposer(statusTimeout?: number) {
await expect
.poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout })
.toMatchObject({ agent: { status: "idle", archivedAt: null } });
await expectSelected();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await assertComposerIdle({ page });
await info.attach("restored-agent-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("restored-agent-accessibility", {
body: await page.locator("body").ariaSnapshot(),
contentType: "text/plain",
});
},
async dispose() {
const testFailed = info.status !== info.expectedStatus;
const errors: unknown[] = [];
try {
await info.attach("final-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" });
await info.attach("final-agent", {
body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2),
contentType: "application/json",
});
await info.attach("daemon-log", {
body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")),
contentType: "text/plain",
});
} catch (error) {
errors.push(error);
}
const cleanup: Array<() => Promise<unknown>> = [() => mcp.close()];
if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") {
const projectId = workspace?.projectId;
if (projectId) cleanup.push(() => client.removeProject(projectId));
cleanup.push(() => repo.cleanup());
}
for (const release of cleanup) {
try {
await release();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
const error = new AggregateError(errors, "Codex restore fixture teardown failed");
if (!testFailed) throw error;
// Keep the journey failure primary while reporting teardown failures in the test log.
console.error(error);
}
},
};
}
export const test = base.extend<{
codexRestore: Awaited<ReturnType<typeof createCodexRestoreJourney>>;
}>({
// This fixture owns cleanup so a failed reproduction can retain its daemon state.
projectOwnership: async ({ e2eWorkerClient }, provide) => {
void e2eWorkerClient;
await provide();
},
codexRestore: async ({ page, e2eWorkerClient }, provide, info) => {
const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info);
try {
await provide(journey);
} finally {
await journey.dispose();
}
},
});