diff --git a/docs/hub.md b/docs/hub.md index bf44067b67..8c6493504a 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -49,6 +49,10 @@ Provider controls remain provider-native; see [providers.md](providers.md). `send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts; creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide; a message ID is scoped to its agent. Reusing either with different arguments is a conflict. +`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and +worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it. +Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys +for the lifetime of a draft and reuses the first message ID after failures. The daemon journals the assigned agent ID before creation. Concurrent retries share one operation, and a retry after a lost acknowledgement or restart returns the durable agent without creating a diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts new file mode 100644 index 0000000000..239226c417 --- /dev/null +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -0,0 +1,304 @@ +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { expect, test, type Page } from "../support/fixtures"; +import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { gotoAppShell } from "../support/helpers/app"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { fillComposerDraft } from "../support/helpers/composer"; +import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, + selectWorkspaceIsolation, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise { + const create = page.getByRole("button", { name, exact: true }); + await expect(create).toBeEnabled(); + // Dispatch the queued clicks in one JS task, before pending state can paint. + // Exercise DOM events rather than calling the app's submit handler directly. + await create.evaluate((button) => { + for (let click = 0; click < 3; click++) { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + }); +} + +function observeCreationRequests(page: Page) { + const pending = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const envelope = JSON.parse(payload.toString()) as { message?: SessionInboundMessage }; + const request = envelope.message; + if ( + request?.type === "workspace.create.request" || + request?.type === "create_agent_request" + ) { + pending.add(request.requestId); + } + }); + socket.on("framereceived", ({ payload }) => { + const envelope = JSON.parse(payload.toString()) as { message?: SessionOutboundMessage }; + const response = envelope.message; + if ( + response?.type === "workspace.create.response" || + (response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed")) + ) { + const requestId = response.payload.requestId; + if (typeof requestId === "string") pending.delete(requestId); + } + }); + }); + return { + async settled() { + await expect.poll(() => pending.size).toBe(0); + }, + }; +} + +async function retryNextAgentCreation(page: Page) { + const retryIds = new Set(); + const results: Array<{ status: string; agentId?: string }> = []; + let repeated = false; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + const envelope = JSON.parse(frame.toString()) as { message?: SessionInboundMessage }; + const request = envelope.message; + if (!repeated && request?.type === "create_agent_request") { + repeated = true; + for (let attempt = 1; attempt <= 3; attempt++) { + const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`; + retryIds.add(requestId); + // Keep the app's operation key and payload; only RPC correlation changes. + server.send(JSON.stringify({ ...envelope, message: { ...request, requestId } })); + } + return; + } + server.send(frame); + }); + server.onMessage((frame) => { + const envelope = JSON.parse(frame.toString()) as { message?: SessionOutboundMessage }; + const response = envelope.message; + if ( + response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed") && + typeof response.payload.requestId === "string" && + retryIds.delete(response.payload.requestId) + ) { + results.push(response.payload); + } + browser.send(frame); + }); + }); + return { + async completedAgentIds() { + await expect.poll(() => results.length).toBe(3); + expect(results.map((result) => result.status)).toEqual([ + "agent_created", + "agent_created", + "agent_created", + ]); + return results.map((result) => result.agentId); + }, + }; +} + +for (const isolation of ["local", "worktree"] as const) { + test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({ + page, + }) => { + const requests = observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + + await pressSubmitBeforeTheNextRender(page, "Create"); + + await expect(page).toHaveURL(/\/workspace\//); + await requests.settled(); + const workspaces = await project.client.fetchWorkspaces(); + expect( + workspaces.entries + .filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ) + .map((workspace) => workspace.id), + ).toHaveLength(1); + } finally { + await project.cleanup(); + } + }); +} + +test("repeated Send clicks before a render create only one agent", async ({ page }) => { + const requests = observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "agent-creation-idempotency-" }); + try { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + await fillComposerDraft(page, "Start exactly one agent for this prompt."); + + await pressSubmitBeforeTheNextRender(page, "Send message"); + + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await requests.settled(); + const agents = await project.client.fetchAgents(); + expect( + agents.entries + .filter(({ agent }) => agent.workspaceId === project.workspaceId) + .map(({ agent }) => agent.id), + ).toHaveLength(1); + } finally { + await project.cleanup(); + } +}); + +test("retrying the app's agent creation returns the same agent for every attempt", async ({ + page, +}) => { + const retries = await retryNextAgentCreation(page); + const project = await seedWorkspace({ repoPrefix: "agent-creation-retries-" }); + try { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + await fillComposerDraft(page, "Start exactly one agent for this prompt."); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + + const agentIds = await retries.completedAgentIds(); + + expect(new Set(agentIds).size).toBe(1); + const agents = await project.client.fetchAgents(); + expect( + agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId), + ).toHaveLength(1); + } finally { + await project.cleanup(); + } +}); + +test("the first prompt still names an agent created with a receipt", async ({ page }) => { + const project = await seedWorkspace({ repoPrefix: "creation-title-" }); + try { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + await fillComposerDraft(page, "Name this new agent from its first prompt."); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await expect + .poll(async () => { + const agents = await project.client.fetchAgents(); + return agents.entries.find(({ agent }) => agent.workspaceId === project.workspaceId)?.agent + .title; + }) + .toBe("Name this new agent from its first prompt."); + } finally { + await project.cleanup(); + } +}); + +test("retrying after a lost first-prompt acknowledgement reuses the agent and message", async ({ + page, +}) => { + const gate = await installDaemonWebSocketGate(page); + const project = await seedWorkspace({ repoPrefix: "creation-lost-ack-" }); + try { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + gate.holdNextServerMessage("send_agent_message_response"); + await fillComposerDraft(page, "Deliver this initial prompt once."); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await gate.waitForHeldServerMessage("send_agent_message_response"); + const firstMessage = gate.getClientRequests("send_agent_message_request").at(-1); + const agentFetches = gate.getClientRequestCount("fetch_agents_request"); + await gate.drop(); + gate.restore(); + await expect + .poll(() => gate.getClientRequestCount("fetch_agents_request")) + .toBeGreaterThan(agentFetches); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await fillComposerDraft(page, "Deliver this initial prompt once."); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect.poll(() => gate.getClientRequestCount("send_agent_message_request")).toBe(2); + const secondMessage = gate.getClientRequests("send_agent_message_request").at(-1); + expect(secondMessage).toMatchObject({ + agentId: firstMessage?.agentId, + messageId: firstMessage?.messageId, + }); + await expect(page.getByTestId("user-message")).toHaveCount(1); + const agents = await project.client.fetchAgents(); + expect( + agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId), + ).toHaveLength(1); + } finally { + gate.restore(); + await project.cleanup(); + } +}); + +test("repeated new-workspace prompt submissions create one workspace and one agent", async ({ + page, +}) => { + const requests = observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "creation-workspace-prompt-" }); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, "local"); + await fillComposerDraft(page, "Start one agent in one new workspace."); + await pressSubmitBeforeTheNextRender(page, "Create"); + await expect(page).toHaveURL(/\/workspace\//); + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await requests.settled(); + const workspaces = (await project.client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ); + expect(workspaces).toHaveLength(1); + const agents = await project.client.fetchAgents(); + expect( + agents.entries.filter(({ agent }) => agent.workspaceId === workspaces[0]!.id), + ).toHaveLength(1); + } finally { + await project.cleanup(); + } +}); + +test("separate drafts can intentionally create two agents in the same workspace", async ({ + page, +}) => { + const delayed = await delayBrowserAgentCreatedStatus(page); + const project = await seedWorkspace({ repoPrefix: "creation-distinct-drafts-" }); + try { + await gotoWorkspace(page, project.workspaceId); + for (const prompt of ["Start the first agent.", "Start the second agent."]) { + await createAgentTabFromMenu(page); + await fillComposerDraft(page, prompt); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible(); + await delayed.waitForDelayedCreatedStatus(); + } + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.filter( + ({ agent }) => agent.workspaceId === project.workspaceId, + ).length, + ) + .toBe(2); + delayed.release(); + } finally { + delayed.release(); + await project.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 2f3d6b22c6..7c9e212c93 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -21,6 +21,7 @@ interface ClientRequest { mode?: unknown; path?: unknown; agentId?: unknown; + messageId?: unknown; text?: unknown; activeTurnBehavior?: unknown; } diff --git a/packages/app/e2e/support/helpers/workspace.ts b/packages/app/e2e/support/helpers/workspace.ts index 23bfc4bf9b..14b31be926 100644 --- a/packages/app/e2e/support/helpers/workspace.ts +++ b/packages/app/e2e/support/helpers/workspace.ts @@ -18,12 +18,11 @@ export interface TempDirectory { } /** - * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → - * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows - * `/tmp` doesn't exist, so fall back to the OS temp dir. + * Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture + * paths match the daemon's resolved paths. */ export async function resolveTempRoot(): Promise { - return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); + return await realpath(tmpdir()); } async function configureRemote(input: { diff --git a/packages/app/src/agent-creation/index.ts b/packages/app/src/agent-creation/index.ts new file mode 100644 index 0000000000..0a643e83c9 --- /dev/null +++ b/packages/app/src/agent-creation/index.ts @@ -0,0 +1,24 @@ +import type { + DaemonClient, + CreateAgentRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; + +/** Creation and prompt delivery have separate durable receipts on the daemon. */ +export async function createAgentWithInitialMessage( + client: Pick, + options: Omit & { + idempotencyKey: string; + clientMessageId: string; + }, +) { + const { initialPrompt = "", clientMessageId, images, attachments, ...creation } = options; + const agent = await client.createAgent(creation); + if (initialPrompt || images?.length || attachments?.length) { + await client.sendMessage(agent.id, initialPrompt, { + messageId: clientMessageId, + images, + attachments, + }); + } + return agent; +} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 968b721eb2..4b96b8469a 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,9 +1,9 @@ +import { createAgentWithInitialMessage } from "@/agent-creation"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; @@ -85,20 +85,22 @@ function buildChatDraftComposerArgs({ async function callWorkspaceCreation({ creationMethod, connectedClient, + creationId, + worktreeSlug, input, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; + creationId: string; + worktreeSlug: string; input: { cwd: string }; }) { - if (creationMethod === "create_worktree") { - return connectedClient.createPaseoWorktree({ - cwd: input.cwd, - worktreeSlug: createNameId(), - }); - } return connectedClient.createWorkspace({ - source: { kind: "directory", path: input.cwd }, + idempotencyKey: creationId, + source: + creationMethod === "create_worktree" + ? { kind: "worktree", cwd: input.cwd, worktreeSlug } + : { kind: "directory", path: input.cwd }, }); } @@ -208,7 +210,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + }, [pendingWorkspaceSetup?.creationId]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -261,6 +263,8 @@ export function WorkspaceSetupDialog() { const connectedClient = withConnectedClient(); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, + creationId: pendingWorkspaceSetup.creationId, + worktreeSlug: pendingWorkspaceSetup.worktreeSlug, connectedClient, input, }); @@ -326,8 +330,11 @@ export function WorkspaceSetupDialog() { workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, }); - const agent = await connectedClient.createAgent( - buildCreateAgentOptions({ + if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired")); + const agent = await createAgentWithInitialMessage(connectedClient, { + idempotencyKey: pendingWorkspaceSetup.creationId, + clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`, + ...buildCreateAgentOptions({ composerState, text, attachments: wirePayload.attachments, @@ -336,7 +343,7 @@ export function WorkspaceSetupDialog() { workspaceId: ensuredWorkspace.id, provider: composerState.selectedProvider, }), - ); + }); if (!getIsStillActive()) { return; @@ -362,6 +369,7 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 8587fa3db6..4c020a2a10 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -250,4 +250,40 @@ describe("useDraftAgentCreateFlow", () => { }); expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); + + it("unlocks a remounted draft when the original request fails", async () => { + let rejectCreation!: (error: Error) => void; + const creation = new Promise((_resolve, reject) => { + rejectCreation = reject; + }); + const options = { + draftId: "draft-remount", + getPendingServerId: () => "server-1", + buildDraftAgent: () => ({ provider: "codex" }), + createRequest: async () => creation, + onCreateSuccess: () => undefined, + }; + const original = renderHook(() => useDraftAgentCreateFlow(options)); + let submission!: Promise; + const captureError = (error: unknown) => error; + await act(async () => { + submission = original.result.current + .handleCreateFromInput({ text: "hello", attachments: [], cwd: "/repo" }) + .catch(captureError); + }); + const pending = useCreateFlowStore.getState().pendingByDraftId[options.draftId]!; + const restored = renderHook(() => + useDraftAgentCreateFlow({ + ...options, + initialAttempt: { ...pending, timestamp: new Date(pending.timestamp) }, + }), + ); + expect(restored.result.current.isSubmitting).toBe(true); + await act(async () => { + rejectCreation(new Error("Provider unavailable")); + await submission; + }); + expect(restored.result.current.isSubmitting).toBe(false); + expect(restored.result.current.formErrorMessage).toBe("Provider unavailable"); + }); }); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..4d0cecc210 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,12 +5,11 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, - generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; @@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( + const [localMachine, dispatch] = useReducer( reducer, initialAttempt, (attempt): DraftAgentMachineState => @@ -135,10 +134,19 @@ export function useDraftAgentCreateFlow({ }, ); + const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); + // A remounted view observes completion from the same attempt as its original view. + const machine = useMemo>( + () => + localMachine.tag === "creating" && pending?.lifecycle === "abandoned" + ? { tag: "draft", errorMessage: pending.errorMessage ?? "" } + : localMachine, + [pending, localMachine], + ); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; @@ -233,14 +241,16 @@ export function useDraftAgentCreateFlow({ const resolved = error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); + markPendingCreateLifecycle({ + draftId, + lifecycle: "abandoned", + errorMessage: resolved.message, + }); onCreateError?.(resolved); throw error; } }, [ - clearPendingCreateAttempt, createRequest, draftId, getPendingServerId, @@ -255,7 +265,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { + const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + if ( + isSubmitting || + isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) + ) { throw new Error(t("composer.errors.alreadyLoading")); } @@ -296,10 +310,10 @@ export function useDraftAgentCreateFlow({ } const attempt: CreateAttempt = { - clientMessageId: generateMessageId(), + clientMessageId: `${draftId}:initial-message`, text: trimmedPrompt, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(images.length > 0 ? { images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4dca9df166..7864165b7f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,3 +1,4 @@ +import { createAgentWithInitialMessage } from "@/agent-creation"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -24,7 +25,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -136,6 +137,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -195,12 +197,14 @@ async function submitDraftCreateRequest(input: { }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await requestWorkspaceDraftAgent(client, { + const imagesData = await encodeImages(images); + const result = await createAgentWithInitialMessage(client, { + idempotencyKey: input.draftId, config, workspaceId, - text, + initialPrompt: text, clientMessageId: attempt.clientMessageId, - ...(images ? { images } : {}), + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(attachmentsArray ? { attachments: attachmentsArray } : {}), }); @@ -389,10 +393,7 @@ export function WorkspaceDraftAgentTab({ ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); const initialCreateAttempt = useMemo(() => { - if (!pendingAutoSubmit || !pendingCreateAttempt) { - return null; - } - if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + if (!pendingCreateAttempt) { return null; } return { @@ -406,7 +407,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -488,6 +489,7 @@ export function WorkspaceDraftAgentTab({ }), createRequest: async ({ attempt, text, images, attachments, cwd }) => submitDraftCreateRequest({ + draftId, attempt, text, images, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8adf2a188a..e097593db9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, @@ -25,8 +25,6 @@ import { } from "@getpaseo/protocol/agent-attention-notification"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; @@ -45,9 +43,7 @@ import { createInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; -import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import type { AttachmentMetadata } from "@/attachments/types"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [client], ); - const _createAgent = useCallback( - async ({ - config, - initialPrompt, - images, - attachments, - git, - worktreeName, - requestId, - }: { - config: AgentSessionConfig; - initialPrompt: string; - images?: AttachmentMetadata[]; - attachments?: AgentAttachment[]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - }) => { - if (!client) { - console.warn("[Session] createAgent skipped: daemon unavailable"); - return; - } - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - await client.createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }); - }, - [client], - ); - const _setAgentMode = useCallback( (agentId: string, modeId: string) => { if (!client) { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7f7b6b13a8..10314ed4de 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -68,7 +68,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { @@ -821,6 +820,8 @@ async function createAndMergeWorkspace(input: { } async function createMultiplicityWorkspace(input: { + idempotencyKey: string; + worktreeSlug: string; client: NonNullable>; isolation: "local" | "worktree"; project: HostProjectListItem; @@ -844,12 +845,13 @@ async function createMultiplicityWorkspace(input: { attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + idempotencyKey: input.idempotencyKey, source: isWorktree ? { kind: "worktree", cwd: input.sourceDirectory, projectId, - worktreeSlug: createNameId(), + worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, } : { @@ -1066,7 +1068,9 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); + const [creationIdentity] = useState(() => ({ + draftId: draftId ?? generateDraftId(), + worktreeSlug: createNameId(), + })); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState; setPending: (pending: Omit) => void; updateAgentId: (input: { draftId: string; agentId: string }) => void; - markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void; + markLifecycle: (input: { + draftId: string; + lifecycle: CreateFlowLifecycleState; + errorMessage?: string; + }) => void; rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: (input: { draftId: string }) => void; clearByAgent: (input: { serverId: string; agentId: string }) => void; @@ -67,7 +72,7 @@ export const useCreateFlowStore = create((set) => ({ }, }; }), - markLifecycle: ({ draftId, lifecycle }) => + markLifecycle: ({ draftId, lifecycle, errorMessage }) => set((state) => { const current = state.pendingByDraftId[draftId]; if (!current || current.lifecycle === lifecycle) { @@ -76,7 +81,7 @@ export const useCreateFlowStore = create((set) => ({ return { pendingByDraftId: { ...state.pendingByDraftId, - [draftId]: { ...current, lifecycle }, + [draftId]: { ...current, lifecycle, errorMessage }, }, }; }), diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts new file mode 100644 index 0000000000..87fa3fad74 --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -0,0 +1,46 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { transformFileSync } from "@babel/core"; +import { expect, test } from "vitest"; +import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store"; + +// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug. +function loadProductionStore() { + const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url)); + const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false }; + const result = transformFileSync(filename, { + envName: "production", + caller, + }); + if (!result?.code) throw new Error("Expo produced no store code"); + const module = { + exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore }, + }; + new Function("require", "module", "exports", result.code)( + createRequire(import.meta.url), + module, + module.exports, + ); + return module.exports.useWorkspaceDraftSubmissionStore; +} + +test("the production app consumes a draft once and leaves other drafts pending", () => { + const store = loadProductionStore(); + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-1", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + } as const; + store.getState().setPending({ ...submission, attachments: [] }); + store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" }); + expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull(); + expect(store.getState().consumePending(submission)).toEqual(submission); + expect(store.getState().consumePending(submission)).toBeNull(); + expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); +}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 2dbd27f2a5..a37c3a1f80 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -96,8 +96,10 @@ export const useWorkspaceDraftSubmissionStore = create { sourceWorkspaceId: "42", displayName: "project", creationMethod: "open_project", + creationId: expect.any(String), + worktreeSlug: expect.any(String), }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 31e3bfc024..d0205ba27a 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,3 +1,5 @@ +import { createNameId } from "mnemonic-id"; +import { generateDraftId } from "@/stores/draft-keys"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -44,7 +46,9 @@ export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | n } interface WorkspaceSetupStoreState { - pendingWorkspaceSetup: PendingWorkspaceSetup | null; + pendingWorkspaceSetup: + | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) + | null; snapshots: Record; requestedKeys: Set; surfacedFailedSetupKeys: Set; @@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create()((set, g requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { - set({ pendingWorkspaceSetup: value }); + set({ + pendingWorkspaceSetup: { + ...value, + creationId: generateDraftId(), + worktreeSlug: createNameId(), + }, + }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e012c7c3df..38c4c20c1b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4333,15 +4333,24 @@ export class DaemonClient { input: { source: WorkspaceCreateRequest["source"]; title?: string; + idempotencyKey?: string; firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; }, requestId?: string, ): Promise { + // COMPAT(workspaceRequestReceipts): added in v0.7.3; 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 } : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b8d0e56cc2..8e54cba18d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2563,6 +2563,7 @@ export const ArchiveWorkspaceRequestSchema = z.object({ export const WorkspaceCreateRequestSchema = z.object({ type: z.literal("workspace.create.request"), requestId: z.string(), + idempotencyKey: z.string().min(1).max(512).optional(), // Optional user-set title applied to the created workspace. title: z.string().optional(), // Optional prompt context for workspace-level name/branch generation. @@ -3480,6 +3481,7 @@ export const ServerInfoStatusPayloadSchema = z .object({ // COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05. agentRequestReceipts: z.boolean().optional(), + workspaceRequestReceipts: z.boolean().optional(), // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. hubAgentRpc: z.boolean().optional(), providersSnapshot: z.boolean().optional(), diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..dc93e30298 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + workspaceId?: string; projectId?: string; title?: string; } @@ -93,6 +94,7 @@ async function createPaseoWorktreeWithPriority( const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, + workspaceId: input.workspaceId, repoRoot: createdWorktree.repoRoot, cwd: workspaceCwd, worktreeRoot: createdWorktree.worktree.worktreePath, diff --git a/packages/server/src/server/agent/requests/index.test.ts b/packages/server/src/server/request-receipts/index.test.ts similarity index 51% rename from packages/server/src/server/agent/requests/index.test.ts rename to packages/server/src/server/request-receipts/index.test.ts index de3b010148..dbc2f8c262 100644 --- a/packages/server/src/server/agent/requests/index.test.ts +++ b/packages/server/src/server/request-receipts/index.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; -import { AgentRequests } from "./index.js"; +import { RequestReceipts } from "./index.js"; const directories: string[] = []; afterEach(async () => { @@ -13,7 +13,7 @@ afterEach(async () => { async function fixture() { const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); directories.push(directory); - return { directory, requests: new AgentRequests(directory) }; + return { directory, requests: new RequestReceipts(directory) }; } test("concurrent and reconstructed creates return the same durable agent", async () => { @@ -29,9 +29,9 @@ test("concurrent and reconstructed creates return the same durable agent", async agents.add(id); }, }; - const ids = await Promise.all([requests.create(input), requests.create(input)]); + const ids = await Promise.all([requests.createAgent(input), requests.createAgent(input)]); expect(ids[0]).toBe(ids[1]); - expect(await new AgentRequests(directory).create(input)).toBe(ids[0]); + expect(await new RequestReceipts(directory).createAgent(input)).toBe(ids[0]); expect(creations).toBe(1); }); @@ -47,8 +47,8 @@ test("recovers creation when the agent was persisted before acknowledgement fail throw new Error("acknowledgement lost"); }, }; - await expect(requests.create(input)).rejects.toThrow("acknowledgement lost"); - expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]); + await expect(requests.createAgent(input)).rejects.toThrow("acknowledgement lost"); + expect(await new RequestReceipts(directory).createAgent(input)).toBe([...agents][0]); expect(agents.size).toBe(1); }); @@ -60,8 +60,8 @@ test("reusing a create key with different configuration is a conflict", async () findAgent: async () => true, create: async () => {}, }; - await requests.create(input); - await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow( + await requests.createAgent(input); + await expect(requests.createAgent({ ...input, request: { model: "b" } })).rejects.toThrow( "agent_request_key_conflict", ); }); @@ -77,10 +77,10 @@ test("message retries survive reconstruction without submitting twice", async () deliveries++; }, }; - await Promise.all([requests.send(input), requests.send(input)]); - await new AgentRequests(directory).send(input); + await Promise.all([requests.sendMessage(input), requests.sendMessage(input)]); + await new RequestReceipts(directory).sendMessage(input); expect(deliveries).toBe(1); - await requests.send({ ...input, agentId: "another" }); + await requests.sendMessage({ ...input, agentId: "another" }); expect(deliveries).toBe(2); }); @@ -96,8 +96,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn throw new Error("connection lost"); }, }; - await expect(requests.send(input)).rejects.toThrow("connection lost"); - await expect(new AgentRequests(directory).send(input)).rejects.toThrow( + await expect(requests.sendMessage(input)).rejects.toThrow("connection lost"); + await expect(new RequestReceipts(directory).sendMessage(input)).rejects.toThrow( "agent_request_outcome_unknown", ); expect(deliveries).toBe(1); @@ -114,9 +114,9 @@ test("a creation failure with no stored agent can be retried", async () => { if (!available) throw new Error("provider unavailable"); }, }; - await expect(requests.create(input)).rejects.toThrow("provider unavailable"); + await expect(requests.createAgent(input)).rejects.toThrow("provider unavailable"); available = true; - await expect(requests.create(input)).resolves.toEqual(expect.any(String)); + await expect(requests.createAgent(input)).resolves.toEqual(expect.any(String)); }); test("failed local message preparation does not leave an ambiguous receipt", async () => { @@ -134,10 +134,66 @@ test("failed local message preparation does not leave an ambiguous receipt", asy sends++; }, }; - await expect(requests.send(input)).rejects.toThrow("load failed"); + await expect(requests.sendMessage(input)).rejects.toThrow("load failed"); available = true; - await new AgentRequests(directory).send(input); + await new RequestReceipts(directory).sendMessage(input); available = false; - await requests.send(input); + await requests.sendMessage(input); expect(sends).toBe(1); }); + +test("workspace receipts serialize concurrent requests and survive reconstruction", async () => { + const { requests, directory } = await fixture(); + const workspaces = new Set(); + let creations = 0; + const input = { + key: "workspace", + workspaceId: "wks_first", + request: { source: { kind: "directory", path: "/project" } }, + findWorkspace: async (id: string) => workspaces.has(id), + create: async (id: string) => { + creations++; + workspaces.add(id); + }, + }; + expect( + await Promise.all([ + requests.createWorkspace(input), + requests.createWorkspace({ ...input, workspaceId: "wks_second" }), + ]), + ).toEqual(["wks_first", "wks_first"]); + expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_first"); + expect(creations).toBe(1); + await expect( + requests.createWorkspace({ + ...input, + request: { source: { kind: "directory", path: "/other" } }, + }), + ).rejects.toThrow("workspace_request_key_conflict"); + expect( + await requests.createAgent({ + key: input.key, + request: {}, + findAgent: async () => false, + create: async () => {}, + }), + ).not.toBe("wks_first"); +}); + +test("a workspace persisted before a lost acknowledgement is recovered", async () => { + const { requests, directory } = await fixture(); + const workspaces = new Set(); + const input = { + key: "lost-workspace", + workspaceId: "wks_created", + request: {}, + findWorkspace: async (id: string) => workspaces.has(id), + create: async (id: string) => { + workspaces.add(id); + throw new Error("acknowledgement lost"); + }, + }; + await expect(requests.createWorkspace(input)).rejects.toThrow("acknowledgement lost"); + expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_created"); + expect([...workspaces]).toEqual(["wks_created"]); +}); diff --git a/packages/server/src/server/agent/requests/index.ts b/packages/server/src/server/request-receipts/index.ts similarity index 54% rename from packages/server/src/server/agent/requests/index.ts rename to packages/server/src/server/request-receipts/index.ts index ad64331c35..cbd6d71278 100644 --- a/packages/server/src/server/agent/requests/index.ts +++ b/packages/server/src/server/request-receipts/index.ts @@ -2,36 +2,57 @@ import { createHash, randomUUID } from "node:crypto"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { writeJsonFileAtomic } from "../../atomic-file.js"; +import { writeJsonFileAtomic } from "../atomic-file.js"; -const ReceiptSchema = z.object({ +const ReceiptStateSchema = z.object({ fingerprint: z.string(), - agentId: z.string(), state: z.enum(["pending", "completed"]), }); -type Receipt = z.infer; +// Preserve existing agent receipt files while giving workspaces their own identity. +const ReceiptSchema = z.union([ + ReceiptStateSchema.extend({ agentId: z.string() }), + ReceiptStateSchema.extend({ workspaceId: z.string() }), +]); +type Receipt = z.infer & { + resource: { kind: "agent" | "workspace"; id: string }; +}; /** One daemon-owned request journal, shared by all of its socket sessions. */ -export class AgentRequests { +export class RequestReceipts { private readonly pending = new Map>(); constructor(private readonly directory: string) {} - create(input: { + createAgent(input: { key: string; request: unknown; findAgent: (agentId: string) => Promise; create: (agentId: string) => Promise; }): Promise { return this.execute(["create", input.key], input.request, { - agentId: randomUUID(), + resource: { kind: "agent", id: randomUUID() }, recover: input.findAgent, run: input.create, retrySafe: async (agentId) => !(await input.findAgent(agentId)), }); } - async send(input: { + createWorkspace(input: { + key: string; + request: unknown; + workspaceId: string; + findWorkspace: (workspaceId: string) => Promise; + create: (workspaceId: string) => Promise; + }): Promise { + return this.execute(["create-workspace", input.key], input.request, { + resource: { kind: "workspace", id: input.workspaceId }, + recover: input.findWorkspace, + run: input.create, + retrySafe: async (workspaceId) => !(await input.findWorkspace(workspaceId)), + }); + } + + async sendMessage(input: { agentId: string; messageId: string; request: unknown; @@ -39,7 +60,7 @@ export class AgentRequests { prepare?: () => Promise; }): Promise { await this.execute(["send", input.agentId, input.messageId], input.request, { - agentId: input.agentId, + resource: { kind: "agent", id: input.agentId }, // A provider call can take effect before the daemon records its outcome. // Never repeat that call merely because a process died in this window. recover: async () => false, @@ -52,7 +73,7 @@ export class AgentRequests { identity: string[], request: unknown, operation: { - agentId: string; + resource: Receipt["resource"]; recover: (agentId: string) => Promise; run: (agentId: string) => Promise; prepare?: (() => Promise) | undefined; @@ -79,7 +100,7 @@ export class AgentRequests { key: string, fingerprint: string, operation: { - agentId: string; + resource: Receipt["resource"]; recover: (agentId: string) => Promise; run: (agentId: string) => Promise; prepare?: (() => Promise) | undefined; @@ -89,39 +110,58 @@ export class AgentRequests { const file = path.join(this.directory, `${key}.json`); const existing = await readReceipt(file); if (existing) { - if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); - if (existing.state === "completed") return existing.agentId; - if (!(await operation.recover(existing.agentId))) { - throw new Error("agent_request_outcome_unknown"); + if (existing.fingerprint !== fingerprint) + throw new Error(`${operation.resource.kind}_request_key_conflict`); + if (existing.state === "completed") return existing.resource.id; + if (!(await operation.recover(existing.resource.id))) { + throw new Error(`${operation.resource.kind}_request_outcome_unknown`); } - await writeJsonFileAtomic(file, { ...existing, state: "completed" }); - return existing.agentId; + await writeReceipt(file, { ...existing, state: "completed" }); + return existing.resource.id; } await operation.prepare?.(); - const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" }; - await writeJsonFileAtomic(file, receipt); + const receipt: Receipt = { fingerprint, resource: operation.resource, state: "pending" }; + await writeReceipt(file, receipt); try { - await operation.run(receipt.agentId); + await operation.run(receipt.resource.id); } catch (error) { - // Keyed creation has no initial prompt. Once its normal cleanup finished, - // absence of an agent confirms that retrying cannot duplicate one. - if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true }); + // Once creation cleanup finished, absence of its persisted resource + // confirms that retrying cannot duplicate it. + if (await operation.retrySafe?.(receipt.resource.id)) await rm(file, { force: true }); throw error; } - await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); - return receipt.agentId; + await writeReceipt(file, { ...receipt, state: "completed" }); + return receipt.resource.id; } } async function readReceipt(file: string): Promise { try { - return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + const stored = ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + return { + fingerprint: stored.fingerprint, + state: stored.state, + resource: + "agentId" in stored + ? { kind: "agent", id: stored.agentId } + : { kind: "workspace", id: stored.workspaceId }, + }; } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; throw error; } } +async function writeReceipt(file: string, receipt: Receipt): Promise { + await writeJsonFileAtomic(file, { + fingerprint: receipt.fingerprint, + state: receipt.state, + ...(receipt.resource.kind === "agent" + ? { agentId: receipt.resource.id } + : { workspaceId: receipt.resource.id }), + }); +} + function digest(value: unknown): string { return createHash("sha256") .update( diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index a32f5265c9..e5a0572581 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -364,7 +364,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 20f4143edf..94825738a8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { AgentRequests } from "./agent/requests/index.js"; +import type { RequestReceipts } from "./request-receipts/index.js"; import equal from "fast-deep-equal"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; @@ -147,6 +147,7 @@ import { checkoutLiteFromGitSnapshot, checkoutFromPersistedWorkspacePlacement, deriveWorkspaceDisplayName, + generateWorkspaceId, } from "./workspace-registry-model.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { @@ -458,7 +459,7 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; - agentRequests: Pick; + requestReceipts: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -780,7 +781,10 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; - private readonly agentRequests: Pick; + private readonly requestReceipts: Pick< + RequestReceipts, + "createAgent" | "createWorkspace" | "sendMessage" + >; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; constructor(options: SessionOptions) { @@ -855,7 +859,7 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; - this.agentRequests = options.agentRequests; + this.requestReceipts = options.requestReceipts; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; @@ -3826,7 +3830,7 @@ export class Session { throw new Error("Idempotent creation requires sending the initial prompt separately"); } const { requestId: _requestId, idempotencyKey, ...request } = msg; - const id = await this.agentRequests.create({ + const id = await this.requestReceipts.createAgent({ key: idempotencyKey, request, findAgent: async (agentId) => @@ -6346,23 +6350,67 @@ export class Session { request: Extract, ): Promise { try { - if (this.pluginRuntime) { - const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); - request = { ...transformed, type, requestId }; + const create = async (workspaceId?: string) => { + let creationRequest = request; + // Hooks belong to the operation: retries fingerprint the caller's input + // and must not rerun hooks or compare their potentially changing output. + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + creationRequest = { ...transformed, type, requestId }; + } + return creationRequest.source.kind === "directory" + ? this.handleWorkspaceCreateLocal(creationRequest, workspaceId) + : this.handleWorkspaceCreateWorktree(creationRequest, workspaceId); + }; + let descriptor: WorkspaceDescriptorPayload | undefined; + if (request.idempotencyKey !== undefined) { + const { requestId: _requestId, idempotencyKey, ...payload } = request; + const workspaceId = await this.requestReceipts.createWorkspace({ + key: idempotencyKey, + request: payload, + workspaceId: generateWorkspaceId(), + findWorkspace: async (id) => (await this.workspaceRegistry.get(id)) !== null, + create: async (id) => { + descriptor = await create(id); + }, + }); + if (!descriptor) { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) throw new Error("Previously created workspace no longer exists"); + descriptor = await this.describeWorkspaceRecordWithGitData(workspace); + } + } else { + descriptor = await create(); + } - if (request.source.kind === "directory") { - await this.handleWorkspaceCreateLocal(request); - return; - } - await this.handleWorkspaceCreateWorktree(request); + const workspace = await this.workspaceRegistry.get(descriptor.id); + this.emit({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: descriptor, + setupTerminalId: null, + ...(workspace?.untrustedSource + ? { + setupSkippedReason: formatWorkspaceAutomationBlockedMessage( + workspace.untrustedSource, + ), + } + : {}), + error: null, + }, + }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create workspace"; this.sessionLogger.error( { err: error, sourceKind: request.source.kind, requestId: request.requestId }, "Failed to create workspace", ); - const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; + const errorCode = + error instanceof WorkspaceProvisioningError || error instanceof SessionRequestError + ? error.code + : undefined; this.emit({ type: "workspace.create.response", payload: { @@ -6378,25 +6426,16 @@ export class Session { private async handleWorkspaceCreateLocal( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "directory") { - return; + throw new Error("Unexpected workspace source"); } const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: `Directory not found: ${cwd}`, - errorCode: "directory_not_found", - }, - }); - return; + throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`); } const explicitTitle = request.title?.trim() || null; @@ -6405,19 +6444,10 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, @@ -6441,29 +6471,24 @@ export class Session { { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } + return descriptor; } private async handleWorkspaceCreateWorktree( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "worktree") { - return; + throw new Error("Unexpected workspace source"); } const source = request.source; if (!source.cwd && !source.projectId) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd or projectId is required for a worktree-backed workspace", - errorCode: "source_required", - }, - }); - return; + throw new SessionRequestError( + "source_required", + "cwd or projectId is required for a worktree-backed workspace", + ); } const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); @@ -6471,6 +6496,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6487,26 +6513,11 @@ export class Session { ); const descriptor = await this.describeCreatedWorktreeWorkspace(result); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - ...(result.workspace.untrustedSource - ? { - setupSkippedReason: formatWorkspaceAutomationBlockedMessage( - result.workspace.untrustedSource, - ), - } - : {}), - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, ); + return descriptor; } private async handleOpenProjectRequest( @@ -7837,6 +7848,19 @@ export class Session { } } + private async prepareAgentMessage(agentId: string, text: string): Promise { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const stored = await this.agentStorage.get(agentId); + if (stored && !stored.title && !stored.lastUserMessageAt) { + const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text }); + if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -7887,16 +7911,12 @@ export class Session { } }; if (msg.messageId) { - await this.agentRequests.send({ + await this.requestReceipts.sendMessage({ agentId, messageId: msg.messageId, request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, prepare: async () => { - await ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); + await this.prepareAgentMessage(agentId, msg.text); }, send, }); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 581a142572..a78b2210b1 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -185,7 +185,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index c444d9ed24..69714acd16 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; // Invariant tests for cwd → workspace resolution on the open_project_request path. // Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`. // Run to see which invariants the current code already satisfies (green) and which @@ -89,7 +89,7 @@ function createHarness(input: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test", permissions: OWNER_PERMISSIONS, appVersion: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e8df2788cf..61212705a8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -642,7 +642,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -1003,7 +1003,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1157,7 +1157,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1295,7 +1295,7 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1565,7 +1565,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2048,7 +2048,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2217,7 +2217,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2377,7 +2377,7 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3649,7 +3649,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index a89e044883..ecd338ff4e 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -40,6 +40,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -61,7 +62,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -201,7 +202,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -211,7 +212,7 @@ export function createWorkspaceProvisioningService(deps: { await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, @@ -237,7 +238,7 @@ export function createWorkspaceProvisioningService(deps: { }); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: input.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "created_worktree", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 8e32c27cbe..bc6d01c203 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -100,7 +100,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a6f404e62a..15879f4b9e 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -281,14 +281,18 @@ export function createProviderSnapshotManagerStub(): { }; } -export function createAgentRequestsStub(): SessionOptions["agentRequests"] { +export function createRequestReceiptsStub(): SessionOptions["requestReceipts"] { return { - async create(input) { + async createAgent(input) { const agentId = randomUUID(); await input.create(agentId); return agentId; }, - send: (input) => input.send(), + async createWorkspace(input) { + await input.create(input.workspaceId); + return input.workspaceId; + }, + sendMessage: (input) => input.send(), }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8b0ab3879f..9760acf973 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,4 @@ -import { AgentRequests } from "./agent/requests/index.js"; +import { RequestReceipts } from "./request-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; @@ -529,7 +529,7 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; - private readonly agentRequests: AgentRequests; + private readonly requestReceipts: RequestReceipts; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -649,7 +649,7 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; - this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); + this.requestReceipts = new RequestReceipts(join(paseoHome, "agent-requests")); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -1425,7 +1425,7 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, - agentRequests: this.agentRequests, + requestReceipts: this.requestReceipts, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1646,6 +1646,7 @@ export class VoiceAssistantWebSocketServer { features: { ownedSubscriptions: true, agentRequestReceipts: true, + workspaceRequestReceipts: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index aca7046728..7fa159de43 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,4 +1,4 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { createRequestReceiptsStub } from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -208,7 +208,7 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ - agentRequests: createAgentRequestsStub(), + requestReceipts: createRequestReceiptsStub(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null,