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:
@@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => {
|
||||
expect(refreshing.models).toEqual([grokModel]);
|
||||
});
|
||||
|
||||
it("excludes compatibility-only models from display and cache", () => {
|
||||
const compatibilityModel: AgentModelDefinition = {
|
||||
...piModel,
|
||||
id: "pi/model-legacy",
|
||||
label: "Pi Model legacy",
|
||||
isSelectable: false,
|
||||
};
|
||||
|
||||
const result = resolveModels({
|
||||
provider: "pi",
|
||||
currentModels: [piModel, compatibilityModel],
|
||||
});
|
||||
|
||||
expect(result.models).toEqual([piModel]);
|
||||
expect(result.cache?.models).toEqual([piModel]);
|
||||
});
|
||||
|
||||
it("does not show one provider's cached models while another provider loads", () => {
|
||||
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
|
||||
export interface ProviderDiscoveredModelsCache {
|
||||
serverId: string;
|
||||
@@ -26,9 +27,10 @@ export function resolveProviderDiscoveredModels({
|
||||
providerSnapshotRefreshing,
|
||||
previousCache,
|
||||
}: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult {
|
||||
if (currentModels && currentModels.length > 0) {
|
||||
const cache = { serverId, provider, models: currentModels };
|
||||
return { models: currentModels, cache };
|
||||
const selectableModels = filterSelectableModels(currentModels ?? null) ?? [];
|
||||
if (selectableModels.length > 0) {
|
||||
const cache = { serverId, provider, models: selectableModels };
|
||||
return { models: selectableModels, cache };
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
buildSelectableProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -1448,7 +1449,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
[snapshotEntries, agent?.provider],
|
||||
);
|
||||
|
||||
const models = snapshotSelectedEntry?.models ?? null;
|
||||
const models = filterSelectableModels(snapshotSelectedEntry?.models ?? null);
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
|
||||
const agentProviderDefinitions = useMemo(
|
||||
|
||||
@@ -53,6 +53,28 @@ describe("normalizeModelId", () => {
|
||||
});
|
||||
|
||||
describe("resolveAgentModelSelection", () => {
|
||||
it("resolves a configured model alias to its canonical catalog model", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5",
|
||||
aliases: ["claude-fable-5[1m]"],
|
||||
label: "Fable 5",
|
||||
thinkingOptions: [{ id: "high", label: "High" }],
|
||||
defaultThinkingOptionId: "high",
|
||||
},
|
||||
],
|
||||
runtimeModelId: null,
|
||||
configuredModelId: "claude-fable-5[1m]",
|
||||
explicitThinkingOptionId: null,
|
||||
});
|
||||
|
||||
expect(selection.activeModelId).toBe("claude-fable-5");
|
||||
expect(selection.displayModel).toBe("Fable 5");
|
||||
expect(selection.selectedThinkingId).toBe("high");
|
||||
});
|
||||
|
||||
it("prefers runtime model over configured model", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
|
||||
@@ -55,7 +55,11 @@ function findModelById(
|
||||
if (!models || !modelId) {
|
||||
return null;
|
||||
}
|
||||
return models.find((model) => model.id === modelId) ?? null;
|
||||
return (
|
||||
models.find((model) => model.id === modelId) ??
|
||||
models.find((model) => model.aliases?.includes(modelId)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OptimisticFormPreferences } from "./optimistic-preferences";
|
||||
|
||||
describe("optimistic form preferences", () => {
|
||||
it("removes a rejected update while preserving later queued updates", () => {
|
||||
const preferences = new OptimisticFormPreferences({});
|
||||
const failed = preferences.begin({ provider: "claude" });
|
||||
const pending = preferences.begin({ isolation: "worktree" });
|
||||
|
||||
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
|
||||
|
||||
preferences.reject(failed);
|
||||
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||
|
||||
preferences.commit(pending, { isolation: "worktree" });
|
||||
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||
});
|
||||
|
||||
it("does not replace pending optimistic state with a stale external snapshot", () => {
|
||||
const preferences = new OptimisticFormPreferences({ provider: "codex" });
|
||||
const pending = preferences.begin({ provider: "claude" });
|
||||
|
||||
preferences.reconcile({ provider: "codex", isolation: "local" });
|
||||
expect(preferences.current()).toEqual({ provider: "claude" });
|
||||
|
||||
preferences.commit(pending, { provider: "claude" });
|
||||
preferences.reconcile({ provider: "claude", isolation: "worktree" });
|
||||
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FormPreferences } from "./preferences";
|
||||
import type { FormPreferenceUpdate } from "./service";
|
||||
|
||||
type PendingUpdateId = number;
|
||||
|
||||
export class OptimisticFormPreferences {
|
||||
private committed: FormPreferences;
|
||||
private readonly pending = new Map<PendingUpdateId, FormPreferenceUpdate>();
|
||||
private nextId = 0;
|
||||
|
||||
constructor(initial: FormPreferences) {
|
||||
this.committed = initial;
|
||||
}
|
||||
|
||||
current(): FormPreferences {
|
||||
let current = this.committed;
|
||||
for (const update of this.pending.values()) {
|
||||
current = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
begin(update: FormPreferenceUpdate): PendingUpdateId {
|
||||
const id = this.nextId++;
|
||||
this.pending.set(id, update);
|
||||
return id;
|
||||
}
|
||||
|
||||
commit(id: PendingUpdateId, persisted: FormPreferences): void {
|
||||
this.pending.delete(id);
|
||||
this.committed = persisted;
|
||||
}
|
||||
|
||||
reject(id: PendingUpdateId): void {
|
||||
this.pending.delete(id);
|
||||
}
|
||||
|
||||
reconcile(preferences: FormPreferences): void {
|
||||
if (this.pending.size === 0) {
|
||||
this.committed = preferences;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,25 @@ describe("create agent preferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not commit a failed write or leak it into the next update", async () => {
|
||||
const storage = new FakeCreateAgentPreferenceStorage();
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const failedWrite = preferences.update({ provider: "claude" });
|
||||
await storage.nextWrite();
|
||||
storage.failOldestWrite(new Error("disk full"));
|
||||
await expect(failedWrite).rejects.toThrow("disk full");
|
||||
|
||||
expect(await preferences.load()).toEqual({});
|
||||
|
||||
const successfulWrite = preferences.update({ isolation: "worktree" });
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
await successfulWrite;
|
||||
|
||||
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
|
||||
});
|
||||
|
||||
it("flushes the full create-agent selection into provider preferences", async () => {
|
||||
const storage = new FakeCreateAgentPreferenceStorage();
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
@@ -50,9 +50,10 @@ export class CreateAgentPreferencesService {
|
||||
await previousWrite;
|
||||
const current = await this.load();
|
||||
const next = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||
this.preferences = parseFormPreferences(next);
|
||||
const parsed = parseFormPreferences(next);
|
||||
await this.storage.write(parsed);
|
||||
this.preferences = parsed;
|
||||
this.isLoaded = true;
|
||||
await this.storage.write(this.preferences);
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CreateAgentPreferenceStorage } from "../storage";
|
||||
interface PendingWrite {
|
||||
preferences: FormPreferences;
|
||||
finish: () => void;
|
||||
fail: (error: Error) => void;
|
||||
}
|
||||
|
||||
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
|
||||
@@ -20,13 +21,16 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
|
||||
}
|
||||
|
||||
write(preferences: FormPreferences): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const write = {
|
||||
preferences,
|
||||
finish: () => {
|
||||
this.stored = clone(preferences);
|
||||
resolve();
|
||||
},
|
||||
fail: (error: Error) => {
|
||||
reject(error);
|
||||
},
|
||||
};
|
||||
this.pendingWrites.push(write);
|
||||
this.pendingWriteWaiters.shift()?.(write);
|
||||
@@ -55,6 +59,14 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
|
||||
write.finish();
|
||||
}
|
||||
|
||||
failOldestWrite(error: Error): void {
|
||||
const write = this.pendingWrites.shift();
|
||||
if (!write) {
|
||||
throw new Error("No pending create-agent preference write");
|
||||
}
|
||||
write.fail(error);
|
||||
}
|
||||
|
||||
savedPreferences(): unknown {
|
||||
return this.stored;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
buildSelectableProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -156,7 +158,7 @@ function buildAllProviderModels(
|
||||
): Map<string, AgentModelDefinition[]> {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
map.set(entry.provider, entry.models ?? []);
|
||||
map.set(entry.provider, filterSelectableModels(entry.models ?? []) ?? []);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -166,7 +168,7 @@ function buildProviderModelsByProvider(
|
||||
): ProviderModelsByProvider {
|
||||
const map: ProviderModelsByProvider = new Map();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
map.set(entry.provider, entry.models ?? null);
|
||||
map.set(entry.provider, filterSelectableModels(entry.models ?? null));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -177,7 +179,7 @@ async function persistProviderPreferences(input: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
updatePreferences: (
|
||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||
) => Promise<void>;
|
||||
) => Promise<FormPreferences>;
|
||||
}): Promise<void> {
|
||||
const { provider, formState, availableModels, updatePreferences } = input;
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
@@ -208,6 +210,28 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = options;
|
||||
|
||||
const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences();
|
||||
const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences));
|
||||
|
||||
useEffect(() => {
|
||||
preferenceOverlayRef.current.reconcile(preferences);
|
||||
}, [preferences]);
|
||||
|
||||
const updateCurrentPreferences = useCallback(
|
||||
async (
|
||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||
): Promise<FormPreferences> => {
|
||||
const pendingId = preferenceOverlayRef.current.begin(updates);
|
||||
try {
|
||||
const persisted = await updatePreferences(updates);
|
||||
preferenceOverlayRef.current.commit(pendingId, persisted);
|
||||
return persisted;
|
||||
} catch (error) {
|
||||
preferenceOverlayRef.current.reject(pendingId);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const daemons = useHosts();
|
||||
|
||||
@@ -294,7 +318,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
: null,
|
||||
[formState.provider, snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||
const snapshotSelectedProviderModels = filterSelectableModels(
|
||||
snapshotSelectedEntry?.models ?? null,
|
||||
);
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
const snapshotSelectedProviderModes = resolveSelectedProviderModes({
|
||||
selectedEntry: snapshotSelectedEntry,
|
||||
@@ -404,7 +430,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
@@ -413,14 +439,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
providerDef,
|
||||
providerPrefs,
|
||||
});
|
||||
void updatePreferences({ provider });
|
||||
void updateCurrentPreferences({ provider });
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
preferences?.providerPreferences,
|
||||
selectableProviderDefinitionMap,
|
||||
updatePreferences,
|
||||
],
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
@@ -430,7 +451,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
|
||||
@@ -442,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
});
|
||||
void updatePreferences((current) =>
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
@@ -452,12 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}),
|
||||
);
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
preferences?.providerPreferences,
|
||||
selectableProviderDefinitionMap,
|
||||
updatePreferences,
|
||||
],
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
@@ -469,7 +485,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
if (provider) {
|
||||
void updatePreferences((current) =>
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
@@ -480,17 +496,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[updatePreferences],
|
||||
[updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels,
|
||||
providerPrefs,
|
||||
});
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
void updatePreferences((current) =>
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
@@ -501,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[availableModels, updatePreferences],
|
||||
[availableModels, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
@@ -509,7 +533,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||
if (provider && modelId) {
|
||||
void updatePreferences((current) =>
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
@@ -522,7 +546,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[updatePreferences],
|
||||
[updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
@@ -556,9 +580,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
provider: formState.provider,
|
||||
formState,
|
||||
availableModels,
|
||||
updatePreferences,
|
||||
updatePreferences: updateCurrentPreferences,
|
||||
});
|
||||
}, [availableModels, formState, updatePreferences]);
|
||||
}, [availableModels, formState, updateCurrentPreferences]);
|
||||
|
||||
const agentDefinition = formState.provider
|
||||
? providerDefinitionMap.get(formState.provider)
|
||||
|
||||
@@ -29,7 +29,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
||||
export interface UseFormPreferencesReturn {
|
||||
preferences: FormPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
|
||||
updatePreferences: (updates: FormPreferenceUpdate) => Promise<FormPreferences>;
|
||||
}
|
||||
|
||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
@@ -47,6 +47,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
async (updates: FormPreferenceUpdate) => {
|
||||
const next = await createAgentPreferencesService.update(updates);
|
||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||
return next;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
@@ -188,7 +189,7 @@ function ProviderRow({
|
||||
entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry.models?.length ?? 0;
|
||||
const modelCount = filterSelectableModels(entry.models ?? null)?.length ?? 0;
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user