diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index e712a2938fc..78b950fe5a9 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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({ diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 466166cfcad..48ae7f8f6f2 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -409,6 +409,26 @@ function getLocalAgentRuntimeTarget( : { runtime: 'host' } } +async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + 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[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, diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 3d435ee9477..3048b2d0f87 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -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 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 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 void>() const child = { diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 98314f6975b..7115ce9d3f4 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -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>, models = spec.models, - defaultModelId = spec.defaultModelId + defaultModelId = spec.defaultModelId, + catalogOrigin: 'probe' | 'spec' = 'spec' ): Extract { 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((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) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0c57731e1e..93b4a2e4d23 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3019,6 +3019,7 @@ export type PreloadApi = { capability: CommitMessageAgentCapability models: CommitMessageModelCapability[] defaultModelId: string + catalogOrigin: 'probe' | 'spec' } | { success: false; error: string } > diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index bef6151532a..4eac2b5739c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -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 } | 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( + 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( + 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( diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts index e57f42e2efe..ed4d9f725b0 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -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() diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index 64828018181..b96d3fad358 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -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 { diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index e095adf1132..a41d62eeb7a 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 8475167186f..c4de028b244 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -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', diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index a97b471edee..a48a3611a22 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -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) => 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 diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index bdf8ee6d1a2..035f05312d4 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -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[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 { 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 + }) + : [] })) } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 008d857ec9c..b46f78ef1b3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 5fde5625c52..ee2ad888e6f 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -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]) } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts index 125bc1dbb9b..273267f3690 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -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[] = [ diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index 23c5f87a294..b0dfe6503fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -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: diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index aaab680a016..fba530089bf 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -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') }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 2398252afb6..adf06f74206 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -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 } diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 47fa8226af7..71327f1d532 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -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 } } diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index ffabb087e61..e7aae4aaaaa 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -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' diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index 6ba6ee65043..76074aeb7b7 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -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[] diff --git a/src/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts new file mode 100644 index 00000000000..86305b45549 --- /dev/null +++ b/src/shared/claude-model-list-probe.test.ts @@ -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([]) + }) +}) diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts new file mode 100644 index 00000000000..fa953d3da16 --- /dev/null +++ b/src/shared/claude-model-list-probe.ts @@ -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() + 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 [] +} diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 3d8eac29c81..af2d0ea43ee 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -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( diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 98ed09d681e..a6b91755b74 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -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 ({ 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 } : {}) })) } } diff --git a/src/shared/commit-message-host-key.ts b/src/shared/commit-message-host-key.ts index 6edf51a9bc3..9a93dec3992 100644 --- a/src/shared/commit-message-host-key.ts +++ b/src/shared/commit-message-host-key.ts @@ -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 {