Track Claude models from the installed CLI per host (STA-3330) (#12369)

* feat(native-chat): track Claude models from the installed CLI per host (STA-3330)

The Claude seed no longer pins version labels to aliases that resolve
differently across CLI versions, and the catalog now defines listModels
backed by a one-shot list_models control request over --print stream-json.
Hosts whose CLI predates the request answer with a control error and keep
the seed. Discovery also feeds Source Control AI via the commit-message
spec, and the /model echo detector matches resolved model names.

* fix(native-chat): preserve discovered Claude capabilities

* fix(native-chat): tolerate malformed Claude model entries

* fix(native-chat): discover models in folder workspaces

* fix(native-chat): trust discovered Claude capabilities

* fix(native-chat): remove Claude model fallbacks

* fix(native-chat): keep the Claude model picker rendered

The Claude picker rendered nothing until the per-host `list_models` probe
returned, so it popped in ~1s after mount and never appeared at all when
the probe failed — an old CLI without `list_models`, no `claude` on PATH,
or an older remote runtime whose response omits `catalogOrigin`.

Restore the version-neutral family seed as the starting list; discovery
still replaces it wholesale on success, so a host with a real catalog
never shows an obsolete hardcoded row.

Separately, the tracked model could fall outside the active list: the
terminal header scrape yields family ids (`opus`) while a current CLI
lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and
dropped the model's effort and fast-mode controls. Reconcile the tracked
id into the active list once, so the snapshot, the appliers, and typed
command recording all see a labelled, operable row for it.
This commit is contained in:
Brennan Benson
2026-08-04 15:47:29 -07:00
committed by GitHub
parent 9ee359550b
commit 40ea4ece1a
26 changed files with 1203 additions and 50 deletions
@@ -12,6 +12,7 @@ import {
listCommitMessageAgentCapabilities,
listCommitMessageAgentIds,
parseAntigravityModels,
parseClaudeModels,
parseCodexModels,
parseCursorModels,
parseLineModels,
@@ -194,6 +195,81 @@ describe('buildArgs (Claude)', () => {
})
describe('model discovery parsers', () => {
it('parses Claude list_models output into commit-message models', () => {
const stdout = `${JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'orca-model-discovery',
response: {
models: [
{
value: 'default',
displayName: 'Default (recommended)',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
},
{
value: 'opus[1m]',
displayName: 'Opus (1M context)',
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
},
{ value: 'haiku', displayName: 'Haiku' }
]
}
}
})}\n`
expect(parseClaudeModels(stdout)).toEqual([
{
id: 'opus[1m]',
label: 'Opus (1M context)',
description: 'Opus 5 with 1M context · $5/$25 per Mtok',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
],
defaultThinkingLevel: 'low',
supportsFastMode: true
},
{ id: 'haiku', label: 'Haiku' }
])
})
it('returns no Claude models when the CLI lacks list_models so the seed stays', () => {
expect(
parseClaudeModels(
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
)
).toEqual([])
})
it('declares stdin-driven dynamic discovery for Claude', () => {
const discovery = COMMIT_MESSAGE_AGENT_SPECS.claude?.modelDiscovery
expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.modelSource).toBe('dynamic')
expect(discovery?.binary).toBe('claude')
expect(discovery?.args).toEqual([
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose'
])
const payload = JSON.parse(discovery?.stdinPayload ?? '') as {
type?: string
request?: { subtype?: string }
}
expect(payload.type).toBe('control_request')
expect(payload.request?.subtype).toBe('list_models')
expect(discovery?.stdinPayload?.endsWith('\n')).toBe(true)
})
it('parses Codex model JSON', () => {
expect(
parseCodexModels(