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
This commit is contained in:
Mohamed Boudra
2026-08-13 18:48:02 +02:00
committed by GitHub
parent 90f3d95415
commit 9c195feb9b
17 changed files with 622 additions and 193 deletions
@@ -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<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
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<unknown> {
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();
}
});
});
+38
View File
@@ -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`,
);
}
@@ -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<HostSeed> {
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,
},
},
+1
View File
@@ -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,
@@ -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> = {}): 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" });
});
});
@@ -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
@@ -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);
@@ -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<AgentModelDefinition["thinkingOptions"]>;
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<typeof resolveSnapshotSelectedEntry>,
): string[] | null {
if (entry?.status !== "ready" || !entry.modes) {
return null;
}
return entry.modes.map((mode) => mode.id);
}
function buildAgentProviderDefinitions(
agentProvider: string | undefined,
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["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<AgentProfileApplyTarget>(
() => ({ 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,
@@ -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");
});
@@ -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,
@@ -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<typeof formState.applyProfileFromUser>[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,
@@ -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({});
});
@@ -112,7 +112,7 @@ function mergeDefinedRecord<T>(
function applyProviderPreferenceUpdates(
existing: ProviderPreferences,
updates: Partial<ProviderPreferences>,
updates: Omit<Partial<ProviderPreferences>, "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<ProviderPreferences>;
updates: Omit<Partial<ProviderPreferences>, "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<string, unknown>;
}): 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,
},
});
}
+67 -37
View File
@@ -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<void>;
@@ -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,
@@ -145,10 +145,15 @@ export function useDraftAgentFeatures(input: {
[provider, updatePreferences],
);
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -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: "" });
@@ -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<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
type ApplyProfileAction = Extract<AgentFormAction, { type: "APPLY_PROFILE_FROM_USER" }>;
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,