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

This commit is contained in:
Mohamed Boudra
2026-09-14 10:38:49 +02:00
parent d1b705a0cd
commit 815b83c96d
31 changed files with 800 additions and 243 deletions
@@ -0,0 +1,304 @@
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expect, test, type Page } from "../support/fixtures";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { gotoAppShell } from "../support/helpers/app";
import { gotoWorkspace } from "../support/helpers/launcher";
import { fillComposerDraft } from "../support/helpers/composer";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<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 }));
}
});
}
function observeCreationRequests(page: Page) {
const pending = new Set<string>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const envelope = JSON.parse(payload.toString()) as { message?: SessionInboundMessage };
const request = envelope.message;
if (
request?.type === "workspace.create.request" ||
request?.type === "create_agent_request"
) {
pending.add(request.requestId);
}
});
socket.on("framereceived", ({ payload }) => {
const envelope = JSON.parse(payload.toString()) as { message?: SessionOutboundMessage };
const response = envelope.message;
if (
response?.type === "workspace.create.response" ||
(response?.type === "status" &&
(response.payload.status === "agent_created" ||
response.payload.status === "agent_create_failed"))
) {
const requestId = response.payload.requestId;
if (typeof requestId === "string") pending.delete(requestId);
}
});
});
return {
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
};
}
async function retryNextAgentCreation(page: Page) {
const retryIds = new Set<string>();
const results: Array<{ status: string; agentId?: string }> = [];
let repeated = false;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const envelope = JSON.parse(frame.toString()) as { message?: SessionInboundMessage };
const request = envelope.message;
if (!repeated && request?.type === "create_agent_request") {
repeated = true;
for (let attempt = 1; attempt <= 3; attempt++) {
const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`;
retryIds.add(requestId);
// Keep the app's operation key and payload; only RPC correlation changes.
server.send(JSON.stringify({ ...envelope, message: { ...request, requestId } }));
}
return;
}
server.send(frame);
});
server.onMessage((frame) => {
const envelope = JSON.parse(frame.toString()) as { message?: SessionOutboundMessage };
const response = envelope.message;
if (
response?.type === "status" &&
(response.payload.status === "agent_created" ||
response.payload.status === "agent_create_failed") &&
typeof response.payload.requestId === "string" &&
retryIds.delete(response.payload.requestId)
) {
results.push(response.payload);
}
browser.send(frame);
});
});
return {
async completedAgentIds() {
await expect.poll(() => results.length).toBe(3);
expect(results.map((result) => result.status)).toEqual([
"agent_created",
"agent_created",
"agent_created",
]);
return results.map((result) => result.agentId);
},
};
}
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
page,
}) => {
const requests = observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
await pressSubmitBeforeTheNextRender(page, "Create");
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = await project.client.fetchWorkspaces();
expect(
workspaces.entries
.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
)
.map((workspace) => workspace.id),
).toHaveLength(1);
} finally {
await project.cleanup();
}
});
}
test("repeated Send clicks before a render create only one agent", async ({ page }) => {
const requests = observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "agent-creation-idempotency-" });
try {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
await fillComposerDraft(page, "Start exactly one agent for this prompt.");
await pressSubmitBeforeTheNextRender(page, "Send message");
await expect(page.getByTestId("user-message").first()).toBeVisible();
await requests.settled();
const agents = await project.client.fetchAgents();
expect(
agents.entries
.filter(({ agent }) => agent.workspaceId === project.workspaceId)
.map(({ agent }) => agent.id),
).toHaveLength(1);
} finally {
await project.cleanup();
}
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
page,
}) => {
const retries = await retryNextAgentCreation(page);
const project = await seedWorkspace({ repoPrefix: "agent-creation-retries-" });
try {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
await fillComposerDraft(page, "Start exactly one agent for this prompt.");
await page.getByRole("button", { name: "Send message", exact: true }).click();
const agentIds = await retries.completedAgentIds();
expect(new Set(agentIds).size).toBe(1);
const agents = await project.client.fetchAgents();
expect(
agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId),
).toHaveLength(1);
} finally {
await project.cleanup();
}
});
test("the first prompt still names an agent created with a receipt", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "creation-title-" });
try {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
await fillComposerDraft(page, "Name this new agent from its first prompt.");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect
.poll(async () => {
const agents = await project.client.fetchAgents();
return agents.entries.find(({ agent }) => agent.workspaceId === project.workspaceId)?.agent
.title;
})
.toBe("Name this new agent from its first prompt.");
} finally {
await project.cleanup();
}
});
test("retrying after a lost first-prompt acknowledgement reuses the agent and message", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
const project = await seedWorkspace({ repoPrefix: "creation-lost-ack-" });
try {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
gate.holdNextServerMessage("send_agent_message_response");
await fillComposerDraft(page, "Deliver this initial prompt once.");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await gate.waitForHeldServerMessage("send_agent_message_response");
const firstMessage = gate.getClientRequests("send_agent_message_request").at(-1);
const agentFetches = gate.getClientRequestCount("fetch_agents_request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fetch_agents_request"))
.toBeGreaterThan(agentFetches);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await fillComposerDraft(page, "Deliver this initial prompt once.");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect.poll(() => gate.getClientRequestCount("send_agent_message_request")).toBe(2);
const secondMessage = gate.getClientRequests("send_agent_message_request").at(-1);
expect(secondMessage).toMatchObject({
agentId: firstMessage?.agentId,
messageId: firstMessage?.messageId,
});
await expect(page.getByTestId("user-message")).toHaveCount(1);
const agents = await project.client.fetchAgents();
expect(
agents.entries.filter(({ agent }) => agent.workspaceId === project.workspaceId),
).toHaveLength(1);
} finally {
gate.restore();
await project.cleanup();
}
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
page,
}) => {
const requests = observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-workspace-prompt-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, "local");
await fillComposerDraft(page, "Start one agent in one new workspace.");
await pressSubmitBeforeTheNextRender(page, "Create");
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").first()).toBeVisible();
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
const agents = await project.client.fetchAgents();
expect(
agents.entries.filter(({ agent }) => agent.workspaceId === workspaces[0]!.id),
).toHaveLength(1);
} finally {
await project.cleanup();
}
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
page,
}) => {
const delayed = await delayBrowserAgentCreatedStatus(page);
const project = await seedWorkspace({ repoPrefix: "creation-distinct-drafts-" });
try {
await gotoWorkspace(page, project.workspaceId);
for (const prompt of ["Start the first agent.", "Start the second agent."]) {
await createAgentTabFromMenu(page);
await fillComposerDraft(page, prompt);
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible();
await delayed.waitForDelayedCreatedStatus();
}
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === project.workspaceId,
).length,
)
.toBe(2);
delayed.release();
} finally {
delayed.release();
await project.cleanup();
}
});
@@ -21,6 +21,7 @@ interface ClientRequest {
mode?: unknown;
path?: unknown;
agentId?: unknown;
messageId?: unknown;
text?: unknown;
activeTurnBehavior?: unknown;
}
@@ -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: {
+24
View File
@@ -0,0 +1,24 @@
import type {
DaemonClient,
CreateAgentRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
/** Creation and prompt delivery have separate durable receipts on the daemon. */
export async function createAgentWithInitialMessage(
client: Pick<DaemonClient, "createAgent" | "sendMessage">,
options: Omit<CreateAgentRequestOptions, "outputSchema"> & {
idempotencyKey: string;
clientMessageId: string;
},
) {
const { initialPrompt = "", clientMessageId, images, attachments, ...creation } = options;
const agent = await client.createAgent(creation);
if (initialPrompt || images?.length || attachments?.length) {
await client.sendMessage(agent.id, initialPrompt, {
messageId: clientMessageId,
images,
attachments,
});
}
return agent;
}
@@ -1,9 +1,9 @@
import { createAgentWithInitialMessage } from "@/agent-creation";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
@@ -85,20 +85,22 @@ function buildChatDraftComposerArgs({
async function callWorkspaceCreation({
creationMethod,
connectedClient,
creationId,
worktreeSlug,
input,
}: {
creationMethod: "create_worktree" | "open_project";
connectedClient: DaemonClient;
creationId: string;
worktreeSlug: string;
input: { cwd: string };
}) {
if (creationMethod === "create_worktree") {
return connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
});
}
return connectedClient.createWorkspace({
source: { kind: "directory", path: input.cwd },
idempotencyKey: creationId,
source:
creationMethod === "create_worktree"
? { kind: "worktree", cwd: input.cwd, worktreeSlug }
: { kind: "directory", path: input.cwd },
});
}
@@ -208,7 +210,7 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
}, [pendingWorkspaceSetup?.creationId]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
@@ -261,6 +263,8 @@ export function WorkspaceSetupDialog() {
const connectedClient = withConnectedClient();
const payload = await callWorkspaceCreation({
creationMethod: pendingWorkspaceSetup.creationMethod,
creationId: pendingWorkspaceSetup.creationId,
worktreeSlug: pendingWorkspaceSetup.worktreeSlug,
connectedClient,
input,
});
@@ -326,8 +330,11 @@ export function WorkspaceSetupDialog() {
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
});
const agent = await connectedClient.createAgent(
buildCreateAgentOptions({
if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired"));
const agent = await createAgentWithInitialMessage(connectedClient, {
idempotencyKey: pendingWorkspaceSetup.creationId,
clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`,
...buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
@@ -336,7 +343,7 @@ export function WorkspaceSetupDialog() {
workspaceId: ensuredWorkspace.id,
provider: composerState.selectedProvider,
}),
);
});
if (!getIsStillActive()) {
return;
@@ -362,6 +369,7 @@ export function WorkspaceSetupDialog() {
},
[
composerState,
pendingWorkspaceSetup,
getIsStillActive,
navigateAfterCreation,
serverId,
@@ -250,4 +250,40 @@ describe("useDraftAgentCreateFlow", () => {
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("unlocks a remounted draft when the original request fails", async () => {
let rejectCreation!: (error: Error) => void;
const creation = new Promise<never>((_resolve, reject) => {
rejectCreation = reject;
});
const options = {
draftId: "draft-remount",
getPendingServerId: () => "server-1",
buildDraftAgent: () => ({ provider: "codex" }),
createRequest: async () => creation,
onCreateSuccess: () => undefined,
};
const original = renderHook(() => useDraftAgentCreateFlow(options));
let submission!: Promise<unknown>;
const captureError = (error: unknown) => error;
await act(async () => {
submission = original.result.current
.handleCreateFromInput({ text: "hello", attachments: [], cwd: "/repo" })
.catch(captureError);
});
const pending = useCreateFlowStore.getState().pendingByDraftId[options.draftId]!;
const restored = renderHook(() =>
useDraftAgentCreateFlow({
...options,
initialAttempt: { ...pending, timestamp: new Date(pending.timestamp) },
}),
);
expect(restored.result.current.isSubmitting).toBe(true);
await act(async () => {
rejectCreation(new Error("Provider unavailable"));
await submission;
});
expect(restored.result.current.isSubmitting).toBe(false);
expect(restored.result.current.formErrorMessage).toBe("Provider unavailable");
});
});
+24 -10
View File
@@ -5,12 +5,11 @@ import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import {
createUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
@@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer(
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
(attempt): DraftAgentMachineState<TDraftAgent> =>
@@ -135,10 +134,19 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
},
);
const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]);
// A remounted view observes completion from the same attempt as its original view.
const machine = useMemo<DraftAgentMachineState<TDraftAgent>>(
() =>
localMachine.tag === "creating" && pending?.lifecycle === "abandoned"
? { tag: "draft", errorMessage: pending.errorMessage ?? "" }
: localMachine,
[pending, localMachine],
);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
@@ -233,14 +241,16 @@ export function useDraftAgentCreateFlow<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 +265,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 +310,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 } : {}),
};
@@ -1,3 +1,4 @@
import { createAgentWithInitialMessage } from "@/agent-creation";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -24,7 +25,7 @@ import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -136,6 +137,7 @@ function resolveDraftModeId(input: {
}
async function submitDraftCreateRequest(input: {
draftId: string;
attempt: { clientMessageId: string };
text: string;
images?: UserMessageImageAttachment[];
@@ -195,12 +197,14 @@ async function submitDraftCreateRequest(input: {
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await requestWorkspaceDraftAgent(client, {
const imagesData = await encodeImages(images);
const result = await createAgentWithInitialMessage(client, {
idempotencyKey: input.draftId,
config,
workspaceId,
text,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(images ? { images } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
@@ -389,10 +393,7 @@ export function WorkspaceDraftAgentTab({
);
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
if (!pendingAutoSubmit || !pendingCreateAttempt) {
return null;
}
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
if (!pendingCreateAttempt) {
return null;
}
return {
@@ -406,7 +407,7 @@ export function WorkspaceDraftAgentTab({
? { attachments: pendingCreateAttempt.attachments }
: {}),
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
@@ -488,6 +489,7 @@ export function WorkspaceDraftAgentTab({
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
submitDraftCreateRequest({
draftId,
attempt,
text,
images,
+1 -47
View File
@@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
@@ -25,8 +25,6 @@ import {
} from "@getpaseo/protocol/agent-attention-notification";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
@@ -45,9 +43,7 @@ import {
createInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import type { AttachmentMetadata } from "@/attachments/types";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
@@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[client],
);
const _createAgent = useCallback(
async ({
config,
initialPrompt,
images,
attachments,
git,
worktreeName,
requestId,
}: {
config: AgentSessionConfig;
initialPrompt: string;
images?: AttachmentMetadata[];
attachments?: AgentAttachment[];
git?: GitSetupOptions;
worktreeName?: string;
requestId?: string;
}) => {
if (!client) {
console.warn("[Session] createAgent skipped: daemon unavailable");
return;
}
const trimmedPrompt = initialPrompt.trim();
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
try {
imagesData = await encodeImages(images);
} catch (error) {
console.error("[Session] Failed to prepare images for agent creation:", error);
}
await client.createAgent({
config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
...(git ? { git } : {}),
...(worktreeName ? { worktreeName } : {}),
...(requestId ? { requestId } : {}),
});
},
[client],
);
const _setAgentMode = useCallback(
(agentId: string, modeId: string) => {
if (!client) {
@@ -68,7 +68,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
@@ -821,6 +820,8 @@ async function createAndMergeWorkspace(input: {
}
async function createMultiplicityWorkspace(input: {
idempotencyKey: string;
worktreeSlug: string;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isolation: "local" | "worktree";
project: HostProjectListItem;
@@ -844,12 +845,13 @@ async function createMultiplicityWorkspace(input: {
attachments: input.attachments,
});
const payload = await input.client.createWorkspace({
idempotencyKey: input.idempotencyKey,
source: isWorktree
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId,
worktreeSlug: createNameId(),
worktreeSlug: input.worktreeSlug,
...input.checkoutRequest,
}
: {
@@ -1066,7 +1068,9 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
initialSetup,
} = input;
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = generateMessageId();
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
if (existing?.serverId === serverId) return;
const clientMessageId = `${draftId}:initial-message`;
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
@@ -1634,6 +1638,10 @@ 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
@@ -2058,6 +2066,8 @@ export function NewWorkspaceScreen({
: undefined;
const normalizedWorkspace = supportsWorkspaceMultiplicity
? await createMultiplicityWorkspace({
idempotencyKey: creationIdentity.draftId,
worktreeSlug: creationIdentity.worktreeSlug,
client: connectedClient,
isolation: effectiveIsolation,
project: selectedProject,
@@ -2082,6 +2092,7 @@ export function NewWorkspaceScreen({
},
[
buildCreateWorktreeInput,
creationIdentity,
createdWorkspace,
effectiveIsolation,
mergeWorkspaces,
@@ -2134,7 +2145,7 @@ export function NewWorkspaceScreen({
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId,
draftId: creationIdentity.draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
@@ -2157,7 +2168,7 @@ export function NewWorkspaceScreen({
[
composerState,
draftContextScopeKey,
draftId,
creationIdentity,
chatDraft.clear,
draftKey,
ensureWorkspace,
+9 -4
View File
@@ -13,13 +13,14 @@ export interface PendingCreateAttempt {
text: string;
timestamp: number;
lifecycle: CreateFlowLifecycleState;
errorMessage?: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export function isActiveCreateFlowForDraft(input: {
pending: PendingCreateAttempt | null | undefined;
serverId: string;
serverId: string | null;
draftId: string | null | undefined;
}): boolean {
const draftId = input.draftId?.trim();
@@ -35,7 +36,11 @@ interface CreateFlowState {
pendingByDraftId: Record<string, PendingCreateAttempt>;
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
updateAgentId: (input: { draftId: string; agentId: string }) => void;
markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void;
markLifecycle: (input: {
draftId: string;
lifecycle: CreateFlowLifecycleState;
errorMessage?: string;
}) => void;
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
clear: (input: { draftId: string }) => void;
clearByAgent: (input: { serverId: string; agentId: string }) => void;
@@ -67,7 +72,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
},
};
}),
markLifecycle: ({ draftId, lifecycle }) =>
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
@@ -76,7 +81,7 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle },
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
@@ -0,0 +1,46 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { transformFileSync } from "@babel/core";
import { expect, test } from "vitest";
import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store";
// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug.
function loadProductionStore() {
const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url));
const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false };
const result = transformFileSync(filename, {
envName: "production",
caller,
});
if (!result?.code) throw new Error("Expo produced no store code");
const module = {
exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore },
};
new Function("require", "module", "exports", result.code)(
createRequire(import.meta.url),
module,
module.exports,
);
return module.exports.useWorkspaceDraftSubmissionStore;
}
test("the production app consumes a draft once and leaves other drafts pending", () => {
const store = loadProductionStore();
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-1",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
} as const;
store.getState().setPending({ ...submission, attachments: [] });
store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" });
expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull();
expect(store.getState().consumePending(submission)).toEqual(submission);
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
@@ -96,8 +96,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 });