Persist live agent mode preferences
This commit is contained in:
@@ -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<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, serverId: string): Promise<void> {
|
||||||
|
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<unknown> {
|
||||||
|
return page.evaluate((preferencesKey) => {
|
||||||
|
const raw = localStorage.getItem(preferencesKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
providerPreferences?: Record<string, { mode?: unknown }>;
|
||||||
|
};
|
||||||
|
return parsed.providerPreferences?.codex?.mode ?? null;
|
||||||
|
}, CREATE_AGENT_PREFERENCES_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMode(page: Page, label: string): Promise<void> {
|
||||||
|
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<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,
|
||||||
|
}) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
|||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
|
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -261,6 +262,7 @@ export const AgentModeControl = memo(function AgentModeControl({
|
|||||||
compareAvailableModes,
|
compareAvailableModes,
|
||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
const { updatePreferences } = useFormPreferences();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
|
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
|
||||||
|
|
||||||
@@ -272,7 +274,18 @@ export const AgentModeControl = memo(function AgentModeControl({
|
|||||||
|
|
||||||
const handleSelectMode = useCallback(
|
const handleSelectMode = useCallback(
|
||||||
(modeId: string) => {
|
(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
|
void client
|
||||||
.setAgentMode(agentId, modeId)
|
.setAgentMode(agentId, modeId)
|
||||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||||
@@ -281,7 +294,7 @@ export const AgentModeControl = memo(function AgentModeControl({
|
|||||||
toast.error(toErrorMessage(error));
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentId, client, toast],
|
[agentId, client, slice?.provider, toast, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!slice || availableModes.length === 0) return null;
|
if (!slice || availableModes.length === 0) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user