fix(native-chat): apply Claude discovery membership consistently

This commit is contained in:
Brennan Benson
2026-08-23 20:23:41 -07:00
parent 41c4c38c96
commit 4f4290d90e
10 changed files with 128 additions and 21 deletions
@@ -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<CreateNativeChatPtySessionOptionsArgs>): {
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')
})
})
@@ -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 () => {
@@ -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. */
@@ -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'))
@@ -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
@@ -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,
@@ -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<string, SessionOptionValue> | 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
@@ -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<void> {
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.
@@ -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()
})
@@ -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