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
@@ -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,