fix(native-chat): narrow the Fable fallback to consent-gated seed rows

Keep Claude's "discovery decides membership" rule from #12369 and carve out
only ids the CLI hides until a one-time consent, instead of merging the whole
seed back in. Drops the unverified Fable entry for the commit-message spec:
non-interactive `claude -p` cannot show the consent prompt.
This commit is contained in:
Neil
2026-08-21 23:00:22 -07:00
parent 6f76ffba0e
commit 5c5666d6da
9 changed files with 107 additions and 30 deletions
@@ -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')
) {
@@ -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,
@@ -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])
}
@@ -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()
})
@@ -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
@@ -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
@@ -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'
+19 -6
View File
@@ -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. */
-6
View File
@@ -357,12 +357,6 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
{
// Why: Claude Code aliases track the account/provider's supported
// model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex.
id: 'fable',
label: 'Fable',
thinkingLevels: CLAUDE_THINKING_LEVELS,
defaultThinkingLevel: 'high'
},
{
id: 'haiku',
label: 'Haiku'
},