diff --git a/packages/app/e2e/browser/creation-old-daemon.spec.ts b/packages/app/e2e/browser/creation-old-daemon.spec.ts new file mode 100644 index 0000000000..ff28d513ef --- /dev/null +++ b/packages/app/e2e/browser/creation-old-daemon.spec.ts @@ -0,0 +1,230 @@ +/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */ +import { randomUUID } from "node:crypto"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { metroTest, expect } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { withProjectOwnership } from "../support/helpers/project-ownership"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { + openNewWorkspaceComposer, + selectWorkspaceIsolation, + loadSessionMessageReaders, +} from "../support/helpers/new-workspace"; +import { fillComposerDraft } from "../support/helpers/composer"; +import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation"; +import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs"; + +for (const version of ["0.2.5", "0.7.2", "0.8.0"]) { + let daemon: Awaited>; + const test = metroTest.extend<{ + client: DaemonClient; + gate: Awaited>; + repo: Awaited>; + project: Awaited>; + }>({ + gate: async ({ page }, provide) => { + const gate = await holdLegacyAgentCreation(page, daemon.port); + await provide(gate); + gate.release(); + }, + client: async ({}, provide) => { + const client = withProjectOwnership( + await connectDaemonClient({ + clientIdPrefix: "legacy-creation", + port: daemon.port, + }), + ); + await provide(client); + await client.close(); + }, + repo: async ({}, provide) => { + const repo = await createTempGitRepo("legacy-creation-"); + await provide(repo); + await repo.cleanup(); + }, + project: async ({}, provide) => { + const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port }); + await provide(project); + await project.cleanup(); + }, + }); + test.describe(`published daemon ${version}`, () => { + test.beforeAll(async () => { + test.setTimeout(120_000); + daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, { + publishedVersion: version, + }); + }); + test.afterAll(async () => { + await daemon?.close(); + }); + + for (const kind of ["workspace", "agent"] as const) { + test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({ + repo, + client, + }) => { + const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`; + const features = client.getLastServerInfoMessage()?.features; + expect(features?.creationLifecycle).not.toBe(true); + expect(features?.workspaceRequestReceipts).not.toBe(true); + expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0"); + + const create = await prepareCreation(client, kind, repo.path, prompt); + const [agent, duplicate] = await Promise.all([create(), create()]); + expect(duplicate.id).toBe(agent.id); + expect(agent.title).toBe(prompt); + await client.waitForFinish(agent.id, 20_000); + const agents = await client.fetchAgents(); + expect(agents.entries).toHaveLength(1); + expect(agents.entries[0]!.agent.title).toBe(prompt); + const timeline = await client.fetchAgentTimeline(agent.id); + expect( + timeline.entries.filter( + ({ item }) => item.type === "user_message" && item.text === prompt, + ), + ).toHaveLength(1); + }); + } + + test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({ + page, + project, + client, + gate, + }) => { + await openOldHost(page, project.workspaceId); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, "worktree"); + const prompt = "Create this workspace: emit 1 coalesced agent stream updates"; + await fillComposerDraft(page, prompt); + await pressSubmitBeforeTheNextRender(page, "Create"); + await gate.waitForRequest(); + await expect(page).toHaveURL(/\/workspace\//); + await expect( + page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }), + ).toBeVisible(); + expect((await client.fetchAgents()).entries).toHaveLength(0); + const workspaces = (await client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ); + expect(workspaces).toHaveLength(1); + gate.release(); + await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1); + const first = (await client.fetchAgents()).entries[0]!.agent; + await client.waitForFinish(first.id, 20_000); + await expectPromptOnce(client, first.id, prompt); + await expect( + page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }), + ).toHaveText(prompt); + + await createAgentTabFromMenu(page); + const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates"; + await fillComposerDraft(page, secondPrompt); + await pressSubmitBeforeTheNextRender(page, "Send message"); + await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2); + const second = (await client.fetchAgents()).entries.find( + ({ agent }) => agent.id !== first.id, + )!.agent; + expect(second.workspaceId).toBe(first.workspaceId); + await client.waitForFinish(second.id, 20_000); + await expectPromptOnce(client, second.id, secondPrompt); + await expect( + page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }), + ).toHaveText(secondPrompt); + await expect( + page + .getByTestId("user-message") + .filter({ visible: true }) + .filter({ hasText: secondPrompt }), + ).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") }); + }); + + async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) { + const timeline = await client.fetchAgentTimeline(agentId); + expect( + timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text), + ).toHaveLength(1); + } + + async function openOldHost(page: Page, workspaceId: string) { + const host = buildSeededHost({ + serverId: daemon.serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, + ); + await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId)); + } + + async function prepareCreation( + client: DaemonClient, + kind: "workspace" | "agent", + cwd: string, + prompt: string, + ) { + const input = { provider: "mock", cwd, initialPrompt: prompt }; + if (kind === "workspace") + return async () => { + const result = await client.createWorkspace({ + idempotencyKey: "workspace-intent", + source: { kind: "worktree", cwd, worktreeSlug: "created-once" }, + agent: input, + }); + expect(result.error).toBeNull(); + expect(result.workspace?.workspaceDirectory).not.toBe(cwd); + return result.agent!; + }; + const workspace = await client.createWorkspace({ + idempotencyKey: "empty-workspace", + source: { kind: "directory", path: cwd }, + }); + return () => + client.createAgent({ + ...input, + workspaceId: workspace.workspace!.id, + idempotencyKey: "agent-intent", + }); + } + }); +} + +async function holdLegacyAgentCreation(page: Page, port: number) { + const readers = await loadSessionMessageReaders(); + const arrived = Promise.withResolvers(); + let release = () => {}; + let holding = true; + await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + if (holding && readers.client(frame)?.type === "create_agent_request") { + holding = false; + release = () => server.send(frame); + arrived.resolve(); + } else server.send(frame); + }); + server.onMessage((frame) => browser.send(frame)); + }); + return { + waitForRequest: () => arrived.promise, + release() { + const send = release; + release = () => {}; + send(); + }, + }; +} diff --git a/packages/client/src/creation/index.test.ts b/packages/client/src/creation/index.test.ts index 40828d4bfb..1ce46107e4 100644 --- a/packages/client/src/creation/index.test.ts +++ b/packages/client/src/creation/index.test.ts @@ -58,7 +58,7 @@ function fixture(modern: boolean) { resolve = done; }); const client = new CreationClient({ - supports: () => modern, + supports: (feature) => (feature === "creationLifecycle" ? modern : true), requestId: () => "generated-key", request: async (kind, input) => { requests.push({ kind, input }); @@ -73,9 +73,6 @@ function fixture(modern: boolean) { legacy.push({ kind: "agent", input }); return agent; }, - sendMessage: async (id, text, options) => { - legacy.push({ kind: "message", input: { id, text, ...options } }); - }, }); const input = { idempotencyKey: "intent-one", @@ -155,20 +152,10 @@ test("legacy adaptation keeps workspace, agent and initial prompt sequencing ins input: { config: { provider: "codex", cwd: workspace.workspaceDirectory }, workspaceId: workspace.id, - idempotencyKey: "intent-one:agent", + initialPrompt: "Start once", clientMessageId: "message-one", }, }, - { - kind: "message", - input: { - id: agent.id, - text: "Start once", - messageId: "message-one", - images: undefined, - attachments: undefined, - }, - }, ]); expect(f.requests).toEqual([]); f.client.close(); @@ -194,7 +181,10 @@ test("keyed creation with an empty prompt works on legacy daemons without sendin initialPrompt: "", }); expect(f.legacy).toEqual([ - { kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } }, + { + kind: "agent", + input: { idempotencyKey: "empty-agent", config: f.input.agent.config, initialPrompt: "" }, + }, ]); f.client.close(); }); diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts index 4ae64c34ff..fd9db1d9df 100644 --- a/packages/client/src/creation/index.ts +++ b/packages/client/src/creation/index.ts @@ -18,7 +18,7 @@ export interface CreationResult { requestId?: string; } interface Dependencies { - supports: () => boolean; + supports: (feature: "creationLifecycle" | "agentRequestReceipts") => boolean; requestId: () => string; request: (kind: Kind, input: Record) => Promise; observe: ( @@ -31,15 +31,6 @@ interface Dependencies { legacyWorkspace: ( input: CreateWorkspaceRequestOptions, ) => Promise; - sendMessage: ( - id: string, - text: string, - options: { - messageId: string; - images?: CreateAgentRequestOptions["images"]; - attachments?: CreateAgentRequestOptions["attachments"]; - }, - ) => Promise; } interface Operation { kind: Kind; @@ -105,23 +96,15 @@ export class CreationClient { private async legacyAgent(input: CreateAgentRequestOptions): Promise { if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + const { idempotencyKey: _key, ...unkeyed } = input; + const hasPrompt = Boolean( + input.initialPrompt || input.images?.length || input.attachments?.length, + ); // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. - // Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK. - if (input.idempotencyKey) { - const { initialPrompt = "", images, attachments, ...creation } = input; - const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length); - if (hasPrompt && input.outputSchema) - throw new Error("Update the host to create a keyed agent with structured initial output."); - const agent = await this.deps.legacyAgent(creation); - if (hasPrompt) - await this.deps.sendMessage(agent.id, initialPrompt, { - messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`, - images, - attachments, - }); - return { agent, error: null }; - } - return { agent: await this.deps.legacyAgent(input), error: null }; + // Preserve the original create-and-prompt RPC: old hosts use its prompt for + // titles, creation context and startup. Their keyed create cannot accept it. + const request = !hasPrompt && this.deps.supports("agentRequestReceipts") ? input : unkeyed; + return { agent: await this.deps.legacyAgent(request), error: null }; } private start( @@ -165,7 +148,7 @@ export class CreationClient { resolve, reject, settled: false, - modern: this.deps.supports(), + modern: this.deps.supports("creationLifecycle"), recovering: false, }; this.operations.set(identity, operation); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 72a649d664..27d6f7d558 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { DaemonClient, type DaemonClientTrace, + type CreateAgentRequestOptions, type DaemonTransport, type Logger, } from "./daemon-client"; @@ -291,23 +292,165 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); -test("retry-safe creation rejects older hosts before sending any request", async () => { - const transport = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "receipt-gate", - transportFactory: () => transport.transport, - reconnect: { enabled: false }, - }); - clients.push(client); - const connecting = client.connect(); - transport.triggerOpen(); - await connecting; - await expect( - client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }), - ).rejects.toThrow("Update the host to use retry-safe agent creation."); - expect(transport.sent).toEqual([]); -}); +test.each([ + { receipts: false, structured: false }, + { receipts: true, structured: false }, + { receipts: false, structured: true }, + { receipts: true, structured: true }, +])( + "legacy creation preserves the original payload with receipts=$receipts, structured=$structured", + async ({ receipts, structured }) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "receipt-gate", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { agentRequestReceipts: receipts } }); + await connecting; + const input: CreateAgentRequestOptions = { + config: { + provider: "codex", + cwd: "/project", + title: "Explicit title", + model: "gpt-5", + modeId: "full-access", + }, + workspaceId: "workspace", + callerAgentId: "parent", + env: { CREATION_CONTEXT: "preserved" }, + labels: { source: "test" }, + idempotencyKey: "creation", + clientMessageId: "first-message", + initialPrompt: "Start this agent", + images: [{ data: "aGVsbG8=", mimeType: "image/png" }], + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Review this PR", + url: "https://github.com/getpaseo/paseo/pull/123", + }, + ], + ...(structured ? { outputSchema: { type: "object" } } : {}), + }; + const created = client.createAgent(input); + void created.catch(() => {}); + const duplicate = client.createAgent(input); + void duplicate.catch(() => {}); + expect(transport.sent).toHaveLength(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "create_agent_request", + config: input.config, + workspaceId: input.workspaceId, + callerAgentId: input.callerAgentId, + env: input.env, + labels: input.labels, + clientMessageId: input.clientMessageId, + initialPrompt: input.initialPrompt, + images: input.images, + attachments: input.attachments, + ...(structured ? { outputSchema: input.outputSchema } : {}), + }); + expect(request).not.toHaveProperty("idempotencyKey"); + transport.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "Provider failed", + }, + }), + ); + await expect(created).rejects.toThrow("Provider failed"); + await expect(duplicate).rejects.toThrow("Provider failed"); + }, +); + +test.each([false, true])( + "legacy workspace creation preserves availability with receipt support=%s", + async (receipts) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "legacy-workspace", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { workspaceRequestReceipts: receipts } }); + await connecting; + const input = { + source: { kind: "directory" as const, path: "/project" }, + idempotencyKey: "workspace", + }; + const created = client.createWorkspace(input); + void created.catch(() => {}); + const duplicate = client.createWorkspace(input); + void duplicate.catch(() => {}); + expect(transport.sent).toHaveLength(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ type: "workspace.create.request", source: input.source }); + expect(request.idempotencyKey).toBe(receipts ? input.idempotencyKey : undefined); + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + error: "Directory unavailable", + setupTerminalId: null, + }, + }), + ); + await expect(created).resolves.toMatchObject({ error: "Directory unavailable" }); + await expect(duplicate).resolves.toMatchObject({ error: "Directory unavailable" }); + }, +); + +test.each(["agent", "workspace"] as const)( + "a lost legacy %s response is not automatically replayed on reconnect", + async (kind) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "legacy-disconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: {} }); + await connecting; + const creation = + kind === "agent" + ? client.createAgent({ + provider: "codex", + cwd: "/project", + initialPrompt: "Start once", + idempotencyKey: "intent", + }) + : client.createWorkspace({ + source: { kind: "directory", path: "/project" }, + idempotencyKey: "intent", + }); + void creation.catch(() => {}); + expect(transport.sent).toHaveLength(1); + transport.triggerClose({ code: 1006, reason: "Connection lost after dispatch" }); + await expect(creation).rejects.toThrow(); + const reconnecting = client.connect(); + transport.triggerOpen({ features: {} }); + await reconnecting; + expect(transport.sent).toEqual([]); + }, +); test("Hub management requires daemon support before dispatching requests", async () => { const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 653ceb28ea..545baef4ff 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2711,7 +2711,7 @@ export class DaemonClient { // ============================================================================ private readonly creations = new CreationClient({ - supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true, + supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true, requestId: () => this.createRequestId(), request: (kind, input) => kind === "workspace" @@ -2747,7 +2747,6 @@ export class DaemonClient { }, legacyAgent: (input) => this.createLegacyAgent(input), legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), - sendMessage: (id, text, options) => this.sendMessage(id, text, options), }); async createAgent(options: CreateAgentRequestOptions): Promise { @@ -2762,7 +2761,6 @@ export class DaemonClient { private async createLegacyAgent( options: CreateAgentRequestOptions, ): Promise { - if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -2816,13 +2814,6 @@ export class DaemonClient { return status.agent; } - private requireAgentRequestReceipts(): void { - // COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05. - if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) { - throw new Error("Update the host to use retry-safe agent creation."); - } - } - async deleteAgent(agentId: string): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ @@ -4478,19 +4469,16 @@ export class DaemonClient { input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise { - // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. - if ( - input.idempotencyKey !== undefined && - !this.lastServerInfoMessage?.features?.workspaceRequestReceipts - ) { - throw new Error("Update the host to use retry-safe workspace creation."); - } return this.sendCorrelatedSessionRequest({ requestId, message: { type: "workspace.create.request", source: input.source, - ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}), + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove after 2027-03-15 once the daemon floor supports workspace receipts. + ...(this.lastServerInfoMessage?.features?.workspaceRequestReceipts && + input.idempotencyKey !== undefined + ? { idempotencyKey: input.idempotencyKey } + : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext }