mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 } } },
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+5
-4
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user