* fix(server): patch OpenCode SDK unhandled rejection on stream abort The SDK's SSE abort handler calls reader.cancel() without handling its rejection. When Paseo aborts the event stream during normal session close, that promise can reject and crash the daemon with an unhandled rejection outside any Paseo-owned code path. Patches both copies the SDK ships (dist/gen and dist/v2/gen, the latter being what Paseo actually imports). Extends postinstall-patches.mjs to support patch-package running from a non-root cwd, since @opencode-ai/sdk lives in packages/server's own node_modules rather than the hoisted root. * fix(server): stop assuming OpenCode's default agent is "build" OpenCode users can rename or delete any agent, including the built-in "build"/"plan" defaults. Paseo was injecting a hardcoded "build" agent in several places whenever no mode was explicitly requested: - The protocol manifest declared defaultModeId: "build" for opencode, which AgentManager.normalizeConfig applied to every session (including internal metadata-generation sessions) before any provider-level logic ran. - OpenCode's own normalizeOpenCodeModeId defaulted an empty/"default" modeId to "build" rather than omitting the agent field. - Unattended child creates (e.g. subagents spawned by an unattended parent) forced modeId: "build" to express unattendedness, even though that's already carried by the auto_accept feature. Now an unset mode stays unset end-to-end: the "agent" field is omitted from OpenCode prompt/command calls entirely, letting OpenCode fall back to its own configured default agent instead of Paseo guessing one that may not exist. * fix(server): validate agent create mode on the WebSocket session path resolveAndValidateCreateAgentMode already rejects modes unknown to a provider's discovered mode list, but it was only wired into the MCP create path. App-created agents (create_agent_request) skipped it entirely, so a client's remembered mode preference — which can go stale when a user renames or deletes OpenCode agents — sailed through to the provider and failed mid-turn with an opaque error instead of a clear rejection at creation time. resolveSessionCreateAgent now calls providerSnapshotManager.resolveCreateConfig, matching the MCP path, so an invalid mode now throws 'Invalid mode ... Available modes: ...' immediately on create. * chore: address review feedback on create-agent and postinstall script - Document the cleanup-ordering constraint in resolveSessionCreateAgent: mode validation runs after buildSessionConfig (cwd isn't known until that completes), so a thrown validation error leaves any worktree/workspace buildSessionConfig created for the caller to clean up. session.ts already handles this for the worktree path; the directory-only workspace path has a pre-existing gap, not introduced by this validation. - Log spawn errors from patch-package in postinstall-patches.mjs (e.g. ENOENT if it's missing from PATH) instead of failing silently. * fix(server): don't fabricate OpenCode modes when discovery finds none fetchModesFromClient and mergeOpenCodeModes fell back to DEFAULT_MODES ([build, plan]) whenever OpenCode discovery returned nothing — e.g. for a freshly-created worktree whose OpenCode server hasn't loaded the project config yet. That fabricated list let create-time mode validation accept a stale 'plan' preference that the provider then rejects at prompt time (Agent not found: 'plan'). Return an empty list instead: OpenCode users can rename or delete any agent, so any hardcoded fallback risks validating a mode that doesn't exist. DEFAULT_MODES is retained only for description enrichment and sort ordering. * fix(app): reconcile stale selected mode against discovered modes on create The mode picker displays modeOptions[0] when the stored modeId isn't in the discovered list (e.g. a globally-remembered 'plan' that a workspace's OpenCode config no longer defines), but the create request still submitted the raw stored modeId. Result: UI showed 'Build' while the request sent 'plan', which the daemon then rejected. Reconcile the selected mode against the discovered mode ids when building the create config (both the workspace draft composer and the workspace-setup dialog), so the submitted mode matches what the picker shows — falling back to the first available mode when the stored one is absent. Mirrors the existing resolveEffectiveModel reconciliation for models. * test(server): fix opencode mode tests after fallback change + rebase - Replace 'available modes include build and plan' (which relied on the removed [build, plan] discovery-failure fallback) with two tests: one asserting discovered agents map to modes, one asserting empty discovery yields no fabricated modes. - Restore messageId fields in the four provider-subagent timeline assertions. These were accidentally dropped during rebase conflict resolution; the #2013 subagent code legitimately emits messageId, so the expectations must include it. * test(app): fix e2e seed helper using invalid opencode mode createIdleAgent seeded an OpenCode agent with modeId 'bypassPermissions' — a Claude mode OpenCode never had. This worked before only because the session create path silently coerced unknown modes to 'build'. Now that create-time mode validation rejects modes the provider doesn't define, use 'build' + auto_accept (OpenCode's unattended full-access equivalent), matching the rewind-flow helper. Fixes the e2e failures across archive-tab, command-center-host, settings-toggle-tab-regression, workspace-agent-tab-rename, workspace-pane-remount, workspace-navigation-regression, and worktree-restore specs, which all seed via this helper.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
import { getE2EDaemonPort } from "./daemon-port";
|
|
import { getServerId } from "./server-id";
|
|
import { expectAppRoute } from "./route-assertions";
|
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
|
import {
|
|
buildHostAgentDetailRoute,
|
|
buildHostWorkspaceRoute,
|
|
buildSessionsRoute,
|
|
} from "@/utils/host-routes";
|
|
|
|
export interface ArchiveTabAgent {
|
|
id: string;
|
|
title: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
}
|
|
|
|
function buildSeededStoragePayload() {
|
|
const nowIso = new Date().toISOString();
|
|
return {
|
|
daemon: buildSeededHost({
|
|
serverId: getServerId(),
|
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
|
nowIso,
|
|
}),
|
|
preferences: buildCreateAgentPreferences(getServerId()),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
|
|
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
|
|
* idle agent from the same client it uses for everything else.
|
|
*/
|
|
export interface IdleAgentSeedClient {
|
|
createAgent(options: {
|
|
provider: string;
|
|
model: string;
|
|
modeId: string;
|
|
featureValues?: Record<string, unknown>;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
title: string;
|
|
}): Promise<{ id: string }>;
|
|
waitForAgentUpsert(
|
|
agentId: string,
|
|
predicate: (snapshot: { status: string }) => boolean,
|
|
timeout?: number,
|
|
): Promise<{ status: string }>;
|
|
}
|
|
|
|
export async function createIdleAgent(
|
|
client: IdleAgentSeedClient,
|
|
input: { cwd: string; workspaceId: string; title: string },
|
|
): Promise<ArchiveTabAgent> {
|
|
const created = await client.createAgent({
|
|
provider: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
|
// auto_accept for unattended full access — mode validation now rejects modes
|
|
// the provider doesn't define.
|
|
modeId: "build",
|
|
featureValues: { auto_accept: true },
|
|
cwd: input.cwd,
|
|
workspaceId: input.workspaceId,
|
|
title: input.title,
|
|
});
|
|
const snapshot = await client.waitForAgentUpsert(
|
|
created.id,
|
|
(agent) => agent.status === "idle",
|
|
30_000,
|
|
);
|
|
if (snapshot.status !== "idle") {
|
|
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
|
|
}
|
|
return {
|
|
id: created.id,
|
|
title: input.title,
|
|
cwd: input.cwd,
|
|
workspaceId: input.workspaceId,
|
|
};
|
|
}
|
|
|
|
export async function archiveAgentFromDaemon(
|
|
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
|
agentId: string,
|
|
): Promise<void> {
|
|
await client.archiveAgent(agentId);
|
|
}
|
|
|
|
export async function fetchAgentArchivedAt(
|
|
client: {
|
|
fetchAgent(options: {
|
|
agentId: string;
|
|
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
|
},
|
|
agentId: string,
|
|
): Promise<string | null> {
|
|
const result = await client.fetchAgent({ agentId });
|
|
return result?.agent.archivedAt ?? null;
|
|
}
|
|
|
|
export function getWorktreeRestoreFeature(client: {
|
|
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
|
}): boolean {
|
|
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
|
}
|
|
|
|
export async function primeAdditionalPage(page: Page): Promise<void> {
|
|
const seedNonce = randomUUID();
|
|
const { daemon, preferences } = buildSeededStoragePayload();
|
|
|
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
});
|
|
await page.addInitScript(
|
|
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
|
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
|
const disableValue = localStorage.getItem(disableOnceKey);
|
|
if (disableValue) {
|
|
localStorage.removeItem(disableOnceKey);
|
|
if (disableValue === nonce) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
localStorage.removeItem("@paseo:settings");
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
},
|
|
{ daemon, preferences, seedNonce },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
export async function resetSeededPageState(page: Page): Promise<void> {
|
|
const { daemon, preferences } = buildSeededStoragePayload();
|
|
await page.goto("/");
|
|
await page.evaluate(
|
|
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
|
localStorage.clear();
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
localStorage.removeItem("@paseo:settings");
|
|
},
|
|
{ daemon, preferences },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
export async function openWorkspaceWithAgents(
|
|
page: Page,
|
|
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
|
): Promise<void> {
|
|
const serverId = getServerId();
|
|
for (const agent of agents) {
|
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
|
|
|
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
|
// then replaces the URL with the clean workspace route after preparing the tab.
|
|
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
|
// so we allow a generous timeout here (matching terminal-perf pattern).
|
|
await page.waitForURL(
|
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
{ timeout: 60_000 },
|
|
);
|
|
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await expectWorkspaceTabVisible(page, agent.id);
|
|
}
|
|
}
|
|
|
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
|
).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function expectWorkspaceArchiveOutcome(
|
|
page: Page,
|
|
input: { archivedAgentId: string; survivingAgentId: string },
|
|
): Promise<void> {
|
|
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
|
}
|
|
|
|
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
|
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
|
visible: true,
|
|
});
|
|
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
|
await closeButton.first().click();
|
|
await expectWorkspaceTabHidden(page, agentId);
|
|
}
|
|
|
|
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
|
await expectWorkspaceTabVisible(page, agentId);
|
|
await expect(
|
|
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
|
).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
|
const serverId = getServerId();
|
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
|
await waitForWorkspaceTabsVisible(page);
|
|
}
|
|
|
|
export async function openSessions(page: Page): Promise<void> {
|
|
const sessionsButton = page.getByTestId("sidebar-sessions");
|
|
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
|
await sessionsButton.click();
|
|
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
|
|
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
|
|
|
function getSessionRowByTitle(page: Page, title: string) {
|
|
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
|
}
|
|
|
|
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
|
const row = getSessionRowByTitle(page, title);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.click();
|
|
}
|
|
|
|
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
|
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
|
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
|
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function archiveAgentFromSessions(
|
|
page: Page,
|
|
input: { agentId: string; title: string },
|
|
): Promise<void> {
|
|
const row = getSessionRowByTitle(page, input.title);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
const box = await row.boundingBox();
|
|
if (!box) {
|
|
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
|
}
|
|
|
|
// Long-press the row. Idle agents are archived immediately (no modal).
|
|
// Running/initializing agents show a confirmation modal instead.
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
await page.waitForTimeout(900);
|
|
await page.mouse.up();
|
|
|
|
// If a confirmation modal appears (running agent), click the archive button.
|
|
const archiveButton = page.getByTestId("agent-action-archive").first();
|
|
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
|
if (modalVisible) {
|
|
await archiveButton.click();
|
|
}
|
|
|
|
await expectSessionRowArchived(page, input.title);
|
|
}
|