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:
parent
9ee359550b
commit
40ea4ece1a
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -3019,6 +3019,7 @@ export type PreloadApi = {
|
|||
capability: CommitMessageAgentCapability
|
||||
models: CommitMessageModelCapability[]
|
||||
defaultModelId: string
|
||||
catalogOrigin: 'probe' | 'spec'
|
||||
}
|
||||
| { success: false; error: string }
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 []
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue