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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user