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 680f5d56d71..8b0a29bc240 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 @@ -79,7 +79,7 @@ export async function discoverNativeChatCatalogModels( !result.success || result.models.length === 0 || // Why: a spec's static fallback list must never pass as a probe result for an - // agent whose published list replaces rather than extends the seed. + // agent whose published list decides membership rather than only adding to the seed. ((agent === 'claude' || catalog?.discoveredModelsAreAuthoritative) && result.catalogOrigin !== 'probe') ) { 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 2ddb78374a5..bae8a626c97 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 @@ -98,7 +98,7 @@ describe('native chat session option enrichment', () => { ) }) - it('merges seed Claude rows with discovered capabilities per host', async () => { + it('keeps consent-gated Fable while dropping seed aliases the host never listed', async () => { mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ success: true, catalogOrigin: 'probe', @@ -135,10 +135,11 @@ describe('native chat session option enrichment', () => { await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) const models = readNativeChatEnrichedModels('claude', 'ssh:host')! - // Why: seed rows the CLI omits (e.g. fable, which needs a one-time consent - // before list_models advertises it) must stay selectable, so the merge keeps - // every seed id and overlays discovered rows on top. - expect(models.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]']) + // Why: list_models hides Fable until the one-time consent, so its absence is not + // evidence the host lacks it — every other omitted seed alias still disappears. + expect(models.map(({ id }) => id)).toEqual(['fable', 'opus[1m]', 'sonnet']) + expect(models.some(({ id }) => id === 'opus')).toBe(false) + expect(models.some(({ id }) => id === 'haiku')).toBe(false) const sonnetEffort = models.find(({ id }) => id === 'sonnet')?.options[0] expect(sonnetEffort?.kind).toMatchObject({ type: 'select', @@ -163,6 +164,34 @@ describe('native chat session option enrichment', () => { expect(readNativeChatEnrichedModels('claude', 'local')).toBeNull() }) + it('does not duplicate Fable when the host already lists it', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { id: 'fable', label: 'Fable', thinkingLevels: [{ id: 'max', label: 'Max' }] }, + { id: 'sonnet', label: 'Sonnet' } + ] + }) + const discover = vi.fn(() => + discoverNativeChatCatalogModels('claude', { + settings: {}, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('claude', 'ssh:host', listener) + + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'ssh:host', discover }) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('claude', 'ssh:host')! + expect(models.map(({ id }) => id)).toEqual(['fable', 'sonnet']) + // The host's capabilities win over the seed's for a row it did list. + expect(models[0].options[0]?.kind).toMatchObject({ choices: [{ value: 'max', label: 'Max' }] }) + }) + it('carries grok’s probed default through discovery to the published rows', 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 dc089caa573..a18d42a5f71 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 @@ -3,6 +3,7 @@ import { getAgentSessionOptionCatalog, mergeCatalogModels, mergeDiscoveredAuthoritativeModels, + withConsentGatedSeedModels, type CatalogModel } from '../../../../shared/agent-session-option-catalog' import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' @@ -101,9 +102,12 @@ export function ensureNativeChatModelEnrichment(args: { if (!discovered || discovered.length === 0) { return } - entry.models = catalog.discoveredModelsAreAuthoritative - ? mergeDiscoveredAuthoritativeModels(catalog.models, discovered) - : mergeCatalogModels(catalog.models, discovered) + entry.models = + args.agent === 'claude' + ? withConsentGatedSeedModels(catalog, discovered) + : catalog.discoveredModelsAreAuthoritative + ? mergeDiscoveredAuthoritativeModels(catalog.models, discovered) + : mergeCatalogModels(catalog.models, discovered) for (const listener of entry.listeners) { listener([...entry.models]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts index d966777c13f..bd5e7e9aee5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts @@ -82,14 +82,11 @@ describe('useNativeChatSessionOptions model reporting', () => { await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus[1m]') ) - // Seed rows the host omitted (e.g. fable) stay selectable; discovered rows - // overlay the matching seed ids and append any new ones. + // Invented family rows stay gone; only consent-gated Fable is re-added. expect(modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)).toEqual([ 'fable', - 'opus', - 'sonnet', - 'haiku', - 'opus[1m]' + 'opus[1m]', + 'haiku' ]) }) @@ -134,7 +131,7 @@ describe('useNativeChatSessionOptions model reporting', () => { await waitFor(() => expect( modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) - ).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]']) + ).toEqual(['fable', 'opus[1m]', 'haiku']) ) expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() }) @@ -175,7 +172,7 @@ describe('useNativeChatSessionOptions model reporting', () => { await waitFor(() => expect( modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) - ).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]']) + ).toEqual(['fable', 'opus[1m]', 'haiku']) ) expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() }) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed..42cd45db763 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -174,6 +174,9 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { } }, unknownModelOptions: [claudeEffort(true)], + // Why: list_models omits Fable until the account accepts its usage-credit consent, + // which only an interactive /model can collect. + consentGatedModelIds: ['fable'], listModels: { command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`, parse: parseClaudeCatalogModels diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index ae2af3b450f..abe827117f5 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -65,6 +65,9 @@ export type AgentSessionOptionCatalog = { * must be able to drop it rather than only add. Membership only — option menus * still come from the seed. */ discoveredModelsAreAuthoritative?: true + /** Why: seed ids a successful probe may legitimately omit because the CLI hides them + * behind a one-time consent, so their absence is not evidence the host lacks them. */ + consentGatedModelIds?: readonly string[] /** Set only when the `isDefault` model is provably what the CLI runs with no model * flag, so an untouched draft may show it as selected. Off means `isDefault` stays * decorative: agents whose default comes from account or user config would otherwise diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index e7aae4aaaaa..8b89275fa6e 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog' +import { + getAgentSessionOptionCatalog, + mergeCatalogModels, + withConsentGatedSeedModels +} from './agent-session-option-catalog' import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' import { resolveNativeChatSessionOptionDefaults, @@ -121,6 +125,36 @@ describe('agent session option catalog', () => { expect(sonnet.options.map(({ id }) => id)).toEqual(['effort']) }) + it('re-adds only consent-gated Claude seed rows a probe omitted', () => { + const catalog = getAgentSessionOptionCatalog('claude')! + const merged = withConsentGatedSeedModels(catalog, [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'sonnet', label: 'Sonnet', options: [] }, + { id: 'haiku', label: 'Haiku', options: [] } + ]) + expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus[1m]', 'sonnet', 'haiku']) + // The seed's `opus` alias stays out: the host listing `opus[1m]` and not `opus` is + // evidence, unlike the consent-gated Fable row. + expect(merged.some(({ id }) => id === 'opus')).toBe(false) + expect(merged.find(({ id }) => id === 'fable')?.options.map(({ id }) => id)).toEqual(['effort']) + }) + + it('leaves a probe that already lists Fable untouched', () => { + const catalog = getAgentSessionOptionCatalog('claude')! + const discovered = [ + { id: 'sonnet', label: 'Sonnet', options: [] }, + { id: 'fable', label: 'Fable 5', options: [] } + ] + expect(withConsentGatedSeedModels(catalog, discovered)).toEqual(discovered) + }) + + it('returns discovery unchanged for a catalog with no consent-gated ids', () => { + const catalog = getAgentSessionOptionCatalog('cursor')! + expect(catalog.consentGatedModelIds).toBeUndefined() + const discovered = [{ id: 'auto', label: 'Auto', options: [] }] + expect(withConsentGatedSeedModels(catalog, discovered)).toEqual(discovered) + }) + it('parses Cursor model discovery without treating headings as models', () => { const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse( 'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n' diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index 8ef10a18abf..21ffe898b28 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -54,11 +54,7 @@ export function findCatalogOption( return model?.options.find((option) => option.id === optionId) } -/** Merge live rows over the static seed while retaining cataloged option mappings. - * Why: for agents whose discovered rows carry their own option menus (Claude's - * per-model effort levels and fast-mode flag come from the CLI probe), the live - * options win; for agents whose discovery returns no options, the seed's menus - * are kept so the picker does not go blank. */ +/** Merge live rows over the static seed while retaining cataloged option mappings. */ export function mergeCatalogModels( seed: readonly CatalogModel[], discovered: readonly CatalogModel[] @@ -70,11 +66,28 @@ export function mergeCatalogModels( return model } discoveredById.delete(model.id) - return { ...model, ...live, options: live.options.length > 0 ? live.options : model.options } + return { ...model, ...live, options: model.options } }) return [...merged, ...discoveredById.values()] } +/** Prepend seed rows the probe is allowed to hide, keeping discovery authoritative for the rest. + * Why: a probe that omits a consent-gated id is not evidence the host cannot run it, but every + * other seed id it omits is — so only the gated rows survive, ahead of the discovered list. */ +export function withConsentGatedSeedModels( + catalog: AgentSessionOptionCatalog, + discovered: readonly CatalogModel[] +): CatalogModel[] { + const gated = catalog.consentGatedModelIds + if (!gated || gated.length === 0) { + return [...discovered] + } + const missing = catalog.models.filter( + (model) => gated.includes(model.id) && !discovered.some((live) => live.id === model.id) + ) + return [...missing, ...discovered] +} + /** Discovery decides membership; the seed keeps its option menus, which discovery never carries. * Why: an unseeded model inherits the default seed's options because these catalogs' options are * global CLI flags, not per-model capabilities — dropping them would hide the picker entirely. */ diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 0ffdefb1e3a..c65850ba08a 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -357,12 +357,6 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial