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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1203 additions and 50 deletions

View File

@ -2419,6 +2419,111 @@ describe('registerFilesystemHandlers', () => {
)
})
it('discovers models from an exact repo-less folder workspace root', async () => {
const folderPath = path.resolve('/outside-workspace/folder-project')
const folderStore = {
...store,
getFolderWorkspaces: () => [
{
id: 'folder-1',
projectGroupId: 'group-1',
folderPath,
connectionId: null
}
]
}
discoverCommitMessageModelsLocalMock.mockResolvedValue({
success: true,
models: [{ id: 'sonnet', label: 'Sonnet' }],
defaultModelId: 'sonnet'
})
registerFilesystemHandlers(folderStore as never)
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'claude',
worktreePath: folderPath
})
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
'claude',
undefined,
undefined,
{ cwd: folderPath }
)
})
it('does not authorize remote-only folder roots as local discovery paths', async () => {
const folderPath = path.resolve('/remote-only/folder-project')
const folderStore = {
...store,
getFolderWorkspaces: () => [
{
id: 'folder-1',
projectGroupId: 'group-1',
folderPath,
connectionId: 'ssh-1'
}
]
}
registerFilesystemHandlers(folderStore as never)
await expect(
handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'claude',
worktreePath: folderPath
})
).rejects.toThrow('Access denied')
expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
})
it('routes a repo-less WSL folder workspace discovery through its distro', async () => {
await withPlatform('win32', async () => {
const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\tester\\folder-project'
const prepareForClaudeLaunch = vi.fn().mockResolvedValue({
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.claude',
envPatch: { CLAUDE_CONFIG_DIR: '/home/tester/.claude' },
stripAuthEnv: true,
provenance: 'managed:account-1'
})
const folderStore = {
...store,
getFolderWorkspaces: () => [
{
id: 'folder-1',
projectGroupId: 'group-1',
folderPath,
connectionId: null
}
]
}
discoverCommitMessageModelsLocalMock.mockResolvedValue({
success: true,
models: [{ id: 'sonnet', label: 'Sonnet' }],
defaultModelId: 'sonnet'
})
registerFilesystemHandlers(folderStore as never, { prepareForClaudeLaunch })
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'claude',
worktreePath: folderPath
})
expect(prepareForClaudeLaunch).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
'claude',
expect.objectContaining({ CLAUDE_CONFIG_DIR: '/home/tester/.claude' }),
undefined,
{ cwd: path.resolve(folderPath), wslDistro: 'Ubuntu' }
)
})
})
it('routes local WSL project model discovery through the project runtime target', async () => {
await withPlatform('win32', async () => {
discoverCommitMessageModelsLocalMock.mockResolvedValue({

View File

@ -409,6 +409,26 @@ function getLocalAgentRuntimeTarget(
: { runtime: 'host' }
}
async function resolveModelDiscoveryLocalPath(
store: Store,
requestedPath: string
): Promise<string> {
try {
return await resolveRegisteredWorktreePath(requestedPath, store)
} catch (error) {
const folderWorkspaces =
typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : []
const isFolderWorkspaceRoot = folderWorkspaces.some(
(workspace) =>
comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath)
)
if (!isFolderWorkspaceRoot) {
throw error
}
return resolveAuthorizedPath(requestedPath, store)
}
}
function getLocalTextGenerationTarget(
worktreePath: string,
gitOptions: LocalProjectWorktreeGitOptions,
@ -1517,16 +1537,17 @@ export function registerFilesystemHandlers(
let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' }
let localDiscoveryOptions: Parameters<typeof discoverCommitMessageModelsLocal>[3]
if (args.worktreePath) {
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
args.worktreePath,
worktreePath
)
localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions)
localDiscoveryOptions = gitOptions.wslDistro
? { cwd: worktreePath, wslDistro: gitOptions.wslDistro }
: { cwd: worktreePath }
const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro
localRuntimeTarget = wslDistro
? { runtime: 'wsl', wslDistro }
: getLocalAgentRuntimeTarget(gitOptions)
localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath }
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
agentId,

View File

@ -311,6 +311,7 @@ describe('discoverCommitMessageModelsLocal', () => {
expect(result).toMatchObject({
success: true,
catalogOrigin: 'spec',
defaultModelId: 'smart'
})
expect(spawnMock).not.toHaveBeenCalled()
@ -348,6 +349,94 @@ describe('discoverCommitMessageModelsLocal', () => {
)
})
it('writes the Claude list_models request to stdin and parses the control response', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { on: vi.fn(), end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('claude', undefined)
listeners.get('stdout:data')?.(
Buffer.from(
`${JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'orca-model-discovery',
response: {
models: [
{ value: 'default', displayName: 'Default (recommended)' },
{
value: 'opus[1m]',
displayName: 'Opus (1M context)',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
},
{ value: 'sonnet', displayName: 'Sonnet' },
{ value: 'haiku', displayName: 'Haiku' }
]
}
}
})}\n`
)
)
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
catalogOrigin: 'probe',
defaultModelId: 'sonnet',
models: [
{ id: 'opus[1m]', label: 'Opus (1M context)' },
{ id: 'sonnet', label: 'Sonnet' },
{ id: 'haiku', label: 'Haiku' }
]
})
expect(spawnMock).toHaveBeenCalledWith(
'claude',
['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'],
expect.objectContaining({ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
)
expect(child.stdin.end).toHaveBeenCalledWith(expect.stringContaining('"list_models"'))
})
it('falls back to the Claude seed models when the CLI lacks list_models', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {
pid: 123,
kill: vi.fn(),
stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
stdin: { on: vi.fn(), end: vi.fn() },
on: vi.fn((event, callback) => listeners.set(event, callback))
}
spawnMock.mockReturnValue(child as never)
const pending = discoverCommitMessageModelsLocal('claude', undefined)
// Captured from claude 2.1.100: the unsupported subtype still exits 0.
listeners.get('stdout:data')?.(
Buffer.from(
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
)
)
listeners.get('close')?.(0)
await expect(pending).resolves.toMatchObject({
success: true,
catalogOrigin: 'spec',
defaultModelId: 'sonnet',
models: [{ id: 'haiku' }, { id: 'sonnet' }, { id: 'opus' }]
})
})
it('discovers dynamic models through the configured agent command override', async () => {
const listeners = new Map<string, (value: unknown) => void>()
const child = {

View File

@ -75,6 +75,7 @@ export type DiscoverCommitMessageModelsResult =
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
catalogOrigin: 'probe' | 'spec'
}
| { success: false; error: string }
@ -229,7 +230,8 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string {
function toModelDiscoveryCapability(
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
models = spec.models,
defaultModelId = spec.defaultModelId
defaultModelId = spec.defaultModelId,
catalogOrigin: 'probe' | 'spec' = 'spec'
): Extract<DiscoverCommitMessageModelsResult, { success: true }> {
return {
success: true,
@ -241,7 +243,8 @@ function toModelDiscoveryCapability(
models
},
models,
defaultModelId
defaultModelId,
catalogOrigin
}
}
@ -281,7 +284,7 @@ function finalizeModelDiscoveryOutput(
const defaultModelId = models.some((model) => model.id === spec.defaultModelId)
? spec.defaultModelId
: models[0].id
return toModelDiscoveryCapability(spec, models, defaultModelId)
return toModelDiscoveryCapability(spec, models, defaultModelId, 'probe')
}
function planModelDiscovery(
@ -301,7 +304,7 @@ function planModelDiscovery(
plan: {
binary: command.binary,
args: [...command.prefixArgs, ...modelDiscovery.args],
stdinPayload: null,
stdinPayload: modelDiscovery.stdinPayload ?? null,
label: spec.label
}
}
@ -330,6 +333,7 @@ export async function discoverCommitMessageModelsLocal(
const result = new Promise<DiscoverCommitMessageModelsResult>((resolve) => {
let child: ChildProcess
const spawnEnv = env ?? process.env
let discoveryStdin: string | null = null
try {
const planned = planModelDiscovery(spec, agentCommandOverride)
if (!planned.ok) {
@ -337,11 +341,13 @@ export async function discoverCommitMessageModelsLocal(
resolve({ success: false, error: planned.error })
return
}
discoveryStdin = planned.plan.stdinPayload
const stdinMode = discoveryStdin === null ? 'ignore' : 'pipe'
if (process.platform === 'win32' && options.wslDistro) {
child = wslAwareSpawn(planned.plan.binary, planned.plan.args, {
cwd: options.cwd,
env: buildWslLauncherEnv(env),
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [stdinMode, 'pipe', 'pipe'],
windowsHide: true,
wslDistro: options.wslDistro,
useWslLoginShell: true
@ -356,10 +362,16 @@ export async function discoverCommitMessageModelsLocal(
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
child = spawn(spawnCmd, spawnArgs, {
env: spawnEnv,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [stdinMode, 'pipe', 'pipe'],
windowsHide: true
})
}
if (discoveryStdin !== null) {
// Why: a CLI that rejects the args exits before reading stdin; the
// resulting EPIPE must surface as exit-code fallback, not a crash.
child.stdin?.on?.('error', () => {})
child.stdin?.end(discoveryStdin)
}
} catch (error) {
markProcessClosed()
console.error('[commit-message] Failed to spawn model discovery:', error)

View File

@ -3019,6 +3019,7 @@ export type PreloadApi = {
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
catalogOrigin: 'probe' | 'spec'
}
| { success: false; error: string }
>

View File

@ -8,6 +8,7 @@ import type {
} from '../../../../shared/native-chat-session-options'
import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles'
import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache'
import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment'
const mocks = vi.hoisted(() => ({
cancelPendingSends: vi.fn(),
@ -28,6 +29,7 @@ const mocks = vi.hoisted(() => ({
dispose: ReturnType<typeof vi.fn>
} | null,
createClaudeModelSwitchConfirmationObserver: vi.fn(),
discoverCommitMessageModels: vi.fn(),
getMainBufferSnapshot: vi.fn(),
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
sendNativeChatMessage: vi.fn(),
@ -137,6 +139,7 @@ describe('NativeChatComposer', () => {
beforeEach(() => {
vi.clearAllMocks()
clearNativeChatSessionOptionCacheForTests()
clearNativeChatModelEnrichmentForTests()
mocks.fieldProps = null
mocks.modelSwitchOutcome = 'applied'
mocks.draftScopeKeys.length = 0
@ -153,12 +156,36 @@ describe('NativeChatComposer', () => {
return observer
})
mocks.getMainBufferSnapshot.mockResolvedValue(null)
mocks.discoverCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'probe',
models: [
{
id: 'opus',
label: 'Opus',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' }
]
},
{
id: 'sonnet',
label: 'Sonnet',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' }
]
},
{ id: 'fable', label: 'Fable' }
]
})
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
mocks.sendHandle.settleAfterMs = 500
Object.defineProperty(window, 'api', {
configurable: true,
value: {
git: { discoverCommitMessageModels: mocks.discoverCommitMessageModels },
pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot },
ui: { onFileDrop: () => vi.fn() }
}
@ -293,6 +320,59 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).not.toHaveBeenCalled()
})
it('renders the Claude model picker while host discovery is still pending', () => {
mocks.discoverCommitMessageModels.mockReturnValue(new Promise(() => {}))
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
readTerminalScreen={() => null}
/>
)
expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({
id: 'model',
kind: {
choices: expect.arrayContaining([
expect.objectContaining({ value: 'opus', label: 'Opus' }),
expect.objectContaining({ value: 'sonnet', label: 'Sonnet' })
])
}
})
})
it('keeps the Claude model picker when an older remote runtime omits the catalog origin', async () => {
mocks.discoverCommitMessageModels.mockResolvedValue({
success: true,
defaultModelId: 'sonnet',
models: [{ id: 'sonnet', label: 'Sonnet' }]
})
render(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="claude"
readTerminalScreen={() => null}
/>
)
await waitFor(() => expect(mocks.discoverCommitMessageModels).toHaveBeenCalled())
await act(async () => undefined)
expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({
id: 'model',
kind: {
choices: expect.arrayContaining([
expect.objectContaining({ value: 'fable', label: 'Fable' }),
expect.objectContaining({ value: 'haiku', label: 'Haiku' })
])
}
})
})
it('shows the model already selected in the Claude TUI when chat opens', async () => {
mocks.getMainBufferSnapshot.mockResolvedValue({
data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing',
@ -398,7 +478,7 @@ describe('NativeChatComposer', () => {
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
ptyId: 'pty-1',
settings: {},
expectedModelLabel: 'Opus 4.8'
expectedModelLabel: 'Opus'
})
expect(onSwitchToTerminal).not.toHaveBeenCalled()
})
@ -429,7 +509,7 @@ describe('NativeChatComposer', () => {
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
ptyId: 'pty-1',
settings: {},
expectedModelLabel: 'Fable 5'
expectedModelLabel: 'Fable'
})
expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce()
expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan(

View File

@ -40,6 +40,56 @@ describe('Claude model switch confirmation detection', () => {
expect(unsubscribe).toHaveBeenCalledOnce()
})
it('matches the resolved-model echo when the picker label carries no version', async () => {
// Why: the CLI echoes what the alias resolved to ("Opus 5 (1M context)")
// while discovered picker labels read "Opus (1M context)"; the family word
// must bridge the two so verified switches do not report as unverifiable.
const dataObserver = { current: (_data: string): void => {} }
const observer = createClaudeModelSwitchConfirmationObserver({
ptyId: 'pty-1',
settings: {},
expectedModelLabel: 'Opus (1M context)',
subscribeToData: (watcher) => {
dataObserver.current = watcher
return vi.fn(() => {})
},
timeoutMs: 100
})
await observer.ready
observer.arm()
dataObserver.current('Set model to Opus 5 (1M context) and saved as your default')
await expect(observer.result).resolves.toBe('applied')
})
it('does not confirm a different context variant from the same model family', async () => {
vi.useFakeTimers()
try {
const dataObserver = { current: (_data: string): void => {} }
const observer = createClaudeModelSwitchConfirmationObserver({
ptyId: 'pty-1',
settings: {},
expectedModelLabel: 'Opus (1M context)',
subscribeToData: (watcher) => {
dataObserver.current = watcher
return vi.fn(() => {})
},
timeoutMs: 100
})
await observer.ready
observer.arm()
observer.startDetection()
dataObserver.current('Set model to Opus 5 and saved as your default')
await vi.advanceTimersByTimeAsync(100)
await expect(observer.result).resolves.toBe('unknown')
} finally {
vi.useRealTimers()
}
})
it('accepts the exact cached-history confirmation once and keeps observing', async () => {
const dataObserver = { current: (_data: string): void => {} }
const submitConfirmation = vi.fn()

View File

@ -36,7 +36,26 @@ function compactTerminalText(buffer: string): string {
function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean {
const text = compactTerminalText(buffer)
const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}`
return text.includes(marker)
if (text.includes(marker)) {
return true
}
// Why: resolved echoes insert a version ("Opus 5 (1M context)"); retaining
// every picker token prevents one context variant from confirming another.
const labelTokens = modelLabel.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? []
const successStart = text.lastIndexOf('setmodelto')
if (successStart < 0 || labelTokens.length === 0) {
return false
}
const successText = text.slice(successStart)
let tokenEnd = 0
for (const token of labelTokens) {
const tokenStart = successText.indexOf(token, tokenEnd)
if (tokenStart < 0) {
return false
}
tokenEnd = tokenStart + token.length
}
return true
}
function hasClaudeModelSwitchRejection(buffer: string): boolean {

View File

@ -37,6 +37,19 @@ describe('Claude terminal session option detection', () => {
})
})
it('matches headers from newer CLIs where the alias resolves to another version', () => {
// Why: family labels must keep matching as `opus` moves across releases.
const screen =
'Claude Code v2.1.220\r\n' +
'Opus 5 (1M context) with xhigh effort · API Usage Billing\r\n' +
'~/repo'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'opus',
effort: 'xhigh'
})
})
it('reports an option-less Haiku model without inventing effort', () => {
expect(
readClaudeSessionOptionsFromTerminalScreen(

View File

@ -9,17 +9,36 @@ import { createNativeChatPtySessionOptions } from './native-chat-pty-session-opt
describe('native chat PTY session options', () => {
beforeEach(() => clearNativeChatSessionOptionCacheForTests())
it('starts attached sessions unknown and hides model-scoped options', () => {
it('renders nothing when no model list exists at all', () => {
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
initialModels: [],
mode: 'live',
dispatchCommand: vi.fn()
})!
expect(surface.getSnapshot()).toEqual([])
})
it('renders the version-neutral seed picker before any host catalog arrives', () => {
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
mode: 'live',
dispatchCommand: vi.fn()
})!
expect(surface.getSnapshot()).toHaveLength(1)
expect(surface.getSnapshot()[0]).toMatchObject({
id: 'model',
valueSource: 'unknown'
valueSource: 'unknown',
kind: {
choices: [
expect.objectContaining({ value: 'fable', label: 'Fable' }),
expect.objectContaining({ value: 'opus', label: 'Opus' }),
expect.objectContaining({ value: 'sonnet', label: 'Sonnet' }),
expect.objectContaining({ value: 'haiku', label: 'Haiku' })
]
}
})
})
@ -122,7 +141,7 @@ describe('native chat PTY session options', () => {
expect(dispatch).toHaveBeenCalledWith('/model fable', {
detectAgentInteraction: 'claude-model-switch-confirmation',
expectedChoiceLabel: 'Fable 5'
expectedChoiceLabel: 'Fable'
})
expect(onAgentPicker).not.toHaveBeenCalled()
expect(result.snapshot[0]).toMatchObject({
@ -603,6 +622,58 @@ describe('native chat PTY session options', () => {
})
})
it('keeps a tracked alias selectable when the host catalog omits it', async () => {
seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
model: 'opus',
effort: 'xhigh'
})
const dispatch = vi.fn()
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
// Why: current CLIs list `opus[1m]` and no plain `opus`.
initialModels: [
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
{ id: 'sonnet', label: 'Sonnet', options: [] }
],
mode: 'live',
dispatchCommand: dispatch
})!
expect(surface.getSnapshot()[0].kind).toMatchObject({
currentValue: 'opus',
choices: expect.arrayContaining([
{ value: 'opus', label: 'Opus', description: expect.any(String) }
])
})
expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({
settable: true,
kind: { currentValue: 'xhigh' }
})
await surface.setOption('effort', 'high')
expect(dispatch).toHaveBeenCalledWith('/effort high')
})
it('drops the reconciled row once the tracked model moves onto the host catalog', async () => {
seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'opus' })
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
initialModels: [{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] }],
mode: 'live',
dispatchCommand: vi.fn()
})!
await surface.setOption('model', 'opus[1m]')
expect(surface.getSnapshot()[0].kind).toMatchObject({
currentValue: 'opus[1m]',
choices: [{ value: 'opus[1m]', label: 'Opus (1M context)' }]
})
})
it('recomposes Cursor model slugs for live option changes', async () => {
seedNativeChatAppliedSessionOptions('pty-1', 'cursor', {
model: 'gpt-5.3-codex',

View File

@ -1,5 +1,6 @@
import {
getAgentSessionOptionCatalog,
type AgentSessionOptionCatalog,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import type { AgentType } from '../../../../shared/agent-status-types'
@ -11,7 +12,8 @@ import type {
import {
createNativeChatSessionOptionRecord,
readNativeChatSessionOptionCache,
writeNativeChatSessionOptionCache
writeNativeChatSessionOptionCache,
type NativeChatSessionOptionRecord
} from './native-chat-session-option-cache'
import { createSessionOptionAppliers } from './native-chat-session-option-apply'
import {
@ -47,6 +49,24 @@ export type CreateNativeChatPtySessionOptionsArgs = {
onDraftValuesChanged?: (values: Record<string, SessionOptionValue>) => void
}
/**
* Why: the tracked model can sit outside the active list a persisted default,
* or an alias this host's CLI no longer lists. Keeping a row for it preserves
* the labelled selection and the model's own options instead of blanking both.
*/
function withTrackedModel(
catalog: AgentSessionOptionCatalog,
models: readonly CatalogModel[],
record: NativeChatSessionOptionRecord
): CatalogModel[] {
const trackedId = typeof record.model?.value === 'string' ? record.model.value : null
if (!trackedId || models.some((model) => model.id === trackedId)) {
return [...models]
}
const seeded = catalog.models.find((model) => model.id === trackedId)
return [...models, seeded ?? { id: trackedId, label: trackedId, options: [] }]
}
export function createNativeChatPtySessionOptions(
args: CreateNativeChatPtySessionOptionsArgs
): NativeChatPtySessionOptionsSurface | null {
@ -65,9 +85,10 @@ export function createNativeChatPtySessionOptions(
if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) {
writeNativeChatSessionOptionCache(args.scopeKey, record)
}
const activeModels = (): CatalogModel[] => withTrackedModel(catalog, models, record)
let snapshot = buildNativeChatSessionOptionSnapshot({
catalog,
models,
models: activeModels(),
record,
mode: args.mode
})
@ -77,7 +98,7 @@ export function createNativeChatPtySessionOptions(
writeNativeChatSessionOptionCache(args.scopeKey, record)
snapshot = buildNativeChatSessionOptionSnapshot({
catalog,
models,
models: activeModels(),
record,
mode: args.mode
})
@ -124,7 +145,7 @@ export function createNativeChatPtySessionOptions(
const appliers = createSessionOptionAppliers({
mode: args.mode,
catalog,
getModels: () => models,
getModels: activeModels,
getRecord: () => record,
dispatchCommand: args.dispatchCommand,
onAgentPicker: args.onAgentPicker,
@ -146,7 +167,7 @@ export function createNativeChatPtySessionOptions(
recordOutgoingCommand: (command) => {
const result = recordNativeChatSessionOptionCommand({
catalog,
models,
models: activeModels(),
record,
command,
persist

View File

@ -1,8 +1,18 @@
import type { AgentType } from '../../../../shared/agent-status-types'
import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
import {
createClaudeCatalogOptions,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import {
getCommitMessageModelDiscoveryHostKeyForLocalRuntime,
getCommitMessageModelDiscoveryHostKeyForScope
} from '../../../../shared/commit-message-host-key'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { getConnectionIdFromState } from '@/lib/connection-context'
import {
getLocalProjectExecutionRuntimeContext,
getWslDistroFromPath
} from '@/lib/local-preflight-context'
import {
discoverRuntimeCommitMessageModels,
getRuntimeGitScope,
@ -15,6 +25,23 @@ export type NativeChatModelDiscoveryContext = {
runtime: RuntimeGitContext
}
export function resolveNativeChatModelDiscoveryHostKey(
state: Parameters<typeof getLocalProjectExecutionRuntimeContext>[0],
worktreeId: string | null,
worktreePath: string,
scope: string | null | undefined
): string {
if (scope !== null) {
return getCommitMessageModelDiscoveryHostKeyForScope(scope)
}
const localProjectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId)
const wslDistro =
localProjectRuntime?.status === 'resolved' && localProjectRuntime.runtime.kind === 'wsl'
? localProjectRuntime.runtime.distro
: getWslDistroFromPath(worktreePath)
return getCommitMessageModelDiscoveryHostKeyForLocalRuntime(wslDistro)
}
export function resolveNativeChatModelDiscoveryContext(
terminalTabId: string
): NativeChatModelDiscoveryContext | null {
@ -31,7 +58,7 @@ export function resolveNativeChatModelDiscoveryContext(
const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : ''
const scope = getRuntimeGitScope(settings, connectionId)
return {
hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope),
hostKey: resolveNativeChatModelDiscoveryHostKey(state, worktreeId, worktreePath, scope),
runtime: {
settings,
worktreeId,
@ -46,12 +73,23 @@ export async function discoverNativeChatCatalogModels(
context: RuntimeGitContext
): Promise<CatalogModel[] | null> {
const result = await discoverRuntimeCommitMessageModels(context, agent)
if (!result.success || result.models.length === 0) {
if (
!result.success ||
result.models.length === 0 ||
(agent === 'claude' && result.catalogOrigin !== 'probe')
) {
return null
}
return result.models.map((model) => ({
id: model.id,
label: model.label,
options: []
...(model.description ? { description: model.description } : {}),
options:
agent === 'claude'
? createClaudeCatalogOptions({
effortLevelIds: model.thinkingLevels?.map(({ id }) => id) ?? [],
supportsFastMode: model.supportsFastMode
})
: []
}))
}

View File

@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
import {
discoverNativeChatCatalogModels,
resolveNativeChatModelDiscoveryHostKey
} from './native-chat-session-option-discovery'
import {
clearNativeChatModelEnrichmentForTests,
ensureNativeChatModelEnrichment,
@ -7,8 +11,20 @@ import {
subscribeNativeChatEnrichedModels
} from './native-chat-session-option-enrichment'
const mocks = vi.hoisted(() => ({
discoverRuntimeCommitMessageModels: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', () => ({
discoverRuntimeCommitMessageModels: mocks.discoverRuntimeCommitMessageModels,
getRuntimeGitScope: vi.fn()
}))
describe('native chat session option enrichment', () => {
beforeEach(() => clearNativeChatModelEnrichmentForTests())
beforeEach(() => {
clearNativeChatModelEnrichmentForTests()
mocks.discoverRuntimeCommitMessageModels.mockReset()
})
it('keeps reads synchronous while one host-scoped probe is in flight', async () => {
let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined
@ -55,7 +71,107 @@ describe('native chat session option enrichment', () => {
it('does not probe agents whose catalogs have no discovery command', () => {
const discover = vi.fn()
ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover })
ensureNativeChatModelEnrichment({ agent: 'gemini', hostKey: 'local', discover })
expect(discover).not.toHaveBeenCalled()
})
it('keeps WSL discovery separate from the Windows host and other distros', () => {
expect(
resolveNativeChatModelDiscoveryHostKey(
{} as never,
null,
'\\\\wsl.localhost\\Ubuntu\\home\\orca',
null
)
).toBe('wsl:Ubuntu')
expect(
resolveNativeChatModelDiscoveryHostKey(
{} as never,
null,
'\\\\wsl.localhost\\Debian\\home\\orca',
null
)
).toBe('wsl:Debian')
expect(resolveNativeChatModelDiscoveryHostKey({} as never, null, 'C:\\repo', null)).toBe(
'local'
)
})
it('uses only discovered Claude rows and capabilities per host', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'probe',
models: [
{
id: 'opus[1m]',
label: 'Opus (1M context)',
description: 'Opus 5 with 1M context',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' }
],
defaultThinkingLevel: 'low',
supportsFastMode: true
},
{
id: 'sonnet',
label: 'Sonnet',
thinkingLevels: [{ id: 'medium', label: 'Medium' }]
}
]
})
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(['opus[1m]', 'sonnet'])
const sonnetEffort = models.find(({ id }) => id === 'sonnet')?.options[0]
expect(sonnetEffort?.kind).toMatchObject({
type: 'select',
choices: [{ value: 'medium', label: 'Medium' }]
})
expect(models.find(({ id }) => id === 'opus[1m]')).toMatchObject({
id: 'opus[1m]',
description: 'Opus 5 with 1M context',
options: [
expect.objectContaining({
id: 'effort',
kind: expect.objectContaining({
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
})
}),
expect.objectContaining({ id: 'fastMode' })
]
})
expect(readNativeChatEnrichedModels('claude', 'local')).toBeNull()
})
it('does not advertise the Claude spec fallback when probing is unavailable', async () => {
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
success: true,
catalogOrigin: 'spec',
models: [{ id: 'sonnet', label: 'Sonnet' }]
})
await expect(
discoverNativeChatCatalogModels('claude', {
settings: {},
worktreeId: 'repo::/worktree',
worktreePath: '/worktree'
})
).resolves.toBeNull()
})
})

View File

@ -72,7 +72,8 @@ export function ensureNativeChatModelEnrichment(args: {
if (!discovered || discovered.length === 0) {
return
}
entry.models = mergeCatalogModels(catalog.models, discovered)
entry.models =
args.agent === 'claude' ? [...discovered] : mergeCatalogModels(catalog.models, discovered)
for (const listener of entry.listeners) {
listener([...entry.models])
}

View File

@ -120,15 +120,17 @@ export function buildNativeChatSessionOptionSnapshot(args: {
mode: NativeChatSessionOptionMode
}): SessionOptionDescriptor[] {
const { catalog, models, record, mode } = args
if (models.length === 0) {
return []
}
const modelTracked = record.model
const modelChoices = choiceWithCurrent(
models.map(({ id, label, description }) => ({
value: id,
label,
...(description ? { description } : {})
})),
modelTracked
)
// Why: callers reconcile the tracked model into `models`, so every listed row
// is a real choice and the trigger never shows a value without one.
const modelChoices = models.map(({ id, label, description }) => ({
value: id,
label,
...(description ? { description } : {})
}))
const modelSettable = settableState({ mode, apply: catalog.modelApply })
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode)
const snapshot: SessionOptionDescriptor[] = [

View File

@ -54,6 +54,9 @@ export function useNativeChatSessionOptions(args: {
agent,
scopeKey,
...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}),
// Why: the catalog seed carries version-neutral family labels, so it is
// safe on every host while the once-per-host probe runs or after it fails
// — without it the whole picker would pop in late or never appear.
...(discoveryContext
? {
initialModels:

View File

@ -5,6 +5,7 @@ import type { GlobalSettings } from '../../../../shared/types'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import {
getCommitMessageModelDiscoveryHostKey,
getCommitMessageModelDiscoveryHostKeyForLocalRuntime,
getCommitMessageModelDiscoveryHostKeyForScope
} from '../../../../shared/commit-message-host-key'
import { useAppStore } from '../../store'
@ -441,6 +442,8 @@ describe('CommitMessageAiPane', () => {
expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local')
expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1')
expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown')
expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime('Ubuntu')).toBe('wsl:Ubuntu')
expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime(null)).toBe('local')
expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1')
expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1')
})

View File

@ -62,6 +62,8 @@ type RuntimeDiscoverCommitMessageModelsResult =
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
/** Missing only when an older remote runtime produced the response. */
catalogOrigin?: 'probe' | 'spec'
}
| { success: false; error: string }

View File

@ -1,4 +1,13 @@
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
import type {
AgentSessionOptionCatalog,
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN,
parseClaudeModelList
} from './claude-model-list-probe'
function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean {
return tokens.some((token) =>
@ -40,14 +49,20 @@ const EXTENDED_EFFORT_CHOICES = [
]
function claudeEffort(extended: boolean): CatalogOption {
return claudeEffortWithChoices(extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES)
}
function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): CatalogOption {
return {
id: 'effort',
label: 'Effort',
category: 'thought_level',
kind: {
type: 'select',
choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES,
defaultValue: 'high'
choices,
defaultValue: choices.some((choice) => choice.value === 'high')
? 'high'
: (choices[0]?.value ?? 'high')
},
apply: {
launchArgs: (value) => ['--effort', String(value)],
@ -57,6 +72,33 @@ function claudeEffort(extended: boolean): CatalogOption {
}
}
export function createClaudeCatalogOptions(args: {
effortLevelIds: readonly string[]
supportsFastMode?: boolean
}): CatalogOption[] {
const effortChoices = EXTENDED_EFFORT_CHOICES.filter((choice) =>
args.effortLevelIds.includes(choice.value)
)
return [
...(effortChoices.length > 0 ? [claudeEffortWithChoices(effortChoices)] : []),
...(args.supportsFastMode ? [CLAUDE_FAST_MODE] : [])
]
}
function parseClaudeCatalogModels(stdout: string): CatalogModel[] {
return parseClaudeModelList(stdout).map((model) => {
return {
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
options: createClaudeCatalogOptions({
effortLevelIds: model.effortLevels,
supportsFastMode: model.supportsFastMode
})
}
})
}
const CLAUDE_FAST_MODE: CatalogOption = {
id: 'fastMode',
label: 'Fast mode',
@ -66,26 +108,35 @@ const CLAUDE_FAST_MODE: CatalogOption = {
}
export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// Why: these ids are Claude CLI aliases that resolve to the newest model of
// each family on the host's CLI (`opus` is Opus 5 on current CLIs, older
// Opus on older CLIs), so pinned version labels lie on part of the fleet.
// Family labels also keep header scraping and /model echo detection working
// across CLI versions; listModels overlays exact per-host names below.
models: [
{
id: 'fable',
label: 'Fable 5',
label: 'Fable',
description: 'Most capable for the hardest, longest-running tasks',
options: [claudeEffort(true)]
},
{
id: 'opus',
label: 'Opus 4.8',
label: 'Opus',
description: 'Best for everyday, complex tasks',
options: [claudeEffort(true), CLAUDE_FAST_MODE]
},
{
id: 'sonnet',
label: 'Sonnet 5',
label: 'Sonnet',
description: 'Efficient for routine tasks',
isDefault: true,
options: [claudeEffort(true)]
},
{
id: 'haiku',
label: 'Haiku',
description: 'Fastest for quick answers',
options: []
}
],
@ -100,6 +151,10 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// actual prompt so ordinary model changes stay in native chat.
detectAgentInteraction: 'claude-model-switch-confirmation'
}
},
listModels: {
command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`,
parse: parseClaudeCatalogModels
}
}

View File

@ -36,6 +36,91 @@ describe('agent session option catalog', () => {
})
})
it('labels Claude seed models by alias family so no host is mislabeled', () => {
const catalog = getAgentSessionOptionCatalog('claude')!
expect(catalog.models.map(({ id, label }) => ({ id, label }))).toEqual([
{ id: 'fable', label: 'Fable' },
{ id: 'opus', label: 'Opus' },
{ id: 'sonnet', label: 'Sonnet' },
{ id: 'haiku', label: 'Haiku' }
])
expect(catalog.models.find((model) => model.isDefault)?.id).toBe('sonnet')
})
it('parses Claude list_models discovery into catalog models with options', () => {
const stdout = JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
response: {
models: [
{
value: 'default',
displayName: 'Default (recommended)',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
},
{
value: 'opus[1m]',
displayName: 'Opus (1M context)',
description: 'Opus 5 with 1M context',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
},
{
value: 'sonnet',
displayName: 'Sonnet',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high']
},
{ value: 'haiku', displayName: 'Haiku' }
]
}
}
})
const parsed = getAgentSessionOptionCatalog('claude')!.listModels!.parse(stdout)
expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
expect(parsed[0]).toMatchObject({
label: 'Opus (1M context)',
description: 'Opus 5 with 1M context'
})
expect(parsed[0].options.map(({ id }) => id)).toEqual(['effort', 'fastMode'])
const opusEffort = parsed[0].options[0]
expect(opusEffort.kind).toMatchObject({ defaultValue: 'high' })
expect(
opusEffort.kind.type === 'select' ? opusEffort.kind.choices.map((c) => c.value) : []
).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
const sonnetEffort = parsed[1].options[0]
expect(
sonnetEffort.kind.type === 'select' ? sonnetEffort.kind.choices.map((c) => c.value) : []
).toEqual(['low', 'medium', 'high'])
expect(parsed[2].options).toEqual([])
})
it('keeps the Claude seed when list_models output is unsupported or malformed', () => {
const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
const unsupported =
'{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}'
expect(parse(unsupported)).toEqual([])
expect(parse('')).toEqual([])
expect(parse('garbage')).toEqual([])
})
it('merges discovered Claude variants after the seed and overlays matched labels', () => {
const catalog = getAgentSessionOptionCatalog('claude')!
const merged = mergeCatalogModels(catalog.models, [
{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
{ id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] }
])
expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
const sonnet = merged.find((model) => model.id === 'sonnet')!
expect(sonnet.description).toBe('Sonnet 5 · Efficient')
expect(sonnet.isDefault).toBe(true)
expect(sonnet.options.map(({ id }) => id)).toEqual(['effort'])
})
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'

View File

@ -1,7 +1,8 @@
import type { AgentType } from './agent-status-types'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
CODEX_SESSION_OPTION_CATALOG,
createClaudeCatalogOptions
} from './agent-session-option-catalog-claude-codex'
import {
CURSOR_SESSION_OPTION_CATALOG,
@ -23,6 +24,7 @@ export type {
CatalogOption,
CatalogOptionApply
} from './agent-session-option-catalog-types'
export { createClaudeCatalogOptions }
const CATALOGS: AgentSessionOptionCatalogMap = {
claude: CLAUDE_SESSION_OPTION_CATALOG,
@ -49,8 +51,7 @@ export function findCatalogOption(
return model?.options.find((option) => option.id === optionId)
}
/** Merge live rows over the static seed while retaining only option shapes Orca
* can actually map. Newly discovered ids remain model-only until cataloged. */
/** Merge live rows over the static seed while retaining cataloged option mappings. */
export function mergeCatalogModels(
seed: readonly CatalogModel[],
discovered: readonly CatalogModel[]

View File

@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { parseClaudeModelList } from './claude-model-list-probe'
function controlResponseLine(models: unknown[]): string {
return JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'orca-model-discovery',
response: { models }
}
})
}
// Captured from `claude` 2.1.220 answering a list_models control request.
const LIVE_MODELS = [
{
value: 'default',
resolvedModel: 'claude-opus-5[1m]',
displayName: 'Default (recommended)',
description: 'Use the default model (currently Opus 5 (1M context)) · $5/$25 per Mtok',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
},
{
value: 'opus[1m]',
resolvedModel: 'claude-opus-5[1m]',
displayName: 'Opus (1M context)',
description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
},
{
value: 'sonnet',
resolvedModel: 'claude-sonnet-5',
displayName: 'Sonnet',
description: 'Sonnet 5 · Efficient for routine tasks · $2/$10 per Mtok',
supportsEffort: true,
supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsAdaptiveThinking: true
},
{
value: 'haiku',
resolvedModel: 'claude-haiku-4-5-20251001',
displayName: 'Haiku',
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok'
}
]
describe('parseClaudeModelList', () => {
it('parses the picker catalog and drops the mirror default row', () => {
const parsed = parseClaudeModelList(`${controlResponseLine(LIVE_MODELS)}\n`)
expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
expect(parsed[0]).toEqual({
id: 'opus[1m]',
label: 'Opus (1M context)',
description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
supportsFastMode: true
})
expect(parsed[2]).toMatchObject({ effortLevels: [], supportsFastMode: false })
})
it('skips init noise, CRLF endings, and duplicate values', () => {
const stdout =
'{"type":"system","subtype":"init","model":"claude-sonnet-5"}\r\n' +
'not json at all\r\n' +
`${controlResponseLine([
{ value: 'sonnet', displayName: 'Sonnet' },
{ value: 'sonnet', displayName: 'Sonnet (duplicate)' },
{ value: ' ', displayName: 'Blank' }
])}\r\n`
expect(parseClaudeModelList(stdout)).toEqual([
{ id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
])
})
it('skips non-object model entries instead of failing the discovery response', () => {
const stdout = controlResponseLine([null, 7, [], { value: 'sonnet', displayName: 'Sonnet' }])
expect(parseClaudeModelList(stdout)).toEqual([
{ id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
])
})
it('returns no models for the control error emitted by CLIs without list_models', () => {
// Captured from `claude` 2.1.100: unsupported subtype still exits 0.
const stdout =
'{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
expect(parseClaudeModelList(stdout)).toEqual([])
})
it('returns no models for empty, malformed, or structurally hostile output', () => {
expect(parseClaudeModelList('')).toEqual([])
expect(parseClaudeModelList('{"type":"control_response"')).toEqual([])
expect(
parseClaudeModelList(
`{"type":"control_response","response":{"subtype":"success","response":{"models":${'['.repeat(64)}${']'.repeat(64)}}}}`
)
).toEqual([])
const hostile = `{"a":${'['.repeat(40)}${']'.repeat(40)},"type":"control_response"}`
expect(parseClaudeModelList(hostile)).toEqual([])
})
it('ignores effort levels when the model does not declare effort support', () => {
const parsed = parseClaudeModelList(
controlResponseLine([
{ value: 'haiku', displayName: 'Haiku', supportedEffortLevels: ['low', 'high'] }
])
)
expect(parsed[0]?.effortLevels).toEqual([])
})
})

View File

@ -0,0 +1,120 @@
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
// Why: the Claude CLI has no model-listing subcommand (`claude models` starts a
// chat session). One `list_models` control request over --print stream-json
// returns the CLI's /model picker catalog without starting an API turn. CLIs
// that predate the request answer `{"subtype":"error"}` and still exit 0, so
// parsing yields no models and callers keep their seed list.
export const CLAUDE_MODEL_LIST_STDIN = `${JSON.stringify({
type: 'control_request',
request_id: 'orca-model-discovery',
request: { subtype: 'list_models' }
})}\n`
// Why: --print rejects stream-json output unless --verbose is also set.
export const CLAUDE_MODEL_LIST_ARGS = [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose'
]
export type ClaudeListedModel = {
/** Value the CLI accepts for `--model` and `/model` (e.g. `opus[1m]`). */
id: string
/** The CLI's own picker label (e.g. `Opus (1M context)`). */
label: string
/** Names what the value resolves to on this host (e.g. `Opus 5 with 1M context …`). */
description?: string
/** `--effort` values this model accepts; empty when it has no effort control. */
effortLevels: string[]
supportsFastMode: boolean
}
const CLAUDE_MODEL_LIST_JSON_LIMITS = {
structuralTokens: 64 * 1024,
nestingDepth: 16
} as const
type RawControlResponse = {
type?: unknown
response?: {
subtype?: unknown
response?: { models?: unknown }
}
}
type RawListedModel = {
value?: unknown
displayName?: unknown
description?: unknown
supportsEffort?: unknown
supportedEffortLevels?: unknown
supportsFastMode?: unknown
}
function toListedModel(value: unknown): ClaudeListedModel | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const raw = value as RawListedModel
const id = typeof raw.value === 'string' ? raw.value.trim() : ''
if (!id) {
return null
}
const label = typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName : id
const description =
typeof raw.description === 'string' && raw.description.trim() ? raw.description : undefined
const effortLevels =
raw.supportsEffort === true && Array.isArray(raw.supportedEffortLevels)
? raw.supportedEffortLevels.filter((level): level is string => typeof level === 'string')
: []
return {
id,
label,
...(description ? { description } : {}),
effortLevels,
supportsFastMode: raw.supportsFastMode === true
}
}
export function parseClaudeModelList(stdout: string): ClaudeListedModel[] {
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line.startsWith('{') || !line.includes('control_response')) {
continue
}
let parsed: RawControlResponse
try {
assertJsonTextStructureWithinLimits(line, CLAUDE_MODEL_LIST_JSON_LIMITS)
parsed = JSON.parse(line) as RawControlResponse
} catch {
continue
}
if (parsed.type !== 'control_response' || parsed.response?.subtype !== 'success') {
continue
}
const models = parsed.response.response?.models
if (!Array.isArray(models)) {
continue
}
const seen = new Set<string>()
const listed: ClaudeListedModel[] = []
for (const entry of models) {
const model = toListedModel(entry)
// Why: the `default` row mirrors whichever entry it currently resolves
// to; Orca's pickers manage their own default selection.
if (!model || model.id === 'default' || seen.has(model.id)) {
continue
}
seen.add(model.id)
listed.push(model)
}
if (listed.length > 0) {
return listed
}
}
return []
}

View File

@ -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(

View File

@ -1,6 +1,11 @@
import type { TuiAgent } from './types'
import { isTuiAgentEnabled } from './tui-agent-selection'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN,
parseClaudeModelList
} from './claude-model-list-probe'
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
@ -16,10 +21,14 @@ export type CommitMessageModel = {
id: string
/** Visible label in the model dropdown. */
label: string
/** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */
description?: string
/** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */
thinkingLevels?: ThinkingLevel[]
/** Required when thinkingLevels is present. */
defaultThinkingLevel?: string
/** Whether the model exposes Claude's mid-session Fast mode toggle. */
supportsFastMode?: boolean
}
export type CommitMessageAgentSpec = {
@ -37,6 +46,8 @@ export type CommitMessageAgentSpec = {
modelDiscovery?: {
binary: string
args: string[]
/** Written to the CLI's stdin, for CLIs whose listing is request-driven. */
stdinPayload?: string
parse: (stdout: string) => CommitMessageModel[]
}
models: CommitMessageModel[]
@ -46,8 +57,10 @@ export type CommitMessageAgentSpec = {
export type CommitMessageModelCapability = {
id: string
label: string
description?: string
thinkingLevels?: ThinkingLevel[]
defaultThinkingLevel?: string
supportsFastMode?: boolean
}
export type CommitMessageAgentCapability = {
@ -139,6 +152,30 @@ function withOpenAiThinking(
: {}
}
export function parseClaudeModels(stdout: string): CommitMessageModel[] {
return uniqueModels(
parseClaudeModelList(stdout).map((model) => {
const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) =>
model.effortLevels.includes(level.id)
)
return {
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(thinkingLevels.length > 0
? {
thinkingLevels,
defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low')
? 'low'
: thinkingLevels[0].id
}
: {}),
...(model.supportsFastMode ? { supportsFastMode: true } : {})
}
})
)
}
export function parseCodexModels(stdout: string): CommitMessageModel[] {
try {
assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS)
@ -311,7 +348,16 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
'plan',
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
],
modelSource: 'static',
modelSource: 'dynamic',
// Why: the Claude CLI has no listing subcommand; one list_models control
// request over --print stream-json returns the /model picker catalog.
// Older CLIs answer with a control error and exit 0, keeping the fallback.
modelDiscovery: {
binary: 'claude',
args: [...CLAUDE_MODEL_LIST_ARGS],
stdinPayload: CLAUDE_MODEL_LIST_STDIN,
parse: parseClaudeModels
},
models: [
{
// Why: Claude Code aliases track the account/provider's supported
@ -744,8 +790,10 @@ function toCommitMessageAgentCapability(
models: spec.models.map((model) => ({
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}),
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {})
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}),
...(model.supportsFastMode ? { supportsFastMode: true } : {})
}))
}
}

View File

@ -2,6 +2,13 @@ export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local'
export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown'
export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:'
export function getCommitMessageModelDiscoveryHostKeyForLocalRuntime(
wslDistro: string | null | undefined
): string {
const distro = wslDistro?.trim()
return distro ? `wsl:${distro}` : LOCAL_COMMIT_MESSAGE_HOST_KEY
}
export function getCommitMessageModelDiscoveryHostKey(
connectionId: string | null | undefined
): string {