From 9c195feb9bc6048406e1c71c44dfade9e7e8d905 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 13 Aug 2026 18:48:02 +0200 Subject: [PATCH] Keep agent profile settings valid across providers (#3331) * fix(app): keep profile modes scoped to providers Applying a profile through sequential field setters allowed a provider change to race mode persistence, poisoning modeless provider preferences with another provider's mode. Apply profiles as one form transition and heal invalid provider-scoped mode preferences when profiles are used. * test(app): cover profile features across providers --- ...agent-profile-modeless-preferences.spec.ts | 225 ++++++++++++++++++ packages/app/e2e/fixtures/fake-pi-rpc.mjs | 38 +++ .../app/e2e/support/helpers/agent-profiles.ts | 7 +- packages/app/src/agent-profiles/index.ts | 1 + .../internal/materialize-profile.test.ts | 32 ++- .../internal/materialize-profile.ts | 13 + .../internal/use-agent-profile-picker.ts | 50 ++-- .../app/src/composer/agent-controls/index.tsx | 36 +-- .../src/composer/agent-controls/mode.test.ts | 2 +- .../src/composer/draft/input-draft-core.ts | 5 +- .../app/src/composer/draft/input-draft.ts | 11 + .../preferences.test.ts | 45 ++++ .../create-agent-preferences/preferences.ts | 46 +++- .../app/src/hooks/use-agent-form-state.ts | 104 +++++--- .../app/src/hooks/use-draft-agent-features.ts | 5 + .../resolve-agent-form.test.ts | 93 ++++---- .../provider-selection/resolve-agent-form.ts | 102 ++++---- 17 files changed, 622 insertions(+), 193 deletions(-) create mode 100644 packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts create mode 100644 packages/app/e2e/fixtures/fake-pi-rpc.mjs diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts new file mode 100644 index 000000000..980eaea5d --- /dev/null +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -0,0 +1,225 @@ +import { expect, test, type Page } from "../support/fixtures"; +import path from "node:path"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; +import { + applyProfileFromPicker, + drillIntoProvider, + openModelPicker, + seedAgentProfiles, + seedModelProvider, +} from "../support/helpers/agent-profiles"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +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 | 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) + : null; + } catch { + return null; + } +} + +async function recordAndBlockCreateAgentRequest(page: Page): Promise<{ + waitForRequest(): Promise; +}> { + let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; + const request = 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 { waitForRequest: () => request }; +} + +async function seedPoisonedModelessPreference(page: Page): Promise { + await page.addInitScript( + ({ preferencesKey, provider, model }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + provider, + providerPreferences: { + [provider]: { model, mode: "full-access" }, + mock: { model: "ten-second-stream", mode: "load-test" }, + }, + } satisfies FormPreferences), + ); + }, + { + preferencesKey: CREATE_AGENT_PREFERENCES_KEY, + provider: MODELESS_PROVIDER, + model: MODELESS_MODEL, + }, + ); +} + +async function readProviderModePreference(page: Page, provider: string): Promise { + return page.evaluate( + ({ preferencesKey, providerId }) => { + const raw = localStorage.getItem(preferencesKey); + if (!raw) return null; + const preferences = JSON.parse(raw) as FormPreferences; + return preferences.providerPreferences?.[providerId]?.mode ?? null; + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, + ); +} + +test.describe("Agent profiles repair modeless provider preferences", () => { + test.describe.configure({ timeout: 240_000 }); + + test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" }); + const profiles = await seedAgentProfiles([ + { + id: "agent_profile_modeless_repair", + name: "Approval work", + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + }, + ]); + const provider = await seedModelProvider({ + id: MODELESS_PROVIDER, + label: "Pi profile test", + extends: "pi", + command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")], + models: [ + { + id: MODELESS_MODEL, + label: "Pi profile model", + description: "Modeless profile regression model", + }, + ], + }); + await seedPoisonedModelessPreference(page); + const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await openModelPicker(page); + await applyProfileFromPicker(page, "Approval work"); + + await expect + .poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 }) + .toBeNull(); + await expect + .poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 }) + .toBe("approval-test"); + + await openModelPicker(page); + await drillIntoProvider(page, MODELESS_PROVIDER); + await page.getByRole("button", { name: /Pi profile model/ }).click(); + + await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences."); + const createAgentRequest = await createAgentRecorder.waitForRequest(); + expect(createAgentRequest).toMatchObject({ + config: { provider: MODELESS_PROVIDER }, + }); + expect(createAgentRequest.config).not.toHaveProperty("modeId"); + } finally { + await workspace.cleanup(); + await provider.restore(); + await profiles.restore(); + } + }); + + test("cross-provider profile features survive immediate workspace creation", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" }); + const featureProviderId = "profile-feature-e2e"; + const featureModelId = "claude-opus-5"; + const profiles = await seedAgentProfiles([ + { + id: "agent_profile_feature_transition", + name: "Fast profile", + provider: featureProviderId, + model: featureModelId, + modeId: "bypassPermissions", + featureValues: { fast_mode: true }, + }, + ]); + const provider = await seedModelProvider({ + id: featureProviderId, + label: "Profile feature test", + models: [ + { + id: featureModelId, + label: "Profile feature model", + description: "Cross-provider feature regression model", + }, + ], + }); + const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await openModelPicker(page); + await applyProfileFromPicker(page, "Fast profile"); + await submitNewWorkspacePrompt(page, "Create an agent with profile features."); + + const createAgentRequest = await createAgentRecorder.waitForRequest(); + expect(createAgentRequest).toMatchObject({ + config: { + provider: featureProviderId, + featureValues: { fast_mode: true }, + }, + }); + } finally { + await workspace.cleanup(); + await provider.restore(); + await profiles.restore(); + } + }); +}); diff --git a/packages/app/e2e/fixtures/fake-pi-rpc.mjs b/packages/app/e2e/fixtures/fake-pi-rpc.mjs new file mode 100644 index 000000000..b6d156ddf --- /dev/null +++ b/packages/app/e2e/fixtures/fake-pi-rpc.mjs @@ -0,0 +1,38 @@ +import readline from "node:readline"; + +const lines = readline.createInterface({ input: process.stdin }); + +for await (const line of lines) { + const command = JSON.parse(line); + let data = {}; + if (command.type === "get_available_models") { + data = { + models: [ + { + provider: "e2e", + id: "pi-profile-model", + name: "Pi profile model", + }, + ], + }; + } else if (command.type === "get_state") { + data = { + sessionId: "e2e-pi-session", + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + messageCount: 0, + pendingMessageCount: 0, + }; + } + + process.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data, + })}\n`, + ); +} diff --git a/packages/app/e2e/support/helpers/agent-profiles.ts b/packages/app/e2e/support/helpers/agent-profiles.ts index 1f77369ee..59deb8863 100644 --- a/packages/app/e2e/support/helpers/agent-profiles.ts +++ b/packages/app/e2e/support/helpers/agent-profiles.ts @@ -104,21 +104,24 @@ export interface SeededProviderModel { * list is what avoids running anything: replacement models skip catalog * discovery, and Claude's static modes mean the provider never spawns. The * command only has to resolve for the availability probe, hence `node`. + * Providers with dynamic catalogs can pass a small RPC fixture as `command`. */ export async function seedModelProvider(input: { id: string; label: string; models: SeededProviderModel[]; + extends?: "claude" | "pi"; + command?: string[]; }): Promise { const client = await connectAgentProfilesClient(); await client.patchDaemonConfig({ providers: { [input.id]: { - extends: "claude", + extends: input.extends ?? "claude", label: input.label, description: `${input.label} test provider`, enabled: true, - command: ["node"], + command: input.command ?? ["node"], models: input.models, }, }, diff --git a/packages/app/src/agent-profiles/index.ts b/packages/app/src/agent-profiles/index.ts index a00ad7617..d2ffebe78 100644 --- a/packages/app/src/agent-profiles/index.ts +++ b/packages/app/src/agent-profiles/index.ts @@ -14,6 +14,7 @@ * here. */ export type { AgentProfile } from "@getpaseo/protocol/messages"; +export type { MaterializedAgentProfile } from "./internal/materialize-profile"; export { useAgentProfiles } from "./internal/use-agent-profiles"; export { useAgentProfilePicker, diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts index b228c32f3..dd60b6680 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { AgentProfile } from "@getpaseo/protocol/messages"; -import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; +import { + materializeAgentProfile, + reconcileMaterializedProfileMode, + toAgentConfigApply, +} from "./materialize-profile"; function profile(overrides: Partial = {}): AgentProfile { return { @@ -87,3 +91,29 @@ describe("toAgentConfigApply", () => { ).not.toHaveProperty("provider"); }); }); + +describe("reconcileMaterializedProfileMode", () => { + it("waits when the current provider mode catalog is unknown", () => { + expect( + reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), null), + ).toBeNull(); + }); + + it("drops a profile mode removed from the current provider catalog", () => { + expect( + reconcileMaterializedProfileMode( + materializeAgentProfile(profile({ modeId: "removed-mode" })), + ["plan", "default"], + ), + ).toMatchObject({ modeId: "" }); + }); + + it("preserves a profile mode still offered by the provider", () => { + expect( + reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), [ + "plan", + "default", + ]), + ).toMatchObject({ modeId: "plan" }); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts index bdedf604a..fc393f5ec 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -29,6 +29,19 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen }; } +export function reconcileMaterializedProfileMode( + profile: MaterializedAgentProfile, + availableModeIds: readonly string[] | null, +): MaterializedAgentProfile | null { + if (availableModeIds === null) { + return null; + } + if (!profile.modeId || availableModeIds.includes(profile.modeId)) { + return profile; + } + return { ...profile, modeId: "" }; +} + /** * The payload for a running agent. Omitted fields are left alone by the daemon, * so a profile that names no mode does not reset the agent's mode. Provider is diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index 38799d2a2..e90b8e8bb 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -1,7 +1,5 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { AgentProfile } from "@getpaseo/protocol/messages"; import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -9,21 +7,22 @@ import { useSessionStore } from "@/stores/session-store"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; +import { + materializeAgentProfile, + reconcileMaterializedProfileMode, + toAgentConfigApply, + type MaterializedAgentProfile, +} from "./materialize-profile"; import { buildAgentProfileTags } from "./profile-summary"; import { useAgentProfiles } from "./use-agent-profiles"; -/** The draft composer's own setters. Applying a profile drives them like a user would. */ +/** The draft composer owns profile application as one state transition. */ export interface DraftAgentProfileControls { - selectProvider: (provider: AgentProvider) => void; - selectProviderAndModel: (provider: AgentProvider, modelId: string) => void; - selectMode: (modeId: string) => void; - selectThinkingOption: (thinkingOptionId: string) => void; - setFeature?: (featureId: string, value: unknown) => void; + applyProfile: (profile: MaterializedAgentProfile) => void; } export type AgentProfileApplyTarget = - | { kind: "agent"; agentId: string } + | { kind: "agent"; agentId: string; availableModeIds: readonly string[] | null } | { kind: "draft"; controls: DraftAgentProfileControls }; /** Everything the model picker renders for one profile. It never sees the profile itself. */ @@ -107,8 +106,7 @@ export function useAgentProfilePicker( ); const persistSelection = useCallback( - (profile: AgentProfile) => { - const resolved = materializeAgentProfile(profile); + (resolved: MaterializedAgentProfile) => { void updatePreferences((current) => mergeCreateAgentSelectionPreferences({ preferences: current, @@ -135,35 +133,21 @@ export function useAgentProfilePicker( } const resolved = materializeAgentProfile(profile); - // Persist on both paths, before driving the controls. The draft form drops - // its local feature values when the provider changes, so preferences are - // the only channel that survives a profile that switches provider. - persistSelection(profile); - if (target.kind === "draft") { - const { controls } = target; - if (resolved.modelId) { - controls.selectProviderAndModel(resolved.provider, resolved.modelId); - } else { - controls.selectProvider(resolved.provider); - } - if (resolved.modeId) { - controls.selectMode(resolved.modeId); - } - if (resolved.thinkingOptionId) { - controls.selectThinkingOption(resolved.thinkingOptionId); - } - for (const [featureId, value] of Object.entries(resolved.featureValues)) { - controls.setFeature?.(featureId, value); - } + target.controls.applyProfile(resolved); return; } + const reconciled = reconcileMaterializedProfileMode(resolved, target.availableModeIds); + if (!reconciled) { + return; + } + persistSelection(reconciled); if (!client) { return; } void client - .applyAgentConfig(target.agentId, toAgentConfigApply(resolved)) + .applyAgentConfig(target.agentId, toAgentConfigApply(reconciled)) .then((notice) => showProviderNoticeToast(toast, notice)) .catch((error) => { console.warn("[useAgentProfilePicker] applyAgentConfig failed", error); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a659b46d1..5494de61b 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -81,6 +81,7 @@ import { useAgentProfilePicker, type AgentProfileApplyTarget, type AgentProfilePicker, + type DraftAgentProfileControls, } from "@/agent-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; @@ -125,7 +126,6 @@ interface ControlledAgentControlsProps { export interface DraftAgentControlsProps { providerDefinitions: AgentProviderDefinition[]; selectedProvider: AgentProvider | null; - onSelectProvider: (provider: AgentProvider) => void; modeOptions: AgentMode[]; selectedMode: string; onSelectMode: (modeId: string) => void; @@ -139,6 +139,7 @@ export interface DraftAgentControlsProps { thinkingOptions: NonNullable; selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; + onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; @@ -385,6 +386,15 @@ function resolveSnapshotSelectedEntry( return snapshotEntries.find((e) => e.provider === agentProvider) ?? null; } +function resolveSnapshotModeIds( + entry: ReturnType, +): string[] | null { + if (entry?.status !== "ready" || !entry.modes) { + return null; + } + return entry.modes.map((mode) => mode.id); +} + function buildAgentProviderDefinitions( agentProvider: string | undefined, snapshotEntries: ReturnType["entries"], @@ -1550,9 +1560,13 @@ export const AgentControls = memo(function AgentControls({ // A running agent is one provider's process, so only that provider's profiles // can apply to it. const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]); + const profileModeIds = useMemo( + () => resolveSnapshotModeIds(snapshotSelectedEntry), + [snapshotSelectedEntry], + ); const profileTarget = useMemo( - () => ({ kind: "agent", agentId }), - [agentId], + () => ({ kind: "agent", agentId, availableModeIds: profileModeIds }), + [agentId, profileModeIds], ); const agentProfiles = useAgentProfilePicker({ serverId, @@ -1692,7 +1706,6 @@ export const AgentControls = memo(function AgentControls({ export function DraftAgentControls({ providerDefinitions, selectedProvider, - onSelectProvider, modeOptions, selectedMode, onSelectMode, @@ -1706,6 +1719,7 @@ export function DraftAgentControls({ thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, + onApplyAgentProfile, features, onSetFeature, onDropdownClose, @@ -1742,20 +1756,10 @@ export function DraftAgentControls({ () => ({ kind: "draft", controls: { - selectProvider: onSelectProvider, - selectProviderAndModel: onSelectProviderAndModel, - selectMode: onSelectMode, - selectThinkingOption: onSelectThinkingOption, - setFeature: onSetFeature, + applyProfile: onApplyAgentProfile, }, }), - [ - onSelectMode, - onSelectProvider, - onSelectProviderAndModel, - onSelectThinkingOption, - onSetFeature, - ], + [onApplyAgentProfile], ); const agentProfiles = useAgentProfilePicker({ serverId: modelSelectorServerId, diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index 89c22787b..0905e3cd4 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -20,7 +20,6 @@ describe("resolveAgentControlsMode", () => { resolveAgentControlsMode({ providerDefinitions: [], selectedProvider: "codex", - onSelectProvider: () => undefined, modeOptions: [], selectedMode: "", onSelectMode: () => undefined, @@ -34,6 +33,7 @@ describe("resolveAgentControlsMode", () => { thinkingOptions: [], selectedThinkingOptionId: "", onSelectThinkingOption: () => undefined, + onApplyAgentProfile: () => undefined, }), ).toBe("draft"); }); diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index ad7ce7b85..9e727433d 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -22,13 +22,13 @@ export function buildDraftAgentControls(input: { formState: UseAgentFormStateResult; features?: DraftAgentControlsProps["features"]; onSetFeature?: DraftAgentControlsProps["onSetFeature"]; + onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; }): DraftAgentControlsProps { - const { formState, features, onSetFeature, onDropdownClose } = input; + const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, - onSelectProvider: formState.setProviderFromUser, modeOptions: formState.modeOptions, selectedMode: formState.selectedMode, onSelectMode: formState.setModeFromUser, @@ -42,6 +42,7 @@ export function buildDraftAgentControls(input: { thinkingOptions: formState.availableThinkingOptions, selectedThinkingOptionId: formState.selectedThinkingOptionId, onSelectThinkingOption: formState.setThinkingOptionFromUser, + onApplyAgentProfile, features, onSetFeature, onDropdownClose, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 995a5382f..1a6875517 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -190,6 +190,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr features: draftFeatures, featureValues: draftFeatureValues, setFeatureValue: setDraftFeatureValue, + applyProfileFeatureValues, } = useDraftAgentFeatures({ serverId: formState.selectedServerId, provider: formState.selectedProvider, @@ -200,6 +201,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr initialFeatureValues: composerOptions?.initialFeatureValues, }); + const applyDraftAgentProfile = useCallback( + (profile: Parameters[0]) => { + formState.applyProfileFromUser(profile); + applyProfileFeatureValues(profile.featureValues); + }, + [applyProfileFeatureValues, formState], + ); + const commandDraftConfig = useMemo( () => composerOptions @@ -236,6 +245,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr formState, features: draftFeatures, onSetFeature: setDraftFeatureValue, + onApplyAgentProfile: applyDraftAgentProfile, }), commandDraftConfig, }; @@ -246,6 +256,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveThinkingOptionId, draftFeatures, draftFeatureValues, + applyDraftAgentProfile, formState, setDraftFeatureValue, workingDir, diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index cb48416d7..b30b2a66c 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CreateAgentPreferencesService } from "./service"; import { + applyAgentProfilePreferences, mergeCreateAgentSelectionPreferences, mergeProviderPreferences, parseFormPreferences, @@ -133,6 +134,50 @@ describe("create agent preferences", () => { }); }); + it("erases a saved mode when a complete selection explicitly has no mode", () => { + expect( + mergeCreateAgentSelectionPreferences({ + preferences: { + provider: "pi", + providerPreferences: { pi: { model: "anthropic/sonnet", mode: "full-access" } }, + }, + provider: "pi", + modelId: "anthropic/sonnet", + modeId: null, + }), + ).toEqual({ + provider: "pi", + providerPreferences: { pi: { model: "anthropic/sonnet" } }, + }); + }); + + it("repairs the previous provider while applying a profile", () => { + expect( + applyAgentProfilePreferences({ + preferences: { + provider: "pi", + providerPreferences: { + pi: { model: "anthropic/sonnet", mode: "full-access" }, + mock: { model: "ten-second-stream", mode: "load-test" }, + }, + }, + previousProvider: "pi", + previousProviderModeIds: [], + provider: "mock", + modelId: "one-minute-stream", + modeId: "approval-test", + thinkingOptionId: "", + featureValues: {}, + }), + ).toEqual({ + provider: "mock", + providerPreferences: { + pi: { model: "anthropic/sonnet" }, + mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} }, + }, + }); + }); + it("loads invalid stored preferences as empty preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 710df9efd..bdade894e 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -112,7 +112,7 @@ function mergeDefinedRecord( function applyProviderPreferenceUpdates( existing: ProviderPreferences, - updates: Partial, + updates: Omit, "mode"> & { mode?: string | null }, ): ProviderPreferences { const next: ProviderPreferences = { ...existing }; const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); @@ -121,7 +121,9 @@ function applyProviderPreferenceUpdates( if (updates.model !== undefined) { next.model = updates.model; } - if (updates.mode !== undefined) { + if (updates.mode === null) { + delete next.mode; + } else if (updates.mode !== undefined) { next.mode = updates.mode; } if (nextThinkingByModel !== undefined) { @@ -137,7 +139,7 @@ function applyProviderPreferenceUpdates( export function mergeProviderPreferences(args: { preferences: FormPreferences; provider: AgentProvider; - updates: Partial; + updates: Omit, "mode"> & { mode?: string | null }; }): FormPreferences { const { preferences, provider, updates } = args; const existingProviderPreferences = preferences.providerPreferences ?? {}; @@ -175,9 +177,45 @@ export function mergeCreateAgentSelectionPreferences(args: { provider: args.provider, updates: { model: modelId || undefined, - mode: modeId || undefined, + mode: args.modeId === undefined ? undefined : modeId || null, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), ...(featureValues.success ? { featureValues: featureValues.data } : {}), }, }); } + +export function applyAgentProfilePreferences(args: { + preferences: FormPreferences; + previousProvider: AgentProvider | null; + previousProviderModeIds: readonly string[]; + provider: AgentProvider; + modelId: string; + modeId: string; + thinkingOptionId: string; + featureValues: Record; +}): FormPreferences { + let next = args.preferences; + if (args.previousProvider) { + const previousMode = next.providerPreferences?.[args.previousProvider]?.mode; + if (previousMode && !args.previousProviderModeIds.includes(previousMode)) { + next = mergeProviderPreferences({ + preferences: next, + provider: args.previousProvider, + updates: { mode: null }, + }); + } + } + + return mergeProviderPreferences({ + preferences: next, + provider: args.provider, + updates: { + model: args.modelId || undefined, + mode: args.modeId || null, + ...(args.modelId && args.thinkingOptionId + ? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } } + : {}), + featureValues: args.featureValues, + }, + }); +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 29e90a6a9..456ce249c 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -14,6 +14,7 @@ import { } from "@/provider-selection/provider-selection"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences"; +import { applyAgentProfilePreferences } from "@/create-agent-preferences/preferences"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -37,6 +38,7 @@ import { type FormState, type ProviderModelsByProvider, } from "@/provider-selection/resolve-agent-form"; +import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; @@ -54,7 +56,6 @@ export interface UseAgentFormStateResult { setSelectedServerId: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void; selectedProvider: AgentProvider | null; - setProviderFromUser: (provider: AgentProvider) => void; selectedMode: string; setModeFromUser: (modeId: string) => void; selectedModel: string; @@ -80,6 +81,7 @@ export interface UseAgentFormStateResult { refreshProviderModels: (provider?: AgentProvider) => void; refetchProviderModelsIfStale: () => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; + applyProfileFromUser: (profile: MaterializedAgentProfile) => void; clearProviderSelectionFromUser: () => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; @@ -254,11 +256,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }), ); - const reducerStateRef = useRef({ form: formState, userModified }); - useEffect(() => { - reducerStateRef.current = { form: formState, userModified }; - }, [formState, userModified]); - useEffect(() => { if (!isVisible) { dispatch({ type: "RESET" }); @@ -423,27 +420,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); }, []); - const setProviderFromUser = useCallback( - (provider: AgentProvider) => { - if (!selectableProviderDefinitionMap.has(provider)) { - return; - } - const providerModels = allProviderModels.get(provider) ?? null; - const providerDef = selectableProviderDefinitionMap.get(provider); - const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; - - dispatch({ - type: "SET_PROVIDER_FROM_USER", - provider, - providerModels, - providerDef, - providerPrefs, - }); - void updateCurrentPreferences({ provider }); - }, - [allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences], - ); - const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { if (!selectableProviderDefinitionMap.has(provider)) { @@ -480,10 +456,64 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }); }, []); + const applyProfileFromUser = useCallback( + (profile: MaterializedAgentProfile) => { + const provider = profile.provider as AgentProvider; + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } + + const previousProvider = formState.provider; + const providerDef = selectableProviderDefinitionMap.get(provider); + const providerModels = allProviderModels.get(provider) ?? null; + const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; + const action = { + type: "APPLY_PROFILE_FROM_USER" as const, + provider, + modelId: profile.modelId, + modeId: profile.modeId, + thinkingOptionId: profile.thinkingOptionId, + providerDef, + providerModels, + providerPrefs, + }; + const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action); + const previousProviderModeIds = previousProvider + ? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? []) + : []; + + dispatch(action); + void updateCurrentPreferences((current) => { + const { model, modeId, thinkingOptionId } = nextState.form; + return applyAgentProfilePreferences({ + preferences: current, + previousProvider, + previousProviderModeIds, + provider, + modelId: model, + modeId, + thinkingOptionId, + featureValues: profile.featureValues, + }); + }).catch((error) => { + console.warn("[useAgentFormState] persist profile preference failed", error); + }); + }, + [ + allProviderModels, + formState, + providerDefinitionMap, + resolution, + selectableProviderDefinitionMap, + updateCurrentPreferences, + userModified, + ], + ); + const setModeFromUser = useCallback( (modeId: string) => { dispatch({ type: "SET_MODE_FROM_USER", modeId }); - const provider = reducerStateRef.current.form.provider; + const provider = formState.provider; if (provider) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -496,12 +526,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updateCurrentPreferences], + [formState.provider, updateCurrentPreferences], ); const setModelFromUser = useCallback( (modelId: string) => { - const provider = reducerStateRef.current.form.provider; + const provider = formState.provider; const providerPrefs = provider ? preferenceOverlayRef.current.current().providerPreferences?.[provider] : undefined; @@ -525,13 +555,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [availableModels, updateCurrentPreferences], + [availableModels, formState.provider, updateCurrentPreferences], ); const setThinkingOptionFromUser = useCallback( (thinkingOptionId: string) => { dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId }); - const { provider, model: modelId } = reducerStateRef.current.form; + const { provider, model: modelId } = formState; if (provider && modelId) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -546,7 +576,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updateCurrentPreferences], + [formState, updateCurrentPreferences], ); const setWorkingDir = useCallback((value: string) => { @@ -569,8 +599,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); const refetchProviderModelsIfStale = useCallback(() => { - refetchSnapshotIfStale(reducerStateRef.current.form.provider); - }, [refetchSnapshotIfStale]); + refetchSnapshotIfStale(formState.provider); + }, [formState.provider, refetchSnapshotIfStale]); const persistFormPreferences = useCallback(async () => { if (!formState.provider) { @@ -604,7 +634,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setSelectedServerId, setSelectedServerIdFromUser, selectedProvider: formState.provider, - setProviderFromUser, selectedMode: formState.modeId, setModeFromUser, selectedModel: formState.model, @@ -630,6 +659,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + applyProfileFromUser, clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, @@ -643,7 +673,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg formState.workingDir, setSelectedServerId, setSelectedServerIdFromUser, - setProviderFromUser, setModeFromUser, setModelFromUser, setThinkingOptionFromUser, @@ -665,6 +694,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + applyProfileFromUser, clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index 207ac72e8..9ab23ad6d 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -145,10 +145,15 @@ export function useDraftAgentFeatures(input: { [provider, updatePreferences], ); + const applyProfileFeatureValues = useCallback((values: Record) => { + setLocalFeatureValues(values); + }, []); + return { features, featureValues: effectiveFeatureValues, isLoading: featuresQuery.isLoading, setFeatureValue, + applyProfileFeatureValues, }; } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index f6170394c..5b6cc2a50 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -47,6 +47,14 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = { ], }; +const TEST_PI_DEFINITION: AgentProviderDefinition = { + id: "pi", + label: "Pi", + description: "Pi test provider", + defaultModeId: null, + modes: [], +}; + const CODEX_MODELS: AgentModelDefinition[] = [ { provider: "codex", @@ -970,55 +978,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_PROVIDER_FROM_USER", () => { - it("switches provider, picks preferred model and mode, marks provider modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" }, - }); - - expect(next.form.provider).toBe("codex"); - expect(next.form.model).toBe("gpt-5.3-codex"); - expect(next.form.modeId).toBe("full-access"); - expect(next.userModified.provider).toBe(true); - expect(next.userModified.model).toBe(false); - }); - - it("falls back to provider defaults when no prefs", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: undefined, - }); - - expect(next.form.modeId).toBe("auto"); - expect(next.form.model).toBe("gpt-5.3-codex"); - }); - - it("canonicalizes an aliased preferred model and restores its thinking option", () => { - const next = resolveAgentForm(makeState(), { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: ALIASED_CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: { - model: "gpt-5.3-codex-legacy", - thinkingByModel: { "gpt-5.3-codex-legacy": "low" }, - }, - }); - - expect(next.form.model).toBe("gpt-5.3-codex"); - expect(next.form.thinkingOptionId).toBe("low"); - }); - }); - describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { it("sets provider, model, and default mode; marks both modified", () => { const state = makeState(); @@ -1090,6 +1049,42 @@ describe("resolveAgentForm", () => { }); }); + describe("APPLY_PROFILE_FROM_USER", () => { + it("drops a stale saved mode for a modeless profile provider", () => { + const next = resolveAgentForm(makeState({ provider: "codex", modeId: "full-access" }), { + type: "APPLY_PROFILE_FROM_USER", + provider: "pi", + modelId: "anthropic/sonnet", + modeId: "", + thinkingOptionId: "", + providerDef: TEST_PI_DEFINITION, + providerModels: [{ provider: "pi", id: "anthropic/sonnet", label: "Sonnet" }], + providerPrefs: { mode: "full-access" }, + }); + + expect(next.form).toMatchObject({ + provider: "pi", + model: "anthropic/sonnet", + modeId: "", + }); + }); + + it("restores thinking for the selected model when the profile omits it", () => { + const next = resolveAgentForm(makeState(), { + type: "APPLY_PROFILE_FROM_USER", + provider: "codex", + modelId: "gpt-5.3-codex", + modeId: "full-access", + thinkingOptionId: "", + providerDef: TEST_CODEX_DEFINITION, + providerModels: CODEX_MODELS, + providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } }, + }); + + expect(next.form.thinkingOptionId).toBe("low"); + }); + }); + describe("SET_MODEL_FROM_USER", () => { it("updates model and resets thinking to model default when thinking is not user-modified", () => { const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" }); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 1e2b56bab..9fa187200 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -79,13 +79,6 @@ export type AgentFormAction = } | { type: "SET_SERVER_ID"; value: string | null } | { type: "SET_SERVER_ID_FROM_USER"; value: string | null } - | { - type: "SET_PROVIDER_FROM_USER"; - provider: AgentProvider; - providerModels: AgentModelDefinition[] | null; - providerDef: AgentProviderDefinition | undefined; - providerPrefs: ProviderPrefs | undefined; - } | { type: "SET_PROVIDER_AND_MODEL_FROM_USER"; provider: AgentProvider; @@ -94,6 +87,16 @@ export type AgentFormAction = providerModels: AgentModelDefinition[] | null; providerPrefs?: ProviderPrefs | undefined; } + | { + type: "APPLY_PROFILE_FROM_USER"; + provider: AgentProvider; + modelId: string; + modeId: string; + thinkingOptionId: string; + providerDef: AgentProviderDefinition | undefined; + providerModels: AgentModelDefinition[] | null; + providerPrefs?: ProviderPrefs | undefined; + } | { type: "SET_MODE_FROM_USER"; modeId: string } | { type: "SET_MODEL_FROM_USER"; @@ -109,6 +112,7 @@ export type AgentFormAction = | { type: "RESET" }; type CompleteResolutionAction = Extract; +type ApplyProfileAction = Extract; export function normalizeSelectedModelId(modelId: string | null | undefined): string { return typeof modelId === "string" ? modelId.trim() : ""; @@ -484,20 +488,6 @@ export function resolveFormStateFromProviderModels( ); } -function pickNextModelForProvider(input: { - providerModels: AgentModelDefinition[] | null; - providerPrefs: ProviderPrefs | undefined; -}): string { - const { providerModels, providerPrefs } = input; - const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(providerModels); - if (preferredModel) { - if (!providerModels) return preferredModel; - return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId; - } - return defaultModelId; -} - function pickNextModeForProvider(input: { providerDef: AgentProviderDefinition | undefined; providerPrefs: ProviderPrefs | undefined; @@ -586,6 +576,45 @@ function completeResolution( return { ...nextState, form: resolved }; } +function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) { + const preferredModelId = action.modelId || action.providerPrefs?.model || ""; + const normalizedModelId = resolveCanonicalModelId(action.providerModels, preferredModelId); + const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); + const availableModeIds = new Set(action.providerDef?.modes.map((mode) => mode.id) ?? []); + const preferredModeId = action.modeId || action.providerPrefs?.mode || ""; + const defaultModeId = action.providerDef?.defaultModeId ?? ""; + let nextModeId = ""; + if (availableModeIds.has(preferredModeId)) { + nextModeId = preferredModeId; + } else if (availableModeIds.has(defaultModeId)) { + nextModeId = defaultModeId; + } + const nextThinkingOptionId = + action.thinkingOptionId || + pickNextThinkingOptionForProvider({ + providerModels: action.providerModels, + providerPrefs: action.providerPrefs, + modelId: nextModelId, + }); + return { + ...state, + form: { + ...state.form, + provider: action.provider, + model: nextModelId, + modeId: nextModeId, + thinkingOptionId: nextThinkingOptionId, + }, + userModified: { + ...state.userModified, + provider: true, + model: true, + modeId: true, + thinkingOptionId: true, + }, + }; +} + export function resolveAgentForm( state: AgentFormReducerState, action: AgentFormAction, @@ -611,33 +640,6 @@ export function resolveAgentForm( userModified: { ...state.userModified, serverId: true }, }; - case "SET_PROVIDER_FROM_USER": { - const nextModelId = pickNextModelForProvider({ - providerModels: action.providerModels, - providerPrefs: action.providerPrefs, - }); - const nextModeId = pickNextModeForProvider({ - providerDef: action.providerDef, - providerPrefs: action.providerPrefs, - }); - const nextThinkingOptionId = pickNextThinkingOptionForProvider({ - providerModels: action.providerModels, - providerPrefs: action.providerPrefs, - modelId: nextModelId, - }); - return { - ...state, - form: { - ...state.form, - provider: action.provider, - modeId: nextModeId, - model: nextModelId, - thinkingOptionId: nextThinkingOptionId, - }, - userModified: { ...state.userModified, provider: true }, - }; - } - case "SET_PROVIDER_AND_MODEL_FROM_USER": { const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); @@ -669,6 +671,10 @@ export function resolveAgentForm( }; } + case "APPLY_PROFILE_FROM_USER": { + return applyProfile(state, action); + } + case "SET_MODE_FROM_USER": return { ...state,