Preserve Claude model and thinking preferences (#2912)
* fix(agents): preserve Claude thinking selections Model switches now resolve thinking against the target model's saved preference instead of resetting to the catalog default. Claude owns its configured-model defaults and legacy Fable alias handling, while persisted permission modes remain unchanged. * chore(claude): tag Fable compatibility shim * fix(app): resolve persisted model aliases * fix(models): preserve aliases for older clients * fix(models): hide compatibility aliases from listings * fix(settings): hide compatibility model aliases * test(claude): cover legacy catalog entry * test(app): expect mock thinking controls in schedules * fix(app): preserve queued thinking preferences * fix(app): reconcile schedule provider snapshots * fix(app): preserve schedule model selections * fix(claude): preserve exact configured model ids * fix(app): preserve committed thinking preferences * fix(app): preserve aliased schedule thinking
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findModelByReference } from "./model-catalog";
|
||||
|
||||
describe("findModelByReference", () => {
|
||||
it("prefers an exact model id over another model's alias", () => {
|
||||
const models: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "canonical-model",
|
||||
label: "Canonical model",
|
||||
aliases: ["gateway-model"],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "gateway-model",
|
||||
label: "Exact gateway model",
|
||||
},
|
||||
];
|
||||
|
||||
expect(findModelByReference(models, "gateway-model")?.label).toBe("Exact gateway model");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function findModelByReference(
|
||||
models: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): AgentModelDefinition | null {
|
||||
if (!models || models.length === 0) return null;
|
||||
const normalizedModelId = modelId.trim();
|
||||
if (!normalizedModelId) return null;
|
||||
return (
|
||||
models.find((model) => model.id === normalizedModelId) ??
|
||||
models.find((model) => model.aliases?.includes(normalizedModelId)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function filterSelectableModels(
|
||||
models: AgentModelDefinition[] | null,
|
||||
): AgentModelDefinition[] | null {
|
||||
return models?.filter((model) => model.isSelectable !== false) ?? null;
|
||||
}
|
||||
@@ -66,6 +66,24 @@ describe("combined model selector data", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides compatibility-only model entries from new clients", () => {
|
||||
const compatibilityModel: AgentModelDefinition = {
|
||||
...codexModel,
|
||||
id: "gpt-5.4-legacy",
|
||||
label: "GPT-5.4 legacy",
|
||||
isSelectable: false,
|
||||
};
|
||||
|
||||
const [provider] = buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] }),
|
||||
]);
|
||||
|
||||
expect(provider?.modelSelection).toMatchObject({
|
||||
kind: "models",
|
||||
rows: [{ modelId: "gpt-5.4" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("synthesizes a default model row for ready enabled providers without explicit models", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
import { filterSelectableModels } from "./model-catalog";
|
||||
|
||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
@@ -76,10 +77,11 @@ function buildModelSelection(
|
||||
if (models === null) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
const selectableModels = filterSelectableModels(models) ?? [];
|
||||
if (selectableModels.length === 0) {
|
||||
return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] };
|
||||
}
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, selectableModels) };
|
||||
}
|
||||
|
||||
function buildEntryModelSelection(
|
||||
|
||||
@@ -61,6 +61,10 @@ const CODEX_MODELS: AgentModelDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASED_CODEX_MODELS: AgentModelDefinition[] = [
|
||||
{ ...CODEX_MODELS[0], aliases: ["gpt-5.3-codex-legacy"] },
|
||||
];
|
||||
|
||||
function makeProviderMap(
|
||||
...definitions: AgentProviderDefinition[]
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
@@ -120,6 +124,79 @@ describe("resolveDefaultModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("model aliases", () => {
|
||||
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.3-codex-legacy",
|
||||
thinkingByModel: { "gpt-5.3-codex-legacy": "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
ALIASED_CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState().form,
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("prefers thinking stored under the canonical model id over an alias", () => {
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.3-codex-legacy",
|
||||
thinkingByModel: {
|
||||
"gpt-5.3-codex": "xhigh",
|
||||
"gpt-5.3-codex-legacy": "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ALIASED_CODEX_MODELS,
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState().form,
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("prefers an exact configured model id over another model's alias", () => {
|
||||
const configuredAlias: AgentModelDefinition = {
|
||||
provider: "codex",
|
||||
id: "gpt-5.3-codex-legacy",
|
||||
label: "Gateway legacy model",
|
||||
defaultThinkingOptionId: "medium",
|
||||
thinkingOptions: [{ id: "medium", label: "medium", isDefault: true }],
|
||||
};
|
||||
const resolved = resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: { codex: { model: configuredAlias.id } },
|
||||
},
|
||||
[...ALIASED_CODEX_MODELS, configuredAlias],
|
||||
INITIAL_USER_MODIFIED,
|
||||
makeState().form,
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex-legacy");
|
||||
expect(resolved.thinkingOptionId).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThinkingOptionId", () => {
|
||||
it("returns empty string when model has no thinking options", () => {
|
||||
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||
@@ -843,6 +920,7 @@ describe("resolveAgentForm", () => {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.4-codex",
|
||||
availableModels: alternateModels,
|
||||
providerPrefs: undefined,
|
||||
});
|
||||
const next = resolveAgentForm(userChanged, {
|
||||
type: "COMPLETE_RESOLUTION",
|
||||
@@ -925,6 +1003,22 @@ describe("resolveAgentForm", () => {
|
||||
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", () => {
|
||||
@@ -981,9 +1075,10 @@ describe("resolveAgentForm", () => {
|
||||
modelId: "gpt-5.3-codex",
|
||||
providerDef: TEST_CODEX_DEFINITION,
|
||||
providerModels: CODEX_MODELS,
|
||||
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1004,6 +1099,7 @@ describe("resolveAgentForm", () => {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.3-codex",
|
||||
availableModels: CODEX_MODELS,
|
||||
providerPrefs: undefined,
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
@@ -1020,6 +1116,7 @@ describe("resolveAgentForm", () => {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.3-codex",
|
||||
availableModels: CODEX_MODELS,
|
||||
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "xhigh" } },
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
@@ -1031,10 +1128,37 @@ describe("resolveAgentForm", () => {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: " ",
|
||||
availableModels: CODEX_MODELS,
|
||||
providerPrefs: undefined,
|
||||
});
|
||||
|
||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("restores the target model's saved thinking option", () => {
|
||||
const models = [
|
||||
...CODEX_MODELS,
|
||||
{
|
||||
provider: "codex" as const,
|
||||
id: "gpt-other",
|
||||
label: "Other",
|
||||
defaultThinkingOptionId: "xhigh",
|
||||
thinkingOptions: CODEX_MODELS[0].thinkingOptions,
|
||||
},
|
||||
];
|
||||
const state = makeState({
|
||||
provider: "codex",
|
||||
model: "gpt-other",
|
||||
thinkingOptionId: "xhigh",
|
||||
});
|
||||
const next = resolveAgentForm(state, {
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId: "gpt-5.3-codex",
|
||||
availableModels: models,
|
||||
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
|
||||
});
|
||||
|
||||
expect(next.form.thinkingOptionId).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "@/hooks/use-form-preferences";
|
||||
import { findModelByReference } from "./model-catalog";
|
||||
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
@@ -98,6 +99,7 @@ export type AgentFormAction =
|
||||
type: "SET_MODEL_FROM_USER";
|
||||
modelId: string;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}
|
||||
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||
@@ -123,17 +125,36 @@ export function resolveDefaultModelId(availableModels: AgentModelDefinition[] |
|
||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveCanonicalModelId(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): string {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
if (!normalizedModelId || !availableModels) return normalizedModelId;
|
||||
return findModelByReference(availableModels, normalizedModelId)?.id ?? "";
|
||||
}
|
||||
|
||||
export function resolveEffectiveModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): AgentModelDefinition | null {
|
||||
if (!availableModels || availableModels.length === 0) return null;
|
||||
const normalizedModelId = modelId.trim();
|
||||
if (!normalizedModelId) return null;
|
||||
return (
|
||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||
resolveDefaultModel(availableModels)
|
||||
);
|
||||
if (!normalizeSelectedModelId(modelId)) return null;
|
||||
return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels);
|
||||
}
|
||||
|
||||
function resolvePreferredThinkingOptionId(input: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
modelId: string;
|
||||
}): string {
|
||||
const model = findModelByReference(input.availableModels, input.modelId);
|
||||
const modelReferences = model ? [model.id, ...(model.aliases ?? [])] : [input.modelId];
|
||||
for (const modelReference of modelReferences) {
|
||||
const thinkingOptionId = input.providerPrefs?.thinkingByModel?.[modelReference]?.trim();
|
||||
if (thinkingOptionId) return thinkingOptionId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveThinkingOptionId(args: {
|
||||
@@ -313,15 +334,18 @@ function resolveModelField(input: {
|
||||
input;
|
||||
if (userModified) return currentModel;
|
||||
if (!provider) return "";
|
||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||
if (initialModel) {
|
||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
||||
return !availableModels
|
||||
? initialModel
|
||||
: resolveCanonicalModelId(availableModels, initialModel) || defaultModelId;
|
||||
}
|
||||
if (preferredModel) {
|
||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
||||
return !availableModels
|
||||
? preferredModel
|
||||
: resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -333,19 +357,28 @@ function resolveThinkingOption(input: {
|
||||
modelId: string;
|
||||
initialValues: FormInitialValues | undefined;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
}): string {
|
||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
||||
input;
|
||||
const {
|
||||
provider,
|
||||
userModified,
|
||||
currentThinkingOptionId,
|
||||
modelId,
|
||||
initialValues,
|
||||
providerPrefs,
|
||||
availableModels,
|
||||
} = input;
|
||||
if (!provider) return "";
|
||||
if (userModified) return currentThinkingOptionId;
|
||||
const initialThinkingOptionId =
|
||||
typeof initialValues?.thinkingOptionId === "string"
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
const effectiveModelId = modelId.trim();
|
||||
const preferredThinking = effectiveModelId
|
||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||
: "";
|
||||
const preferredThinking = resolvePreferredThinkingOptionId({
|
||||
availableModels,
|
||||
providerPrefs,
|
||||
modelId,
|
||||
});
|
||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||
if (preferredThinking.length > 0) return preferredThinking;
|
||||
return "";
|
||||
@@ -399,6 +432,7 @@ export function resolveFormState(
|
||||
modelId: result.model,
|
||||
initialValues,
|
||||
providerPrefs,
|
||||
availableModels,
|
||||
});
|
||||
|
||||
if (result.provider && availableModels) {
|
||||
@@ -455,11 +489,11 @@ function pickNextModelForProvider(input: {
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
}): string {
|
||||
const { providerModels, providerPrefs } = input;
|
||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
||||
return preferredModel;
|
||||
if (preferredModel) {
|
||||
if (!providerModels) return preferredModel;
|
||||
return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId;
|
||||
}
|
||||
return defaultModelId;
|
||||
}
|
||||
@@ -496,9 +530,11 @@ function pickNextThinkingOptionForProvider(input: {
|
||||
modelId: string;
|
||||
}): string {
|
||||
const { providerModels, providerPrefs, modelId } = input;
|
||||
const preferredThinking = modelId
|
||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
||||
: "";
|
||||
const preferredThinking = resolvePreferredThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
providerPrefs,
|
||||
modelId,
|
||||
});
|
||||
return resolveThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
modelId,
|
||||
@@ -506,6 +542,30 @@ function pickNextThinkingOptionForProvider(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function pickNextThinkingOptionForTarget(input: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
modelId: string;
|
||||
currentModelId: string;
|
||||
currentThinkingOptionId: string;
|
||||
isSameProvider: boolean;
|
||||
}): string {
|
||||
const requestedThinkingOptionId =
|
||||
input.isSameProvider &&
|
||||
resolveCanonicalModelId(input.availableModels, input.currentModelId) === input.modelId
|
||||
? input.currentThinkingOptionId
|
||||
: resolvePreferredThinkingOptionId({
|
||||
availableModels: input.availableModels,
|
||||
providerPrefs: input.providerPrefs,
|
||||
modelId: input.modelId,
|
||||
});
|
||||
return resolveThinkingOptionId({
|
||||
availableModels: input.availableModels,
|
||||
modelId: input.modelId,
|
||||
requestedThinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
function completeResolution(
|
||||
state: AgentFormReducerState,
|
||||
action: CompleteResolutionAction,
|
||||
@@ -579,12 +639,15 @@ export function resolveAgentForm(
|
||||
}
|
||||
|
||||
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||
const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
const nextThinkingOptionId = pickNextThinkingOptionForTarget({
|
||||
availableModels: action.providerModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: "",
|
||||
providerPrefs: action.providerPrefs,
|
||||
currentModelId: state.form.model,
|
||||
currentThinkingOptionId: state.form.thinkingOptionId,
|
||||
isSameProvider: state.form.provider === action.provider,
|
||||
});
|
||||
const nextModeId = pickNextModeForProviderAndModel({
|
||||
currentProvider: state.form.provider,
|
||||
@@ -614,14 +677,15 @@ export function resolveAgentForm(
|
||||
};
|
||||
|
||||
case "SET_MODEL_FROM_USER": {
|
||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||
const normalizedModelId = resolveCanonicalModelId(action.availableModels, action.modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
const nextThinkingOptionId = pickNextThinkingOptionForTarget({
|
||||
availableModels: action.availableModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
||||
? state.form.thinkingOptionId
|
||||
: "",
|
||||
providerPrefs: action.providerPrefs,
|
||||
currentModelId: state.form.model,
|
||||
currentThinkingOptionId: state.form.thinkingOptionId,
|
||||
isSameProvider: true,
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
|
||||
Reference in New Issue
Block a user