fix(native-chat): keep Claude Fable selectable when CLI probe omits it

The Claude CLI's list_models control request does not advertise Fable
until the user has completed its one-time usage-credit consent. Because
the native-chat enrichment replaced the seed model list with the probe
result for Claude, Fable vanished from the model picker on any host
whose CLI had not yet listed it.

Switch Claude to the same additive merge used by other non-authoritative
agents: seed rows (fable, opus, sonnet, haiku) are retained, and
discovered rows overlay matching ids and append new ones. For matching
ids the discovered option menus (effort levels, fast mode) win when
present, so per-host capabilities still drive the picker.

Also add Fable to the commit-message Claude agent's fallback model list
so it is available for non-interactive generation too.
This commit is contained in:
PannenetsF
2026-08-21 22:57:24 -07:00
committed by Neil
parent 2b1254d681
commit 6f76ffba0e
5 changed files with 29 additions and 15 deletions
@@ -98,7 +98,7 @@ describe('native chat session option enrichment', () => {
)
})
it('uses only discovered Claude rows and capabilities per host', async () => {
it('merges seed Claude rows with discovered capabilities per host', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'probe',
@@ -135,7 +135,10 @@ describe('native chat session option enrichment', () => {
await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce())
const models = readNativeChatEnrichedModels('claude', 'ssh:host')!
expect(models.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet'])
// 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]'])
const sonnetEffort = models.find(({ id }) => id === 'sonnet')?.options[0]
expect(sonnetEffort?.kind).toMatchObject({
type: 'select',
@@ -101,12 +101,9 @@ export function ensureNativeChatModelEnrichment(args: {
if (!discovered || discovered.length === 0) {
return
}
entry.models =
args.agent === 'claude'
? [...discovered]
: catalog.discoveredModelsAreAuthoritative
? mergeDiscoveredAuthoritativeModels(catalog.models, discovered)
: mergeCatalogModels(catalog.models, discovered)
entry.models = catalog.discoveredModelsAreAuthoritative
? mergeDiscoveredAuthoritativeModels(catalog.models, discovered)
: mergeCatalogModels(catalog.models, discovered)
for (const listener of entry.listeners) {
listener([...entry.models])
}
@@ -82,10 +82,14 @@ describe('useNativeChatSessionOptions model reporting', () => {
await waitFor(() =>
expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus[1m]')
)
// The invented family row is gone: every choice is one the host listed.
// Seed rows the host omitted (e.g. fable) stay selectable; discovered rows
// overlay the matching seed ids and append any new ones.
expect(modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)).toEqual([
'opus[1m]',
'haiku'
'fable',
'opus',
'sonnet',
'haiku',
'opus[1m]'
])
})
@@ -130,7 +134,7 @@ describe('useNativeChatSessionOptions model reporting', () => {
await waitFor(() =>
expect(
modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)
).toEqual(['opus[1m]', 'haiku'])
).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
)
expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined()
})
@@ -171,7 +175,7 @@ describe('useNativeChatSessionOptions model reporting', () => {
await waitFor(() =>
expect(
modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)
).toEqual(['opus[1m]', 'haiku'])
).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
)
expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined()
})
+6 -2
View File
@@ -54,7 +54,11 @@ export function findCatalogOption(
return model?.options.find((option) => option.id === optionId)
}
/** Merge live rows over the static seed while retaining cataloged option mappings. */
/** 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. */
export function mergeCatalogModels(
seed: readonly CatalogModel[],
discovered: readonly CatalogModel[]
@@ -66,7 +70,7 @@ export function mergeCatalogModels(
return model
}
discoveredById.delete(model.id)
return { ...model, ...live, options: model.options }
return { ...model, ...live, options: live.options.length > 0 ? live.options : model.options }
})
return [...merged, ...discoveredById.values()]
}
+6
View File
@@ -357,6 +357,12 @@ 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'
},