diff --git a/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts index 8c3e59c9ba7..cfaf7174785 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mergeDiscoveredAuthoritativeModels } from '../../../../shared/agent-session-option-catalog' +import { + mergeDiscoveredAuthoritativeModels, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-claude-codex' import { GROK_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-grok' import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' import type { PersistedNativeChatSessionOptions } from '../../../../shared/native-chat-session-options' @@ -94,3 +98,60 @@ describe('retired grok model untracking', () => { expect(persisted().grok?.model).toBe('grok-5') }) }) + +describe('retired Claude model untracking', () => { + beforeEach(() => clearNativeChatSessionOptionCacheForTests()) + + const discoveredClaude = (): CatalogModel[] => [ + { + ...CLAUDE_SESSION_OPTION_CATALOG.models.find((model) => model.id === 'fable')!, + isDefault: true, + options: CLAUDE_SESSION_OPTION_CATALOG.models.find((model) => model.id === 'fable')!.options + }, + { id: 'sonnet', label: 'Sonnet', options: [] } + ] + + function createClaudeSurface(args?: Partial): { + surface: NativeChatPtySessionOptionsSurface + persisted: () => PersistedNativeChatSessionOptions + } { + let persisted: PersistedNativeChatSessionOptions = {} + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'claude-pty-1', + mode: 'live', + dispatchCommand: vi.fn(), + persistSelection: ({ modelId, optionId, value, adoptModelAsLaunchDefault }) => { + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId, + optionId, + value, + adoptModelAsLaunchDefault + }) + }, + ...args + })! + return { surface, persisted: () => persisted } + } + + it('untracks an omitted Claude alias', () => { + seedNativeChatAppliedSessionOptions('claude-pty-1', 'claude', { model: 'opus' }) + const { surface, persisted } = createClaudeSurface() + + surface.replaceModels(discoveredClaude()) + + expect(readNativeChatSessionOptionCache('claude-pty-1')?.model).toBeUndefined() + expect(persisted().claude).toBeUndefined() + }) + + it('does not adopt an omitted typed Claude alias as launch default', () => { + const { surface, persisted } = createClaudeSurface({ initialModels: discoveredClaude() }) + + surface.recordOutgoingCommand('/model opus') + expect(persisted().claude?.model).toBeUndefined() + surface.recordOutgoingCommand('/model fable') + expect(persisted().claude?.model).toBe('fable') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index e653a29258f..50acadaac35 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -631,12 +631,11 @@ describe('native chat PTY session options', () => { }) }) - it('keeps a tracked alias selectable when the host catalog omits it', async () => { + it('drops a tracked Claude alias when the host catalog omits it', () => { seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'opus', effort: 'xhigh' }) - const dispatch = vi.fn() const surface = createNativeChatPtySessionOptions({ agent: 'claude', scopeKey: 'pty-1', @@ -646,23 +645,13 @@ describe('native chat PTY session options', () => { { id: 'sonnet', label: 'Sonnet', options: [] } ], mode: 'live', - dispatchCommand: dispatch + dispatchCommand: vi.fn() })! expect(surface.getSnapshot()[0].kind).toMatchObject({ - currentValue: 'opus', - choices: expect.arrayContaining([ - { value: 'opus', label: 'Opus', description: expect.any(String) } - ]) + choices: expect.not.arrayContaining([{ value: 'opus' }]) }) - expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ - settable: true, - kind: { currentValue: 'xhigh' } - }) - - await surface.setOption('effort', 'high') - - expect(dispatch).toHaveBeenCalledWith('/effort high') + expect(readNativeChatSessionOptionCache('pty-1')?.model).toBeUndefined() }) it('drops the reconciled row once the tracked model moves onto the host catalog', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index aa7562b5944..4ceb178ce21 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -1,4 +1,5 @@ import { + discoveredModelsDefineCatalogMembership, getAgentSessionOptionCatalog, type CatalogModel } from '../../../../shared/agent-session-option-catalog' @@ -80,7 +81,7 @@ export function createNativeChatPtySessionOptions( * the picker via re-injection and re-persist the fatal `-m` on any later option * write, undoing the settings retirement. */ const untrackRetiredModel = (): boolean => { - if (!catalog.discoveredModelsAreAuthoritative || !modelsAreDiscovered) { + if (!discoveredModelsDefineCatalogMembership(catalog) || !modelsAreDiscovered) { return false } const trackedId = typeof record.model?.value === 'string' ? record.model.value : null @@ -143,7 +144,8 @@ export function createNativeChatPtySessionOptions( * adopting either would emit an `-m` that is fatal on an account without it. */ const modelIsAdoptableAsLaunchDefault = (modelId: string): boolean => modelsAreDiscovered - ? !catalog.discoveredModelsAreAuthoritative || models.some((model) => model.id === modelId) + ? !discoveredModelsDefineCatalogMembership(catalog) || + models.some((model) => model.id === modelId) : record.model !== undefined /** Every persist path — picker applies and typed commands — funnels through here. */ diff --git a/src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts b/src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts index 2ac8eac3a16..5af6025b300 100644 --- a/src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts @@ -60,6 +60,16 @@ describe('retirePersistedModelMissingFromDiscovery', () => { }) }) + it('clears a persisted Claude alias omitted by a replacing probe', async () => { + persist({ claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } } }) + await retirePersistedModelMissingFromDiscovery('claude', models('fable', 'sonnet')) + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + nativeChatSessionOptions: { + claude: { valuesByModel: { opus: { effort: 'high' } } } + } + }) + }) + it('keeps a persisted id the probe still lists', async () => { persist({ grok: { model: 'grok-4.5' } }) await retirePersistedModelMissingFromDiscovery('grok', models('grok-4.5', 'grok-build')) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 4e61f560577..694abde3bbd 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -1,6 +1,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { createClaudeCatalogOptions, + discoveredModelsDefineCatalogMembership, getAgentSessionOptionCatalog, type CatalogModel } from '../../../../shared/agent-session-option-catalog' @@ -79,7 +80,8 @@ export async function discoverNativeChatCatalogModels( !result.success || result.models.length === 0 || // Why: static fallbacks must never impersonate a probe whose rows replace the seed. - ((catalog?.discoveredModelsReplaceSeed || catalog?.discoveredModelsAreAuthoritative) && + (catalog && + discoveredModelsDefineCatalogMembership(catalog) && result.catalogOrigin !== 'probe') ) { return null diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 3765a1b53a3..cdf6e6e3135 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -357,6 +357,22 @@ describe('native chat session option enrichment', () => { ).toEqual({ model: 'retired' }) }) + it('drops a persisted Claude alias missing from a replacing probe but keeps Fable', async () => { + const persisted = { + claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } } + } + const discover = vi.fn().mockResolvedValue([{ id: 'sonnet', label: 'Sonnet', options: [] }]) + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + await vi.waitFor(() => expect(readNativeChatEnrichedModels('claude', 'local')).not.toBeNull()) + + expect(resolveNativeChatLaunchSessionOptions(persisted, 'claude')).toBeUndefined() + expect(resolveNativeChatLaunchSessionOptions({ claude: { model: 'fable' } }, 'claude')).toEqual( + { + model: 'fable' + } + ) + }) + it('rejects a spec fallback for Claude, whose successful probe replaces the seed', async () => { mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ success: true, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index fd2036e0023..be586e4c8fa 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -1,5 +1,6 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { + discoveredModelsDefineCatalogMembership, getAgentSessionOptionCatalog, mergeCatalogModels, mergeDiscoveredModelsWithConsentGatedSeeds, @@ -55,7 +56,8 @@ export function resolveNativeChatLaunchSessionOptions( agent: AgentType ): Record | undefined { const values = resolveNativeChatSessionOptionDefaults(persisted, agent) - if (!values || !getAgentSessionOptionCatalog(agent)?.discoveredModelsAreAuthoritative) { + const catalog = getAgentSessionOptionCatalog(agent) + if (!values || !catalog || !discoveredModelsDefineCatalogMembership(catalog)) { return values } let probed = false diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index 54237b05628..a0efa633377 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { + discoveredModelsDefineCatalogMembership, getAgentSessionOptionCatalog, type CatalogModel } from '../../../../shared/agent-session-option-catalog' @@ -68,7 +69,8 @@ export async function retirePersistedModelMissingFromDiscovery( agent: AgentType, models: readonly CatalogModel[] ): Promise { - if (!getAgentSessionOptionCatalog(agent)?.discoveredModelsAreAuthoritative) { + const catalog = getAgentSessionOptionCatalog(agent) + if (!catalog || !discoveredModelsDefineCatalogMembership(catalog)) { return } // An empty list means the probe failed, not that the account has no models. diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index a3b096ce4f1..672af7cee8b 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + discoveredModelsDefineCatalogMembership, getAgentSessionOptionCatalog, mergeCatalogModels, mergeDiscoveredModelsWithConsentGatedSeeds @@ -11,6 +12,18 @@ import { } from './native-chat-session-option-defaults' describe('agent session option catalog', () => { + it('shares membership policy across replacing and authoritative catalogs', () => { + expect(discoveredModelsDefineCatalogMembership(getAgentSessionOptionCatalog('claude')!)).toBe( + true + ) + expect(discoveredModelsDefineCatalogMembership(getAgentSessionOptionCatalog('grok')!)).toBe( + true + ) + expect(discoveredModelsDefineCatalogMembership(getAgentSessionOptionCatalog('cursor')!)).toBe( + false + ) + }) + it('returns no catalog for unknown agents', () => { expect(getAgentSessionOptionCatalog('future-agent')).toBeNull() }) diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index 7b2bab3c5a2..a6ce7dedb79 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -40,6 +40,16 @@ export function getAgentSessionOptionCatalog(agent: AgentType): AgentSessionOpti return CATALOGS[agent] ?? null } +/** Whether a successful discovery result defines the catalog's model membership. */ +export function discoveredModelsDefineCatalogMembership( + catalog: AgentSessionOptionCatalog +): boolean { + return ( + catalog.discoveredModelsReplaceSeed === true || + catalog.discoveredModelsAreAuthoritative === true + ) +} + export function findCatalogModel( catalog: AgentSessionOptionCatalog, modelId: string