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:
Mohamed Boudra
2026-08-06 11:33:18 +02:00
committed by GitHub
parent 73ba37a07a
commit f2054cc701
43 changed files with 1369 additions and 220 deletions
@@ -32,6 +32,7 @@ const HOST_B_MODELS: AgentModelDefinition[] = [
provider: "mock",
id: "model-b",
label: "Model B",
aliases: ["model-b-legacy"],
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
@@ -41,6 +42,20 @@ const HOST_B_MODELS: AgentModelDefinition[] = [
},
];
const THINKING_MODELS: AgentModelDefinition[] = [
HOST_B_MODELS[0]!,
{
provider: "mock",
id: "model-c",
label: "Model C",
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low", isDefault: true },
{ id: "high", label: "High" },
],
},
];
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
function target(input: {
@@ -81,6 +96,7 @@ function scheduleOnHost(input: {
serverName: string;
cwd: string;
model: string;
thinkingOptionId?: string | null;
cadence?: ScheduleSummary["cadence"];
}): TestSchedule {
return {
@@ -97,7 +113,8 @@ function scheduleOnHost(input: {
cwd: input.cwd,
model: input.model,
modeId: "load-test",
thinkingOptionId: "high",
thinkingOptionId:
input.thinkingOptionId === undefined ? "high" : (input.thinkingOptionId ?? undefined),
archiveOnFinish: false,
isolation: "worktree",
},
@@ -133,13 +150,16 @@ function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule {
};
}
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
function providerSnapshot(
models: AgentModelDefinition[],
status: ProviderSnapshotEntry["status"] = "ready",
): { entries: ProviderSnapshotEntry[] } {
return {
entries: [
{
provider: "mock",
label: "Mock",
status: "ready",
status,
enabled: true,
fetchedAt: "2026-07-01T00:00:00.000Z",
models,
@@ -223,6 +243,69 @@ describe("schedule form model", () => {
});
});
it("reconciles untouched thinking when a loading provider snapshot becomes ready", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
thinkingOptionId: null,
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
const loadingModel = { ...HOST_B_MODELS[0] };
delete loadingModel.thinkingOptions;
delete loadingModel.defaultThinkingOptionId;
form.applyProviderSnapshot("host-b", providerSnapshot([loadingModel], "loading"));
expect(form.getState().selectedThinkingOptionId).toBe("");
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
selectedModel: "model-b",
selectedThinkingOptionId: "high",
selectedThinkingDisplay: { label: "High" },
});
form.setThinking("low");
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState().selectedThinkingOptionId).toBe("low");
});
it("preserves per-model thinking across model switches and snapshot updates", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b-legacy",
thinkingOptionId: "low",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS));
form.setModel("mock", "model-b");
expect(form.getState().selectedThinkingOptionId).toBe("low");
form.setThinking("high");
form.setModel("mock", "model-c");
expect(form.getState().selectedThinkingOptionId).toBe("low");
form.setModel("mock", "model-b");
expect(form.getState().selectedThinkingOptionId).toBe("high");
form.setModel("mock", "model-b");
expect(form.getState().selectedThinkingOptionId).toBe("high");
form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS));
expect(form.getState().selectedThinkingOptionId).toBe("high");
});
it("opens create pristine after an edit instance closes", () => {
const edit = open({
mode: "edit",
@@ -11,6 +11,7 @@ import {
buildSelectableProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { filterSelectableModels, findModelByReference } from "@/provider-selection/model-catalog";
import {
buildProviderDefinitionMapForStatuses,
INITIAL_USER_MODIFIED,
@@ -221,7 +222,7 @@ function resolveProjectDisplay(input: {
function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider {
const map: ProviderModelsByProvider = new Map();
for (const entry of entries) {
map.set(entry.provider, entry.models ?? null);
map.set(entry.provider, filterSelectableModels(entry.models ?? null));
}
return map;
}
@@ -247,7 +248,7 @@ function resolveAvailableModels(
entries: readonly ProviderSnapshotEntry[],
provider: AgentProvider | null,
): AgentModelDefinition[] | null {
return resolveSelectedEntry(entries, provider)?.models ?? null;
return filterSelectableModels(resolveSelectedEntry(entries, provider)?.models ?? null);
}
function resolveEffectiveModel(
@@ -789,6 +790,58 @@ function pickModelForProvider(input: {
return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider));
}
function thinkingDraftKey(provider: AgentProvider, modelId: string): string {
return `${provider}:${modelId}`;
}
function seedThinkingDrafts(
drafts: Map<string, string>,
preferences: FormPreferences | null,
): void {
for (const [provider, providerPreferences] of Object.entries(
preferences?.providerPreferences ?? {},
)) {
for (const [modelId, thinkingOptionId] of Object.entries(
providerPreferences?.thinkingByModel ?? {},
)) {
const key = thinkingDraftKey(provider as AgentProvider, modelId);
if (!drafts.has(key)) {
drafts.set(key, thinkingOptionId);
}
}
}
}
function canonicalizeThinkingDrafts(
drafts: Map<string, string>,
entries: readonly ProviderSnapshotEntry[],
): void {
for (const entry of entries) {
const models = filterSelectableModels(entry.models ?? null);
if (!models) {
continue;
}
for (const model of models) {
const canonicalKey = thinkingDraftKey(entry.provider, model.id);
if (drafts.has(canonicalKey)) {
continue;
}
const resolvedAlias = model.aliases?.find(
(alias) =>
findModelByReference(models, alias)?.id === model.id &&
drafts.has(thinkingDraftKey(entry.provider, alias)),
);
if (!resolvedAlias) {
continue;
}
const aliasedThinking = drafts.get(thinkingDraftKey(entry.provider, resolvedAlias));
if (aliasedThinking !== undefined) {
drafts.set(canonicalKey, aliasedThinking);
}
}
}
}
export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel {
const listeners = new Set<() => void>();
const initialValues = normalizeInitialValues({
@@ -799,6 +852,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
let hosts = snapshot.hosts;
let projectTargets = snapshot.defaults.projectTargets;
let preferences = snapshot.defaults.preferences ?? null;
const thinkingDrafts = new Map<string, string>();
seedThinkingDrafts(thinkingDrafts, preferences);
const initialAgentConfig = newAgentConfig(snapshot.schedule);
if (
initialAgentConfig?.provider &&
initialAgentConfig.model &&
initialAgentConfig.thinkingOptionId
) {
thinkingDrafts.set(
thinkingDraftKey(initialAgentConfig.provider, initialAgentConfig.model),
initialAgentConfig.thinkingOptionId,
);
}
let providerEntries: ProviderSnapshotEntry[] = [];
let userModified = { ...INITIAL_USER_MODIFIED, isolation: false };
const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE;
@@ -913,6 +979,7 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
return;
}
preferences = normalizedPreferences;
seedThinkingDrafts(thinkingDrafts, preferences);
publish(resolvePreferences(state));
},
applyProviderSnapshot(serverId, providerSnapshot) {
@@ -920,17 +987,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
return;
}
providerEntries = providerSnapshot.entries;
canonicalizeThinkingDrafts(thinkingDrafts, providerEntries);
const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId;
const resolved = isPendingResolution
? resolveSnapshotSelection({
state,
snapshot,
initialValues,
preferences: preferencesForSnapshotResolution(snapshot, preferences),
providerEntries,
userModified,
})
: state;
const resolved =
state.targetKind === "new-agent"
? resolveSnapshotSelection({
state,
snapshot,
initialValues,
preferences: preferencesForSnapshotResolution(snapshot, preferences),
providerEntries,
userModified,
})
: state;
const providerResolutionByServerId: Record<string, ProviderResolutionStatus> = {
...state.providerResolutionByServerId,
};
@@ -1000,9 +1069,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
const selectedThinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: selectedModel,
requestedThinkingOptionId: "",
requestedThinkingOptionId:
thinkingDrafts.get(thinkingDraftKey(provider, selectedModel)) ?? "",
});
userModified = { ...userModified, provider: true, model: true };
if (selectedModel && selectedThinkingOptionId) {
thinkingDrafts.set(thinkingDraftKey(provider, selectedModel), selectedThinkingOptionId);
}
userModified = {
...userModified,
provider: true,
model: true,
modeId: true,
thinkingOptionId: true,
};
publish({
...state,
selectedProvider: provider,
@@ -1020,6 +1099,12 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
if (closed) {
return;
}
if (state.selectedProvider && state.selectedModel) {
thinkingDrafts.set(
thinkingDraftKey(state.selectedProvider, state.selectedModel),
thinkingOptionId,
);
}
userModified = { ...userModified, thinkingOptionId: true };
publish({ ...state, selectedThinkingOptionId: thinkingOptionId });
},