From 4b45bab7e3f5df9b290ec0bc514d5149246370b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 21:05:52 +0700 Subject: [PATCH] Persist live agent mode preferences --- ...w-workspace-codex-mode-preferences.spec.ts | 220 ++++++++++++++++++ .../composer/agent-controls/mode-control.tsx | 17 +- 2 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts new file mode 100644 index 000000000..ff45fb383 --- /dev/null +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -0,0 +1,220 @@ +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { openAgentRoute } from "./helpers/mock-agent"; +import { + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + submitNewWorkspacePrompt, +} from "./helpers/new-workspace"; +import { escapeRegex } from "./helpers/regex"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./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 | 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; +} + +async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise { + await page.addInitScript( + ({ preferencesKey, serverId: seededServerId }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + serverId: seededServerId, + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "auto", + thinkingByModel: { + "gpt-5.4-mini": "low", + }, + }, + mock: { + model: "ten-second-stream", + }, + }, + }), + ); + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + ); +} + +async function readCodexModePreference(page: Page): Promise { + return page.evaluate((preferencesKey) => { + const raw = localStorage.getItem(preferencesKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as { + providerPreferences?: Record; + }; + return parsed.providerPreferences?.codex?.mode ?? null; + }, CREATE_AGENT_PREFERENCES_KEY); +} + +async function selectMode(page: Page, label: string): Promise { + const modeControl = page.getByTestId("mode-control").first(); + await expect(modeControl).toBeVisible({ timeout: 30_000 }); + await modeControl.click(); + + const searchInput = page.getByRole("textbox", { name: /search mode/i }); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill(label); + + const option = page + .getByRole("dialog") + .last() + .getByText(new RegExp(`^${escapeRegex(label)}$`, "i")) + .first(); + await expect(option).toBeVisible({ timeout: 10_000 }); + await option.click({ force: true }); + await expect(searchInput).not.toBeVisible({ timeout: 5_000 }); +} + +async function recordAndBlockCreateAgentRequests(page: Page): Promise<{ + waitForCreateAgentRequest(): Promise; +}> { + let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; + const createAgentSeen = new Promise((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, + }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); + const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); + await seedCodexDefaultPermissionPreferences(page, serverId); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }); + + await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { + timeout: 30_000, + }); + await selectMode(page, "Full access"); + await expect(page.getByTestId("mode-control").first()).toContainText("Full access"); + + await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally."); + const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest(); + + expect(createAgentRequest.config).toMatchObject({ + provider: "codex", + modeId: "full-access", + }); + await expect + .poll(() => readCodexModePreference(page), { timeout: 10_000 }) + .toBe("full-access"); + } finally { + await seeded.cleanup(); + } + }); + + test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); + await seedCodexDefaultPermissionPreferences(page, serverId); + + try { + const agent = await seeded.client.createAgent({ + provider: "codex", + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Codex live mode preference e2e", + modeId: "auto", + model: "gpt-5.4-mini", + }); + + await openAgentRoute(page, { + workspaceId: seeded.workspaceId, + agentId: agent.id, + }); + await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { + timeout: 30_000, + }); + + await selectMode(page, "Full access"); + await expect(page.getByTestId("mode-control").first()).toContainText("Full access", { + timeout: 30_000, + }); + + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }); + + await expect(page.getByTestId("mode-control").first()).toContainText("Full access", { + timeout: 30_000, + }); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index c4daf344a..9d32ee748 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -18,6 +18,7 @@ import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { useToast } from "@/contexts/toast-context"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -261,6 +262,7 @@ export const AgentModeControl = memo(function AgentModeControl({ compareAvailableModes, ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const { updatePreferences } = useFormPreferences(); const toast = useToast(); const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd }); @@ -272,7 +274,18 @@ export const AgentModeControl = memo(function AgentModeControl({ const handleSelectMode = useCallback( (modeId: string) => { - if (!client) return; + if (!client || !slice?.provider) return; + void updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider: slice.provider, + updates: { + mode: modeId || undefined, + }, + }), + ).catch((error) => { + console.warn("[AgentModeControl] persist mode preference failed", error); + }); void client .setAgentMode(agentId, modeId) .then((notice) => showProviderNoticeToast(toast, notice)) @@ -281,7 +294,7 @@ export const AgentModeControl = memo(function AgentModeControl({ toast.error(toErrorMessage(error)); }); }, - [agentId, client, toast], + [agentId, client, slice?.provider, toast, updatePreferences], ); if (!slice || availableModes.length === 0) return null;