fix(creation): prevent duplicate workspaces and agents on retries (#4442)

* fix(creation): prevent duplicate workspaces and agents on retries

* test(creation): cover plugin hooks across receipt replays

* fix(creation): recover draft retries without accepting partial workspaces

* refactor(creation): name receipt inputs and tag compatibility gate

* fix(file-observer): track files announced by native change events

* fix(creation): keep initial prompts in daemon-owned creation requests

* fix(creation): reuse connection-owned progress and recovery

* test(creation): adapt handoff gates to combined requests

* ci(desktop): avoid xz compression for smoke packages

* ci(desktop): pass valid RPM compression through FPM config

* refactor(creation): give legacy and modern requests one identity owner

* fix(creation): log observer failures without interrupting creation
This commit is contained in:
Mohamed Boudra
2026-09-14 16:55:42 +02:00
committed by GitHub
parent cdc669fde6
commit d63132a37d
55 changed files with 3702 additions and 976 deletions
+4 -1
View File
@@ -251,7 +251,10 @@ jobs:
- name: Build Linux desktop artifacts
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64
# Smoke package contents and installation without spending minutes on xz compression.
run: |
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
+30
View File
@@ -319,6 +319,36 @@ Example: adding a new enum value
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
sequence after the client process disappears. Explicitly requested IDs or receipts
that an old host cannot honor produce an unsupported error. Keep these adapters inside
the client package, as an exception to the default no-fallback feature policy.
Creation executes through the existing Session capabilities. Connection-owned delivery
controls observation only: detaching a socket or cleaning up its Session does not cancel
accepted creation. Updates require an explicit subscription and go only to that socket;
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
existing response contract.
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
+4
View File
@@ -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
@@ -9,7 +9,7 @@ import {
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
},
],
});
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -0,0 +1,130 @@
import type { createCreationScenario } from "../support/helpers/creation";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
creation,
}) => {
await creation.openWorkspaceForm(isolation);
await creation.submitRepeatedly("Create");
await creation.expectOneCreatedWorkspace();
});
}
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
await creation.expectPromptVisible();
await creation.expectAgentCount(1);
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
creation,
agentRetries,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start exactly one agent for this prompt.");
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
await creation.expectAgentCount(1);
});
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitPrompt("Name this new agent from its first prompt.");
await creation.expectPromptVisible();
await creation.expectAgentTitle("Name this new agent from its first prompt.");
});
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("a remounted draft reconciles its original creation after reconnect", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Deliver this initial prompt once.");
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
creation,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
await creation.expectPromptVisible();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
creation,
delayedCreation,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start the first agent.");
await creation.expectPromptVisible("Start the first agent.");
await delayedCreation.waitForDelayedCreatedStatus();
await creation.startAnotherDraft();
await creation.submitPrompt("Start the second agent.");
await creation.expectPromptVisible("Start the second agent.");
await delayedCreation.waitForDelayedCreatedStatus();
delayedCreation.release();
await creation.expectAgentCount(2);
});
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
creation,
startup,
delayedCreation,
}) => {
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
await creation.expectAgentStillStarting();
delayedCreation.expectSingleWorkspaceIntent();
delayedCreation.release();
await startup.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -1,9 +1,10 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, type Page } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
@@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "codex-mode-preferences",
@@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
startup,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false });
try {
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
await gotoAppShell(page);
@@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => {
).toBeVisible();
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
const createAgentRequest = await createAgentRecorder.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByText("Keep Codex full access selected globally.", { exact: true }).first(),
).toBeVisible();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
@@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => {
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
await startup.fail();
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,92 @@
import { writeFile, rm } from "node:fs/promises";
import path from "node:path";
import { copyPluginExample } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { test as base } from "./fixtures";
import {
createCreationScenario,
createPromptRetryScenario,
retryNextAgentCreation,
} from "./helpers/creation";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
if (request.reason !== "create") return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
await new Promise(resolve => setTimeout(resolve, 20));
command = await readFile(gate, "utf8").catch(() => "release");
}
if (command === "fail") {
await rm(gate, { force: true });
throw new Error("Creation startup failed for test");
}
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
fail: () => writeFile(gate, "fail"),
});
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
await provide(creation);
} finally {
await creation.cleanup();
}
},
promptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
delayedCreation: async ({ page }, provide) => {
const delayed = await delayBrowserAgentCreatedStatus(page);
try {
await provide(delayed);
} finally {
delayed.release();
}
},
});
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { loadSessionMessageReaders } from "./new-workspace";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
type WebSocketMessage = string | Buffer;
@@ -321,6 +322,7 @@ export async function holdRewindCompletion(
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
const frames = await loadSessionMessageReaders();
let createdAgentId: string | null = null;
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -350,19 +352,24 @@ export async function delayCreatedAgentInitialTailResponse(
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
const agentId = payload.agentId;
if (typeof agentId === "string") {
createdAgentId = agentId;
resolveCreatedAgent?.(agentId);
}
const sessionMessage = frames.server(message);
let createdId: unknown;
if (sessionMessage?.type === "agent.create.response") {
createdId = sessionMessage.payload.agent?.id;
} else if (
sessionMessage?.type === "status" &&
sessionMessage.payload.status === "agent_created"
) {
createdId = sessionMessage.payload.agentId;
}
if (typeof createdId === "string") {
createdAgentId = createdId;
resolveCreatedAgent?.(createdId);
}
if (sessionMessage?.type === "fetch_agent_timeline_response") {
const agentId = payload?.agentId;
const direction = payload?.direction;
const agentId = sessionMessage.payload.agentId;
const direction = sessionMessage.payload.direction;
if (
!delayedResponseSeen &&
typeof agentId === "string" &&
@@ -0,0 +1,277 @@
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import {
waitForSidebarHydration,
switchWorkspaceViaSidebar,
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */
export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) {
const frames = await loadSessionMessageReaders();
type AgentIntent = NonNullable<WorkspaceCreateRequest["agent"]>;
let resolveRequest!: (agent: AgentIntent) => void;
const request = new Promise<AgentIntent>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "workspace.create.request" && message.agent) {
resolveRequest(message.agent);
if (options.block) return;
}
server.send(frame);
});
server.onMessage((frame) => ws.send(frame));
});
return { waitForRequest: () => request };
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
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 }));
}
});
}
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
) {
pending.add(request.requestId);
}
});
socket.on("framereceived", ({ payload }) => {
const response = frames.server(payload);
if (
response?.type === "workspace.create.response" ||
response?.type === "agent.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);
},
};
}
export async function retryNextAgentCreation(page: Page) {
const frames = await loadSessionMessageReaders();
const retryIds = new Set<string>();
const results: Array<{ status: string; agentId?: string }> = [];
let repeated = false;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const request = frames.client(frame);
if (!repeated && request?.type === "agent.create.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({ type: "session", message: { ...request, requestId } }));
}
return;
}
server.send(frame);
});
server.onMessage((frame) => {
const response = frames.server(frame);
if (
response?.type === "agent.create.response" &&
retryIds.delete(response.payload.requestId)
) {
results.push({
status: response.payload.error ? "agent_create_failed" : "agent_created",
agentId: response.payload.agent?.id,
});
}
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);
},
};
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
},
async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
},
async startAnotherDraft() {
await createAgentTabFromMenu(page);
},
async submitPrompt(prompt: string, button = "Send message") {
await fillComposerDraft(page, prompt);
await page.getByRole("button", { name: button, exact: true }).click();
},
async submitRepeatedly(button: string, prompt?: string) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectPromptVisible(prompt?: string) {
const rows = page.getByTestId("user-message");
await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible();
},
async expectWorkspaceReadyBeforeAgentCompletion() {
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
async expectAgentStillStarting() {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
},
async expectAgentCount(count: number) {
await requests.settled();
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
).length,
)
.toBe(count);
},
async expectAgentTitle(title: string) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.title,
)
.toBe(title);
},
async evictAndReturnToDraft() {
const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
expect(draftTestId).not.toBeNull();
for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) {
const result = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: result.workspace.id,
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
.filter({ visible: true })
.click({ position: { x: 12, y: 13 } });
},
};
}
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response");
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fetch_agents_request"))
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
@@ -64,3 +64,10 @@ function loadAppVersion(): string {
}
return packageJson.version;
}
export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/protocol/messages")> {
const moduleUrl = pathToFileURL(
path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"),
).href;
return import(moduleUrl);
}
@@ -21,6 +21,7 @@ interface ClientRequest {
mode?: unknown;
path?: unknown;
agentId?: unknown;
messageId?: unknown;
text?: unknown;
activeTurnBehavior?: unknown;
}
@@ -134,15 +134,20 @@ export async function verifyDelayedWorkspaceCreation(
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
const agents = await client.fetchAgents();
expect(
agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent,
).toMatchObject({
cwd: created.workspaceDirectory,
});
expect(delay.agentRequests[0]).toMatchObject({
workspaceId: created.id,
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: created.workspaceDirectory,
cwd: repo.path,
},
attachments: [
expect.objectContaining({
@@ -1,8 +1,8 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { projectEquivalenceViewKey } from "./project-view-key";
import { expectWorkspaceHeader } from "./workspace-ui";
@@ -535,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown {
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
export async function loadSessionMessageReaders() {
// Use the same ESM module instance as the dynamically loaded daemon client.
const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas();
return {
client(message: WebSocketMessage) {
const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
server(message: WebSocketMessage) {
const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
};
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
expectSingleWorkspaceIntent(): void;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const creationRequests: SessionInboundMessage[] = [];
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
@@ -581,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus(
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "send_agent_message_request")
creationRequests.push(sessionMessage);
if (
sessionMessage?.type === "create_agent_request" ||
sessionMessage?.type === "agent.create.request" ||
sessionMessage?.type === "workspace.create.request"
) {
creationRequests.push(sessionMessage);
createRequestIds.add(sessionMessage.requestId);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
const sessionMessage = frames.server(message);
const isCreated =
(sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") ||
sessionMessage?.type === "agent.create.response" ||
sessionMessage?.type === "workspace.create.response";
if (
isCreated &&
typeof sessionMessage.payload.requestId === "string" &&
createRequestIds.has(sessionMessage.payload.requestId)
) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
@@ -621,26 +625,41 @@ export async function delayBrowserAgentCreatedStatus(
forward();
}
},
expectSingleWorkspaceIntent() {
expect(creationRequests).toHaveLength(1);
expect(creationRequests[0]).toMatchObject({
type: "workspace.create.request",
agent: { initialPrompt: expect.any(String) },
});
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
type AgentCreationIntent = Pick<
CreateAgentRequestMessage,
"config" | "initialPrompt" | "attachments"
>;
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly CreateAgentRequestMessage[];
agentRequests: readonly AgentCreationIntent[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
* Holds workspace readiness events and responses so a test can act while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: CreateAgentRequestMessage[] = [];
const agentRequests: AgentCreationIntent[] = [];
const creationKeys = new Set<string>();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
@@ -653,29 +672,25 @@ export async function delayBrowserWorkspaceCreatedResponse(
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request")
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage);
if (sessionMessage?.type === "workspace.create.request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
createRequestIds.add(sessionMessage.requestId);
if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey);
if (sessionMessage.agent) agentRequests.push(sessionMessage.agent);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "workspace.create.response" &&
typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
const sessionMessage = frames.server(message);
const isCreation =
(sessionMessage?.type === "workspace.create.response" &&
createRequestIds.has(sessionMessage.payload.requestId)) ||
(sessionMessage?.type === "workspace.create.update" &&
creationKeys.has(sessionMessage.payload.idempotencyKey));
if (isCreation && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
@@ -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<string> {
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
return await realpath(tmpdir());
}
async function configureRemote(input: {
@@ -1,9 +1,10 @@
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
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";
@@ -32,7 +33,6 @@ import type {
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
@@ -85,20 +85,23 @@ function buildChatDraftComposerArgs({
async function callWorkspaceCreation({
creationMethod,
connectedClient,
creationId,
worktreeSlug,
input,
}: {
creationMethod: "create_worktree" | "open_project";
connectedClient: DaemonClient;
input: { cwd: string };
creationId: string;
worktreeSlug: string;
input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] };
}) {
if (creationMethod === "create_worktree") {
return connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
});
}
return connectedClient.createWorkspace({
source: { kind: "directory", path: input.cwd },
idempotencyKey: creationId,
agent: input.agent,
source:
creationMethod === "create_worktree"
? { kind: "worktree", cwd: input.cwd, worktreeSlug }
: { kind: "directory", path: input.cwd },
});
}
@@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
}, [pendingWorkspaceSetup?.creationId]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
@@ -249,18 +252,25 @@ export function WorkspaceSetupDialog() {
}, [client, isConnected, t]);
const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
async (input: {
cwd: string;
attachments: MessagePayload["attachments"];
agent?: CreateWorkspaceRequestOptions["agent"];
onAgentCreated?: (agent: AgentSnapshotPayload) => void;
}) => {
if (!pendingWorkspaceSetup) {
throw new Error(t("workspaceSetup.errors.pendingRequired"));
}
if (createdWorkspace) {
if (createdWorkspace && !input.agent) {
return createdWorkspace;
}
const connectedClient = withConnectedClient();
const payload = await callWorkspaceCreation({
creationMethod: pendingWorkspaceSetup.creationMethod,
creationId: pendingWorkspaceSetup.creationId,
worktreeSlug: pendingWorkspaceSetup.worktreeSlug,
connectedClient,
input,
});
@@ -271,6 +281,7 @@ export function WorkspaceSetupDialog() {
);
}
if (payload.agent) input.onAgentCreated?.(payload.agent);
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
if (pendingWorkspaceSetup.creationMethod === "open_project") {
@@ -307,8 +318,7 @@ export function WorkspaceSetupDialog() {
try {
setPendingAction("chat");
setErrorMessage(null);
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
@@ -322,21 +332,30 @@ export function WorkspaceSetupDialog() {
}),
});
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceDirectory({
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired"));
let createdAgent: AgentSnapshotPayload | undefined;
const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
encodedImages: encodedImages ?? null,
workspaceDirectory: cwd,
workspaceId: "",
provider: composerState.selectedProvider,
});
const agent = await connectedClient.createAgent(
buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
encodedImages: encodedImages ?? null,
workspaceDirectory,
workspaceId: ensuredWorkspace.id,
provider: composerState.selectedProvider,
}),
);
const ensuredWorkspace = await ensureWorkspace({
cwd,
attachments,
agent: {
...agentInput,
clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`,
},
onAgentCreated: (value) => {
createdAgent = value;
},
});
if (!createdAgent) throw new Error("The daemon did not create the requested agent");
const agent = createdAgent;
if (!getIsStillActive()) {
return;
@@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() {
},
[
composerState,
pendingWorkspaceSetup,
getIsStillActive,
navigateAfterCreation,
serverId,
ensureWorkspace,
t,
toast,
withConnectedClient,
supportsForgeSearch,
],
);
+27 -10
View File
@@ -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<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer(
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
(attempt): DraftAgentMachineState<TDraftAgent> =>
@@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
},
);
const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]);
// Remounts can precede model hydration. Rebuild the preview when its inputs
// arrive, and observe the original request's failure through shared state.
const machine = useMemo<DraftAgentMachineState<TDraftAgent>>(() => {
if (pending?.lifecycle === "abandoned") {
return { tag: "draft", errorMessage: pending.errorMessage ?? "" };
}
if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) {
return prepareCreateAttempt(initialAttempt, buildDraftAgent);
}
return localMachine;
}, [pending, localMachine, initialAttempt, buildDraftAgent]);
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 +244,16 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
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 +268,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
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 +313,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
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 } : {}),
};
@@ -25,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 {
@@ -137,6 +137,7 @@ function resolveDraftModeId(input: {
}
async function submitDraftCreateRequest(input: {
draftId: string;
attempt: { clientMessageId: string };
text: string;
images?: UserMessageImageAttachment[];
@@ -196,14 +197,18 @@ async function submitDraftCreateRequest(input: {
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await requestWorkspaceDraftAgent(client, {
const imagesData = await encodeImages(images);
const options = {
idempotencyKey: input.draftId,
config,
workspaceId,
text,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
return {
agentId: result.id,
@@ -390,10 +395,7 @@ export function WorkspaceDraftAgentTab({
);
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
if (!pendingAutoSubmit || !pendingCreateAttempt) {
return null;
}
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
if (!pendingCreateAttempt) {
return null;
}
return {
@@ -407,7 +409,7 @@ export function WorkspaceDraftAgentTab({
? { attachments: pendingCreateAttempt.attachments }
: {}),
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
@@ -487,8 +489,13 @@ export function WorkspaceDraftAgentTab({
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
submitDraftCreateRequest({
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
text,
images,
@@ -501,7 +508,8 @@ export function WorkspaceDraftAgentTab({
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
});
},
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
+1 -47
View File
@@ -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) {
+160 -161
View File
@@ -1,3 +1,9 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactElement, RefObject } from "react";
@@ -70,7 +76,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 {
@@ -87,15 +92,10 @@ import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
CreatePaseoWorktreeInput,
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
@@ -130,10 +130,7 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./new-workspace/background-handoff";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
const ThemedFolderPlus = withUnistyles(FolderPlus);
@@ -767,6 +764,10 @@ type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -798,37 +799,22 @@ interface WorkspaceDraftSubmissionConfig {
target: WorkspaceTabTarget;
}
async function createAndMergeWorkspace(input: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
createInput: Parameters<
NonNullable<ReturnType<typeof useHostRuntimeClient>>["createPaseoWorktree"]
>[0];
mergeWorkspaces: (
serverId: string,
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
) => void;
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const payload = await input.client.createPaseoWorktree(input.createInput);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? input.createFailedMessage);
}
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
const workspaceForInitialMerge = input.createInput.firstAgentContext
? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() }
: normalizedWorkspace;
input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]);
return normalizedWorkspace;
interface WorkspaceCreationResult {
workspace: ReturnType<typeof normalizeWorkspaceDescriptor>;
agent?: AgentSnapshotPayload;
}
async function createMultiplicityWorkspace(input: {
idempotencyKey: string;
worktreeSlug: string;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isolation: "local" | "worktree";
project: HostProjectListItem;
sourceDirectory: string;
checkoutRequest: PickerCheckoutRequest | undefined;
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
prompt: string;
attachments: AgentAttachment[];
mergeWorkspaces: (
@@ -837,7 +823,7 @@ async function createMultiplicityWorkspace(input: {
) => void;
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
}): Promise<WorkspaceCreationResult> {
const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree";
@@ -846,12 +832,15 @@ async function createMultiplicityWorkspace(input: {
attachments: input.attachments,
});
const payload = await input.client.createWorkspace({
idempotencyKey: input.idempotencyKey,
agent: input.agent,
onEvent: input.onEvent,
source: isWorktree
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId,
worktreeSlug: createNameId(),
worktreeSlug: input.worktreeSlug,
...input.checkoutRequest,
}
: {
@@ -869,7 +858,7 @@ async function createMultiplicityWorkspace(input: {
? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() }
: normalizedWorkspace;
input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]);
return normalizedWorkspace;
return { workspace: normalizedWorkspace, agent: payload.agent };
}
interface CreateChatAgentInput {
@@ -881,7 +870,9 @@ interface CreateChatAgentInput {
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
}) => Promise<WorkspaceCreationResult>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -945,7 +936,18 @@ function buildComposerInitialValues(input: {
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const pendingWorkspaceSubmissions = new Map<string, Promise<SubmitOutcome>>();
function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const key = JSON.stringify([input.serverId, input.draftId]);
const pending = pendingWorkspaceSubmissions.get(key);
if (pending) return pending;
const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input));
pendingWorkspaceSubmissions.set(key, submission);
void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined);
return submission;
}
async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const { text, attachments, cwd } = payload;
@@ -963,36 +965,86 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
format: attachmentSubmitFormat,
});
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const ensuredWorkspace = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
});
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
provider,
composerState,
});
return await submitWorkspaceDraft({
clearConsumedDraft,
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
text,
attachments,
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const images = await encodeImages(wirePayload.images);
let navigated = false;
let outcome: SubmitOutcome = "background";
const initialAgent: NonNullable<CreateWorkspaceRequestOptions["agent"]> = {
config: {
provider,
cwd,
modeId: composerState.selectedMode || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
},
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
if (!input.isStillOnCreateScreen()) return;
const workspace = normalizeWorkspaceDescriptor(snapshot.workspace);
getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [
{ ...workspace, status: "running" },
]);
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: workspace.workspaceDirectory,
provider,
composerState,
});
outcome = submitWorkspaceDraft({
clearConsumedDraft,
draftKey: input.draftKey,
draftContextScopeKey: input.draftContextScopeKey,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
serverId,
clearDraft,
draftId: input.draftId,
initialSetup,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
text,
attachments,
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
agentCreation,
});
},
});
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute({
...initialAgent,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initialAgent.clientMessageId,
images: request.images,
attachments: request.attachments,
}),
};
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
}
function buildComposerConfig(input: {
@@ -1054,7 +1106,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
};
}
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
const {
serverId,
clearDraft,
@@ -1068,7 +1120,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
initialSetup,
} = input;
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = generateMessageId();
const clientMessageId = `${draftId}:initial-message`;
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
@@ -1082,34 +1134,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
composerState,
initialSetup,
});
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
if (!input.isStillOnCreateScreen()) {
await createWorkspaceAgentInBackground({
clearConsumedDraft: input.clearConsumedDraft,
createAgent: () =>
requestWorkspaceDraftAgent(input.resolveClient(), {
workspaceId,
config: buildWorkspaceDraftAgentConfig({
provider: submission.provider,
cwd: submission.cwd,
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId
? { thinkingOptionId: submission.thinkingOptionId }
: {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
}),
text: text.trim(),
clientMessageId,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
}),
});
return "background";
}
useCreateFlowStore.getState().setPending({
const started = useCreateFlowStore.getState().trySetPending({
serverId,
draftId,
workspaceId,
@@ -1120,6 +1145,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
});
if (!started) return "navigated";
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId,
@@ -1135,6 +1161,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
agentCreation: input.agentCreation,
});
clearDraft("sent");
navigateToWorkspace({
@@ -1636,10 +1663,14 @@ export function NewWorkspaceScreen({
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= 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<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [creationResult, setCreationResult] = useState<
WorkspaceCreationResult | { workspace: null }
>({ workspace: null });
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
@@ -1696,7 +1727,7 @@ export function NewWorkspaceScreen({
return () => clearTimeout(timer);
}, [pickerSearchQuery]);
const workspace = createdWorkspace;
const { workspace } = creationResult;
const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId);
const {
@@ -1997,45 +2028,17 @@ export function NewWorkspaceScreen({
setProjectPickerOpen(nextOpen);
}, []);
const buildCreateWorktreeInput = useCallback(
(input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
checkoutRequest: PickerCheckoutRequest | undefined;
}): CreatePaseoWorktreeInput => {
if (!selectedProject) {
throw new Error("Choose a project");
}
if (!selectedSourceDirectory) {
throw new Error("Choose a host for this project");
}
const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) {
throw new Error("Project is not available on the selected host");
}
return {
cwd: selectedSourceDirectory,
projectId: hostProjectId,
worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}),
...input.checkoutRequest,
};
},
[selectedProject, selectedServerId, selectedSourceDirectory],
);
const ensureWorkspace = useCallback(
async (input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
}) => {
if (createdWorkspace) {
return createdWorkspace;
if (creationResult.workspace) {
return creationResult;
}
if (!selectedProject) {
throw new Error("Choose a project");
@@ -2058,33 +2061,29 @@ export function NewWorkspaceScreen({
selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate),
)
: undefined;
const normalizedWorkspace = supportsWorkspaceMultiplicity
? await createMultiplicityWorkspace({
client: connectedClient,
isolation: effectiveIsolation,
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
checkoutRequest,
withInitialAgent: input.withInitialAgent,
prompt: input.prompt,
attachments: input.attachments,
mergeWorkspaces,
serverId: selectedServerId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
})
: await createAndMergeWorkspace({
client: connectedClient,
createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }),
mergeWorkspaces,
serverId: selectedServerId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
});
setCreatedWorkspace(normalizedWorkspace);
const normalizedWorkspace = await createMultiplicityWorkspace({
idempotencyKey: creationIdentity.draftId,
worktreeSlug: creationIdentity.worktreeSlug,
client: connectedClient,
isolation: createsWorktree ? "worktree" : "local",
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
checkoutRequest,
withInitialAgent: input.withInitialAgent,
prompt: input.prompt,
attachments: input.attachments,
agent: input.agent,
onEvent: input.onEvent,
mergeWorkspaces,
serverId: selectedServerId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
});
setCreationResult(normalizedWorkspace);
return normalizedWorkspace;
},
[
buildCreateWorktreeInput,
createdWorkspace,
creationIdentity,
creationResult,
effectiveIsolation,
mergeWorkspaces,
queryClient,
@@ -2109,7 +2108,7 @@ export function NewWorkspaceScreen({
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
@@ -2136,7 +2135,7 @@ export function NewWorkspaceScreen({
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId,
draftId: creationIdentity.draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
@@ -2159,7 +2158,7 @@ export function NewWorkspaceScreen({
[
composerState,
draftContextScopeKey,
draftId,
creationIdentity,
chatDraft.clear,
draftKey,
ensureWorkspace,
@@ -2186,7 +2185,7 @@ export function NewWorkspaceScreen({
prompt: terminalPromptText,
profile: selectedTerminalProfile,
profileName: selectedTerminalProfile?.name,
ensureWorkspace,
ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace,
createTerminal: async (input) => {
const connectedClient = withConnectedClient();
const createdTerminal = await connectedClient.createTerminal(
@@ -6,6 +6,33 @@ describe("create-flow-store", () => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("accepts a retry after failure while rejecting repeated active or completed submissions", () => {
const store = useCreateFlowStore.getState();
const submission = {
draftId: "draft-retry",
serverId: "server-1",
workspaceId: "workspace-1",
agentId: null,
clientMessageId: "draft-retry:initial-message",
text: "hello",
timestamp: 1,
};
expect(store.trySetPending(submission)).toBe(true);
expect(store.trySetPending(submission)).toBe(false);
store.markLifecycle({
draftId: submission.draftId,
lifecycle: "abandoned",
errorMessage: "Disconnected",
});
expect(store.trySetPending(submission)).toBe(true);
expect(useCreateFlowStore.getState().pendingByDraftId[submission.draftId]).toEqual({
...submission,
lifecycle: "active",
});
store.markLifecycle({ draftId: submission.draftId, lifecycle: "sent" });
expect(store.trySetPending(submission)).toBe(false);
});
it("tracks lifecycle transitions explicitly", () => {
const store = useCreateFlowStore.getState();
store.setPending({
+17 -5
View File
@@ -13,13 +13,14 @@ export interface PendingCreateAttempt {
text: string;
timestamp: number;
lifecycle: CreateFlowLifecycleState;
errorMessage?: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export function isActiveCreateFlowForDraft(input: {
pending: PendingCreateAttempt | null | undefined;
serverId: string;
serverId: string | null;
draftId: string | null | undefined;
}): boolean {
const draftId = input.draftId?.trim();
@@ -34,15 +35,20 @@ export function isActiveCreateFlowForDraft(input: {
interface CreateFlowState {
pendingByDraftId: Record<string, PendingCreateAttempt>;
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
trySetPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => boolean;
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;
clearAll: () => void;
}
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
export const useCreateFlowStore = create<CreateFlowState>((set, get) => ({
pendingByDraftId: {},
setPending: (pending) =>
set((state) => ({
@@ -54,6 +60,12 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
},
},
})),
trySetPending: (pending) => {
const existing = get().pendingByDraftId[pending.draftId];
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false;
get().setPending(pending);
return true;
},
updateAgentId: ({ draftId, agentId }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
@@ -67,7 +79,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
},
};
}),
markLifecycle: ({ draftId, lifecycle }) =>
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
@@ -76,7 +88,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle },
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
@@ -0,0 +1,71 @@
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, {
cwd: fileURLToPath(new URL("../../", import.meta.url)),
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"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,9 +1,16 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
draftId: string;
@@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -82,9 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
if (!state.setupByDraftId[normalizedDraftId]) return state;
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId };
return { setupByDraftId, creationByDraftId };
});
},
consumePending: (input) => {
@@ -96,8 +109,10 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
if (!matchesPendingSubmission(state.pendingByDraftId[input.draftId], input)) {
return state;
}
const { [input.draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
// Expo miscompiles computed member-key rest omissions; test the emitted code.
const pendingByDraftId = { ...state.pendingByDraftId };
delete pendingByDraftId[input.draftId];
return { pendingByDraftId };
});
return pending;
},
@@ -145,6 +145,8 @@ describe("workspace-setup-store", () => {
sourceWorkspaceId: "42",
displayName: "project",
creationMethod: "open_project",
creationId: expect.any(String),
worktreeSlug: expect.any(String),
});
});
@@ -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<string, WorkspaceSetupSnapshot>;
requestedKeys: Set<string>;
surfacedFailedSetupKeys: Set<string>;
@@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
requestedKeys: new Set(),
surfacedFailedSetupKeys: new Set(),
beginWorkspaceSetup: (value) => {
set({ pendingWorkspaceSetup: value });
set({
pendingWorkspaceSetup: {
...value,
creationId: generateDraftId(),
worktreeSlug: createNameId(),
},
});
},
clearWorkspaceSetup: () => {
set({ pendingWorkspaceSetup: null });
+234
View File
@@ -0,0 +1,234 @@
import { expect, test } from "vitest";
import type {
AgentSnapshotPayload,
CreationSnapshot,
WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { CreationClient, type CreationResult } from "./index.js";
const workspace: WorkspaceDescriptorPayload = {
id: "wks_0123456789abcdef",
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/project",
workspaceDirectory: "/project/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "Worktree",
status: "running",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
scripts: [],
};
const agent: AgentSnapshotPayload = {
id: "00000000-0000-4000-8000-000000000001",
workspaceId: workspace.id,
provider: "codex",
cwd: "/project/worktree",
model: null,
createdAt: "2026-09-11T00:00:00Z",
updatedAt: "2026-09-11T00:00:00Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Start once",
labels: {},
};
function fixture(modern: boolean) {
const requests: Array<{ kind: string; input: Record<string, unknown> }> = [];
const legacy: Array<{ kind: string; input: unknown }> = [];
let resolve!: (result: CreationResult) => void;
const result = new Promise<CreationResult>((done) => {
resolve = done;
});
const client = new CreationClient({
supports: () => modern,
requestId: () => "generated-key",
request: async (kind, input) => {
requests.push({ kind, input });
return result;
},
observe: () => () => {},
legacyWorkspace: async (input) => {
legacy.push({ kind: "workspace", input });
return { requestId: "legacy-workspace", workspace, error: null, setupTerminalId: null };
},
legacyAgent: async (input) => {
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",
source: { kind: "directory" as const, path: "/project" },
agent: {
config: { provider: "codex", cwd: "/project" },
initialPrompt: "Start once",
clientMessageId: "message-one",
},
};
return { client, requests, legacy, resolve, input };
}
test("duplicate client submissions join one complete intent and cumulative updates never regress", async () => {
const f = fixture(true);
const phases: string[] = [];
const first = f.client.createWorkspace({
...f.input,
onEvent: (snapshot) => phases.push(snapshot.phase),
});
const duplicate = f.client.createWorkspace(f.input);
expect(duplicate).toBe(first);
expect(f.requests).toEqual([{ kind: "workspace", input: { ...f.input, subscribe: true } }]);
await expect(
f.client.createWorkspace({
...f.input,
agent: { ...f.input.agent, initialPrompt: "Different intent" },
}),
).rejects.toThrow("workspace_request_key_conflict");
const snapshot: CreationSnapshot = {
kind: "workspace",
idempotencyKey: "intent-one",
revision: 2,
phase: "workspace_ready",
workspaceId: workspace.id,
agentId: agent.id,
workspace,
error: null,
};
f.client.receive(snapshot);
f.client.receive({ ...snapshot, revision: 1, phase: "accepted" });
expect(phases).toEqual(["workspace_ready"]);
const late: string[] = [];
f.client.createWorkspace({ ...f.input, onEvent: (update) => late.push(update.phase) });
expect(late).toEqual(["workspace_ready"]);
f.resolve({
workspace,
agent,
error: null,
setupSkippedReason: "Untrusted source",
requestId: "response-one",
});
expect(await first).toMatchObject({
workspace,
agent,
setupSkippedReason: "Untrusted source",
requestId: "response-one",
});
expect(await duplicate).toEqual(await first);
expect(f.legacy).toEqual([]);
f.client.close();
});
test("legacy adaptation keeps workspace, agent and initial prompt sequencing inside the client", async () => {
const f = fixture(false);
const phases: string[] = [];
const result = await f.client.createWorkspace({
...f.input,
onEvent: (snapshot) => phases.push(snapshot.phase),
});
expect(result).toMatchObject({ workspace, agent, error: null });
expect(phases).toEqual(["workspace_ready"]);
expect(f.legacy).toEqual([
{ kind: "workspace", input: { idempotencyKey: "intent-one", source: f.input.source } },
{
kind: "agent",
input: {
config: { provider: "codex", cwd: workspace.workspaceDirectory },
workspaceId: workspace.id,
idempotencyKey: "intent-one:agent",
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();
});
test("legacy daemons cannot silently replace caller-selected IDs", async () => {
const f = fixture(false);
await expect(f.client.createWorkspace({ ...f.input, workspaceId: workspace.id })).rejects.toThrow(
"caller-selected",
);
await expect(
f.client.createAgent({ agentId: agent.id, config: f.input.agent.config }),
).rejects.toThrow("caller-selected");
expect(f.legacy).toEqual([]);
f.client.close();
});
test("keyed creation with an empty prompt works on legacy daemons without sending an empty message", async () => {
const f = fixture(false);
await f.client.createAgent({
idempotencyKey: "empty-agent",
config: f.input.agent.config,
initialPrompt: "",
});
expect(f.legacy).toEqual([
{ kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } },
]);
f.client.close();
});
test.each([
["/repo/project", "/repo/project/../outside"],
["C:\\repo\\project", "C:\\repo\\project\\..\\outside"],
])("legacy creation rejects escaping %s before creating a workspace", async (source, cwd) => {
const f = fixture(false);
await expect(
f.client.createWorkspace({
...f.input,
source: { kind: "directory", path: source },
agent: { ...f.input.agent, config: { provider: "codex", cwd } },
}),
).rejects.toThrow("inside the workspace source");
expect(f.legacy).toEqual([]);
f.client.close();
});
test.each([
{ source: { kind: "directory" as const, path: "/project" }, cwd: "/project/src" },
{ source: { kind: "directory" as const, path: "C:\\project" }, cwd: "C:\\project\\src" },
{ source: { kind: "worktree" as const, projectId: "project" }, cwd: "/project/src" },
])("legacy creation preserves the agent subdirectory for $source", async ({ source, cwd }) => {
const f = fixture(false);
await f.client.createWorkspace({
...f.input,
source,
agent: { config: { provider: "codex", cwd } },
});
expect(f.legacy[1]).toMatchObject({
kind: "agent",
input: { workspaceId: workspace.id, config: { cwd: "/project/worktree/src" } },
});
f.client.close();
});
+276
View File
@@ -0,0 +1,276 @@
import type {
AgentSnapshotPayload,
CreationSnapshot,
WorkspaceCreateResponse,
} from "@getpaseo/protocol/messages";
import type { CreateAgentRequestOptions, CreateWorkspaceRequestOptions } from "../daemon-client.js";
type Kind = CreationSnapshot["kind"];
type Observer = (snapshot: CreationSnapshot) => void;
export interface CreationResult {
agent?: AgentSnapshotPayload | null;
workspace?: WorkspaceCreateResponse["payload"]["workspace"];
creation?: CreationSnapshot;
error: string | null;
errorCode?: string;
setupTerminalId?: string | null;
setupSkippedReason?: string;
requestId?: string;
}
interface Dependencies {
supports: () => boolean;
requestId: () => string;
request: (kind: Kind, input: Record<string, unknown>) => Promise<CreationResult>;
observe: (
kind: Kind,
key: string,
next: (snapshot: CreationSnapshot | null) => void,
error: (error: unknown) => void,
) => () => void;
legacyAgent: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
legacyWorkspace: (
input: CreateWorkspaceRequestOptions,
) => Promise<WorkspaceCreateResponse["payload"]>;
sendMessage: (
id: string,
text: string,
options: {
messageId: string;
images?: CreateAgentRequestOptions["images"];
attachments?: CreateAgentRequestOptions["attachments"];
},
) => Promise<unknown>;
}
interface Operation {
kind: Kind;
key: string;
fingerprint: string;
input: Record<string, unknown>;
observers: Set<Observer>;
snapshot?: CreationSnapshot;
result: Promise<CreationResult>;
resolve: (result: CreationResult) => void;
reject: (error: Error) => void;
settled: boolean;
modern: boolean;
recovering: boolean;
stopObserving?: () => void;
}
/** Client-owned observation and compatibility. UI callbacks never advance creation. */
export class CreationClient {
private readonly operations = new Map<string, Operation>();
constructor(private readonly deps: Dependencies) {}
createAgent(input: CreateAgentRequestOptions): Promise<CreationResult> {
const { onEvent, ...request } = input;
return this.start("agent", request, onEvent, () => this.legacyAgent(request));
}
createWorkspace(input: CreateWorkspaceRequestOptions): Promise<CreationResult> {
const { onEvent, ...request } = input;
return this.start("workspace", request, onEvent, async (operation) => {
if (request.workspaceId || request.agent?.agentId)
throw new Error("Update the host to use caller-selected creation IDs.");
const { agent, ...workspaceInput } = request;
const sourceCwd =
request.source.kind === "directory" ? request.source.path : request.source.cwd;
const relativeCwd =
agent && sourceCwd ? relativeDirectory(agent.config!.cwd, sourceCwd) : undefined;
const workspace = await this.deps.legacyWorkspace(workspaceInput);
if (workspace.error || !workspace.workspace) return workspace;
this.receive({
kind: "workspace",
idempotencyKey: operation.key,
revision: 1,
phase: "workspace_ready",
workspaceId: workspace.workspace.id,
agentId: null,
workspace: workspace.workspace,
setupSkippedReason: workspace.setupSkippedReason,
error: null,
});
if (!agent) return workspace;
const cwd = `${workspace.workspace.workspaceDirectory!.replace(/[\\/]+$/, "")}${
relativeCwd ?? relativeDirectory(agent.config!.cwd, workspace.workspace.projectRootPath)
}`;
const created = await this.legacyAgent({
...agent,
config: { ...agent.config!, cwd },
workspaceId: workspace.workspace.id,
idempotencyKey: `${operation.key}:agent`,
});
return { ...workspace, agent: created.agent };
});
}
private async legacyAgent(input: CreateAgentRequestOptions): Promise<CreationResult> {
if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs.");
// 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 };
}
private start(
kind: Kind,
input: Record<string, unknown>,
observer: Observer | undefined,
legacy: (operation: Operation) => Promise<CreationResult>,
): Promise<CreationResult> {
const key =
typeof input.idempotencyKey === "string" ? input.idempotencyKey : this.deps.requestId();
const identity = JSON.stringify([kind, key]);
const { requestId: _requestId, ...intent } = input;
const fingerprint = JSON.stringify(intent, (_key, value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
: value,
);
const previous = this.operations.get(identity);
if (previous) {
if (previous.fingerprint !== fingerprint)
return Promise.reject(new Error(`${kind}_request_key_conflict`));
if (observer) {
previous.observers.add(observer);
if (previous.snapshot) this.notify(observer, previous.snapshot);
}
return previous.result;
}
let resolve!: Operation["resolve"];
let reject!: Operation["reject"];
const result = new Promise<CreationResult>((done, fail) => {
resolve = done;
reject = fail;
});
const operation: Operation = {
kind,
key,
input: { ...input, idempotencyKey: key },
fingerprint,
observers: new Set(observer ? [observer] : []),
result,
resolve,
reject,
settled: false,
modern: this.deps.supports(),
recovering: false,
};
this.operations.set(identity, operation);
if (operation.modern) void this.submit(operation);
else
void legacy(operation).then(
(value) => this.finish(operation, value),
(error) => this.fail(operation, error),
);
return result;
}
private async submit(operation: Operation): Promise<void> {
try {
const result = await this.deps.request(operation.kind, {
...operation.input,
subscribe: true,
});
if (result.creation) this.receive(result.creation);
this.finish(operation, result);
} catch (error) {
if (
operation.settled ||
(error instanceof Error && "code" in error && error.code === "DAEMON_CONNECTION_LOST")
)
return;
this.fail(operation, error);
}
}
receive(snapshot: CreationSnapshot): void {
const operation = this.operations.get(JSON.stringify([snapshot.kind, snapshot.idempotencyKey]));
if (!operation || (operation.snapshot && operation.snapshot.revision > snapshot.revision))
return;
if (operation.snapshot?.revision !== snapshot.revision) {
operation.snapshot = snapshot;
for (const observer of operation.observers) this.notify(observer, snapshot);
}
if (operation.recovering && (snapshot.phase === "completed" || snapshot.phase === "failed")) {
this.finish(operation, {
agent: snapshot.agent,
workspace: snapshot.workspace,
creation: snapshot,
setupSkippedReason: snapshot.setupSkippedReason,
error: snapshot.error,
errorCode: snapshot.errorCode,
});
}
}
reconnect(): void {
for (const operation of this.operations.values()) {
if (operation.settled || !operation.modern || operation.recovering) continue;
operation.recovering = true;
operation.stopObserving = this.deps.observe(
operation.kind,
operation.key,
(snapshot) => {
if (!snapshot) void this.submit(operation);
else {
// Re-subscription is authoritative even if its revision matches the last live event.
this.receive(snapshot);
}
},
(error) => this.fail(operation, error),
);
}
}
close(): void {
for (const operation of this.operations.values())
if (!operation.settled) this.fail(operation, new Error("Daemon client closed"));
this.operations.clear();
}
private finish(operation: Operation, result: CreationResult): void {
if (operation.settled) return;
operation.settled = true;
operation.stopObserving?.();
operation.resolve(result);
operation.observers.clear();
this.operations.delete(JSON.stringify([operation.kind, operation.key]));
}
private fail(operation: Operation, error: unknown): void {
if (operation.settled) return;
operation.settled = true;
operation.stopObserving?.();
operation.reject(error instanceof Error ? error : new Error(String(error)));
operation.observers.clear();
this.operations.delete(JSON.stringify([operation.kind, operation.key]));
}
private notify(observer: Observer, snapshot: CreationSnapshot): void {
try {
observer(snapshot);
} catch {
/* Observation does not control execution. */
}
}
}
function relativeDirectory(cwd: string, source: string): string {
const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, "");
const normalized = normalize(cwd),
root = normalize(source);
// Parent traversal must never survive mapping into the newly created checkout.
if ([normalized, root].some((path) => path.split("/").includes("..")))
throw new Error("Agent directory must be inside the workspace source");
if (normalized === root) return "";
if (!normalized.startsWith(`${root}/`))
throw new Error("Agent directory must be inside the workspace source");
return normalized.slice(root.length);
}
+166
View File
@@ -6323,3 +6323,169 @@ test("wire snapshot callers own expansion and receive hash references unchanged"
);
expect(await request).toEqual(body);
});
test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-contract",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } });
await connected;
const creation = client.createAgent({
idempotencyKey: "draft-one",
provider: "codex",
cwd: "/project",
workspaceId: "wks_0123456789abcdef",
initialPrompt: "Start once",
clientMessageId: "first-message",
});
void creation.catch(() => undefined);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "agent.create.request",
idempotencyKey: "draft-one",
initialPrompt: "Start once",
clientMessageId: "first-message",
});
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.response",
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
}),
);
await expect(creation).rejects.toThrow("provider unavailable");
expect(transport.sent).toHaveLength(1);
});
test("creation lifecycle acknowledgement reaches the observer before the final response", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-progress",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({ features: { creationLifecycle: true } });
await connected;
const phases: string[] = [];
const creation = client.createAgent({
provider: "codex",
cwd: "/project",
idempotencyKey: "observe-one",
onEvent: (snapshot) => phases.push(snapshot.phase),
});
void creation.catch(() => undefined);
const request = parseSentFrame(transport.sent[0]);
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.update",
payload: {
kind: "agent",
idempotencyKey: "observe-one",
revision: 0,
phase: "accepted",
workspaceId: null,
agentId: "00000000-0000-4000-8000-000000000001",
error: null,
},
}),
);
expect(phases).toEqual(["accepted"]);
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.response",
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
}),
);
await expect(creation).rejects.toThrow("provider unavailable");
});
test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-reconnect",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const features = { creationLifecycle: true, ownedSubscriptions: true };
const connect = client.connect();
transport.triggerOpen({ features });
await connect;
const phases: string[] = [];
const creation = client.createWorkspace({
idempotencyKey: "reconnect-one",
source: { kind: "directory", path: "/project" },
onEvent: (snapshot) => phases.push(snapshot.phase),
});
void creation.catch(() => undefined);
const snapshot = {
kind: "workspace",
idempotencyKey: "reconnect-one",
phase: "accepted",
revision: 0,
workspaceId: "wks_0123456789abcdef",
agentId: null,
error: null,
};
for (const subscriptionId of ["first-connection", "second-connection"]) {
transport.triggerClose();
const reconnected = client.connect();
transport.triggerOpen({ features });
await reconnected;
await expect.poll(() => transport.sent.length).toBe(1);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "creation.subscribe.request",
idempotencyKey: "reconnect-one",
});
transport.triggerMessage(
wrapSessionMessage({
type: "creation.subscribe.response",
payload: {
requestId: request.requestId,
subscriptionId,
snapshot,
error: null,
},
}),
);
}
transport.triggerMessage(
wrapSessionMessage({
type: "workspace.create.update",
payload: {
...snapshot,
subscriptionId: "second-connection",
revision: 1,
phase: "failed",
error: "Provisioning failed",
},
}),
);
expect(await creation).toMatchObject({ error: "Provisioning failed" });
await expect.poll(() => transport.sent.length).toBe(2);
const release = parseSentFrame(transport.sent.at(-1));
expect(release).toMatchObject({
type: "subscription.release.request",
subscriptionId: "second-connection",
});
transport.triggerMessage(
wrapSessionMessage({
type: "subscription.release.response",
payload: {
requestId: release.requestId,
subscriptionId: "second-connection",
},
}),
);
expect(phases).toEqual(["accepted", "failed"]);
});
+116 -5
View File
@@ -6,6 +6,8 @@ import {
DEFAULT_CLIENT_CAPABILITIES,
type TimelineSubscription,
} from "./connection/index.js";
import { CreationClient } from "./creation/index.js";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
@@ -370,6 +372,8 @@ export interface AgentAttentionRequiredNotification {
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
export interface CreateAgentRequestOptions extends AgentConfigOverrides {
agentId?: string;
onEvent?: (snapshot: CreationSnapshot) => void;
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
@@ -392,6 +396,20 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
labels?: Record<string, string>;
}
export interface CreateWorkspaceRequestOptions {
source: WorkspaceCreateRequest["source"];
title?: string;
idempotencyKey?: string;
workspaceId?: string;
agent?: Omit<
CreateAgentRequestOptions,
"workspaceId" | "onEvent" | "worktree" | "git" | "worktreeName" | "idempotencyKey" | "requestId"
>;
onEvent?: (snapshot: CreationSnapshot) => void;
firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"];
requestId?: string;
}
export interface CreatePaseoWorktreeInput extends Pick<
CreatePaseoWorktreeRequest,
| "cwd"
@@ -1416,6 +1434,7 @@ export class DaemonClient {
this.providerSnapshotUpdates.clear();
this.clearWaiters(new Error("Daemon client closed"));
await this.owned.close();
this.creations.close();
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.rejectPingProbe(new Error("Daemon client closed"));
this.terminalStreams.clearSlots();
@@ -2656,7 +2675,58 @@ export class DaemonClient {
// Agent Lifecycle
// ============================================================================
private readonly creations = new CreationClient({
supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true,
requestId: () => this.createRequestId(),
request: (kind, input) =>
kind === "workspace"
? this.sendCorrelatedSessionRequest({
requestId: input.requestId as string | undefined,
message: { ...input, type: "workspace.create.request" },
responseType: "workspace.create.response",
timeout: 0,
})
: this.sendCorrelatedSessionRequest({
requestId: input.requestId as string | undefined,
message: { ...input, type: "agent.create.request" },
responseType: "agent.create.response",
timeout: 0,
}),
observe: (kind, idempotencyKey, next, error) => {
const observation = this.observe("creation.subscribe.response", {
type: "creation.subscribe.request",
kind,
idempotencyKey,
});
observation.subscribe({
snapshot: (result) => next(result.snapshot),
update: (message) => {
if (message.type === "workspace.create.update" || message.type === "agent.create.update")
next(message.payload);
},
error,
});
return () => {
void observation.release().catch(error);
};
},
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<AgentSnapshotPayload> {
const result = await this.creations.createAgent({
...options,
config: resolveAgentConfig(options),
});
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
return result.agent;
}
private async createLegacyAgent(
options: CreateAgentRequestOptions,
): Promise<AgentSnapshotPayload> {
if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts();
const requestId = this.createRequestId(options.requestId);
const config = resolveAgentConfig(options);
@@ -4334,18 +4404,43 @@ export class DaemonClient {
}
async createWorkspace(
input: {
source: WorkspaceCreateRequest["source"];
title?: string;
firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"];
},
input: CreateWorkspaceRequestOptions,
requestId?: string,
): Promise<WorkspaceCreatePayload> {
const resolvedRequestId = this.createRequestId(requestId ?? input.requestId);
const result = await this.creations.createWorkspace({
...input,
requestId: resolvedRequestId,
...(input.agent
? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
: {}),
});
return {
...result,
workspace: result.workspace ?? null,
agent: result.agent ?? undefined,
setupTerminalId: result.setupTerminalId ?? null,
requestId: result.requestId ?? resolvedRequestId,
};
}
private async createLegacyWorkspace(
input: CreateWorkspaceRequestOptions,
requestId?: string,
): Promise<WorkspaceCreatePayload> {
// 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 } : {}),
...(input.title !== undefined ? { title: input.title } : {}),
...(input.firstAgentContext !== undefined
? { firstAgentContext: input.firstAgentContext }
@@ -6276,6 +6371,7 @@ export class DaemonClient {
...DEFAULT_CLIENT_CAPABILITIES,
...this.config.capabilities,
});
this.creations.reconnect();
this.flushPendingSendQueue();
this.resolveConnect();
}
@@ -6310,6 +6406,12 @@ export class DaemonClient {
}
}
if (
consumerMessage.type === "workspace.create.update" ||
consumerMessage.type === "agent.create.update"
) {
if (!consumerMessage.payload.subscriptionId) this.creations.receive(consumerMessage.payload);
}
this.resolveWaiters(consumerMessage);
this.owned.receive(consumerMessage);
}
@@ -6493,6 +6595,15 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
config,
provider,
cwd,
agentId: _agentId,
onEvent: _onEvent,
idempotencyKey: _idempotencyKey,
clientMessageId: _clientMessageId,
callerAgentId: _callerAgentId,
outputSchema: _outputSchema,
attachments: _attachments,
worktree: _worktree,
autoArchive: _autoArchive,
env: _env,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
+43 -22
View File
@@ -4,6 +4,7 @@ import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type {
AgentSnapshotPayload,
CreationSnapshot,
CreateAgentRequestMessage,
FetchWorkspacesRequestMessage,
FetchWorkspacesResponseMessage,
@@ -28,7 +29,7 @@ import type {
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
} from "@getpaseo/protocol/messages";
import { DaemonClient } from "./daemon-client.js";
import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js";
import {
createTerminalActions,
type PaseoTerminalActions,
@@ -142,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions {
requestId?: string;
}
export type PaseoWorkspaceCreateOptions = Omit<WorkspaceCreateRequest, "type" | "requestId"> & {
export type PaseoWorkspaceCreateOptions = Omit<
WorkspaceCreateRequest,
"type" | "requestId" | "agent" | "subscribe"
> & {
requestId?: string;
agent?: Omit<
PaseoAgentCreateOptions,
"worktree" | "git" | "onEvent" | "idempotencyKey" | "requestId"
>;
onEvent?: (snapshot: CreationSnapshot) => void;
};
export interface PaseoWorkspaceArchiveResult {
@@ -232,6 +241,9 @@ export interface PaseoAgentConfig {
}
export interface PaseoAgentCreateOptions {
idempotencyKey?: string;
agentId?: string;
onEvent?: (snapshot: CreationSnapshot) => void;
config: PaseoAgentConfig;
cwd: string;
parent?: string | PaseoAgentHandle;
@@ -513,6 +525,29 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
};
}
function toDaemonAgentCreateOptions(
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
): CreateAgentRequestOptions {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
return {
...requestOptions,
config: {
...runtimeConfig,
provider,
model,
cwd: placement?.cwd ?? cwd,
...(title !== undefined ? { title } : {}),
...(providerOptions !== undefined ? { providerOptions } : {}),
},
...(placement ? { workspaceId: placement.workspaceId } : {}),
...(parent ? { callerAgentId: resolveAgentId(parent) } : {}),
...(prompt !== undefined ? { initialPrompt: prompt } : {}),
};
}
export function createPaseoApi(
daemonClient: DaemonClient,
scopeOptions?: { signal?: AbortSignal },
@@ -555,24 +590,7 @@ export function createPaseoApi(
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
const effectiveCwd = placement?.cwd ?? cwd;
const agent = await daemonClient.createAgent({
...requestOptions,
config: {
...runtimeConfig,
provider,
model,
cwd: effectiveCwd,
...(title !== undefined ? { title } : {}),
...(providerOptions !== undefined ? { providerOptions } : {}),
},
...(placement ? { workspaceId: placement.workspaceId } : {}),
...(parent ? { callerAgentId: resolveAgentId(parent) } : {}),
...(prompt !== undefined ? { initialPrompt: prompt } : {}),
});
const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement));
return createAgentHandle(agent);
};
const terminals = createTerminalActions(daemonClient, async (workspaceId) => {
@@ -694,8 +712,11 @@ export function createPaseoApi(
ref: (workspace) => createWorkspaceHandle(workspace),
open: (input, requestId) =>
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: async ({ requestId, ...options }) => {
const result = await daemonClient.createWorkspace(options, requestId);
create: async ({ requestId, agent, ...options }) => {
const result = await daemonClient.createWorkspace(
{ ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) },
requestId,
);
if (result.error || !result.workspace) {
throw new Error(result.error ?? "The daemon did not create a workspace");
}
+98 -1
View File
@@ -1681,7 +1681,7 @@ export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTarget
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
// A creation key requires initialPrompt to be sent separately with a stable messageId.
// Legacy create_agent_request uses a separate initial-message receipt when keyed.
idempotencyKey: z.string().min(1).max(512).optional(),
config: AgentSessionConfigSchema,
env: z.record(z.string(), z.string()).optional(),
@@ -1702,6 +1702,33 @@ export const CreateAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
/** Resource IDs are optional on creation; supplied IDs retain their identity on replay. */
export const AgentCreateRequestSchema = CreateAgentRequestMessageSchema.extend({
type: z.literal("agent.create.request"),
agentId: z.uuid().optional(),
attachments: z.array(AgentAttachmentSchema).optional(),
subscribe: z.boolean().optional(),
});
export const WorkspaceInitialAgentSchema = AgentCreateRequestSchema.omit({
type: true,
requestId: true,
idempotencyKey: true,
subscribe: true,
workspaceId: true,
worktree: true,
worktreeName: true,
git: true,
});
export const CreationSubscribeRequestSchema = z.object({
type: z.literal("creation.subscribe.request"),
requestId: z.string(),
kind: z.enum(["workspace", "agent"]),
idempotencyKey: z.string().min(1).max(512),
subscribe: z.boolean().optional(),
});
export const ListProviderModelsRequestMessageSchema = z.object({
type: z.literal("list_provider_models_request"),
provider: AgentProviderSchema,
@@ -2562,7 +2589,14 @@ export const ArchiveWorkspaceRequestSchema = z.object({
// between an existing local directory and a newly created paseo worktree.
export const WorkspaceCreateRequestSchema = z.object({
type: z.literal("workspace.create.request"),
workspaceId: z
.string()
.regex(/^wks_[a-f0-9]{16}$/)
.optional(),
agent: WorkspaceInitialAgentSchema.optional(),
subscribe: z.boolean().optional(),
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.
@@ -3254,6 +3288,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectGithubCloneRequestSchema,
ArchiveWorkspaceRequestSchema,
WorkspaceCreateRequestSchema,
AgentCreateRequestSchema,
CreationSubscribeRequestSchema,
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
@@ -3480,6 +3516,9 @@ export const ServerInfoStatusPayloadSchema = z
.object({
// COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05.
agentRequestReceipts: z.boolean().optional(),
// COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07.
workspaceRequestReceipts: z.boolean().optional(),
creationLifecycle: z.boolean().optional(),
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
hubAgentRpc: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
@@ -4736,9 +4775,63 @@ export const ClearAgentAttentionResponseMessageSchema = z.object({
}),
});
export const CreationSnapshotSchema = z.object({
kind: z.enum(["workspace", "agent"]),
idempotencyKey: z.string(),
revision: z.number().int().nonnegative(),
phase: z.enum([
"accepted",
"workspace_ready",
"agent_ready",
"prompt_started",
"completed",
"failed",
]),
workspaceId: z.string().nullable(),
agentId: z.string().nullable(),
workspace: WorkspaceDescriptorPayloadSchema.optional(),
agent: AgentSnapshotPayloadSchema.optional(),
error: z.string().nullable(),
setupSkippedReason: z.string().optional(),
errorCode: z.string().optional(),
failedStage: z.enum(["workspace", "agent", "prompt"]).optional(),
outcomeUnknown: z.boolean().optional(),
});
export type CreationSnapshot = z.infer<typeof CreationSnapshotSchema>;
export type AgentCreateRequest = z.infer<typeof AgentCreateRequestSchema>;
export type WorkspaceInitialAgent = z.infer<typeof WorkspaceInitialAgentSchema>;
export const WorkspaceCreationUpdateSchema = z.object({
type: z.literal("workspace.create.update"),
payload: CreationSnapshotSchema.extend({ subscriptionId: z.string().optional() }),
});
export const AgentCreationUpdateSchema = WorkspaceCreationUpdateSchema.extend({
type: z.literal("agent.create.update"),
});
export const CreationSubscribeResponseSchema = z.object({
type: z.literal("creation.subscribe.response"),
payload: z.object({
requestId: z.string(),
subscriptionId: z.string().optional(),
snapshot: CreationSnapshotSchema.nullable(),
error: z.string().nullable(),
}),
});
export const AgentCreateResponseSchema = z.object({
type: z.literal("agent.create.response"),
payload: z.object({
requestId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
error: z.string().nullable(),
creation: CreationSnapshotSchema.optional(),
}),
});
export const WorkspaceCreateResponseSchema = z.object({
type: z.literal("workspace.create.response"),
payload: z.object({
agent: AgentSnapshotPayloadSchema.optional(),
creation: CreationSnapshotSchema.optional(),
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
setupTerminalId: z.string().nullable(),
setupSkippedReason: z.string().optional(),
@@ -6649,6 +6742,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
WorkspaceCreateResponseSchema,
AgentCreateResponseSchema,
WorkspaceCreationUpdateSchema,
AgentCreationUpdateSchema,
CreationSubscribeResponseSchema,
WorkspaceClearAttentionResponseSchema,
WorkspaceMarkUnreadResponseSchema,
SendAgentMessageResponseMessageSchema,
@@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = (
export interface CreateAgentFromSessionInput {
kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
@@ -190,6 +191,8 @@ export async function createAgentCommand(
agentId: snapshot.id,
});
if (input.kind === "session") await input.onAgentReady?.(snapshot);
let liveSnapshot = snapshot;
let initialPromptStarted = false;
let initialPromptError: unknown | null = null;
@@ -1,143 +0,0 @@
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";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture() {
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
directories.push(directory);
return { directory, requests: new AgentRequests(directory) };
}
test("concurrent and reconstructed creates return the same durable agent", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
let creations = 0;
const input = {
key: "create-1",
request: { provider: "test", cwd: "/project" },
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
creations++;
agents.add(id);
},
};
const ids = await Promise.all([requests.create(input), requests.create(input)]);
expect(ids[0]).toBe(ids[1]);
expect(await new AgentRequests(directory).create(input)).toBe(ids[0]);
expect(creations).toBe(1);
});
test("recovers creation when the agent was persisted before acknowledgement failed", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
const input = {
key: "lost-create",
request: {},
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
agents.add(id);
throw new Error("acknowledgement lost");
},
};
await expect(requests.create(input)).rejects.toThrow("acknowledgement lost");
expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]);
expect(agents.size).toBe(1);
});
test("reusing a create key with different configuration is a conflict", async () => {
const { requests } = await fixture();
const input = {
key: "key",
request: { model: "a" },
findAgent: async () => true,
create: async () => {},
};
await requests.create(input);
await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow(
"agent_request_key_conflict",
);
});
test("message retries survive reconstruction without submitting twice", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
deliveries++;
},
};
await Promise.all([requests.send(input), requests.send(input)]);
await new AgentRequests(directory).send(input);
expect(deliveries).toBe(1);
await requests.send({ ...input, agentId: "another" });
expect(deliveries).toBe(2);
});
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
deliveries++;
throw new Error("connection lost");
},
};
await expect(requests.send(input)).rejects.toThrow("connection lost");
await expect(new AgentRequests(directory).send(input)).rejects.toThrow(
"agent_request_outcome_unknown",
);
expect(deliveries).toBe(1);
});
test("a creation failure with no stored agent can be retried", async () => {
const { requests } = await fixture();
let available = false;
const input = {
key: "unavailable",
request: {},
findAgent: async () => false,
create: async () => {
if (!available) throw new Error("provider unavailable");
},
};
await expect(requests.create(input)).rejects.toThrow("provider unavailable");
available = true;
await expect(requests.create(input)).resolves.toEqual(expect.any(String));
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
const input = {
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
available = true;
await new AgentRequests(directory).send(input);
available = false;
await requests.send(input);
expect(sends).toBe(1);
});
@@ -1,138 +0,0 @@
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";
const ReceiptSchema = z.object({
fingerprint: z.string(),
agentId: z.string(),
state: z.enum(["pending", "completed"]),
});
type Receipt = z.infer<typeof ReceiptSchema>;
/** One daemon-owned request journal, shared by all of its socket sessions. */
export class AgentRequests {
private readonly pending = new Map<string, Promise<string>>();
constructor(private readonly directory: string) {}
create(input: {
key: string;
request: unknown;
findAgent: (agentId: string) => Promise<boolean>;
create: (agentId: string) => Promise<void>;
}): Promise<string> {
return this.execute(["create", input.key], input.request, {
agentId: randomUUID(),
recover: input.findAgent,
run: input.create,
retrySafe: async (agentId) => !(await input.findAgent(agentId)),
});
}
async send(input: {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}): Promise<void> {
await this.execute(["send", input.agentId, input.messageId], input.request, {
agentId: 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,
run: input.send,
prepare: input.prepare,
});
}
private execute(
identity: string[],
request: unknown,
operation: {
agentId: string;
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
const key = digest(identity);
const fingerprint = digest(request);
const previous = this.pending.get(key);
// Serialize even conflicting requests: each caller validates its own fingerprint.
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
this.executeOnce(key, fingerprint, operation),
);
this.pending.set(key, result);
void result
.finally(() => {
if (this.pending.get(key) === result) this.pending.delete(key);
})
.catch(() => undefined);
return result;
}
private async executeOnce(
key: string,
fingerprint: string,
operation: {
agentId: string;
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
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");
}
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
return existing.agentId;
}
await operation.prepare?.();
const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" };
await writeJsonFileAtomic(file, receipt);
try {
await operation.run(receipt.agentId);
} 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 });
throw error;
}
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
return receipt.agentId;
}
}
async function readReceipt(file: string): Promise<Receipt | null> {
try {
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function digest(value: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(value, (_key, candidate: unknown) => {
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
return Object.fromEntries(
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
);
}
return candidate;
}),
)
.digest("hex");
}
@@ -62,6 +62,8 @@ const INBOUND_PERMISSION = {
client_heartbeat: "workspace.read",
close_items_request: "workspace.manage",
create_agent_request: ["workspace.write", "hub.execute"],
"agent.create.request": ["workspace.write", "hub.execute"],
"creation.subscribe.request": "workspace.read",
create_paseo_worktree_request: "workspace.manage",
create_terminal_request: "workspace.write",
"daemon.config.reload.request": "daemon.manage",
@@ -209,6 +211,10 @@ const INBOUND_PERMISSION = {
} as const satisfies Record<InboundOperation, PermissionRequirement>;
const OUTBOUND_PERMISSION = {
"agent.create.response": ["workspace.write", "hub.execute"],
"agent.create.update": ["workspace.write", "hub.execute"],
"workspace.create.update": "workspace.manage",
"creation.subscribe.response": "workspace.read",
activity_log: "workspace.read",
"agent.config.apply.response": ["workspace.write", "hub.execute"],
"agent.detach.response": "workspace.write",
@@ -0,0 +1,286 @@
import { execFileSync } from "node:child_process";
import type { z } from "zod";
import { WebSocket } from "ws";
import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import pino from "pino";
import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => {
const provider = deferred<void>();
const ready = deferred<CreationSnapshot>();
const disconnected = deferred<void>();
const cleaned = deferred<void>();
const directory = await mkdtemp(join(tmpdir(), "creation-wire-"));
let agents = 0;
let prompts = 0;
const daemon = await createTestPaseoDaemon({
logger: pino(
{ level: "trace" },
{
write(line) {
const { msg } = JSON.parse(line);
if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve();
if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve();
},
},
),
agentClients: createTestAgentClients({
beforeCreateSession: async () => {
agents++;
await provider.promise;
},
onStartTurn: () => {
prompts++;
},
}),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.7.2",
clientId: "creation-subscriber",
});
const observer = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.7.2",
clientId: "creation-independent-observer",
});
const observerMessages: SessionOutboundMessage[] = [];
const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message));
const workspaceId = "wks_0123456789abcdef";
const agentId = randomUUID();
const request = {
idempotencyKey: "workspace-one",
workspaceId,
source: { kind: "directory" as const, path: directory },
agent: {
agentId,
provider: "codex",
cwd: directory,
initialPrompt: "Create once",
clientMessageId: "initial-one",
},
};
try {
await client.connect();
await observer.connect();
const first = client.createWorkspace({
...request,
onEvent: (snapshot) => {
if (snapshot.phase === "workspace_ready") ready.resolve(snapshot);
},
});
void first.catch(() => undefined);
const snapshot = await ready.promise;
expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" });
expect((await observer.fetchAgents()).entries).toHaveLength(0);
expect(
observerMessages.filter((message) => message.type === "workspace.create.update"),
).toHaveLength(0);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await client.close();
await disconnected.promise;
await vi.advanceTimersByTimeAsync(90_000);
await cleaned.promise;
vi.useRealTimers();
await expect(first).rejects.toThrow("closed");
// A disconnected form cannot prevent the provider or initial prompt from starting.
provider.resolve();
await expect.poll(() => prompts).toBe(1);
const replay = await observer.createWorkspace(request);
expect(replay.error).toBeNull();
expect(replay.workspace?.id).toBe(workspaceId);
expect(replay.agent?.id).toBe(agentId);
expect(replay.agent?.title).toBe("Create once");
expect(agents).toBe(1);
expect(prompts).toBe(1);
const conflict = await observer.createWorkspace({
...request,
idempotencyKey: "another-intent",
});
expect(conflict.error).toBe("workspace_id_conflict");
expect(agents).toBe(1);
} finally {
vi.useRealTimers();
provider.resolve();
unsubscribe();
await client.close();
await observer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
async function connectCreationPeer(port: number) {
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const frames: SessionOutboundMessage[] = [];
socket.on("message", (data) => {
const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString()));
if (frame.type === "session") frames.push(frame.message);
});
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
socket.send(
JSON.stringify({
type: "hello",
clientType: "browser",
clientId: randomUUID(),
protocolVersion: 1,
appVersion: "0.8.0",
}),
);
await expect
.poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"))
.toBe(true);
return {
close: () => socket.close(),
request: async (
message: z.input<typeof SessionInboundMessageSchema> & { requestId: string },
) => {
socket.send(JSON.stringify({ type: "session", message }));
const response = () =>
frames.find(
(m) =>
"payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId,
);
await expect.poll(response).toBeDefined();
return response()!;
},
};
}
test.each([false, true])(
"workspace identity does not depend on subscribing (first subscribe=%s)",
async (subscribe) => {
const directory = await mkdtemp(join(tmpdir(), "creation-identity-"));
const daemon = await createTestPaseoDaemon();
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
type: "workspace.create.request" as const,
source: { kind: "directory" as const, path: directory },
idempotencyKey: "same-workspace",
};
const first = await peer.request({ ...request, requestId: "first", subscribe });
const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe });
if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response")
throw new Error("Expected workspace responses");
expect(first.payload.error).toBeNull();
expect(replay.payload.error).toBeNull();
expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test.each(["create_agent_request", "agent.create.request"] as const)(
"legacy and modern agent RPCs share one creation identity (first %s)",
async (type) => {
const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-"));
let creations = 0;
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
beforeCreateSession: async () => {
creations++;
},
}),
});
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
config: { provider: "codex", cwd: directory },
idempotencyKey: "same-agent",
};
const first = await peer.request({ ...request, type, requestId: "first" });
const replay = await peer.request({
...request,
type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request",
requestId: "replay",
});
const createdAgent = (message: SessionOutboundMessage) => {
if (message.type === "agent.create.response") {
expect(message.payload.error).toBeNull();
return message.payload.agent;
}
if (message.type === "status" && message.payload.status === "agent_created")
return message.payload.agent;
throw new Error(`Unexpected creation result ${JSON.stringify(message)}`);
};
expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id);
const agentId = createdAgent(first)!.id;
await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation");
await daemon.daemon.agentManager.flush();
const legacyReplay = await peer.request({
...request,
type: "create_agent_request",
requestId: "legacy-after-update",
});
expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation");
expect(creations).toBe(1);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test("legacy keyed creation preserves checkout error codes", async () => {
const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-"));
execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: directory, stdio: "pipe" },
);
const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() });
const peer = await connectCreationPeer(daemon.port);
try {
const result = await peer.request({
type: "create_agent_request",
requestId: "missing-branch",
idempotencyKey: "missing-branch",
config: { provider: "codex", cwd: directory },
worktree: { mode: "checkout-branch", branch: "does-not-exist" },
});
expect(result).toMatchObject({
type: "status",
payload: { status: "agent_create_failed", errorCode: "unknown_branch" },
});
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
@@ -0,0 +1,324 @@
import pino from "pino";
import { createHash } from "node:crypto";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import type {
AgentSnapshotPayload,
CreationSnapshot,
WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { CreationService, type CreationInput } from "./index.js";
const silentLogger = pino({ level: "silent" });
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
const workspace: WorkspaceDescriptorPayload = {
id: "wks_0123456789abcdef",
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/project",
workspaceDirectory: "/project/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "Worktree",
status: "running",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
scripts: [],
};
const agent: AgentSnapshotPayload = {
id: "00000000-0000-4000-8000-000000000001",
workspaceId: workspace.id,
provider: "codex",
cwd: "/project/worktree",
model: null,
createdAt: "2026-09-11T00:00:00Z",
updatedAt: "2026-09-11T00:00:00Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Start once",
labels: {},
};
async function fixture() {
const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-"));
directories.push(directory);
const service = new CreationService(directory, silentLogger);
const provider = deferred();
const ready = deferred();
const calls: string[] = [];
const input: CreationInput = {
kind: "workspace",
key: "submit-one",
request: { source: "/project", prompt: "Start once" },
workspaceId: workspace.id,
agentId: agent.id,
hasAgent: true,
hasPrompt: true,
exists: async () => false,
provision: async () => {
calls.push("workspace");
return { workspace };
},
createAgent: async (_id, _workspace, onReady) => {
await provider.promise;
calls.push("agent");
await onReady(agent);
calls.push("prompt");
return agent;
},
};
const updates: CreationSnapshot[] = [];
const observe = (snapshot: CreationSnapshot) => {
updates.push(snapshot);
if (snapshot.phase === "workspace_ready") ready.resolve();
};
return { directory, service, input, provider, ready, calls, updates, observe };
}
test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
expect(f.calls).toEqual(["workspace"]);
expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]);
expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id });
const duplicate = f.service.create(f.input);
f.provider.resolve();
expect(await first).toMatchObject({ phase: "completed", workspace, agent });
expect(await duplicate).toEqual(await first);
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first);
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
});
test("a failed agent startup retries only that stage with the reserved IDs", async () => {
const f = await fixture();
let attempts = 0;
f.input.createAgent = async (id, readyWorkspace, onReady) => {
attempts++;
expect(id).toBe(agent.id);
expect(readyWorkspace).toEqual(workspace);
if (attempts === 1) throw new Error("provider unavailable");
await onReady(agent);
return agent;
};
expect(await f.service.create(f.input)).toMatchObject({
phase: "failed",
failedStage: "agent",
outcomeUnknown: false,
workspace,
});
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({
phase: "completed",
agent,
});
expect(f.calls).toEqual(["workspace"]);
expect(attempts).toBe(2);
});
test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => {
const f = await fixture();
let prompts = 0;
f.input.createAgent = async (_id, _workspace, onReady) => {
await onReady(agent);
prompts++;
throw new Error("connection lost after provider accepted prompt");
};
const result = await f.service.create(f.input);
expect(result).toMatchObject({
phase: "failed",
failedStage: "prompt",
outcomeUnknown: true,
workspace,
agent,
});
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
expect(prompts).toBe(1);
});
test("conflicting intents and resource IDs cannot start more work", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow(
"workspace_request_key_conflict",
);
await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow(
"workspace_id_conflict",
);
f.provider.resolve();
await first;
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
});
test("late subscribers recover readiness and removing an observer cannot stop creation", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
const updates: CreationSnapshot[] = [];
const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) =>
updates.push(snapshot),
);
expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace });
subscription.unsubscribe();
f.provider.resolve();
await first;
expect(updates).toEqual([]);
const completed = await new CreationService(f.directory, silentLogger).subscribe(
"workspace",
f.input.key,
() => {},
);
expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace });
completed.unsubscribe();
});
test("omitted resource IDs are generated once and preserved on replay", async () => {
const f = await fixture();
f.input.workspaceId = undefined;
f.input.agentId = undefined;
f.input.provision = async (id) => ({ workspace: { ...workspace, id } });
f.input.createAgent = async (id, createdWorkspace, onReady) => {
const created = { ...agent, id, workspaceId: createdWorkspace!.id };
await onReady(created);
return created;
};
const result = await f.service.create(f.input);
expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
expect(result.agentId).toMatch(
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/,
);
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
});
test("restart after a committed agent without a prompt completes without recreating the agent", async () => {
const f = await fixture();
const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-"));
directories.push(recoveredDirectory);
f.input.hasPrompt = false;
let starts = 0;
f.input.createAgent = async (_id, _workspace, onReady) => {
starts++;
await onReady(agent);
// Capture the durable state at the exact milestone a process could stop after.
await cp(f.directory, recoveredDirectory, { recursive: true });
return agent;
};
await f.service.create(f.input);
const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input);
expect(recovered).toMatchObject({ phase: "completed", workspace, agent });
expect(starts).toBe(1);
});
test("partial provisioning without a registered workspace is not repeated", async () => {
const f = await fixture();
let provisions = 0;
f.input.provision = async () => {
provisions++;
await writeFile(join(f.directory, "partial-checkout"), "disk was changed");
throw new Error("Git failed after creating the checkout");
};
const result = await f.service.create(f.input);
expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true });
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
expect(provisions).toBe(1);
});
test.each(["pending", "completed"])(
"imports a %s legacy agent receipt without creating another agent",
async (state) => {
const f = await fixture();
const legacyDirectory = join(f.directory, "legacy");
await mkdir(legacyDirectory);
const request = { config: { cwd: "/project", provider: "codex" } };
const hash = (value: unknown) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
await writeFile(
join(legacyDirectory, `${hash(["create", "old-key"])}.json`),
JSON.stringify({
fingerprint: hash({ ...request, type: "create_agent_request" }),
state,
agentId: agent.id,
}),
);
const input: CreationInput = {
kind: "agent",
key: "old-key",
request,
hasAgent: true,
hasPrompt: false,
exists: async () => true,
readAgent: async () => agent,
createAgent: async () => {
throw new Error("Must not create again");
},
};
const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory);
await expect(
service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }),
).rejects.toThrow("agent_request_key_conflict");
expect(await service.create(input)).toMatchObject({ phase: "completed", agent });
expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({
phase: "completed",
agent,
});
},
);
test("observer failures are logged without failing creation", async () => {
const f = await fixture();
const logs: unknown[] = [];
const logger = pino(
{ level: "warn" },
{
write: (line) => {
logs.push(JSON.parse(line));
},
},
);
const service = new CreationService(f.directory, logger);
f.provider.resolve();
const result = await service.create(f.input, () => {
throw new Error("delivery failed");
});
expect(result.phase).toBe("completed");
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
expect(logs).toContainEqual(
expect.objectContaining({
level: 40,
kind: "workspace",
idempotencyKey: f.input.key,
phase: "workspace_ready",
err: expect.objectContaining({ message: "delivery failed" }),
}),
);
});
@@ -0,0 +1,351 @@
import type { Logger } from "pino";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import {
CreationSnapshotSchema,
type AgentSnapshotPayload,
type CreationSnapshot,
type WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { generateWorkspaceId } from "../workspace-registry-model.js";
type Observer = (snapshot: CreationSnapshot) => void;
interface CreationRequest {
key: string;
request: { [key: string]: unknown };
workspaceId?: string;
agentId?: string;
hasAgent: boolean;
hasPrompt: boolean;
exists: (kind: "workspace" | "agent", id: string) => Promise<boolean>;
provision?: (
workspaceId: string,
) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>;
createAgent?: (
agentId: string,
workspace: WorkspaceDescriptorPayload | undefined,
onReady: (agent: AgentSnapshotPayload) => Promise<void>,
) => Promise<AgentSnapshotPayload>;
}
export type CreationInput = CreationRequest &
(
| { kind: "workspace" }
| { kind: "agent"; readAgent: (id: string) => Promise<AgentSnapshotPayload | null> }
);
const RecordSchema = z.object({
fingerprint: z.string(),
snapshot: CreationSnapshotSchema,
inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(),
});
type Record = z.infer<typeof RecordSchema>;
/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */
export class CreationService {
private admission: Promise<unknown> = Promise.resolve();
private readonly active = new Map<string, Promise<CreationSnapshot>>();
private readonly observers = new Map<string, Set<Observer>>();
constructor(
private readonly directory: string,
private readonly logger: Logger,
private readonly validateCompleted: (
snapshot: CreationSnapshot,
) => Promise<void> = async () => {},
private readonly legacyDirectory?: string,
) {}
async create(input: CreationInput, observer?: Observer): Promise<CreationSnapshot> {
const identity = identityFor(input.kind, input.key);
const admitted = this.admission.then(async () => {
await mkdir(this.directory, { recursive: true });
const fingerprint = digest(input.request);
let record = await this.read(identity);
if (!record) record = await this.readLegacyAgent(input, fingerprint);
if (record && record.fingerprint !== fingerprint)
throw new Error(`${input.kind}_request_key_conflict`);
const running = this.active.get(identity);
if (running && record) return { completion: running, snapshot: record.snapshot };
if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot);
if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) {
return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot };
}
if (record?.inFlight) {
await this.publish(identity, record, {
phase: "failed",
error: `${input.kind}_request_outcome_unknown`,
failedStage: record.inFlight,
outcomeUnknown: true,
});
return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot };
}
if (!record) {
record = initialRecord(input, fingerprint);
// Persist identity before claiming resources. A failed admission can only retry these IDs.
await this.write(identity, record);
}
await this.claimResources(identity, record.snapshot, input);
if (record.snapshot.phase === "failed") {
await this.publish(identity, record, {
phase: record.snapshot.workspace ? "workspace_ready" : "accepted",
error: null,
errorCode: undefined,
failedStage: undefined,
outcomeUnknown: undefined,
});
}
const snapshot = record.snapshot;
// Defer the runner until admission has installed the initiating observer.
const completion = Promise.resolve().then(() => this.run(identity, record, input));
this.active.set(identity, completion);
void completion.finally(() => this.active.delete(identity)).catch(() => undefined);
return { completion, snapshot };
});
this.admission = admitted.then(
() => undefined,
() => undefined,
);
// Install before admission starts, so even a fast local provision cannot outrun observation.
const unsubscribe = observer ? this.observe(identity, observer) : () => {};
try {
const { completion, snapshot } = await admitted;
if (observer) this.notify(observer, snapshot);
return await completion;
} finally {
unsubscribe();
}
}
async subscribe(
kind: CreationSnapshot["kind"],
key: string,
observer: Observer,
): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> {
const identity = identityFor(kind, key);
const unsubscribe = this.observe(identity, observer);
try {
await this.admission;
const record = await this.read(identity);
if (record?.inFlight && !this.active.has(identity)) {
await this.publish(identity, record, {
phase: "failed",
failedStage: record.inFlight,
outcomeUnknown: true,
error: `${kind}_request_outcome_unknown`,
});
}
if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot);
return { snapshot: record?.snapshot ?? null, unsubscribe };
} catch (error) {
unsubscribe();
throw error;
}
}
private async run(
identity: string,
record: Record,
input: CreationInput,
): Promise<CreationSnapshot> {
try {
if (input.provision && !record.snapshot.workspace) {
record.inFlight = "workspace";
await this.write(identity, record);
const { workspace, setupSkippedReason } = await input.provision(
record.snapshot.workspaceId!,
);
record.inFlight = null;
await this.publish(identity, record, {
phase: "workspace_ready",
workspace,
workspaceId: workspace.id,
setupSkippedReason,
});
}
if (input.hasAgent && input.createAgent && !record.snapshot.agent) {
record.inFlight = "agent";
await this.write(identity, record);
const agent = await input.createAgent(
record.snapshot.agentId!,
record.snapshot.workspace,
async (readyAgent) => {
record.inFlight = input.hasPrompt ? "prompt" : null;
await this.publish(identity, record, {
phase: "agent_ready",
agent: readyAgent,
workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId,
});
},
);
record.inFlight = null;
if (input.hasPrompt)
await this.publish(identity, record, { phase: "prompt_started", agent });
else record.snapshot = { ...record.snapshot, agent };
}
record.inFlight = null;
await this.publish(identity, record, { phase: "completed" });
} catch (error) {
const stage = record.inFlight ?? "agent";
const resourceId =
stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId;
const code =
error instanceof Error && "code" in error && typeof error.code === "string"
? error.code
: undefined;
const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required";
const unknown =
stage === "prompt" ||
(stage === "workspace" && !rejectedBeforeProvision) ||
(resourceId !== null && (await input.exists(stage, resourceId)));
// A failed provider startup with no registered resource can retry under the same ID.
// A partially provisioned resource or an attempted prompt needs reconciliation.
if (!unknown) record.inFlight = null;
await this.publish(identity, record, {
phase: "failed",
error: error instanceof Error ? error.message : String(error),
errorCode: code,
failedStage: stage,
outcomeUnknown: unknown,
});
}
return record.snapshot;
}
// COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire.
// Import the previous receipt format; it is never a second execution path.
private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise<Record | null> {
if (input.kind !== "agent" || !this.legacyDirectory) return null;
const text = await readOptional(
join(this.legacyDirectory, `${digest(["create", input.key])}.json`),
);
if (text === null) return null;
const receipt = z
.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
agentId: z.string(),
})
.parse(JSON.parse(text));
if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request }))
throw new Error("agent_request_key_conflict");
const agent = await input.readAgent(receipt.agentId);
if (!agent && receipt.state === "completed")
throw new Error("Previously created agent no longer exists");
const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint);
record.snapshot = {
...record.snapshot,
phase: agent ? "completed" : "failed",
agent: agent ?? undefined,
workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId,
error: agent ? null : "agent_request_outcome_unknown",
...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }),
};
await this.write(identityFor(input.kind, input.key), record);
return record;
}
private async claimResources(
identity: string,
snapshot: CreationSnapshot,
input: CreationInput,
): Promise<void> {
if (input.kind === "workspace" && snapshot.workspaceId)
await this.claim(identity, "workspace", snapshot.workspaceId, input.exists);
if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists);
}
private async claim(
identity: string,
kind: "workspace" | "agent",
id: string,
exists: CreationInput["exists"],
): Promise<void> {
const file = join(this.directory, `${digest([kind, id])}.claim`);
const owner = await readOptional(file);
if (owner !== null) {
if (owner !== identity) throw new Error(`${kind}_id_conflict`);
return;
}
if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`);
await writeFile(file, identity, { flag: "wx", mode: 0o600 });
}
private observe(identity: string, observer: Observer): () => void {
const observers = this.observers.get(identity) ?? new Set<Observer>();
observers.add(observer);
this.observers.set(identity, observers);
return () => {
observers.delete(observer);
if (!observers.size) this.observers.delete(identity);
};
}
private notify(observer: Observer, snapshot: CreationSnapshot): void {
try {
observer(snapshot);
} catch (err) {
this.logger.warn(
{
err,
kind: snapshot.kind,
idempotencyKey: snapshot.idempotencyKey,
phase: snapshot.phase,
},
"Creation observer failed",
);
}
}
private async publish(
identity: string,
record: Record,
update: Partial<CreationSnapshot>,
): Promise<void> {
record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 };
await this.write(identity, record);
for (const observer of this.observers.get(identity) ?? [])
this.notify(observer, record.snapshot);
}
private write(identity: string, record: Record): Promise<void> {
return writeJsonFileAtomic(join(this.directory, `${identity}.json`), record);
}
private async read(identity: string): Promise<Record | null> {
const text = await readOptional(join(this.directory, `${identity}.json`));
return text === null ? null : RecordSchema.parse(JSON.parse(text));
}
}
function initialRecord(input: CreationInput, fingerprint: string): Record {
return {
fingerprint,
inFlight: null,
snapshot: {
kind: input.kind,
idempotencyKey: input.key,
revision: 0,
phase: "accepted",
error: null,
workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null),
agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null,
},
};
}
function identityFor(kind: string, key: string): string {
return digest([kind, key]);
}
function digest(input: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(input, (_key, value: unknown) => {
if (value && typeof value === "object" && !Array.isArray(value))
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
return value;
}),
)
.digest("hex");
}
async function readOptional(file: string): Promise<string | null> {
try {
return await readFile(file, "utf8");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
@@ -9,66 +9,75 @@ import { createObserverPaths } from "./paths.js";
// Native watchers may coalesce file removals into change notifications. Keep the
// filesystem real while controlling which notifications reach reconciliation.
test("shallow parent scans retain nested change scopes", async () => {
const root = await mkdtemp(join(tmpdir(), "native-scopes-"));
const paths = createObserverPaths(process.platform);
const removed = [
join(root, "root.txt"),
join(root, "child", "child.txt"),
join(root, "child", "deep", "deep.txt"),
];
await mkdir(join(root, "child", "deep"), { recursive: true });
await Promise.all(removed.map((path) => writeFile(path, "before")));
const events: FileChange[] = [];
const notifications = new EventEmitter();
let active = true;
const observer = createFileObserver();
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => {
notifications.removeAllListeners();
test.each(["existing", "new"])(
"shallow scans recover coalesced deletes of %s files in nested scopes",
async (population) => {
const root = await mkdtemp(join(tmpdir(), "native-scopes-"));
const paths = createObserverPaths(process.platform);
const removed = [
join(root, "root.txt"),
join(root, "child", "child.txt"),
join(root, "child", "deep", "deep.txt"),
];
await mkdir(join(root, "child", "deep"), { recursive: true });
if (population === "existing") {
await Promise.all(removed.map((path) => writeFile(path, "before")));
}
const events: FileChange[] = [];
const notifications = new EventEmitter();
let active = true;
const observer = createFileObserver();
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
on: (event, onError) => notifications.on(event, onError),
};
},
);
try {
await backend.start();
await Promise.all(removed.map((path) => rm(path)));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
for (const path of removed) notifications.emit("change", "change", path);
await vi.advanceTimersByTimeAsync(8_000);
vi.useRealTimers();
await expect
.poll(() =>
events
.filter((event) => event.type === "delete")
.map((event) => event.path)
.sort(),
)
.toEqual([...removed].sort());
} finally {
vi.useRealTimers();
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
});
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => {
notifications.removeAllListeners();
},
on: (event, onError) => notifications.on(event, onError),
};
},
);
try {
await backend.start();
if (population === "new") {
await Promise.all(removed.map((path) => writeFile(path, "before")));
for (const path of removed) notifications.emit("change", "change", path);
}
await Promise.all(removed.map((path) => rm(path)));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
for (const path of removed) notifications.emit("change", "change", path);
await vi.advanceTimersByTimeAsync(8_000);
vi.useRealTimers();
await expect
.poll(() =>
events
.filter((event) => event.type === "delete")
.map((event) => event.path)
.sort(),
)
.toEqual([...removed].sort());
} finally {
vi.useRealTimers();
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
},
);
test("a file announced only as changed remains visible to coalesced deletion scans", async () => {
const root = await mkdtemp(join(tmpdir(), "native-change-only-"));
@@ -0,0 +1,77 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { MessageReceipts } from "./index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture() {
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
directories.push(directory);
return { directory, requests: new MessageReceipts(directory) };
}
test("message retries survive reconstruction without submitting twice", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
deliveries++;
},
};
await Promise.all([requests.send(input), requests.send(input)]);
await new MessageReceipts(directory).send(input);
expect(deliveries).toBe(1);
await requests.send({ ...input, agentId: "another" });
expect(deliveries).toBe(2);
});
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
deliveries++;
throw new Error("connection lost");
},
};
await expect(requests.send(input)).rejects.toThrow("connection lost");
await expect(new MessageReceipts(directory).send(input)).rejects.toThrow(
"agent_request_outcome_unknown",
);
expect(deliveries).toBe(1);
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
const input = {
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
available = true;
await new MessageReceipts(directory).send(input);
available = false;
await requests.send(input);
expect(sends).toBe(1);
});
@@ -0,0 +1,81 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
const ReceiptSchema = z.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
agentId: z.string(),
});
interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}
/** Owns message delivery receipts; creation is owned by CreationService. */
export class MessageReceipts {
private readonly pending = new Map<string, Promise<void>>();
constructor(private readonly directory: string) {}
send(input: SendMessageInput): Promise<void> {
// Preserve the existing on-disk identity and shape across daemon upgrades.
const key = digest(["send", input.agentId, input.messageId]);
const previous = this.pending.get(key);
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
this.sendOnce(key, input),
);
this.pending.set(key, result);
void result
.finally(() => {
if (this.pending.get(key) === result) this.pending.delete(key);
})
.catch(() => undefined);
return result;
}
private async sendOnce(key: string, input: SendMessageInput): Promise<void> {
const file = path.join(this.directory, `${key}.json`);
const fingerprint = digest(input.request);
const existing = await readReceipt(file);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
if (existing.state === "completed") return;
// A provider may have accepted the message before its receipt was committed.
throw new Error("agent_request_outcome_unknown");
}
await input.prepare?.();
const receipt = { fingerprint, agentId: input.agentId };
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
await input.send();
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
}
}
async function readReceipt(file: string): Promise<z.infer<typeof ReceiptSchema> | null> {
try {
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function digest(value: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(value, (_key, candidate: unknown) => {
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
return Object.fromEntries(
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
);
}
return candidate;
}),
)
.digest("hex");
}
@@ -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,
@@ -5,7 +5,7 @@ import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("a plugin transforms workspace creation and observes its committed lifecycle", async () => {
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
@@ -19,6 +19,7 @@ test("a plugin transforms workspace creation and observes its committed lifecycl
`
export default function contribute(server) {
server.before("workspace.create", ({ request }) => {
console.log(JSON.stringify({ hook: "workspace.before" }));
return { ...request, title: "Created through a hook" };
});
server.on("workspace.created", (event) => {
@@ -36,10 +37,17 @@ export default function contribute(server) {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const created = await client.createWorkspace({
idempotencyKey: "plugin-workspace",
source: { kind: "directory", path: directory },
});
expect(created.workspace?.name).toBe("Created through a hook");
const workspaceId = created.workspace!.id;
const replay = await client.createWorkspace({
idempotencyKey: "plugin-workspace",
source: { kind: "directory", path: directory },
});
expect(replay.error).toBeNull();
expect(replay.workspace?.id).toBe(workspaceId);
await client.archiveWorkspace(workspaceId);
await expect
.poll(async () => {
@@ -53,6 +61,7 @@ export default function contribute(server) {
});
})
.toMatchObject([
{ hook: "workspace.before" },
{ hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } },
{ hook: "workspace.archived", event: { workspace: { id: workspaceId } } },
]);
+6 -2
View File
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} 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 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
const messages = options.messages ?? [];
const sessionOptions: SessionOptions = {
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: options.clientId ?? "test-client",
onMessage: (message) => messages.push(message),
...(options.targetedMessages
+353 -108
View File
@@ -1,7 +1,11 @@
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 { relative } from "node:path";
import { isAbsolute } from "node:path";
import { CreationService } from "./creation/index.js";
import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages";
import type { MessageReceipts } from "./message-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";
@@ -195,7 +199,6 @@ import {
} from "./session/git-mutation/git-mutation-service.js";
import {
createWorkspaceProvisioningService,
WorkspaceProvisioningError,
type WorkspaceProvisioningService,
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
import {
@@ -452,7 +455,8 @@ export interface SessionOptions {
worktreesRoot?: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentRequests: Pick<AgentRequests, "create" | "send">;
messageReceipts: Pick<MessageReceipts, "send">;
creationService: Pick<CreationService, "create" | "subscribe">;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
directorySync?: DirectorySyncService;
@@ -621,6 +625,16 @@ function resolveWorkspaceLabelService(
return service ?? null;
}
function hasInitialCreationPrompt(
request?: Pick<AgentCreateRequest, "initialPrompt" | "images" | "attachments">,
): boolean {
return Boolean(
request &&
buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments)
.length,
);
}
function workspaceLabelErrorCode(error: unknown): string {
if (
error instanceof WorkspaceLabelError ||
@@ -765,8 +779,9 @@ export class Session {
private readonly daemonSession: DaemonSession;
private readonly hubExecutionController: HubExecutionController | null;
private readonly workspaceScripts: WorkspaceScriptsService;
private readonly agentRequests: Pick<AgentRequests, "create" | "send">;
private readonly messageReceipts: Pick<MessageReceipts, "send">;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private readonly creationService: Pick<CreationService, "create" | "subscribe">;
constructor(options: SessionOptions) {
const {
@@ -840,7 +855,8 @@ export class Session {
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome;
this.agentRequests = options.agentRequests;
this.messageReceipts = options.messageReceipts;
this.creationService = options.creationService;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot;
this.pluginRuntime = pluginRuntime;
@@ -2255,6 +2271,7 @@ export class Session {
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg, source) ??
this.dispatchHubExecutionMessage(msg) ??
this.dispatchCreationMessage(msg, source) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -2631,6 +2648,22 @@ export class Session {
return undefined;
}
private dispatchCreationMessage(
msg: SessionInboundMessage,
source?: object,
): Promise<void> | undefined {
switch (msg.type) {
case "agent.create.request":
return this.handleAgentCreation(msg, source);
case "creation.subscribe.request":
return this.handleCreationSubscription(msg, source);
case "workspace.create.request":
return this.handleWorkspaceCreation(msg, source);
default:
return undefined;
}
}
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agents_request":
@@ -2828,8 +2861,6 @@ export class Session {
return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request":
return this.handleProjectRemoveRequest(msg);
case "workspace.create.request":
return this.handleWorkspaceCreateRequest(msg);
case "workspace.title.set.request":
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
case "workspace.pin.set.request":
@@ -3814,6 +3845,265 @@ export class Session {
/**
* Handle create agent request
*/
private creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage {
return {
type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update",
payload: snapshot,
};
}
private async handleCreationSubscription(
request: Extract<SessionInboundMessage, { type: "creation.subscribe.request" }>,
source?: object,
): Promise<void> {
let unsubscribe = () => {};
const owner =
request.subscribe === false
? undefined
: this.delivery.begin(
"creation",
undefined,
() => unsubscribe(),
JSON.stringify([request.kind, request.idempotencyKey]),
);
try {
const observation = await this.creationService.subscribe(
request.kind,
request.idempotencyKey,
(snapshot) => owner?.emit(this.creationUpdate(snapshot)),
);
unsubscribe = observation.unsubscribe;
if (!owner || owner.signal.aborted) unsubscribe();
this.emitForSource(
{
type: "creation.subscribe.response",
payload: {
requestId: request.requestId,
subscriptionId: owner?.responseId,
snapshot: observation.snapshot,
error: null,
},
},
source,
);
} catch (error) {
await owner?.release();
this.emitForSource(
{
type: "creation.subscribe.response",
payload: {
requestId: request.requestId,
snapshot: null,
error: error instanceof Error ? error.message : String(error),
},
},
source,
);
}
}
private creationResourceExists = async (
kind: "workspace" | "agent",
id: string,
): Promise<boolean> =>
kind === "workspace"
? (await this.workspaceRegistry.get(id)) !== null
: this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null;
private createRequestedAgent(
request: AgentCreateRequest,
observer?: (snapshot: CreationSnapshot) => void,
): Promise<CreationSnapshot> {
const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request;
return this.creationService.create(
{
kind: "agent",
key: idempotencyKey ?? requestId,
request: intent,
workspaceId: request.workspaceId,
agentId: request.agentId,
hasAgent: true,
hasPrompt: hasInitialCreationPrompt(request),
exists: this.creationResourceExists,
readAgent: async (id) => {
const record = await this.agentStorage.get(id);
return record ? this.buildStoredAgentPayload(record) : null;
},
createAgent: async (id, _workspace, onReady) => {
try {
return await this.createSessionAgent(
{ ...request, type: "create_agent_request" },
id,
onReady,
);
} catch (error) {
throw new WorktreeRequestError(toWorktreeWireError(error));
}
},
},
observer,
);
}
private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise<void> {
const { requestId, subscribe } = request;
const progress = subscribe
? this.delivery.operation(
(message) =>
message.type === "workspace.create.update" || message.type === "agent.create.update",
() => {},
)
: undefined;
try {
const creation = await this.createRequestedAgent(
request,
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
);
this.emitForSource(
{
type: "agent.create.response",
payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation },
},
source,
);
} catch (error) {
this.emitForSource(
{
type: "agent.create.response",
payload: {
requestId,
agent: null,
error: error instanceof Error ? error.message : String(error),
},
},
source,
);
} finally {
await progress?.release();
}
}
private async handleWorkspaceCreation(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
source?: object,
): Promise<void> {
const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request;
const progress = subscribe
? this.delivery.operation(
(message) =>
message.type === "workspace.create.update" || message.type === "agent.create.update",
() => {},
)
: undefined;
try {
if (
request.agent &&
!this.authorization.allowsPermission("workspace.write") &&
!this.authorization.allowsPermission("hub.execute")
) {
throw new Error("Session is not authorized to create an agent");
}
const agentInput = request.agent;
const creation = await this.creationService.create(
{
kind: "workspace",
key: idempotencyKey ?? requestId,
request: intent,
workspaceId: request.workspaceId,
agentId: agentInput?.agentId,
hasAgent: Boolean(agentInput),
hasPrompt: hasInitialCreationPrompt(agentInput),
exists: this.creationResourceExists,
provision: async (id) => {
const descriptor = await this.createRequestedWorkspace(
{
...request,
firstAgentContext: agentInput
? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments }
: request.firstAgentContext,
},
id,
);
const workspace = await this.workspaceRegistry.get(descriptor.id);
return {
workspace: descriptor,
setupSkippedReason: workspace?.untrustedSource
? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource)
: undefined,
};
},
createAgent: agentInput
? async (id, workspace, onReady) => {
if (!workspace?.workspaceDirectory)
throw new Error("Created workspace has no directory");
const sourceCwd =
request.source.kind === "directory"
? request.source.path
: await resolveWorktreeSourceCwd(request.source, this.projectRegistry);
const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd));
if (
relativeCwd === ".." ||
relativeCwd.startsWith(`..${sep}`) ||
isAbsolute(relativeCwd)
)
throw new Error("Agent directory must be inside the workspace source");
return this.createSessionAgent(
{
...agentInput,
type: "create_agent_request",
requestId,
config: {
...agentInput.config,
cwd: resolve(workspace.workspaceDirectory, relativeCwd),
},
workspaceId: workspace.id,
},
id,
onReady,
);
}
: undefined,
},
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
);
this.emitForSource(
{
type: "workspace.create.response",
payload: {
requestId,
workspace: creation.workspace ?? null,
agent: creation.agent,
creation,
setupSkippedReason: creation.setupSkippedReason,
error: creation.error,
errorCode: creation.errorCode,
setupTerminalId: null,
},
},
source,
);
} catch (error) {
this.emitForSource(
{
type: "workspace.create.response",
payload: {
requestId,
workspace: null,
error: error instanceof Error ? error.message : String(error),
errorCode:
error instanceof Error && "code" in error && typeof error.code === "string"
? error.code
: undefined,
setupTerminalId: null,
},
},
source,
);
} finally {
await progress?.release();
}
}
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
try {
let agent: AgentSnapshotPayload;
@@ -3821,18 +4111,13 @@ export class Session {
if (msg.initialPrompt !== undefined) {
throw new Error("Idempotent creation requires sending the initial prompt separately");
}
const { requestId: _requestId, idempotencyKey, ...request } = msg;
const id = await this.agentRequests.create({
key: idempotencyKey,
request,
findAgent: async (agentId) =>
this.agentManager.getAgent(agentId) != null ||
(await this.agentStorage.get(agentId)) !== null,
create: async (agentId) => {
await this.createSessionAgent(msg, agentId);
},
});
const record = await this.agentStorage.get(id);
const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" });
if (creation.error || !creation.agent)
throw new SessionRequestError(
creation.errorCode ?? "unknown",
creation.error ?? "Agent creation failed",
);
const record = await this.agentStorage.get(creation.agent.id);
if (!record) throw new Error("Previously created agent no longer exists");
agent = this.buildStoredAgentPayload(record);
} else {
@@ -3848,7 +4133,7 @@ export class Session {
},
});
} catch (error) {
const wireError = toWorktreeWireError(error);
const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
this.emit({
type: "status",
@@ -3874,6 +4159,7 @@ export class Session {
private async createSessionAgent(
msg: CreateAgentRequestMessage,
agentId?: string,
onAgentReady?: (agent: AgentSnapshotPayload) => Promise<void>,
): Promise<AgentSnapshotPayload> {
const {
config,
@@ -3943,6 +4229,10 @@ export class Session {
},
{
kind: "session",
onAgentReady: async (agent) => {
createdAgentId = agent.id;
await onAgentReady?.(await this.buildAgentPayload(agent));
},
agentId,
config: resolvedIntent.config,
workspaceId: resolvedIntent.intent.workspaceId,
@@ -6338,61 +6628,35 @@ export class Session {
}
}
private async handleWorkspaceCreateRequest(
private async createRequestedWorkspace(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
try {
if (this.pluginRuntime) {
const { type, requestId, ...input } = request;
const transformed = await this.pluginRuntime.before("workspace.create", input);
request = { ...transformed, type, requestId };
}
if (request.source.kind === "directory") {
await this.handleWorkspaceCreateLocal(request);
return;
}
await this.handleWorkspaceCreateWorktree(request);
} 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;
this.emit({
type: "workspace.create.response",
payload: {
requestId: request.requestId,
workspace: null,
setupTerminalId: null,
error: message,
errorCode,
},
});
workspaceId?: string,
): Promise<WorkspaceDescriptorPayload> {
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);
}
private async handleWorkspaceCreateLocal(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
workspaceId?: string,
): Promise<WorkspaceDescriptorPayload> {
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;
@@ -6401,19 +6665,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,
@@ -6437,29 +6692,24 @@ export class Session {
{ currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) },
);
}
return descriptor;
}
private async handleWorkspaceCreateWorktree(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
workspaceId?: string,
): Promise<WorkspaceDescriptorPayload> {
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);
@@ -6467,6 +6717,7 @@ export class Session {
const result = await this.createPaseoWorktreeWorkflow(
{
cwd: sourceCwd,
workspaceId,
projectId: source.projectId,
worktreeSlug: source.worktreeSlug,
action: source.action,
@@ -6483,26 +6734,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(
@@ -7763,6 +7999,19 @@ export class Session {
}
}
private async prepareAgentMessage(agentId: string, text: string): Promise<void> {
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<SessionInboundMessage, { type: "send_agent_message_request" }>,
): Promise<void> {
@@ -7813,16 +8062,12 @@ export class Session {
}
};
if (msg.messageId) {
await this.agentRequests.send({
await this.messageReceipts.send({
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,
});
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} 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 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: {
};
const session = new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} 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 +92,8 @@ function createHarness(input: {
};
const session = new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import { execFileSync } from "node:child_process";
import {
existsSync,
@@ -642,7 +645,8 @@ function createSessionForWorkspaceTests(
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: options.appVersion ?? null,
@@ -1003,7 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
serverId: "test-server",
permissions: OWNER_PERMISSIONS,
@@ -1157,7 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work
const emitted: SessionOutboundMessage[] = [];
const session = new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1295,7 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p
let generateCalls = 0;
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
appVersion: null,
@@ -1565,7 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2048,7 +2056,8 @@ 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(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2217,7 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded",
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -2377,7 +2387,8 @@ test("close_items_request continues after an archive failure", async () => {
const killTerminalBestEffort = vi.fn();
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -3649,7 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto
const session = asTestSession(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage: (message) => emitted.push(message),
@@ -40,6 +40,7 @@ export interface ImportWorkspaceResult<T> {
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<PersistedWorkspaceRecord>;
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<PersistedWorkspaceRecord> {
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",
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
@@ -100,7 +103,8 @@ describe("snapshot mutation ownership boundary", () => {
const session = asInternals<SessionInternals>(
new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "test-client",
permissions: OWNER_PERMISSIONS,
onMessage,
@@ -62,6 +62,7 @@ interface FakeAgentSessionOptions {
}
export interface TestAgentClientOptions {
beforeCreateSession?: () => Promise<void>;
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput) => void;
supportsMcpServers?: boolean;
@@ -1212,6 +1213,7 @@ class FakeAgentClient implements AgentClient {
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
await this.options.beforeCreateSession?.();
return new FakeAgentSession({
providerName: this.provider,
config: { ...config },
@@ -1,5 +1,10 @@
import pino from "pino";
import { randomUUID } from "node:crypto";
import { vi } from "vitest";
import { afterEach, vi } from "vitest";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CreationService } from "../creation/index.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -281,13 +286,8 @@ export function createProviderSnapshotManagerStub(): {
};
}
export function createAgentRequestsStub(): SessionOptions["agentRequests"] {
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
return {
async create(input) {
const agentId = randomUUID();
await input.create(agentId);
return agentId;
},
send: (input) => input.send(),
};
}
@@ -304,3 +304,17 @@ export function createProviderSnapshot(
}),
};
}
const creationDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
creationDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
export function createTestCreationService(): SessionOptions["creationService"] {
const directory = join(tmpdir(), `session-creation-${randomUUID()}`);
creationDirectories.push(directory);
return new CreationService(directory, pino({ level: "silent" }));
}
+36 -4
View File
@@ -1,4 +1,7 @@
import { AgentRequests } from "./agent/requests/index.js";
import { stat } from "node:fs/promises";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { CreationService } from "./creation/index.js";
import { MessageReceipts } from "./message-receipts/index.js";
import { WebSocket, WebSocketServer } from "ws";
import type { IncomingMessage, Server as HTTPServer } from "http";
import { join } from "path";
@@ -529,7 +532,8 @@ export class VoiceAssistantWebSocketServer {
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentRequests: AgentRequests;
private readonly messageReceipts: MessageReceipts;
private readonly creationService: CreationService;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly workspaceLabelService: WorkspaceLabelService | null;
@@ -585,6 +589,25 @@ export class VoiceAssistantWebSocketServer {
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
private async validateCompletedCreation(snapshot: CreationSnapshot): Promise<void> {
if (snapshot.workspace && snapshot.kind === "workspace") {
const workspace = await this.workspaceRegistry.get(snapshot.workspace.id);
if (!workspace) throw new Error("Previously created workspace no longer exists");
const directory = await stat(workspace.cwd).catch(() => null);
if (!directory?.isDirectory())
throw Object.assign(new Error(`Directory not found: ${workspace.cwd}`), {
code: "directory_not_found",
});
}
if (
snapshot.agentId &&
!this.agentManager.getAgent(snapshot.agentId) &&
!(await this.agentStorage.get(snapshot.agentId))
) {
throw new Error("Previously created agent no longer exists");
}
}
constructor(
server: HTTPServer,
logger: pino.Logger,
@@ -649,7 +672,13 @@ export class VoiceAssistantWebSocketServer {
this.orchestrationSkills = orchestrationSkills;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests"));
this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests"));
this.creationService = new CreationService(
join(paseoHome, "creations"),
this.logger.child({ module: "creation" }),
(snapshot) => this.validateCompletedCreation(snapshot),
join(paseoHome, "agent-requests"),
);
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
this.workspaceLabelService = workspaceLabelService ?? null;
@@ -1425,7 +1454,8 @@ export class VoiceAssistantWebSocketServer {
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentRequests: this.agentRequests,
messageReceipts: this.messageReceipts,
creationService: this.creationService,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
workspaceLabelService: this.workspaceLabelService ?? undefined,
@@ -1646,6 +1676,8 @@ export class VoiceAssistantWebSocketServer {
features: {
ownedSubscriptions: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
creationLifecycle: true,
hubAgentRpc: true,
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: true,
@@ -1,4 +1,7 @@
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
import {
createMessageReceiptsStub,
createTestCreationService,
} from "./test-utils/session-stubs.js";
import pino from "pino";
import { z } from "zod";
import { describe, expect, test } from "vitest";
@@ -208,7 +211,8 @@ function createSessionForWireCompatTest(options?: {
];
const session = new Session({
agentRequests: createAgentRequestsStub(),
messageReceipts: createMessageReceiptsStub(),
creationService: createTestCreationService(),
clientId: "wire-compat-client",
permissions: OWNER_PERMISSIONS,
clientCapabilities: options?.clientCapabilities ?? null,
@@ -1,5 +1,5 @@
import { test, expect } from "vitest";
import { rmSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => {
rmSync(missingDir, { recursive: true, force: true });
}
}, 180000);
test("workspace creation replay rejects a checkout removed after completion", async () => {
const daemon = await createTestPaseoDaemon();
const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-"));
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
const request = {
source: { kind: "directory" as const, path: directory },
idempotencyKey: "removed-checkout",
};
const created = await client.createWorkspace(request);
expect(created.error).toBeNull();
expect(created.workspace).not.toBeNull();
rmSync(directory, { recursive: true, force: true });
const replayed = await client.createWorkspace(request);
expect(replayed.workspace).toBeNull();
expect(replayed.errorCode).toBe("directory_not_found");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(directory, { recursive: true, force: true });
}
}, 180000);