From fadeca82eb61cec0961f96cd2d0a5cec810aadef Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 23:32:23 +0700 Subject: [PATCH] Use parsed Claude models and remove auto defaults --- .../src/components/agent-status-bar.test.ts | 50 +++- .../app/src/components/agent-status-bar.tsx | 6 +- .../src/components/agent-status-bar.utils.ts | 20 +- .../components/combined-model-selector.tsx | 11 +- .../src/hooks/use-agent-form-state.test.ts | 112 ++++++--- .../app/src/hooks/use-agent-form-state.ts | 160 ++++++------ .../app/src/hooks/use-form-preferences.ts | 45 ---- .../src/screens/agent/draft-agent-screen.tsx | 42 +++- .../workspace/workspace-draft-agent-tab.tsx | 44 +++- .../app/src/utils/extract-agent-model.test.ts | 4 +- packages/app/src/utils/extract-agent-model.ts | 4 +- .../server/src/server/agent/agent-manager.ts | 4 +- .../claude-agent.integration.test.ts | 49 ++++ .../claude-agent.model-normalization.test.ts | 99 -------- .../agent/providers/claude-agent.test.ts | 91 +++++++ .../server/agent/providers/claude-agent.ts | 237 ++++++------------ .../agent/providers/claude/model-catalog.ts | 108 -------- .../claude/sdk-model-resolver.test.ts | 90 +++++++ .../providers/claude/sdk-model-resolver.ts | 140 +++++++++++ .../src/server/client-activity.e2e.test.ts | 2 +- 20 files changed, 750 insertions(+), 568 deletions(-) delete mode 100644 packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts delete mode 100644 packages/server/src/server/agent/providers/claude/model-catalog.ts create mode 100644 packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/components/agent-status-bar.test.ts index 99e65bb46..3c1d9aafc 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/components/agent-status-bar.test.ts @@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => { }); describe("normalizeModelId", () => { - it("treats empty and default values as unset", () => { + it("treats empty values as unset", () => { expect(normalizeModelId("")).toBeNull(); - expect(normalizeModelId(" default ")).toBeNull(); expect(normalizeModelId(undefined)).toBeNull(); }); it("returns trimmed model ids", () => { expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex"); + expect(normalizeModelId(" default ")).toBe("default"); }); }); @@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => { expect(selection.selectedThinkingId).toBe("high"); expect(selection.displayThinking).toBe("High"); }); + + it("falls back to the provider default model label instead of Auto", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "a", + provider: "codex", + label: "Model A", + isDefault: true, + thinkingOptions: [{ id: "low", label: "Low" }], + defaultThinkingOptionId: "low", + }, + ], + runtimeModelId: null, + configuredModelId: null, + explicitThinkingOptionId: null, + }); + + expect(selection.displayModel).toBe("Model A"); + expect(selection.displayThinking).toBe("Low"); + }); + + it("prefers the configured model when runtime model is not in the model list", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "default", + provider: "claude", + label: "Default (Sonnet 4.6)", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + ], + }, + ], + runtimeModelId: "claude-sonnet-4-6-20260101", + configuredModelId: "default", + explicitThinkingOptionId: null, + }); + + expect(selection.activeModelId).toBe("default"); + expect(selection.displayModel).toBe("Default (Sonnet 4.6)"); + expect(selection.selectedThinkingId).toBeNull(); + expect(selection.displayThinking).toBe("Default"); + }); }); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 07e08497f..c9d834d2f 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -166,8 +166,8 @@ function ControlledStatusBar({ const displayModel = isModelLoading && (!modelOptions || modelOptions.length === 0) ? "Loading models..." - : findOptionLabel(modelOptions, selectedModelId, "Auto"); - const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto"); + : findOptionLabel(modelOptions, selectedModelId, "Select model"); + const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "Default"); const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; @@ -777,7 +777,7 @@ export function DraftAgentStatusBar({ label: definition.label, })); - const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }]; + const modelOptions: StatusOption[] = []; for (const model of models) { modelOptions.push({ id: model.id, label: model.label }); } diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts index ce048eb5f..c68318bc5 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string export function normalizeModelId(modelId: string | null | undefined): string | null { const normalized = typeof modelId === "string" ? modelId.trim() : ""; - if (!normalized || normalized.toLowerCase() === "default") { + if (!normalized) { return null; } return normalized; @@ -30,14 +30,22 @@ export function resolveAgentModelSelection(input: { const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); const normalizedConfiguredModelId = normalizeModelId(configuredModelId); - const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId; + const runtimeSelectedModel = + models && normalizedRuntimeModelId + ? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null) + : null; + const preferredModelId = + runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId; + const fallbackModel = + models?.find((model) => model.isDefault) ?? models?.[0] ?? null; const selectedModel = models && preferredModelId - ? (models.find((model) => model.id === preferredModelId) ?? null) - : null; + ? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null) + : fallbackModel; const activeModelId = selectedModel?.id ?? preferredModelId ?? null; - const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto"; + const displayModel = + selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model"; const thinkingOptions = selectedModel?.thinkingOptions ?? null; const selectedThinkingId = @@ -48,7 +56,7 @@ export function resolveAgentModelSelection(input: { thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null; const displayThinking = selectedThinking?.label ?? - (selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto")); + (selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "Default")); return { selectedModel, diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 11bb1efa5..c60fd4e10 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8; type DrillDownView = { provider: string }; +function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string | null { + if (!models || models.length === 0) { + return null; + } + return (models.find((model) => model.isDefault) ?? models[0])?.label ?? null; +} + interface CombinedModelSelectorProps { providerDefinitions: AgentProviderDefinition[]; allProviderModels: Map; @@ -66,9 +73,9 @@ export function CombinedModelSelector({ const selectedModelLabel = useMemo(() => { const models = allProviderModels.get(selectedProvider); - if (!models) return isLoading ? "Loading..." : "Auto"; + if (!models) return isLoading ? "Loading..." : "Select model"; const model = models.find((m) => m.id === selectedModel); - return model?.label ?? "Auto"; + return model?.label ?? resolveDefaultModelLabel(models) ?? "Select model"; }, [allProviderModels, selectedProvider, selectedModel, isLoading]); return ( diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 630f4c051..6cb7f5128 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -80,20 +80,14 @@ describe("useAgentFormState", () => { new Set(), ); + expect(resolved.model).toBe("gpt-5.3-codex"); expect(resolved.thinkingOptionId).toBe("xhigh"); }); - it("keeps provider thinking preference when it is valid for the effective model", () => { + it("prefers provider defaults on fresh drafts", () => { const resolved = __private__.resolveFormState( undefined, - { - provider: "codex", - providerPreferences: { - codex: { - thinkingOptionId: "low", - }, - }, - }, + { provider: "codex" }, codexModels, { serverId: false, @@ -114,20 +108,14 @@ describe("useAgentFormState", () => { new Set(), ); - expect(resolved.thinkingOptionId).toBe("low"); + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("xhigh"); }); it("falls back to model default when saved thinking preference is invalid", () => { const resolved = __private__.resolveFormState( undefined, - { - provider: "codex", - providerPreferences: { - codex: { - thinkingOptionId: "medium", - }, - }, - }, + { provider: "codex" }, codexModels, { serverId: false, @@ -151,7 +139,7 @@ describe("useAgentFormState", () => { expect(resolved.thinkingOptionId).toBe("xhigh"); }); - it("normalizes legacy model id 'default' from initial values to auto", () => { + it("normalizes legacy model id 'default' from initial values to the provider default model", () => { const resolved = __private__.resolveFormState( { model: "default" }, { provider: "codex" }, @@ -175,20 +163,13 @@ describe("useAgentFormState", () => { new Set(), ); - expect(resolved.model).toBe(""); + expect(resolved.model).toBe("gpt-5.3-codex"); }); - it("normalizes legacy model id 'default' from provider preferences to auto", () => { + it("normalizes legacy model id 'default' to the provider default model", () => { const resolved = __private__.resolveFormState( - undefined, - { - provider: "codex", - providerPreferences: { - codex: { - model: "default", - }, - }, - }, + { model: "default" }, + { provider: "codex" }, codexModels, { serverId: false, @@ -209,7 +190,76 @@ describe("useAgentFormState", () => { new Set(), ); - expect(resolved.model).toBe(""); + expect(resolved.model).toBe("gpt-5.3-codex"); + }); + + it("keeps an explicit initial thinking option when it is valid", () => { + const resolved = __private__.resolveFormState( + { thinkingOptionId: "low" }, + { provider: "codex" }, + codexModels, + { + serverId: false, + provider: false, + modeId: false, + model: false, + thinkingOptionId: false, + workingDir: false, + }, + { + serverId: null, + provider: "codex", + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + new Set(), + ); + + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("low"); + }); + + it("leaves thinking unset when the model exposes options without a provider default", () => { + const claudeModels: AgentModelDefinition[] = [ + { + provider: "claude", + id: "default", + label: "Default (Sonnet 4.6)", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + ], + }, + ]; + + const resolved = __private__.resolveFormState( + undefined, + { provider: "claude" }, + claudeModels, + { + serverId: false, + provider: false, + modeId: false, + model: false, + thinkingOptionId: false, + workingDir: false, + }, + { + serverId: null, + provider: "claude", + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + new Set(), + ); + + expect(resolved.model).toBe("default"); + expect(resolved.thinkingOptionId).toBe(""); }); it("resolves provider only from allowed provider map", () => { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index d2af3da12..dd18ca1c8 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -102,7 +102,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? " function normalizeSelectedModelId(modelId: string | null | undefined): string { const normalized = typeof modelId === "string" ? modelId.trim() : ""; - if (!normalized || normalized.toLowerCase() === "default") { + if (!normalized) { return ""; } return normalized; @@ -117,6 +117,10 @@ function resolveDefaultModel( return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null; } +function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string { + return resolveDefaultModel(availableModels)?.id ?? ""; +} + function resolveEffectiveModel( availableModels: AgentModelDefinition[] | null, modelId: string, @@ -134,9 +138,31 @@ function resolveEffectiveModel( ); } +function resolveThinkingOptionId(args: { + availableModels: AgentModelDefinition[] | null; + modelId: string; + requestedThinkingOptionId: string; +}): string { + const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId); + const thinkingOptions = effectiveModel?.thinkingOptions ?? []; + if (thinkingOptions.length === 0) { + return ""; + } + + const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim(); + if ( + normalizedThinkingOptionId && + thinkingOptions.some((option) => option.id === normalizedThinkingOptionId) + ) { + return normalizedThinkingOptionId; + } + + return effectiveModel?.defaultThinkingOptionId ?? ""; +} + /** * Pure function that resolves form state from multiple data sources. - * Priority: explicit (URL params) > preferences > provider defaults > fallback + * Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback * * Only resolves fields that haven't been user-modified. */ @@ -171,8 +197,6 @@ function resolveFormState( } const providerDef = allowedProviderMap.get(result.provider); - const providerPrefs = preferences?.providerPreferences?.[result.provider]; - // 2. Resolve modeId (depends on provider) if (!userModified.modeId) { const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; @@ -183,8 +207,6 @@ function resolveFormState( validModeIds.includes(initialValues.modeId) ) { result.modeId = initialValues.modeId; - } else if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) { - result.modeId = providerPrefs.mode; } else { result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? ""; } @@ -194,26 +216,18 @@ function resolveFormState( if (!userModified.model) { 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) { // If models aren't loaded yet, trust the initial value // It will be validated once models load if (!availableModels || isValidModel(initialModel)) { result.model = initialModel; - } else if (preferredModel && isValidModel(preferredModel)) { - result.model = preferredModel; } else { - result.model = ""; - } - } else if (preferredModel) { - // If models haven't loaded yet, optimistically apply the stored preference. - // We'll validate once models load and clear it if it isn't available. - if (!availableModels || isValidModel(preferredModel)) { - result.model = preferredModel; - } else { - result.model = ""; + result.model = defaultModelId; } + } else if (defaultModelId) { + result.model = defaultModelId; } else { result.model = ""; } @@ -224,13 +238,10 @@ function resolveFormState( typeof initialValues?.thinkingOptionId === "string" ? initialValues.thinkingOptionId.trim() : ""; - const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? ""; if (!userModified.thinkingOptionId) { if (initialThinkingOptionId.length > 0) { result.thinkingOptionId = initialThinkingOptionId; - } else if (preferredThinkingOptionId.length > 0) { - result.thinkingOptionId = preferredThinkingOptionId; } else { result.thinkingOptionId = ""; } @@ -238,18 +249,11 @@ function resolveFormState( // Validate thinking option once model metadata is available. if (availableModels) { - const effectiveModel = resolveEffectiveModel(availableModels, result.model); - const thinkingOptions = effectiveModel?.thinkingOptions ?? []; - if (thinkingOptions.length === 0) { - result.thinkingOptionId = ""; - } else { - const thinkingIds = new Set(thinkingOptions.map((option) => option.id)); - const defaultThinkingOptionId = - effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? ""; - if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) { - result.thinkingOptionId = defaultThinkingOptionId; - } - } + result.thinkingOptionId = resolveThinkingOptionId({ + availableModels, + modelId: result.model, + requestedThinkingOptionId: result.thinkingOptionId, + }); } // 5. Resolve serverId (independent) @@ -313,7 +317,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg preferences, isLoading: isPreferencesLoading, updatePreferences, - updateProviderPreferences, } = useFormPreferences(); const daemons = useHosts(); @@ -568,76 +571,89 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setProviderFromUser = useCallback( (provider: AgentProvider) => { - setFormState((prev) => ({ ...prev, provider })); + const providerModels = allProviderModels.get(provider) ?? null; + const providerDef = providerDefinitionMap.get(provider); + const defaultModelId = resolveDefaultModelId(providerModels); + const defaultThinkingOptionId = resolveThinkingOptionId({ + availableModels: providerModels, + modelId: defaultModelId, + requestedThinkingOptionId: "", + }); + setUserModified((prev) => ({ ...prev, provider: true })); void updatePreferences({ provider }); - // When provider changes, reset mode and model to provider defaults - // (unless user has explicitly set them) - const providerDef = providerDefinitionMap.get(provider); - const providerPrefs = preferences?.providerPreferences?.[provider]; - setFormState((prev) => ({ ...prev, provider, - modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", - model: normalizeSelectedModelId(providerPrefs?.model), - thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", + modeId: providerDef?.defaultModeId ?? "", + model: defaultModelId, + thinkingOptionId: defaultThinkingOptionId, })); }, - [preferences?.providerPreferences, providerDefinitionMap, updatePreferences], + [allProviderModels, providerDefinitionMap, updatePreferences], ); const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { const providerDef = providerDefinitionMap.get(provider); - const providerPrefs = preferences?.providerPreferences?.[provider]; + const providerModels = allProviderModels.get(provider) ?? null; + const normalizedModelId = normalizeSelectedModelId(modelId); + const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); + const nextThinkingOptionId = resolveThinkingOptionId({ + availableModels: providerModels, + modelId: nextModelId, + requestedThinkingOptionId: "", + }); setFormState((prev) => ({ ...prev, provider, - model: modelId, - modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", - thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", + model: nextModelId, + modeId: providerDef?.defaultModeId ?? "", + thinkingOptionId: nextThinkingOptionId, })); setUserModified((prev) => ({ ...prev, provider: true, model: true })); void updatePreferences({ provider }); - void updateProviderPreferences(provider, { model: modelId }); }, - [ - preferences?.providerPreferences, - providerDefinitionMap, - updatePreferences, - updateProviderPreferences, - ], + [allProviderModels, providerDefinitionMap, updatePreferences], ); const setModeFromUser = useCallback( (modeId: string) => { setFormState((prev) => ({ ...prev, modeId })); setUserModified((prev) => ({ ...prev, modeId: true })); - void updateProviderPreferences(formState.provider, { mode: modeId }); }, - [formState.provider, updateProviderPreferences], + [], ); const setModelFromUser = useCallback( (modelId: string) => { const normalizedModelId = normalizeSelectedModelId(modelId); - setFormState((prev) => ({ ...prev, model: normalizedModelId })); + const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels); + const nextThinkingOptionId = resolveThinkingOptionId({ + availableModels, + modelId: nextModelId, + requestedThinkingOptionId: userModified.thinkingOptionId + ? formStateRef.current.thinkingOptionId + : "", + }); + setFormState((prev) => ({ + ...prev, + model: nextModelId, + thinkingOptionId: nextThinkingOptionId, + })); setUserModified((prev) => ({ ...prev, model: true })); - void updateProviderPreferences(formState.provider, { model: normalizedModelId }); }, - [formState.provider, updateProviderPreferences], + [availableModels, userModified.thinkingOptionId], ); const setThinkingOptionFromUser = useCallback( (thinkingOptionId: string) => { setFormState((prev) => ({ ...prev, thinkingOptionId })); setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); - void updateProviderPreferences(formState.provider, { thinkingOptionId }); }, - [formState.provider, updateProviderPreferences], + [], ); const setWorkingDir = useCallback((value: string) => { @@ -662,34 +678,16 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, [providerModelsQuery]); const persistFormPreferences = useCallback(async () => { - const providerPreferenceUpdates: { - mode: string; - model: string; - thinkingOptionId?: string; - } = { - mode: formState.modeId, - model: formState.model, - }; - if (userModified.thinkingOptionId) { - providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId; - } - await updatePreferences({ workingDir: formState.workingDir, provider: formState.provider, serverId: formState.serverId ?? undefined, }); - await updateProviderPreferences(formState.provider, providerPreferenceUpdates); }, [ - formState.modeId, - formState.model, formState.provider, formState.serverId, - formState.thinkingOptionId, formState.workingDir, - userModified.thinkingOptionId, updatePreferences, - updateProviderPreferences, ]); const agentDefinition = providerDefinitionMap.get(formState.provider); @@ -771,5 +769,7 @@ export type CreateAgentInitialValues = FormInitialValues; export const __private__ = { combineInitialValues, + resolveDefaultModel, resolveFormState, + resolveThinkingOptionId, }; diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index 7af0c063a..112f28ee7 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -2,25 +2,15 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; - const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; -const providerPreferencesSchema = z.object({ - model: z.string().optional(), - mode: z.string().optional(), - thinkingOptionId: z.string().optional(), -}); - const formPreferencesSchema = z.object({ workingDir: z.string().optional(), provider: z.string().optional(), serverId: z.string().optional(), - providerPreferences: z.record(providerPreferencesSchema).optional(), }); -export type ProviderPreferences = z.infer; export type FormPreferences = z.infer; const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; @@ -35,12 +25,7 @@ async function loadFormPreferences(): Promise { export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined; updatePreferences: (updates: Partial) => Promise; - updateProviderPreferences: ( - provider: AgentProvider, - updates: Partial, - ) => Promise; } export function useFormPreferences(): UseFormPreferencesReturn { @@ -54,13 +39,6 @@ export function useFormPreferences(): UseFormPreferencesReturn { const preferences = data ?? DEFAULT_FORM_PREFERENCES; - const getProviderPreferences = useCallback( - (provider: AgentProvider): ProviderPreferences | undefined => { - return preferences.providerPreferences?.[provider]; - }, - [preferences.providerPreferences], - ); - const updatePreferences = useCallback( async (updates: Partial) => { const prev = @@ -73,32 +51,9 @@ export function useFormPreferences(): UseFormPreferencesReturn { [queryClient], ); - const updateProviderPreferences = useCallback( - async (provider: AgentProvider, updates: Partial) => { - const prev = - queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ?? - DEFAULT_FORM_PREFERENCES; - const next: FormPreferences = { - ...prev, - providerPreferences: { - ...prev.providerPreferences, - [provider]: { - ...prev.providerPreferences?.[provider], - ...updates, - }, - }, - }; - queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); - await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); - }, - [queryClient], - ); - return { preferences, isLoading: isPending, - getProviderPreferences, updatePreferences, - updateProviderPreferences, }; } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 5ffcfa9f7..a0f9d8ce9 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -744,6 +744,20 @@ function DraftAgentScreenContent({ }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]); const createAgentClient = sessionClient; + const effectiveDraftModelId = useMemo(() => { + if (selectedModel.trim()) { + return selectedModel.trim(); + } + return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? ""; + }, [availableModels, selectedModel]); + const effectiveDraftThinkingOptionId = useMemo(() => { + if (selectedThinkingOptionId.trim()) { + return selectedThinkingOptionId.trim(); + } + const selectedModelDefinition = + availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; + }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); const draftCommandConfig = useMemo(() => { const cwd = ( isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir @@ -756,18 +770,18 @@ function DraftAgentScreenContent({ provider: selectedProvider, cwd, ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), - ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), - ...(selectedThinkingOptionId.trim() - ? { thinkingOptionId: selectedThinkingOptionId.trim() } + ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), + ...(effectiveDraftThinkingOptionId + ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), }; }, [ + effectiveDraftModelId, + effectiveDraftThinkingOptionId, isAttachWorktree, modeOptions.length, selectedMode, - selectedModel, selectedProvider, - selectedThinkingOptionId, selectedWorktreePath, workingDir, ]); @@ -801,6 +815,12 @@ function DraftAgentScreenContent({ if (gitBlockingError) { return gitBlockingError; } + if (isModelLoading) { + return "Model defaults are still loading"; + } + if (!effectiveDraftModelId) { + return "No model is available for the selected provider"; + } if (isAttachWorktree && !selectedWorktreePath) { return "Select a worktree to attach"; } @@ -832,8 +852,8 @@ function DraftAgentScreenContent({ (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; const provider = selectedProvider; - const model = selectedModel.trim() || null; - const thinkingOptionId = selectedThinkingOptionId.trim() || null; + const model = effectiveDraftModelId || null; + const thinkingOptionId = effectiveDraftThinkingOptionId || null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; return { @@ -869,14 +889,14 @@ function DraftAgentScreenContent({ isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - const trimmedThinkingOptionId = selectedThinkingOptionId.trim(); const config: AgentSessionConfig = { provider: selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), - ...(trimmedModel ? { model: trimmedModel } : {}), - ...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), + ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), + ...(effectiveDraftThinkingOptionId + ? { thinkingOptionId: effectiveDraftThinkingOptionId } + : {}), }; const effectiveBaseBranch = baseBranch.trim(); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 4694c0c1c..6ac26eb9d 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -95,6 +95,22 @@ export function WorkspaceDraftAgentTab({ setWorkingDir(workspaceId); }, [setWorkingDir, workingDir, workspaceId]); + const effectiveDraftModelId = useMemo(() => { + if (selectedModel.trim()) { + return selectedModel.trim(); + } + return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? ""; + }, [availableModels, selectedModel]); + + const effectiveDraftThinkingOptionId = useMemo(() => { + if (selectedThinkingOptionId.trim()) { + return selectedThinkingOptionId.trim(); + } + const selectedModelDefinition = + availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; + }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); + const { formErrorMessage, isSubmitting, @@ -111,6 +127,12 @@ export function WorkspaceDraftAgentTab({ if (providerDefinitions.length === 0) { return "No available providers on the selected host"; } + if (isModelLoading) { + return "Model defaults are still loading"; + } + if (!effectiveDraftModelId) { + return "No model is available for the selected provider"; + } if (!client) { return "Host is not connected"; } @@ -125,8 +147,8 @@ export function WorkspaceDraftAgentTab({ }, buildDraftAgent: (attempt) => { const now = attempt.timestamp; - const model = selectedModel.trim() || null; - const thinkingOptionId = selectedThinkingOptionId.trim() || null; + const model = effectiveDraftModelId || null; + const thinkingOptionId = effectiveDraftThinkingOptionId || null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; return { serverId, @@ -156,14 +178,14 @@ export function WorkspaceDraftAgentTab({ } const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - const trimmedThinkingOptionId = selectedThinkingOptionId.trim(); const config: AgentSessionConfig = { provider: selectedProvider, cwd: workspaceId, ...(modeId ? { modeId } : {}), - ...(trimmedModel ? { model: trimmedModel } : {}), - ...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), + ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), + ...(effectiveDraftThinkingOptionId + ? { thinkingOptionId: effectiveDraftThinkingOptionId } + : {}), }; const imagesData = await encodeImages(images); @@ -189,17 +211,17 @@ export function WorkspaceDraftAgentTab({ provider: selectedProvider, cwd: workspaceId, ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), - ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), - ...(selectedThinkingOptionId.trim() - ? { thinkingOptionId: selectedThinkingOptionId.trim() } + ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), + ...(effectiveDraftThinkingOptionId + ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), }; }, [ + effectiveDraftModelId, + effectiveDraftThinkingOptionId, modeOptions.length, selectedMode, - selectedModel, selectedProvider, - selectedThinkingOptionId, workspaceId, ]); diff --git a/packages/app/src/utils/extract-agent-model.test.ts b/packages/app/src/utils/extract-agent-model.test.ts index df52bd6fb..da47619e3 100644 --- a/packages/app/src/utils/extract-agent-model.test.ts +++ b/packages/app/src/utils/extract-agent-model.test.ts @@ -20,12 +20,12 @@ describe("extractAgentModel", () => { expect(extractAgentModel(agent)).toBe("gpt-5.1-codex"); }); - it("treats legacy 'default' model ids as unset", () => { + it("preserves 'default' as a valid model id", () => { const agent = { model: "default", runtimeInfo: { model: "default" }, } as Partial as Agent; - expect(extractAgentModel(agent)).toBeNull(); + expect(extractAgentModel(agent)).toBe("default"); }); }); diff --git a/packages/app/src/utils/extract-agent-model.ts b/packages/app/src/utils/extract-agent-model.ts index a93e1f3a6..928e110dd 100644 --- a/packages/app/src/utils/extract-agent-model.ts +++ b/packages/app/src/utils/extract-agent-model.ts @@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null { const fallbackModel = agent.model; if (typeof runtimeModel === "string") { const normalized = runtimeModel.trim(); - if (normalized.length > 0 && normalized.toLowerCase() !== "default") { + if (normalized.length > 0) { return normalized; } } if (typeof fallbackModel === "string") { const normalized = fallbackModel.trim(); - if (normalized.length > 0 && normalized.toLowerCase() !== "default") { + if (normalized.length > 0) { return normalized; } } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index cdadadd57..c42ee3b35 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1954,6 +1954,7 @@ export class AgentManager { this.emitState(agent); } } + void this.refreshRuntimeInfo(agent); } break; case "timeline": @@ -2369,8 +2370,7 @@ export class AgentManager { if (typeof normalized.model === "string") { const trimmed = normalized.model.trim(); - const normalizedId = trimmed.toLowerCase(); - normalized.model = trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined; + normalized.model = trimmed.length > 0 ? trimmed : undefined; } return normalized; diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 92403101e..10f10f01c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; +import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import { isCommandAvailable } from "../provider-launch-config.js"; @@ -18,6 +19,10 @@ function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); } +function createEmptyPrompt(): AsyncGenerator { + return (async function* empty() {})(); +} + function compactText(value: string): string { return value.replace(/\s+/g, "").toLowerCase(); } @@ -208,6 +213,50 @@ describe("ClaudeAgentSession integration", () => { } }, 60_000); + test.runIf(canRunClaudeIntegration)( + "supportedModels returns the current abstract Claude SDK model shape", + async () => { + const claudeQuery = query({ + prompt: createEmptyPrompt(), + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], + }, + }); + + try { + const models = await claudeQuery.supportedModels(); + + expect(models.length).toBeGreaterThanOrEqual(3); + expect(models).toContainEqual( + expect.objectContaining({ + value: "default", + displayName: "Default (recommended)", + supportedEffortLevels: ["low", "medium", "high", "max"], + }), + ); + expect(models).toContainEqual( + expect.objectContaining({ + value: "haiku", + displayName: "Haiku", + description: expect.stringContaining("Haiku 4.5"), + }), + ); + expect( + models.some( + (model) => + model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), + ), + ).toBe(true); + } finally { + await claudeQuery.return?.(); + } + }, + 60_000, + ); + test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-basic-tool-", diff --git a/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts b/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts deleted file mode 100644 index b5de0698e..000000000 --- a/packages/server/src/server/agent/providers/claude-agent.model-normalization.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeClaudeRuntimeModelId } from "./claude-agent.js"; -import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js"; - -describe("normalizeClaudeRuntimeModelId", () => { - function latestModelId(family: "sonnet" | "opus" | "haiku"): string { - const latest = CLAUDE_MODEL_CATALOG.find( - (model) => model.family === family && model.isLatestInFamily, - ); - if (latest) { - return latest.modelId; - } - const fallback = CLAUDE_MODEL_CATALOG.find((model) => model.family === family); - if (!fallback) { - throw new Error(`Missing Claude model family in catalog: ${family}`); - } - return fallback.modelId; - } - - const SONNET = latestModelId("sonnet"); - const OPUS = latestModelId("opus"); - const HAIKU = latestModelId("haiku"); - const supportedModelIds = new Set([SONNET, OPUS, HAIKU]); - const supportedModelFamilyAliases = new Map([ - ["sonnet", SONNET], - ["opus", OPUS], - ["haiku", HAIKU], - ] as const); - - test("preserves runtime model when it already exists in the supported catalog", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: SONNET, - supportedModelIds, - }); - expect(normalized).toBe(SONNET); - }); - - test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-sonnet-4-6-20260101", - supportedModelIds, - supportedModelFamilyAliases, - }); - expect(normalized).toBe(SONNET); - }); - - test("maps unknown runtime Opus versions to the catalog Opus model ID", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-opus-4-5-20251101", - supportedModelIds, - supportedModelFamilyAliases, - }); - expect(normalized).toBe(OPUS); - }); - - test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-haiku-4-6-20260101", - supportedModelIds, - supportedModelFamilyAliases, - }); - expect(normalized).toBe(HAIKU); - }); - - test("uses configured model when runtime ID is unknown", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-custom-unknown", - supportedModelIds, - configuredModelId: OPUS, - }); - expect(normalized).toBe(OPUS); - }); - - test("uses current model when runtime and configured IDs are unknown", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-custom-unknown", - supportedModelIds, - configuredModelId: "claude-unknown", - currentModelId: HAIKU, - }); - expect(normalized).toBe(HAIKU); - }); - - test("preserves runtime model when mapping is not possible", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-custom-unknown", - supportedModelIds: new Set(["x", "y"]), - }); - expect(normalized).toBe("claude-custom-unknown"); - }); - - test("does not force family fallback for unknown runtime families", () => { - const normalized = normalizeClaudeRuntimeModelId({ - runtimeModelId: "claude-custom-unknown", - supportedModelIds, - }); - expect(normalized).toBe("claude-custom-unknown"); - }); -}); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index e4a246d1e..4a41418f9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from "vitest"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; @@ -241,6 +242,13 @@ describe("convertClaudeHistoryEntry", () => { describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); + function createSupportedModelsQueryMock(models: ModelInfo[]) { + return { + supportedModels: vi.fn(async () => models), + return: vi.fn(async () => ({ done: true, value: undefined })), + }; + } + test("returns models with required fields", async () => { const client = new ClaudeAgentClient({ logger }); const models = await client.listModels(); @@ -267,4 +275,87 @@ describe("ClaudeAgentClient.listModels", () => { ), ).toBe(true); }, 60_000); + + test("prefers provider-discovered Claude defaults and effort levels", async () => { + const queryMock = createSupportedModelsQueryMock([ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + }, + { + value: "opus", + displayName: "Opus", + description: "Opus 4.6 · Most capable for complex work", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + }, + { + value: "haiku", + displayName: "Haiku", + description: "Haiku 4.5 · Fastest for quick answers", + }, + ] satisfies ModelInfo[]); + const queryFactory = vi.fn(() => queryMock); + const client = new ClaudeAgentClient({ + logger, + queryFactory: queryFactory as never, + }); + + const models = await client.listModels({ cwd: process.cwd() }); + + expect(queryFactory).toHaveBeenCalledTimes(1); + expect(queryMock.supportedModels).toHaveBeenCalledTimes(1); + expect(queryMock.return).toHaveBeenCalledTimes(1); + expect(models).toEqual([ + expect.objectContaining({ + id: "claude-sonnet-4-6", + isDefault: true, + label: "Sonnet 4.6", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + }), + expect.objectContaining({ + id: "claude-opus-4-6", + label: "Opus 4.6", + }), + expect.objectContaining({ + id: "claude-haiku-4-5", + label: "Haiku 4.5", + }), + ]); + }); + + test("preserves SDK ids even when descriptions are weak", async () => { + const queryMock = createSupportedModelsQueryMock([ + { + value: "default", + displayName: "Default (recommended)", + description: "Recommended model", + }, + ] satisfies ModelInfo[]); + const client = new ClaudeAgentClient({ + logger, + queryFactory: vi.fn(() => queryMock) as never, + }); + + const models = await client.listModels({ cwd: process.cwd() }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "default", + label: "Default (recommended)", + description: "Recommended model", + }), + ]); + expect(queryMock.return).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index b2d82fad1..48f1534e0 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -9,6 +9,7 @@ import { type AgentDefinition, type CanUseTool, type McpServerConfig as ClaudeSdkMcpServerConfig, + type ModelInfo, type Options, type PermissionMode, type PermissionResult, @@ -33,11 +34,9 @@ import { mapTaskNotificationUserContentToToolCall, } from "./claude/task-notification-tool-call.js"; import { - buildClaudeModelFamilyAliases, - buildClaudeSelectableModelIds, - listClaudeCatalogModels, - type ClaudeModelFamily, -} from "./claude/model-catalog.js"; + normalizeClaudeModelIdFromText, + resolveClaudeModelsFromSdkModels, +} from "./claude/sdk-model-resolver.js"; import { parsePartialJsonObject } from "./claude/partial-json.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; @@ -92,136 +91,6 @@ type AsyncMessageInput = { iterable: AsyncIterable; }; -type NormalizeClaudeRuntimeModelIdOptions = { - runtimeModelId: string; - supportedModelIds: ReadonlySet | null; - supportedModelFamilyAliases?: ReadonlyMap | null; - configuredModelId?: string | null; - currentModelId?: string | null; -}; - -function normalizeModelIdCandidate(modelId: string | null | undefined): string | null { - if (typeof modelId !== "string") { - return null; - } - const trimmed = modelId.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function pickSupportedModelId( - supportedModelIds: ReadonlySet, - candidate: string | null | undefined, -): string | null { - const normalizedCandidate = normalizeModelIdCandidate(candidate); - if (!normalizedCandidate) { - return null; - } - return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null; -} - -function inferClaudeModelFamilyFromText(text: string | null | undefined): ClaudeModelFamily | null { - if (typeof text !== "string") { - return null; - } - const lowerText = text.toLowerCase(); - if (lowerText.includes("sonnet")) { - return "sonnet"; - } - if (lowerText.includes("opus")) { - return "opus"; - } - if (lowerText.includes("haiku")) { - return "haiku"; - } - return null; -} - -function pickFamilyAliasModelId( - familyAliases: ReadonlyMap | null | undefined, - family: ClaudeModelFamily, -): string | null { - if (!familyAliases) { - return null; - } - return normalizeModelIdCandidate(familyAliases.get(family) ?? null); -} - -export function normalizeClaudeRuntimeModelId( - options: NormalizeClaudeRuntimeModelIdOptions, -): string { - const runtimeModel = options.runtimeModelId.trim(); - if (!runtimeModel) { - return runtimeModel; - } - - const supportedModelIds = options.supportedModelIds; - if (!supportedModelIds || supportedModelIds.size === 0) { - return runtimeModel; - } - - if (supportedModelIds.has(runtimeModel)) { - return runtimeModel; - } - - const runtimeFamily = inferClaudeModelFamilyFromText(runtimeModel); - const familyAlias = runtimeFamily - ? pickFamilyAliasModelId(options.supportedModelFamilyAliases, runtimeFamily) - : null; - if (runtimeFamily === "sonnet") { - const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet"); - if (explicitSonnet) { - return explicitSonnet; - } - if (familyAlias && supportedModelIds.has(familyAlias)) { - return familyAlias; - } - const defaultAlias = pickSupportedModelId(supportedModelIds, "default"); - if (defaultAlias) { - return defaultAlias; - } - } - if (runtimeFamily === "opus") { - const alias = pickSupportedModelId(supportedModelIds, "opus"); - if (alias) { - return alias; - } - if (familyAlias && supportedModelIds.has(familyAlias)) { - return familyAlias; - } - } - if (runtimeFamily === "haiku") { - const alias = pickSupportedModelId(supportedModelIds, "haiku"); - if (alias) { - return alias; - } - if (familyAlias && supportedModelIds.has(familyAlias)) { - return familyAlias; - } - } - - const configuredModelId = pickSupportedModelId(supportedModelIds, options.configuredModelId); - if (configuredModelId) { - return configuredModelId; - } - - const currentModelId = pickSupportedModelId(supportedModelIds, options.currentModelId); - if (currentModelId) { - return currentModelId; - } - - // If Claude reports a concrete family ID we can't map directly, prefer the - // provider default alias for unconfigured sessions so UI model/thinking state - // can still reconcile against the current model catalog. - const defaultAlias = pickSupportedModelId(supportedModelIds, "default"); - const hasConfiguredModel = normalizeModelIdCandidate(options.configuredModelId) !== null; - const hasCurrentModel = normalizeModelIdCandidate(options.currentModelId) !== null; - if (runtimeFamily && defaultAlias && !hasConfiguredModel && !hasCurrentModel) { - return defaultAlias; - } - - return runtimeModel; -} - const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -296,6 +165,8 @@ type ClaudeAgentSessionOptions = { queryFactory?: typeof query; }; +type ClaudeThinkingEffort = "low" | "medium" | "high" | "max"; + function resolveClaudeSpawnCommand( spawnOptions: SpawnOptions, runtimeSettings?: ProviderRuntimeSettings, @@ -348,6 +219,14 @@ function applyRuntimeSettingsToClaudeOptions( }; } +function createEmptyClaudePrompt(): AsyncGenerator { + return (async function* empty() {})(); +} + +function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort { + return value === "low" || value === "medium" || value === "high" || value === "max"; +} + type ClaudeOptionsLogSummary = { cwd: string | null; permissionMode: string | null; @@ -1144,8 +1023,34 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listModels(_options?: ListModelsOptions): Promise { - return listClaudeCatalogModels(); + async listModels(options?: ListModelsOptions): Promise { + const claudeQuery = this.queryFactory({ + prompt: createEmptyClaudePrompt(), + options: applyRuntimeSettingsToClaudeOptions( + { + cwd: options?.cwd ?? process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: CLAUDE_SETTING_SOURCES, + }, + this.runtimeSettings, + ), + }); + + try { + const supportedModels = await claudeQuery.supportedModels(); + return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]); + } catch (error) { + this.logger.warn({ err: error }, "Failed to query Claude supportedModels()"); + throw error; + } finally { + try { + await claudeQuery.return?.(); + } catch { + // ignore control-plane shutdown errors + } + } + } async listPersistedAgents( @@ -1223,9 +1128,7 @@ class ClaudeAgentSession implements AgentSession { private cancelCurrentTurn: (() => void) | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private lastOptionsModel: string | null = null; - private selectableModelIds: Set | null = buildClaudeSelectableModelIds(); - private selectableModelFamilyAliases: Map | null = - buildClaudeModelFamilyAliases(); + private lastRuntimeModel: string | null = null; private compacting = false; private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; @@ -1281,6 +1184,13 @@ class ClaudeAgentSession implements AgentSession { sessionId: this.claudeSessionId, model: this.lastOptionsModel, modeId: this.currentMode ?? null, + ...(this.lastRuntimeModel + ? { + extra: { + runtimeModel: this.lastRuntimeModel, + }, + } + : {}), }; this.cachedRuntimeInfo = info; return { ...info }; @@ -1511,6 +1421,7 @@ class ClaudeAgentSession implements AgentSession { await query.setModel(normalizedModelId ?? undefined); this.config.model = normalizedModelId ?? undefined; this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel; + this.lastRuntimeModel = null; this.cachedRuntimeInfo = null; // Model change affects persistence metadata, so invalidate cached handle. this.persistence = null; @@ -1524,10 +1435,8 @@ class ClaudeAgentSession implements AgentSession { if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") { this.config.thinkingOptionId = undefined; - } else if (normalizedThinkingOptionId === "on") { - this.config.thinkingOptionId = "on"; - } else if (normalizedThinkingOptionId === "off") { - this.config.thinkingOptionId = "off"; + } else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) { + this.config.thinkingOptionId = normalizedThinkingOptionId; } else { throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`); } @@ -1914,16 +1823,15 @@ class ClaudeAgentSession implements AgentSession { } private buildOptions(): ClaudeOptions { - const configuredThinkingOptionId = this.config.thinkingOptionId; const thinkingOptionId = - configuredThinkingOptionId && configuredThinkingOptionId !== "default" - ? configuredThinkingOptionId - : "off"; - let maxThinkingTokens: number | undefined; - if (thinkingOptionId === "on") { - maxThinkingTokens = 10000; - } else if (thinkingOptionId === "off") { - maxThinkingTokens = 0; + this.config.thinkingOptionId && this.config.thinkingOptionId !== "default" + ? this.config.thinkingOptionId + : undefined; + let thinking: ClaudeOptions["thinking"]; + let effort: ClaudeOptions["effort"]; + if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { + thinking = { type: "adaptive" }; + effort = thinkingOptionId; } const appendedSystemPrompt = [ @@ -1963,7 +1871,8 @@ class ClaudeAgentSession implements AgentSession { // If we have a session ID from a previous query (e.g., after interrupt), // resume that session to continue the conversation history. ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), - ...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}), + ...(thinking ? { thinking } : {}), + ...(effort ? { effort } : {}), ...this.config.extra?.claude, }; @@ -2704,15 +2613,17 @@ class ClaudeAgentSession implements AgentSession { this.currentMode = message.permissionMode; this.persistence = null; if (message.model) { - const normalizedModel = normalizeClaudeRuntimeModelId({ - runtimeModelId: message.model, - supportedModelIds: this.selectableModelIds, - supportedModelFamilyAliases: this.selectableModelFamilyAliases, - configuredModelId: this.config.model ?? null, - currentModelId: this.lastOptionsModel, - }); - this.logger.debug({ model: message.model, normalizedModel }, "Captured model from SDK init"); - this.lastOptionsModel = normalizedModel; + const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model); + this.logger.debug( + { runtimeModel: message.model, normalizedRuntimeModel }, + "Captured runtime model from SDK init", + ); + if (normalizedRuntimeModel) { + this.lastOptionsModel = normalizedRuntimeModel; + } else if (!this.lastOptionsModel) { + this.lastOptionsModel = this.config.model ?? null; + } + this.lastRuntimeModel = message.model; this.cachedRuntimeInfo = null; } return threadStartedSessionId; diff --git a/packages/server/src/server/agent/providers/claude/model-catalog.ts b/packages/server/src/server/agent/providers/claude/model-catalog.ts deleted file mode 100644 index 8f17cb664..000000000 --- a/packages/server/src/server/agent/providers/claude/model-catalog.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { AgentModelDefinition } from "../../agent-sdk-types.js"; - -/** - * Temporary hardcoded Claude model catalog. - * - * Why: - * - Claude SDK model discovery currently returns abstract options like - * "default", "opus", and "haiku". - * - Runtime init messages report concrete model IDs like - * "claude-opus-4-6". - * - That mismatch breaks model selection + thinking reconciliation in UI. - * - * We keep a single flat list with all model data in one place. - * If Claude SDK model discovery becomes consistent with runtime IDs, switch - * listModels back to SDK discovery and remove this file. - */ - -export type ClaudeCatalogModel = { - family: "sonnet" | "opus" | "haiku"; - modelId: string; - name: string; - description: string; - isDefault?: boolean; - isLatestInFamily?: boolean; -}; - -export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [ - { - family: "opus", - modelId: "claude-opus-4-6", - name: "Opus 4.6", - description: "Opus 4.6 · Most capable for complex work", - isLatestInFamily: true, - }, - { - family: "sonnet", - modelId: "claude-sonnet-4-6", - name: "Sonnet 4.6", - description: "Sonnet 4.6 · Best for everyday tasks", - isLatestInFamily: true, - }, - { - family: "sonnet", - modelId: "claude-sonnet-4-5-20250929", - name: "Sonnet 4.5", - description: "Sonnet 4.5 · Best for everyday tasks", - isDefault: true, - }, - { - family: "haiku", - modelId: "claude-haiku-4-5-20251001", - name: "Haiku 4.5", - description: "Haiku 4.5 · Fastest for quick answers", - isLatestInFamily: true, - }, -]; - -export type ClaudeModelFamily = ClaudeCatalogModel["family"]; - -function toClaudeModelDefinition(params: { - id: string; - label: string; - description?: string; - isDefault?: boolean; -}): AgentModelDefinition { - return { - provider: "claude", - id: params.id, - label: params.label, - description: params.description, - ...(params.isDefault ? { isDefault: true } : {}), - thinkingOptions: [ - { id: "off", label: "Off", isDefault: true }, - { id: "on", label: "On" }, - ], - defaultThinkingOptionId: "off", - metadata: params.description - ? { - description: params.description, - } - : undefined, - }; -} - -export function listClaudeCatalogModels(): AgentModelDefinition[] { - return CLAUDE_MODEL_CATALOG.map((model) => - toClaudeModelDefinition({ - id: model.modelId, - label: model.name, - description: model.description, - isDefault: model.isDefault, - }), - ); -} - -export function buildClaudeSelectableModelIds(): Set { - return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId)); -} - -export function buildClaudeModelFamilyAliases(): Map { - const aliases = new Map(); - for (const model of CLAUDE_MODEL_CATALOG) { - if (model.isLatestInFamily || !aliases.has(model.family)) { - aliases.set(model.family, model.modelId); - } - } - return aliases; -} diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts new file mode 100644 index 000000000..0564cb11b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; + +import { + parseClaudeSdkModelDescriptorForTest, + resolveClaudeModelsFromSdkModels, +} from "./sdk-model-resolver.js"; + +describe("resolveClaudeModelsFromSdkModels", () => { + const sdkModels: ModelInfo[] = [ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + }, + { + value: "opus", + displayName: "Opus", + description: "Opus 4.6 · Most capable for complex work", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + }, + { + value: "sonnet", + displayName: "Sonnet", + description: "Sonnet 4.6 · Best for everyday tasks", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + supportsAdaptiveThinking: true, + }, + { + value: "haiku", + displayName: "Haiku", + description: "Haiku 4.5 · Fastest for quick answers", + }, + ]; + + it("parses family and version from SDK descriptions", () => { + expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({ + family: "sonnet", + version: "4.6", + }); + expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({ + family: "opus", + version: "4.6", + }); + expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({ + family: "sonnet", + version: "4.6", + }); + expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({ + family: "haiku", + version: "4.5", + }); + }); + + it("maps SDK models to parsed Claude model ids", () => { + const models = resolveClaudeModelsFromSdkModels(sdkModels); + + expect(models).toEqual([ + expect.objectContaining({ + provider: "claude", + id: "claude-sonnet-4-6", + label: "Sonnet 4.6", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + }), + expect.objectContaining({ + provider: "claude", + id: "claude-opus-4-6", + label: "Opus 4.6", + }), + expect.objectContaining({ + provider: "claude", + id: "claude-haiku-4-5", + label: "Haiku 4.5", + }), + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts new file mode 100644 index 000000000..ebea0404b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts @@ -0,0 +1,140 @@ +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; + +import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js"; + +type ParsedClaudeSdkModelDescriptor = { + family: "opus" | "sonnet" | "haiku"; + version: string; +}; + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); +} + +function normalizeClaudeVersionId(version: string): string { + return version.replace(/\./g, "-"); +} + +function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string { + return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`; +} + +function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null { + const description = normalizeWhitespace(model.description ?? ""); + if (!description) { + return null; + } + + const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i); + if (!match) { + return null; + } + + const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"]; + const version = match[2]!; + return { family, version }; +} + +export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null { + const normalized = normalizeWhitespace(value ?? ""); + if (!normalized) { + return null; + } + + const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i); + if (!runtimeMatch) { + return null; + } + + const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"]; + const version = runtimeMatch[2]!.replace(/-/g, "."); + return buildClaudeModelId({ family, version }); +} + +function buildModelLabel(model: ModelInfo): string { + const parsed = parseClaudeSdkDescriptor(model); + if (!parsed) { + return normalizeWhitespace(model.displayName || model.value); + } + return `${titleCase(parsed.family)} ${parsed.version}`; +} + +function buildThinkingOptions(model: ModelInfo): { + thinkingOptions?: AgentSelectOption[]; + defaultThinkingOptionId?: string; +} { + const effortLevels = model.supportedEffortLevels ?? []; + if (!model.supportsEffort || effortLevels.length === 0) { + return {}; + } + + const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({ + id: level, + label: titleCase(level), + })); + + return { + thinkingOptions, + }; +} + +export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] { + const resolved = new Map(); + + for (const model of models) { + const thinking = buildThinkingOptions(model); + const parsed = parseClaudeSdkDescriptor(model); + const id = parsed ? buildClaudeModelId(parsed) : model.value; + const existing = resolved.get(id); + resolved.set(id, { + provider: "claude", + id, + label: buildModelLabel(model), + description: normalizeWhitespace(model.description ?? model.displayName ?? model.value), + isDefault: + existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined, + ...(thinking.thinkingOptions || existing?.thinkingOptions + ? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions } + : {}), + ...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId + ? { + defaultThinkingOptionId: + thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId, + } + : {}), + metadata: { + sdkValues: Array.from( + new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]), + ), + sdkDisplayNames: Array.from( + new Set([ + ...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []), + model.displayName, + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)), + ), + sdkDescriptions: Array.from( + new Set([ + ...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []), + model.description, + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)), + ), + supportsEffort: model.supportsEffort === true, + supportedEffortLevels: model.supportedEffortLevels, + supportsAdaptiveThinking: model.supportsAdaptiveThinking === true, + supportsFastMode: model.supportsFastMode === true, + }, + }); + } + + return Array.from(resolved.values()); +} + +export function parseClaudeSdkModelDescriptorForTest( + model: ModelInfo, +): ParsedClaudeSdkModelDescriptor | null { + return parseClaudeSdkDescriptor(model); +} diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index dc5da80bc..a596ba7fb 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -25,7 +25,7 @@ import type { AgentSnapshotPayload } from "./messages.js"; */ describe("client activity tracking", () => { const TEST_PROVIDER = "claude"; - const TEST_MODEL = "claude-haiku-4-5"; + const TEST_MODEL = "haiku"; const TEST_CWD = "/tmp"; let daemon: TestPaseoDaemon; let client1: DaemonClient;