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