diff --git a/cli/src/modules/common/claudeModels.test.ts b/cli/src/modules/common/claudeModels.test.ts new file mode 100644 index 00000000..578ac322 --- /dev/null +++ b/cli/src/modules/common/claudeModels.test.ts @@ -0,0 +1,298 @@ +import { EventEmitter } from 'node:events' +import { homedir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, killMock, claudePathMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + killMock: vi.fn(), + claudePathMock: vi.fn(), +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, spawn: spawnMock } +}) + +vi.mock('../../utils/process', () => ({ + killProcessByChildProcess: killMock, +})) + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: claudePathMock, +})) + +import { + _resetClaudeModelsCacheForTests, + listClaudeModels, + parseClaudeModelsProbeLine, +} from './claudeModels' + +const PROBE_REQUEST_ID = 'hapi-claude-models-probe' + +class FakeChild extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + stdin = Object.assign(new EventEmitter(), { write: vi.fn() }) + pid: number | undefined = 4242 + + respond(models: unknown): void { + this.stdout.emit('data', `${JSON.stringify({ + type: 'control_response', + response: { subtype: 'success', request_id: PROBE_REQUEST_ID, response: { models } }, + })}\n`) + } +} + +function successLine(models: unknown, requestId = PROBE_REQUEST_ID): string { + return JSON.stringify({ + type: 'control_response', + response: { subtype: 'success', request_id: requestId, response: { models } }, + }) +} + +const OPUS_ROW = { + value: 'opus', + resolvedModel: 'claude-opus-5-5', + displayName: 'Opus 5.5', + description: 'For complex work', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'], + supportsFastMode: true, +} + +let children: FakeChild[] + +function lastChild(): FakeChild { + const child = children.at(-1) + if (!child) throw new Error('probe was not spawned') + return child +} + +beforeEach(() => { + _resetClaudeModelsCacheForTests() + children = [] + spawnMock.mockReset() + spawnMock.mockImplementation(() => { + const child = new FakeChild() + children.push(child) + return child + }) + killMock.mockReset() + killMock.mockResolvedValue(true) + claudePathMock.mockReset() + claudePathMock.mockReturnValue('/usr/local/bin/claude') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('parseClaudeModelsProbeLine', () => { + it('keeps only the fields HAPI uses and folds effort support into levels', () => { + const result = parseClaudeModelsProbeLine(successLine([ + OPUS_ROW, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5', displayName: 'Haiku 4.5' }, + { value: 'legacy', supportsEffort: null }, + ])) + + expect(result).toEqual({ + kind: 'models', + models: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low', 'high'] }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + { value: 'legacy', effortLevels: [] }, + ], + }) + }) + + it('leaves effort support unknown when the CLI reports it for no model at all', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'opus', resolvedModel: 'claude-opus-5-5' }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'opus' }] }) + }) + + it('leaves the levels unknown when the CLI supports effort but lists no levels', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'opus', supportsEffort: true }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'opus' }] }) + }) + + it('trusts supportsEffort over a level list the row still carries', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: 'haiku', supportsEffort: false, supportedEffortLevels: ['low'] }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'haiku', effortLevels: [] }] }) + }) + + it('drops rows without a usable value and non-string fields', () => { + const result = parseClaudeModelsProbeLine(successLine([ + { value: '' }, + { displayName: 'No value' }, + 'opus', + { value: 'sonnet', displayName: 42, supportsEffort: true, supportedEffortLevels: ['low', 7] }, + ])) + + expect(result).toEqual({ kind: 'models', models: [{ value: 'sonnet', effortLevels: ['low'] }] }) + }) + + it('reports an explicit control error with the CLI message', () => { + const line = JSON.stringify({ + type: 'control_response', + response: { subtype: 'error', request_id: PROBE_REQUEST_ID, error: 'Not logged in' }, + }) + + expect(parseClaudeModelsProbeLine(line)).toEqual({ kind: 'error', error: 'Not logged in' }) + }) + + it('ignores hook events, other requests and non-JSON output', () => { + expect(parseClaudeModelsProbeLine(JSON.stringify({ type: 'system', subtype: 'hook_started' }))).toBeNull() + expect(parseClaudeModelsProbeLine(successLine([OPUS_ROW], 'someone-else'))).toBeNull() + expect(parseClaudeModelsProbeLine('Welcome to Claude Code')).toBeNull() + }) +}) + +describe('listClaudeModels', () => { + it('spawns the session executable isolated from project settings, hooks and MCP servers', async () => { + const pending = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await pending + + const [command, args, options] = spawnMock.mock.calls[0] as [string, string[], { cwd: string }] + expect(command).toBe('/usr/local/bin/claude') + expect(args).toEqual(expect.arrayContaining(['--print', '--strict-mcp-config'])) + expect(args[args.indexOf('--setting-sources') + 1]).toBe('user') + expect(JSON.parse(args[args.indexOf('--settings') + 1] ?? 'null')).toEqual({ disableAllHooks: true }) + expect(args[args.indexOf('--input-format') + 1]).toBe('stream-json') + expect(args[args.indexOf('--output-format') + 1]).toBe('stream-json') + expect(options.cwd).toBe(homedir()) + }) + + it('asks for the catalog with an initialize control request and no prompt', async () => { + const pending = listClaudeModels() + const child = lastChild() + child.respond([OPUS_ROW]) + await pending + + expect(child.stdin.write).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(child.stdin.write.mock.calls[0]?.[0]))).toEqual({ + type: 'control_request', + request_id: PROBE_REQUEST_ID, + request: { subtype: 'initialize' }, + }) + }) + + it('returns the catalog and stops the probe process', async () => { + const pending = listClaudeModels() + lastChild().respond([OPUS_ROW]) + + await expect(pending).resolves.toMatchObject({ + success: true, + availableModels: [{ value: 'opus', displayName: 'Opus 5.5' }], + }) + expect(killMock).toHaveBeenCalledTimes(1) + }) + + it('shares one probe between concurrent requests', async () => { + const first = listClaudeModels() + const second = listClaudeModels() + lastChild().respond([OPUS_ROW]) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + + it('serves a cached catalog for five minutes, then probes again', async () => { + vi.useFakeTimers() + const first = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await first + + vi.advanceTimersByTime(5 * 60_000 - 1) + await listClaudeModels() + expect(spawnMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1) + const refreshed = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await refreshed + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a separate cache per claude executable', async () => { + const first = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await first + + claudePathMock.mockReturnValue('/opt/other/claude') + const second = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await second + + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[1]?.[0]).toBe('/opt/other/claude') + }) + + it('does not cache a failure, so the next request probes again', async () => { + const failed = listClaudeModels() + lastChild().emit('error', new Error('spawn claude ENOENT')) + await expect(failed).rejects.toThrow('spawn claude ENOENT') + + const retried = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await expect(retried).resolves.toMatchObject({ success: true }) + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('rejects an empty catalog instead of caching it', async () => { + const pending = listClaudeModels() + lastChild().respond([]) + await expect(pending).rejects.toThrow('no models') + + const retried = listClaudeModels() + lastChild().respond([OPUS_ROW]) + await retried + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + + it('surfaces the CLI error and exit output when it quits before answering', async () => { + const pending = listClaudeModels() + const child = lastChild() + child.stderr.emit('data', 'Invalid API key') + child.emit('close', 1) + + await expect(pending).rejects.toThrow('Invalid API key') + }) + + it('times out a probe that never answers and stops it', async () => { + vi.useFakeTimers() + const pending = listClaudeModels() + const assertion = expect(pending).rejects.toThrow('timed out') + + await vi.advanceTimersByTimeAsync(15_000) + await assertion + expect(killMock).toHaveBeenCalled() + }) + + it('reports a missing claude executable without spawning', async () => { + claudePathMock.mockImplementation(() => { + throw new Error('Claude Code CLI not found on PATH') + }) + + await expect(listClaudeModels()).rejects.toThrow('Claude Code CLI not found on PATH') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('does not let a stdin write failure escape as an unhandled error', async () => { + const pending = listClaudeModels() + const child = lastChild() + + expect(() => child.stdin.emit('error', new Error('write EPIPE'))).not.toThrow() + child.emit('close', null) + await expect(pending).rejects.toThrow() + }) +}) diff --git a/cli/src/modules/common/claudeModels.ts b/cli/src/modules/common/claudeModels.ts new file mode 100644 index 00000000..40918357 --- /dev/null +++ b/cli/src/modules/common/claudeModels.ts @@ -0,0 +1,241 @@ +import { spawn } from 'node:child_process' +import { homedir } from 'node:os' +import type { ClaudeModelSummary, ClaudeModelsResponse } from '@hapi/protocol/apiTypes' +import { getDefaultClaudeCodePath } from '@/claude/sdk/utils' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { killProcessByChildProcess } from '../../utils/process' +import { getErrorMessage } from './rpcResponses' + +export type ListClaudeModelsRequest = Record + +export type ListClaudeModelsResponse = ClaudeModelsResponse + +interface CacheEntry { + expiresAt: number + response: ListClaudeModelsResponse +} + +// The catalog changes rarely (a CLI update, a different login, an alias the +// service moves to a newer model) and each probe starts a full claude process +// (~0.75s), so a successful answer is kept for a few minutes. Failures are not +// cached: a user who just logged in should not wait out the TTL. +const CACHE_TTL_MS = 5 * 60_000 +const PROBE_TIMEOUT_MS = 15_000 +const PROBE_REQUEST_ID = 'hapi-claude-models-probe' +const cache = new Map() +const inflight = new Map>() + +/** + * Machine-level discovery of the models the `claude` CLI offers, read from the + * `models` field of its `initialize` control response. No prompt is sent, so + * the probe starts no turn and writes no transcript. + * + * `claude --print` loads the working directory's `.claude/settings*.json` and + * `.mcp.json` without a workspace trust prompt, so any project hook or MCP + * server there would run. The probe therefore runs from the home directory + * and loads only user settings, no MCP servers, and no hooks at all (the + * user's own hooks would otherwise fire on every catalog refresh). + * + * User settings stay loaded so the probe sees the same `env` a session does + * (e.g. `ANTHROPIC_DEFAULT_OPUS_MODEL` changes what `opus` resolves to). + * Project settings that redefine model aliases are ignored here, so in such + * a directory the catalog can differ from what the session resolves. + */ +const CLAUDE_MODELS_PROBE_ARGS = [ + '--print', + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user', + '--strict-mcp-config', + '--settings', JSON.stringify({ disableAllHooks: true }), +] as const + +export type ClaudeModelsProbeLineResult = + | { kind: 'models'; models: ClaudeModelSummary[] } + | { kind: 'error'; error: string } + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +/** + * A row without `supportsEffort` (or with `null`) has no effort control, but + * only when the CLI reports effort support at all: a CLI that predates the + * field would otherwise hide effort for every model. A model that supports + * effort without listing levels keeps them unknown. + */ +function parseClaudeCatalog(rawModels: unknown): ClaudeModelSummary[] { + if (!Array.isArray(rawModels)) { + return [] + } + const rows = rawModels.map(asRecord).filter((row): row is Record => row !== null) + const reportsEffortSupport = rows.some((row) => 'supportsEffort' in row) + const models: ClaudeModelSummary[] = [] + for (const row of rows) { + const value = optionalString(row.value) + if (!value) { + continue + } + const model: ClaudeModelSummary = { value } + const displayName = optionalString(row.displayName) + if (displayName) model.displayName = displayName + if (reportsEffortSupport && row.supportsEffort !== true) { + model.effortLevels = [] + } else if (Array.isArray(row.supportedEffortLevels)) { + model.effortLevels = row.supportedEffortLevels.filter((level): level is string => typeof level === 'string') + } + models.push(model) + } + return models +} + +/** Scans one stdout line for the answer to the probe's initialize request. */ +export function parseClaudeModelsProbeLine(line: string): ClaudeModelsProbeLineResult | null { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) return null + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return null + } + const message = asRecord(parsed) + if (message?.type !== 'control_response') return null + const response = asRecord(message.response) + if (response?.request_id !== PROBE_REQUEST_ID) return null + if (response.subtype !== 'success') { + return { kind: 'error', error: optionalString(response.error) ?? 'Claude rejected the model probe request' } + } + return { kind: 'models', models: parseClaudeCatalog(asRecord(response.response)?.models) } +} + +function runClaudeModelsProbe(executable: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, [...CLAUDE_MODELS_PROBE_ARGS], { + cwd: homedir(), + env: withBunRuntimeEnv(process.env, { allowBunBeBun: false }), + stdio: ['pipe', 'pipe', 'pipe'], + // getDefaultClaudeCodePath resolves an absolute path (claude.exe on + // Windows), matching how sessions spawn it. + shell: false, + windowsHide: process.platform === 'win32', + }) + let stdoutBuffer = '' + let stderr = '' + let settled = false + + // The claude process stays up waiting for input, so every outcome + // stops it before the caller sees a result. + const finish = (settle: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + void (async () => { + if (!child.pid) { + settle() + return + } + let stopped = false + try { + stopped = await killProcessByChildProcess(child, false) + || await killProcessByChildProcess(child, true) + } catch { + stopped = false + } + if (!stopped) { + reject(new Error(`Claude model probe could not be stopped (pid ${child.pid})`)) + return + } + settle() + })() + } + + const timeout = setTimeout(() => { + finish(() => reject(new Error('Claude model discovery timed out'))) + }, PROBE_TIMEOUT_MS) + + child.stdout?.on('data', (chunk) => { + stdoutBuffer += chunk.toString() + let newlineIndex = stdoutBuffer.indexOf('\n') + while (newlineIndex !== -1 && !settled) { + const line = stdoutBuffer.slice(0, newlineIndex) + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + newlineIndex = stdoutBuffer.indexOf('\n') + const result = parseClaudeModelsProbeLine(line) + if (result === null) continue + if (result.kind === 'error') { + finish(() => reject(new Error(result.error))) + return + } + if (result.models.length === 0) { + finish(() => reject(new Error('Claude reported no models'))) + return + } + finish(() => resolve({ success: true, availableModels: result.models })) + return + } + }) + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + finish(() => reject(error)) + }) + child.on('close', (code) => { + finish(() => reject(new Error( + stderr.trim() || `claude exited with code ${code ?? 'unknown'} before answering the model probe` + ))) + }) + // An early exit surfaces through 'close', not as an unhandled EPIPE. + child.stdin?.on('error', () => { /* handled via close */ }) + child.stdin?.write(`${JSON.stringify({ + type: 'control_request', + request_id: PROBE_REQUEST_ID, + request: { subtype: 'initialize' }, + })}\n`) + }) +} + +/** Clear the module-level probe cache and in-flight map between tests. */ +export function _resetClaudeModelsCacheForTests(): void { + cache.clear() + inflight.clear() +} + +/** Throws on failure; the RPC handler turns that into an error response. */ +export async function listClaudeModels(): Promise { + // Keyed by executable: HAPI_CLAUDE_PATH can point a runner at a different + // claude install, whose catalog is its own. + const executable = getDefaultClaudeCodePath() + const now = Date.now() + const cached = cache.get(executable) + if (cached && cached.expiresAt > now) { + return cached.response + } + + const existing = inflight.get(executable) + if (existing) { + return existing + } + + const pending = runClaudeModelsProbe(executable) + .then((response) => { + cache.set(executable, { expiresAt: Date.now() + CACHE_TTL_MS, response }) + return response + }) + .catch((error) => { + throw new Error(getErrorMessage(error, 'Failed to list Claude models')) + }) + .finally(() => { + inflight.delete(executable) + }) + inflight.set(executable, pending) + return pending +} diff --git a/cli/src/modules/common/handlers/claudeModels.test.ts b/cli/src/modules/common/handlers/claudeModels.test.ts new file mode 100644 index 00000000..16b554e9 --- /dev/null +++ b/cli/src/modules/common/handlers/claudeModels.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' + +const { listClaudeModelsMock } = vi.hoisted(() => ({ + listClaudeModelsMock: vi.fn(), +})) + +vi.mock('../claudeModels', () => ({ + listClaudeModels: listClaudeModelsMock, +})) + +import { registerClaudeModelHandlers } from './claudeModels' + +describe('listClaudeModels machine RPC handler', () => { + let rpc: RpcHandlerManager + + beforeEach(() => { + listClaudeModelsMock.mockReset() + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }) + registerClaudeModelHandlers(rpc) + }) + + async function listViaRpc(): Promise { + const raw = await rpc.handleRequest({ + method: `machine-test:${RPC_METHODS.ListClaudeModels}`, + params: '{}' + }) + return JSON.parse(raw) + } + + it('returns the discovered catalog', async () => { + listClaudeModelsMock.mockResolvedValue({ + success: true, + availableModels: [{ value: 'opus' }] + }) + + await expect(listViaRpc()).resolves.toEqual({ + success: true, + availableModels: [{ value: 'opus' }] + }) + }) + + it('answers a failed probe with an error response instead of throwing', async () => { + listClaudeModelsMock.mockRejectedValue(new Error('Claude model discovery timed out')) + + await expect(listViaRpc()).resolves.toEqual({ + success: false, + error: 'Claude model discovery timed out' + }) + }) +}) diff --git a/cli/src/modules/common/handlers/claudeModels.ts b/cli/src/modules/common/handlers/claudeModels.ts new file mode 100644 index 00000000..a3931fa7 --- /dev/null +++ b/cli/src/modules/common/handlers/claudeModels.ts @@ -0,0 +1,25 @@ +import { logger } from '@/ui/logger'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listClaudeModels, + type ListClaudeModelsRequest, + type ListClaudeModelsResponse +} from '../claudeModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerClaudeModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.ListClaudeModels, + async () => { + logger.debug('List Claude models request'); + + try { + return await listClaudeModels(); + } catch (error) { + logger.debug('Failed to list Claude models:', error); + return rpcError(getErrorMessage(error, 'Failed to list Claude models')); + } + } + ); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index d00826b8..647a6332 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerAgyModelHandlers } from './handlers/agyModels' import { registerBashHandlers } from './handlers/bash' +import { registerClaudeModelHandlers } from './handlers/claudeModels' import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' @@ -21,6 +22,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerAgyModelHandlers(rpcHandlerManager) registerBashHandlers(rpcHandlerManager, workingDirectory) + registerClaudeModelHandlers(rpcHandlerManager) registerCodexModelHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 38f57600..a00cf6c7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -9,6 +9,7 @@ import { } from '@hapi/protocol/apiTypes' import type { AgyModelsResponse, + ClaudeModelsResponse, AgentAvailabilityResponse, CodexModelSummary, CodexModelsResponse, @@ -130,6 +131,7 @@ export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEff export type RpcListAgyModelsResponse = AgyModelsResponse export type RpcListDshModelsResponse = DshModelsResponse export type RpcListPiModelsResponse = PiModelsResponse +export type RpcListClaudeModelsResponse = ClaudeModelsResponse export class RpcGateway { constructor( @@ -650,6 +652,10 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListPiModelsForMachine, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListPiModelsResponse } + async listClaudeModelsForMachine(machineId: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListClaudeModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListClaudeModelsResponse + } + private async sessionRpc( sessionId: string, method: string, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d8c70adb..699e8345 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -45,6 +45,7 @@ import { type RpcStatFilesResponse, type RpcListAgyModelsResponse, type RpcListPiModelsResponse, + type RpcListClaudeModelsResponse, type RpcListCodexModelsResponse, type RpcListPiSessionsResponse, type RpcArchiveCodexSessionResponse, @@ -83,6 +84,7 @@ export type { RpcStatFilesResponse, RpcListAgyModelsResponse, RpcListPiModelsResponse, + RpcListClaudeModelsResponse, RpcListCodexModelsResponse, RpcListPiSessionsResponse, RpcListCursorModelsResponse, @@ -4465,6 +4467,10 @@ export class SyncEngine { return await this.rpcGateway.listPiModelsForMachine(machineId) } + async listClaudeModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listClaudeModelsForMachine(machineId) + } + async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index d0109287..7197c355 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -4,7 +4,7 @@ import type { Machine, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { createMachinesRoutes } from './machines' import { RpcTargetMissingError } from '../../sync/rpcGateway' -import { MACHINE_CAPABILITIES } from '@hapi/protocol' +import { MACHINE_CAPABILITIES, RPC_TARGET_MISSING_ERROR_CODE } from '@hapi/protocol' function createMachine(overrides?: Partial): Machine { return { @@ -218,6 +218,60 @@ describe('machines routes', () => { }) }) + it('returns Claude models for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listClaudeModelsForMachine: async () => ({ + success: true, + availableModels: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low'] } + ] + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/claude-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['low'] } + ] + }) + }) + + it('returns a stable code when a runner predates Claude model discovery', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listClaudeModelsForMachine: async () => { + throw new RpcTargetMissingError('machine-1:listClaudeModels', 'handler-not-registered') + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/claude-models') + + expect(response.status).toBe(503) + expect(await response.json()).toMatchObject({ success: false, code: RPC_TARGET_MISSING_ERROR_CODE }) + }) + it('returns a stable code when the Codex machine RPC target is absent', async () => { const machine = createMachine() const engine = { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 8dc203cd..f5024613 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -277,6 +277,36 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/claude-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + try { + const result = await engine.listClaudeModelsForMachine(machineId) + return c.json(result) + } catch (error) { + if (error instanceof RpcTargetMissingError) { + return c.json({ + success: false, + error: error.message, + code: RPC_TARGET_MISSING_ERROR_CODE + }, 503) + } + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Claude models' + }, 500) + } + }) + app.get('/machines/:id/codex-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fcb21e6d..ed744e56 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -768,6 +768,53 @@ describe('sessions routes', () => { ]) }) + it('applies a Claude model and the effort it keeps in one session config change', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'haiku', effort: null }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'haiku', effort: null }] + ]) + }) + + it('rejects an effort sent with a model change for Pi, which applies the two separately', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.6-sol', effort: null }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('rejects an effort sent with a model change for a flavor without effort', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.5', effort: 'high' }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects model changes for local Codex sessions', async () => { const session = createSession({ agentState: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 3669a341..448d058c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -718,8 +718,16 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } } + // Only Claude applies a model and an effort as one config change; Pi + // commits the model before setting its thinking level, so a failure in + // between would leave half of the request applied. + const { model, effort } = parsed.data + if (effort !== undefined && flavor !== 'claude') { + return c.json({ error: 'Effort can only be sent with a model change for Claude sessions' }, 400) + } + try { - await engine.applySessionConfig(sessionResult.sessionId, { model: parsed.data.model }) + await engine.applySessionConfig(sessionResult.sessionId, effort === undefined ? { model } : { model, effort }) return c.json({ ok: true }) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to apply model' diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 3b87ccb0..132200d7 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -306,7 +306,9 @@ export const SessionModelRequestSchema = z.object({ provider: z.string().trim().min(1), modelId: z.string().trim().min(1), }), - ]).nullable() + ]).nullable(), + /** Effort to apply together with the model, e.g. cleared when the new model does not offer it. */ + effort: z.string().trim().min(1).nullable().optional() }) export type SessionModelRequest = z.infer @@ -1008,6 +1010,21 @@ export type PiModelsResponse = { export type ListPiModelsResponse = PiModelsResponse +/** One row of the `claude` CLI's own model picker, as reported by its `initialize` control response. */ +export type ClaudeModelSummary = { + /** What `claude --model` accepts: an alias that follows the family (`opus`) or a full model id. */ + value: string + displayName?: string + /** Effort levels the model accepts: `[]` = no effort control, undefined = unknown. */ + effortLevels?: string[] +} + +export type ClaudeModelsResponse = { + success: boolean + availableModels?: ClaudeModelSummary[] + error?: string +} + export type PiCommandSummary = { name: string description?: string diff --git a/shared/src/models.test.ts b/shared/src/models.test.ts index 69d08be9..e80f38b4 100644 --- a/shared/src/models.test.ts +++ b/shared/src/models.test.ts @@ -5,9 +5,13 @@ import { DEFAULT_GEMINI_MODEL, GEMINI_MODEL_LABELS, GEMINI_MODEL_PRESETS, + getClaudeEffortLevelsForModel, + getClaudeModelChoices, getClaudeModelLabel, isClaudeModelPreset, + resolveClaudeEffortForModel, } from './models' +import type { ClaudeModelSummary } from './apiTypes' describe('isClaudeModelPreset', () => { test('accepts valid presets', () => { @@ -64,3 +68,98 @@ describe('model constants consistency', () => { expect(GEMINI_MODEL_PRESETS).toContain(DEFAULT_GEMINI_MODEL) }) }) + +const FULL = ['low', 'medium', 'high', 'xhigh', 'max'] +const NO_XHIGH = ['low', 'medium', 'high', 'max'] + +// Shape of a subscription account's catalog: an alias per family, older +// versions under their full ids. +const SUBSCRIPTION_CATALOG: ClaudeModelSummary[] = [ + { value: 'default', displayName: 'Default (recommended)', effortLevels: FULL }, + { value: 'opus', displayName: 'Opus 5.5', effortLevels: FULL }, + { value: 'claude-fable-5-1', displayName: 'Fable 5.1', effortLevels: FULL }, + { value: 'sonnet', displayName: 'Sonnet 5', effortLevels: FULL }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7', effortLevels: FULL }, + { value: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6', effortLevels: NO_XHIGH }, +] + +describe('getClaudeModelChoices', () => { + test('lists the CLI picker rows with their display names, splitting each family\'s newest from the rest', () => { + expect(getClaudeModelChoices(SUBSCRIPTION_CATALOG)).toEqual([ + { value: 'opus', label: 'Opus 5.5', section: 'main' }, + { value: 'claude-fable-5-1', label: 'Fable 5.1', section: 'main' }, + { value: 'sonnet', label: 'Sonnet 5', section: 'main' }, + { value: 'haiku', label: 'Haiku 4.5', section: 'main' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', section: 'other' }, + { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6', section: 'other' }, + ]) + }) + + test('reads the family from full ids and variants, keeping the CLI order within each section', () => { + const choices = getClaudeModelChoices([ + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-haiku-4-5-20251001', displayName: 'Haiku 4.5' }, + { value: 'opus[1m]', displayName: 'Opus 5.5 (1M)' }, + { value: 'claude-sonnet-5-5', displayName: 'Sonnet 5.5' }, + { value: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6' }, + ]) + expect(choices.map((choice) => [choice.value, choice.section])).toEqual([ + ['opus', 'main'], + ['claude-haiku-4-5-20251001', 'main'], + ['claude-sonnet-5-5', 'main'], + ['opus[1m]', 'other'], + ['claude-sonnet-4-6', 'other'], + ]) + }) + + test('leaves the default row to the caller, which stores it as no model', () => { + const choices = getClaudeModelChoices(SUBSCRIPTION_CATALOG) + expect(choices.some((choice) => choice.value === 'default')).toBe(false) + }) + + test('falls back to the value when the CLI gives no display name', () => { + expect(getClaudeModelChoices([{ value: 'claude-custom' }])).toEqual([ + { value: 'claude-custom', label: 'claude-custom', section: 'main' }, + ]) + }) +}) + +describe('getClaudeEffortLevelsForModel', () => { + test('reads the default row for the session default', () => { + expect(getClaudeEffortLevelsForModel(null, SUBSCRIPTION_CATALOG)).toEqual(FULL) + }) + + test('reads the listed row for an alias or a full id', () => { + expect(getClaudeEffortLevelsForModel('haiku', SUBSCRIPTION_CATALOG)).toEqual([]) + expect(getClaudeEffortLevelsForModel('claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toEqual(NO_XHIGH) + }) + + test('is unknown for a model the catalog does not list, or without a catalog', () => { + expect(getClaudeEffortLevelsForModel('claude-opus-4-1', SUBSCRIPTION_CATALOG)).toBeUndefined() + expect(getClaudeEffortLevelsForModel('opus', null)).toBeUndefined() + }) +}) + +describe('resolveClaudeEffortForModel', () => { + test('keeps an effort the next model supports', () => { + expect(resolveClaudeEffortForModel('max', 'claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toBe('max') + }) + + test('clears an effort level the next model does not offer', () => { + expect(resolveClaudeEffortForModel('xhigh', 'claude-sonnet-4-6', SUBSCRIPTION_CATALOG)).toBeNull() + }) + + test('clears any effort when the next model has no effort control', () => { + expect(resolveClaudeEffortForModel('low', 'haiku', SUBSCRIPTION_CATALOG)).toBeNull() + }) + + test('keeps the effort when support is unknown', () => { + expect(resolveClaudeEffortForModel('xhigh', 'claude-opus-4-1', SUBSCRIPTION_CATALOG)).toBe('xhigh') + expect(resolveClaudeEffortForModel('xhigh', 'haiku', null)).toBe('xhigh') + }) + + test('leaves an unset effort unset', () => { + expect(resolveClaudeEffortForModel(null, 'haiku', SUBSCRIPTION_CATALOG)).toBeNull() + }) +}) diff --git a/shared/src/models.ts b/shared/src/models.ts index 544fb5dd..14a14e0f 100644 --- a/shared/src/models.ts +++ b/shared/src/models.ts @@ -1,3 +1,5 @@ +import type { ClaudeModelSummary } from './apiTypes' + export const CLAUDE_MODEL_LABELS = { sonnet: 'Sonnet', 'sonnet[1m]': 'Sonnet 1M', @@ -66,3 +68,81 @@ export function getClaudeModelLabel(model: string): string | null { return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null } + +/** + * A Claude model the user can pick. + * + * `value` is stored on the session and passed to `claude --model` unchanged. + * An alias (`opus`) is resolved by the CLI each time the session's claude + * process starts, so the session moves to a newer model on its next start; + * a full id (`claude-opus-4-7`) stays on that model. + */ +export type ClaudeModelChoice = { + value: string + label: string + /** `main` is each family's newest model; `other` is every earlier version. */ + section: 'main' | 'other' +} + +/** `opus`, `opus[1m]`, `claude-opus-4-7` → `opus`. */ +function getClaudeModelFamily(value: string): string { + return value.replace(/^claude-/, '').split(/[-[]/)[0] ?? value +} + +/** + * Picker choices for a catalog reported by the `claude` CLI: its own `/model` + * rows, with its display names, split the way Claude Code's model sheet + * splits them. The CLI lists each family's newest model before its earlier + * versions, so the first row of a family is `main` and later ones are + * `other`; within each section the CLI order is kept. The `default` row is + * omitted; callers represent it as "no model". + */ +export function getClaudeModelChoices(catalog: readonly ClaudeModelSummary[]): ClaudeModelChoice[] { + const families = new Set() + const choices: ClaudeModelChoice[] = catalog + .filter((model) => model.value !== 'default') + .map((model) => { + const family = getClaudeModelFamily(model.value) + const section = families.has(family) ? 'other' : 'main' + families.add(family) + return { value: model.value, label: model.displayName ?? model.value, section } + }) + return [ + ...choices.filter((choice) => choice.section === 'main'), + ...choices.filter((choice) => choice.section === 'other'), + ] +} + +/** + * Effort levels the CLI offers for `model` (`null` = the session default). + * Undefined when the catalog is missing or does not list the model. + */ +export function getClaudeEffortLevelsForModel( + model: string | null, + catalog: readonly ClaudeModelSummary[] | null | undefined +): readonly string[] | undefined { + if (!catalog) { + return undefined + } + if (model === null) { + return catalog.find((row) => row.value === 'default')?.effortLevels + } + return catalog.find((row) => row.value === model)?.effortLevels +} + +/** + * The effort to keep when switching to `model`: an effort the model does not + * offer is cleared rather than left for the CLI to reject or ignore. Unknown + * support keeps the current effort. + */ +export function resolveClaudeEffortForModel( + effort: string | null, + model: string | null, + catalog: readonly ClaudeModelSummary[] | null | undefined +): string | null { + const levels = getClaudeEffortLevelsForModel(model, catalog) + if (effort === null || levels === undefined || levels.includes(effort)) { + return effort + } + return null +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 471a7598..3a2d6548 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -56,6 +56,7 @@ export const RPC_METHODS = { ListDshModelsForMachine: 'listDshModelsForMachine', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions', ListAgyModels: 'listAgyModels', + ListClaudeModels: 'listClaudeModels', /** Deliver one queued message into the active Pi turn (native steer). */ SteerQueuedMessage: 'steer-queued-message', ForkConversation: 'fork-conversation', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1d33801b..e7cc5ef0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,7 @@ import type { OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PiModelsResponse, + ClaudeModelsResponse, QueuedStateResponse, ReopenSessionResponse, SqliteStorageUsageResponse, @@ -705,10 +706,14 @@ export class ApiClient { }) } - async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { + async setModel( + sessionId: string, + model: { provider: string; modelId: string } | string | null, + options?: { effort: string | null } + ): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', - body: JSON.stringify({ model }) + body: JSON.stringify(options ? { model, effort: options.effort } : { model }) }) } @@ -890,6 +895,12 @@ export class ApiClient { ) } + async getMachineClaudeModels(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/claude-models` + ) + } + async getMachineDshModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/dsh-models` diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx index d9d8032d..1d2a94dc 100644 --- a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx +++ b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx @@ -153,6 +153,28 @@ describe('HappyComposer generic model/effort value buttons', () => { expect(screen.getByRole('button', { name: 'Settings' })).toBeTruthy() }) + it('heads grouped model rows with their group name once', () => { + renderComposer('claude', { + model: 'opus', + availableModelOptions: [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + { value: 'claude-opus-4-6', label: 'Opus 4.6', group: 'Other models' }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Opus 5.5' })) + expect(screen.getAllByText('Other models')).toHaveLength(1) + expect(screen.getByRole('button', { name: 'Opus 4.7' })).toBeTruthy() + }) + + it('offers only the effort levels the current Claude model accepts', () => { + renderComposer('claude', { availableEffortOptions: [{ value: 'low' }, { value: 'high' }] }) + fireEvent.click(screen.getByRole('button', { name: 'High' })) + expect(screen.getByRole('button', { name: 'Low' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Max' })).toBeNull() + expect(screen.queryByRole('button', { name: 'XHigh' })).toBeNull() + }) + it('shows only the model button for flavors without effort support', () => { renderComposer('codex') expect(screen.getByRole('button', { name: 'Sonnet 4' })).toBeTruthy() diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6ecedb7c..ac091740 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -13,6 +13,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type SyntheticEvent as ReactSyntheticEvent, + Fragment, useCallback, useEffect, useImperativeHandle, @@ -310,7 +311,7 @@ export function HappyComposer(props: { controlledByUser?: boolean concurrentClients?: boolean agentFlavor?: string | null - availableModelOptions?: Array<{ value: string | null; label: string }> + availableModelOptions?: Array<{ value: string | null; label: string; group?: string }> /** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */ piModels?: PiModelSummary[] /** Pi: provider-qualified selected model from metadata (survives reload; @@ -1108,7 +1109,7 @@ export function HappyComposer(props: { label: option.name ?? option.value })) ] - : getClaudeComposerEffortOptions(effort), + : getClaudeComposerEffortOptions(effort, availableEffortOptions?.map((option) => option.value)), [agentFlavor, effort, selectedPiModel, availableEffortOptions] ) const permissionModes = useMemo( @@ -1803,13 +1804,20 @@ export function HappyComposer(props: { })} )) - : modelOptions.map((option) => { + : modelOptions.map((option, index) => { const isSelected = selectedModelBase !== undefined ? selectedModelBase === option.value : model === option.value + const startsGroup = option.group !== undefined + && option.group !== modelOptions[index - 1]?.group return ( + + {startsGroup ? ( +
+ {option.group} +
+ ) : null} +
) })} diff --git a/web/src/components/AssistantChat/claudeEffortOptions.test.ts b/web/src/components/AssistantChat/claudeEffortOptions.test.ts index a4e41ed9..d38dcb1c 100644 --- a/web/src/components/AssistantChat/claudeEffortOptions.test.ts +++ b/web/src/components/AssistantChat/claudeEffortOptions.test.ts @@ -25,3 +25,27 @@ describe('getClaudeComposerEffortOptions', () => { ]) }) }) + +describe('getClaudeComposerEffortOptions with the model\'s levels', () => { + it('offers only the levels the current model accepts', () => { + expect(getClaudeComposerEffortOptions(null, ['low', 'medium', 'high', 'max'])).toEqual([ + { value: null, label: 'Auto' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, + ]) + }) + + it('offers only Auto for a model without effort control', () => { + expect(getClaudeComposerEffortOptions(null, [])).toEqual([{ value: null, label: 'Auto' }]) + }) + + it('still shows an active effort the model does not list', () => { + expect(getClaudeComposerEffortOptions('xhigh', ['low'])).toEqual([ + { value: null, label: 'Auto' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'low', label: 'Low' }, + ]) + }) +}) diff --git a/web/src/components/AssistantChat/claudeEffortOptions.ts b/web/src/components/AssistantChat/claudeEffortOptions.ts index f73e13cc..b228bca4 100644 --- a/web/src/components/AssistantChat/claudeEffortOptions.ts +++ b/web/src/components/AssistantChat/claudeEffortOptions.ts @@ -19,15 +19,25 @@ function formatEffortLabel(effort: string): string { ?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}` } -export function getClaudeComposerEffortOptions(currentEffort?: string | null): ClaudeComposerEffortOption[] { +/** + * `supportedLevels` narrows the list to what the current model accepts + * (`[]` = no effort control); undefined offers every level. + */ +export function getClaudeComposerEffortOptions( + currentEffort?: string | null, + supportedLevels?: readonly string[] +): ClaudeComposerEffortOption[] { const normalizedCurrentEffort = normalizeClaudeComposerEffort(currentEffort) + const levels: readonly ClaudeEffortLevel[] = supportedLevels + ? CLAUDE_EFFORT_LEVELS.filter((effort) => supportedLevels.includes(effort)) + : CLAUDE_EFFORT_LEVELS const options: ClaudeComposerEffortOption[] = [ { value: null, label: 'Auto' } ] if ( normalizedCurrentEffort - && !CLAUDE_EFFORT_LEVELS.includes(normalizedCurrentEffort as ClaudeEffortLevel) + && !levels.includes(normalizedCurrentEffort as ClaudeEffortLevel) ) { options.push({ value: normalizedCurrentEffort, @@ -35,7 +45,7 @@ export function getClaudeComposerEffortOptions(currentEffort?: string | null): C }) } - options.push(...CLAUDE_EFFORT_LEVELS.map((effort) => ({ + options.push(...levels.map((effort) => ({ value: effort, label: CLAUDE_EFFORT_LABELS[effort] }))) diff --git a/web/src/components/AssistantChat/claudeModelOptions.ts b/web/src/components/AssistantChat/claudeModelOptions.ts index 086cc5dc..aa79375a 100644 --- a/web/src/components/AssistantChat/claudeModelOptions.ts +++ b/web/src/components/AssistantChat/claudeModelOptions.ts @@ -3,6 +3,8 @@ import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol' export type ClaudeComposerModelOption = { value: string | null label: string + /** Heading the option is listed under; ungrouped options come first. */ + group?: string } function normalizeClaudeComposerModel(model?: string | null): string | null { diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 2acde7be..69c4448d 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' +import { getClaudeLaunchModelOptions, getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' describe('getModelOptionsForFlavor', () => { it('never offers the unsupported default reset in an active AGY session', () => { @@ -64,36 +64,65 @@ describe('getModelOptionsForFlavor', () => { expect(options.some((o) => o.value === 'opus')).toBe(true) }) - it('keeps Claude presets when explicit options only include Sonnet models', () => { + it('uses the Claude catalog as the whole list, as the CLI picker shows it', () => { const options = getModelOptionsForFlavor('claude', null, [ - { value: null, label: 'Default' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' } + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7' }, + { value: 'sonnet', label: 'Sonnet 5' }, ]) expect(options).toEqual([ { value: null, label: 'Default' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' }, - { value: 'opus', label: 'Opus' }, - { value: 'opus[1m]', label: 'Opus 1M' }, - { value: 'fable', label: 'Fable' }, - { value: 'fable[1m]', label: 'Fable 1M' } + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7' }, + { value: 'sonnet', label: 'Sonnet 5' }, ]) }) - it('adds non-preset Claude options without hiding Opus presets', () => { + it('offers a 1M variant the CLI lists as its own row', () => { const options = getModelOptionsForFlavor('claude', null, [ - { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' } + { value: 'opus[1m]', label: 'Opus (1M context)' }, + { value: 'claude-fable-5[1m]', label: 'Fable' }, + { value: 'sonnet', label: 'Sonnet' }, ]) expect(options).toEqual([ { value: null, label: 'Default' }, - { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' }, + { value: 'opus[1m]', label: 'Opus (1M context)' }, + { value: 'claude-fable-5[1m]', label: 'Fable' }, { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' }, - { value: 'opus', label: 'Opus' }, + ]) + }) + + it('keeps the group of each Claude catalog option', () => { + const options = getModelOptionsForFlavor('claude', null, [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + }) + + it('keeps a saved built-in alias the catalog does not list as the current model', () => { + const options = getModelOptionsForFlavor('claude', 'opus[1m]', [ + { value: 'opus', label: 'Opus 5.5' }, + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, { value: 'opus[1m]', label: 'Opus 1M' }, - { value: 'fable', label: 'Fable' }, - { value: 'fable[1m]', label: 'Fable 1M' } + { value: 'opus', label: 'Opus 5.5' }, + ]) + }) + + it('keeps a pinned Claude model the catalog no longer lists selected', () => { + const options = getModelOptionsForFlavor('claude', 'claude-opus-4-1', [ + { value: 'opus', label: 'Opus 5.5' }, + ]) + expect(options.slice(0, 3)).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-opus-4-1', label: 'claude-opus-4-1' }, + { value: 'opus', label: 'Opus 5.5' }, ]) }) @@ -245,6 +274,41 @@ describe('getModelOptionsForFlavor', () => { }) }) +describe('getClaudeLaunchModelOptions', () => { + const catalog = [ + { value: 'default', displayName: 'Default (recommended)' }, + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7' }, + ] + + it('offers the catalog choices with Default as auto and earlier versions under Other models', () => { + expect(getClaudeLaunchModelOptions(catalog, 'auto', 'no longer listed', 'Other models')).toEqual([ + { value: 'auto', label: 'Default' }, + { value: 'opus', label: 'Opus 5.5' }, + { value: 'claude-opus-4-7', label: 'Opus 4.7', group: 'Other models' }, + ]) + }) + + it('marks a saved model the catalog no longer lists instead of dropping it', () => { + const options = getClaudeLaunchModelOptions(catalog, 'claude-opus-4-1', 'no longer listed', 'Other models') + expect(options[1]).toEqual({ value: 'claude-opus-4-1', label: 'claude-opus-4-1 (no longer listed)' }) + }) + + it('marks a saved built-in alias the catalog no longer lists, like any other model', () => { + const options = getClaudeLaunchModelOptions(catalog, 'opus[1m]', 'no longer listed', 'Other models') + expect(options[1]).toEqual({ value: 'opus[1m]', label: 'Opus 1M (no longer listed)' }) + }) + + it('keeps a saved model selectable before the catalog arrives, without claiming it is gone', () => { + const options = getClaudeLaunchModelOptions([], 'claude-opus-4-1', 'no longer listed', 'Other models') + expect(options.slice(0, 3)).toEqual([ + { value: 'auto', label: 'Default' }, + { value: 'claude-opus-4-1', label: 'claude-opus-4-1' }, + { value: 'sonnet', label: 'Sonnet' }, + ]) + }) +}) + describe('getNextModelForFlavor', () => { it('cycles AGY null, auto, and unknown current values only to concrete models', () => { const firstConcrete = getModelOptionsForFlavor('agy').find((option) => option.value !== null)?.value @@ -275,12 +339,13 @@ describe('getNextModelForFlavor', () => { expect(next).not.toBeNull() }) - it('cycles through Claude presets when explicit options only include Sonnet models', () => { - const next = getNextModelForFlavor('claude', 'sonnet[1m]', [ - { value: 'sonnet', label: 'Sonnet' }, - { value: 'sonnet[1m]', label: 'Sonnet 1M' } - ]) - expect(next).toBe('opus') + it('cycles through the Claude catalog only', () => { + const catalogOptions = [ + { value: 'opus', label: 'Opus 5.5' }, + { value: 'sonnet', label: 'Sonnet 5.5' } + ] + expect(getNextModelForFlavor('claude', 'opus', catalogOptions)).toBe('sonnet') + expect(getNextModelForFlavor('claude', 'sonnet', catalogOptions)).toBeNull() }) it('cycles explicit model options', () => { diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index e8289725..2ddebc14 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -1,4 +1,5 @@ -import { getAgyModelLabel } from '@hapi/protocol' +import { getAgyModelLabel, getClaudeModelChoices, getClaudeModelLabel } from '@hapi/protocol' +import type { ClaudeModelSummary } from '@hapi/protocol/apiTypes' import { MODEL_OPTIONS } from '@/components/NewSession/types' import { CURSOR_AUTO_MODEL_LABEL } from '@/lib/cursorModelOptions' import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' @@ -53,32 +54,55 @@ function getClaudeModelOptions(currentModel?: string | null, customOptions?: Mod return getClaudeComposerModelOptions(currentModel) } - const options = getClaudeComposerModelOptions(currentModel) - const nextOptions = [...options] - let insertIndex = Math.max(1, nextOptions.findIndex((option) => option.value !== null)) - + // The machine's catalog is the CLI's own /model list, so it replaces the + // built-in presets rather than extending them. A CLI that offers a 1M + // variant as a separate choice lists it as its own row (`opus[1m]`), so + // the built-in `[1m]` presets would only add choices the CLI does not + // offer. A saved model it does not list stays as the current model. + const listed: ModelOption[] = [] for (const option of customOptions) { - const normalizedValue = normalizeCurrentModel(option.value) - if (!normalizedValue) { - continue + const value = normalizeCurrentModel(option.value) + if (value && !listed.some((existing) => existing.value === value)) { + listed.push(option.group ? { value, label: option.label, group: option.group } : { value, label: option.label }) } - - const existingIndex = nextOptions.findIndex((nextOption) => nextOption.value === normalizedValue) - if (existingIndex >= 0) { - if (nextOptions[existingIndex]?.label === normalizedValue) { - nextOptions[existingIndex] = option - } - continue - } - - nextOptions.splice(insertIndex, 0, { - value: normalizedValue, - label: option.label - }) - insertIndex += 1 } - return nextOptions + return withCurrentModelOption( + [{ value: null, label: 'Default' }, ...listed], + currentModel, + getClaudeModelLabel + ) +} + +/** + * Create-session model options for Claude (`auto` = Default). A saved model the + * loaded catalog no longer offers stays selectable, labelled as such, so the + * form never silently switches it to another model. + */ +export function getClaudeLaunchModelOptions( + catalog: readonly ClaudeModelSummary[], + selectedModel: string, + notListedSuffix: string, + otherModelsLabel: string +): Array<{ value: string; label: string; group?: string }> { + const catalogOptions = getClaudeCatalogOptions(catalog, otherModelsLabel) + const selected = normalizeCurrentModel(selectedModel) + const isGone = catalog.length > 0 + && selected !== null + && !catalogOptions.some((option) => option.value === selected) + + return getClaudeModelOptions(selectedModel, catalogOptions).map((option) => ({ + ...option, + value: option.value ?? 'auto', + label: isGone && option.value === selected ? `${option.label} (${notListedSuffix})` : option.label + })) +} + +/** Catalog options with earlier versions under `otherModelsLabel`, as Claude Code's model sheet lists them. */ +export function getClaudeCatalogOptions(catalog: readonly ClaudeModelSummary[], otherModelsLabel: string): ModelOption[] { + return getClaudeModelChoices(catalog).map(({ value, label, section }) => ( + section === 'other' ? { value, label, group: otherModelsLabel } : { value, label } + )) } function getAgyModelOptions(currentModel?: string | null, customOptions?: ModelOption[]): ModelOption[] { diff --git a/web/src/components/NewSession/EffortField.test.tsx b/web/src/components/NewSession/EffortField.test.tsx index e9fca811..3aa270d4 100644 --- a/web/src/components/NewSession/EffortField.test.tsx +++ b/web/src/components/NewSession/EffortField.test.tsx @@ -54,6 +54,19 @@ describe('EffortField', () => { expect(values).toContain('high') }) + it('renders the Claude levels the selected model accepts when provided', () => { + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + + expect(Array.from(select.options).map((option) => option.value)).toEqual(['auto', 'low']) + }) + it('renders Pi thinking levels and forwards the selection', () => { const onChange = vi.fn() const { container } = render( diff --git a/web/src/components/NewSession/EffortField.tsx b/web/src/components/NewSession/EffortField.tsx index f684fac0..b5d674eb 100644 --- a/web/src/components/NewSession/EffortField.tsx +++ b/web/src/components/NewSession/EffortField.tsx @@ -17,6 +17,8 @@ export type EffortFieldProps = { isDisabled: boolean /** Model-dependent launch-effort options (Grok). */ grokOptions?: Array<{ value: string; label: string }> + /** Launch-effort options the selected Claude model accepts; undefined = every level. */ + claudeOptions?: Array<{ value: string; label: string }> /** Model-dependent reasoning-effort options (Codex). */ codexReasoningOptions?: Array<{ value: string; name?: string }> /** Model-dependent variant values from the OpenCode catalog (OpenCode). Array = dynamic list, [] = hide field, null/undefined = static fallback. */ @@ -30,7 +32,7 @@ export type EffortFieldProps = { * configuration descriptor (see shared/src/agentConfig.ts). * * One component serves every flavor with an `effort` field: - * - Claude: static launch-effort levels. + * - Claude: the selected model's launch-effort levels, or every level when unknown. * - Grok: model-dependent launch-effort levels. * - Pi: model-dependent thinking levels (filtered by the selected model's * thinkingLevelMap; hidden when the model cannot reason). @@ -84,7 +86,7 @@ export function EffortField(props: EffortFieldProps) { ) } } else { - options = CLAUDE_EFFORT_OPTIONS + options = props.claudeOptions ?? CLAUDE_EFFORT_OPTIONS } const value = isReasoningEffort ? props.reasoningEffort : props.effort diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index 875cce90..47b5ae63 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ApiClient } from '@/api/client' -import type { Machine, PiModelSummary } from '@/types/api' +import type { ClaudeModelSummary, Machine, PiModelSummary } from '@/types/api' import { saveNewSessionFormDraft } from './newSessionFormDraft' import { loadPreferredLaunchSettings, @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ piModels: [] as PiModelSummary[], piModelsLoading: false, piModelsError: null as string | null, + claudeModels: [] as ClaudeModelSummary[], nextModelValue: 'gpt-5.6-terra', refetchSessions: vi.fn(), addToast: vi.fn() @@ -122,6 +123,9 @@ vi.mock('@/hooks/queries/useAgyModels', () => ({ refetch: vi.fn() }) })) +vi.mock('@/hooks/queries/useClaudeModels', () => ({ + useClaudeModels: () => ({ availableModels: mocks.claudeModels }) +})) vi.mock('@/hooks/queries/useDshModels', () => ({ useDshModelsForMachine: () => ({ availableModels: mocks.dshModels, @@ -340,6 +344,7 @@ describe('NewSession launch preferences', () => { mocks.piModels = [] mocks.piModelsLoading = false mocks.piModelsError = null + mocks.claudeModels = [] mocks.nextModelValue = 'gpt-5.6-terra' mocks.refetchSessions.mockReset() mocks.refetchSessions.mockResolvedValue(undefined) @@ -540,6 +545,107 @@ describe('NewSession launch preferences', () => { }) }) + it('offers the machine Claude catalog and keeps a remembered model it no longer lists', async () => { + mocks.claudeModels = [ + { value: 'default', displayName: 'Default (recommended)' }, + { value: 'opus', displayName: 'Opus 5.5' }, + { value: 'claude-opus-4-7', displayName: 'Opus 4.7' }, + ] + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'claude-opus-4-1', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('claude-opus-4-1') + }) + expect(screen.getByTestId('model-options').textContent?.split(',').slice(0, 4)).toEqual([ + 'Default', + 'claude-opus-4-1 (newSession.claudeModel.notListed)', + 'Opus 5.5', + 'Opus 4.7', + ]) + }) + + it('drops a launch effort the newly picked Claude model does not offer', async () => { + mocks.claudeModels = [ + { value: 'opus', displayName: 'Opus 5.5', effortLevels: ['high', 'xhigh'] }, + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + ] + mocks.nextModelValue = 'haiku' + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'opus', + cursorSelectedBase: 'auto', + effort: 'xhigh', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('launch-effort')).toHaveTextContent('xhigh') + }) + fireEvent.click(screen.getByTestId('model')) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('haiku') + expect(screen.getByTestId('launch-effort')).toHaveTextContent('auto') + }) + }) + + it('drops a remembered launch effort the remembered Claude model does not offer', async () => { + mocks.claudeModels = [ + { value: 'haiku', displayName: 'Haiku 4.5', effortLevels: [] }, + ] + savePreferredAgent('claude') + savePreferredLaunchSettings('machine-1', 'claude', { + model: 'haiku', + cursorSelectedBase: 'auto', + effort: 'high', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => { + expect(screen.getByTestId('model')).toHaveTextContent('haiku') + expect(screen.getByTestId('launch-effort')).toHaveTextContent('auto') + }) + }) + it('restores a remembered Kimi alias instead of resetting it to Default', async () => { mocks.kimiModels = [ { modelId: 'GLM-5.3-flash', provider: 'thehive' } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 6c8083df..26bb79ee 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, ty import type { ApiClient } from '@/api/client' import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api' import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' +import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' @@ -9,6 +10,9 @@ import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useAgyModels } from '@/hooks/queries/useAgyModels' +import { useClaudeModels } from '@/hooks/queries/useClaudeModels' +import { getClaudeLaunchModelOptions } from '@/components/AssistantChat/modelOptions' +import { getClaudeComposerEffortOptions } from '@/components/AssistantChat/claudeEffortOptions' import { useDshModelsForMachine } from '@/hooks/queries/useDshModels' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useOpencodeModelVariants } from '@/hooks/queries/useOpencodeModelVariants' @@ -709,6 +713,36 @@ export function NewSession(props: { machineId, enabled: agent === 'pi' && Boolean(machineId) }) + const claudeModelsState = useClaudeModels({ + api: props.api, + machineId, + enabled: agent === 'claude' && Boolean(machineId) + }) + const claudeModelOptions = useMemo(() => ( + agent === 'claude' + ? getClaudeLaunchModelOptions(claudeModelsState.availableModels, model, t('newSession.claudeModel.notListed'), t('misc.otherModels')) + : undefined + ), [agent, claudeModelsState.availableModels, model, t]) + const claudeEffortOptions = useMemo(() => { + const levels = getClaudeEffortLevelsForModel(model === 'auto' ? null : model, claudeModelsState.availableModels) + return levels + ? getClaudeComposerEffortOptions(effort, levels).map((option) => ({ value: option.value ?? 'auto', label: option.label })) + : undefined + }, [claudeModelsState.availableModels, model, effort]) + // However the model got here (picked, restored from a preference or a + // draft, or the catalog arriving after it), an effort that model does not + // offer is dropped. Only the Claude catalog carries effort levels, so other + // agents keep their effort. + useEffect(() => { + const nextEffort = resolveClaudeEffortForModel( + effort === 'auto' ? null : effort, + model === 'auto' ? null : model, + claudeModelsState.availableModels + ) + if ((nextEffort ?? 'auto') !== effort) { + setEffort('auto') + } + }, [claudeModelsState.availableModels, model, effort]) // Pi models are grouped by provider (optionSource: 'machine' in the agent // config descriptor). Option values are provider-qualified // (`provider/modelId`) so two providers sharing a modelId stay distinct; @@ -2003,7 +2037,7 @@ export function NewSession(props: { ? kimiModelOptions : agent === 'pi' ? (showPiLaunchConfig ? piModelOptions : undefined) - : undefined + : claudeModelOptions } isDisabled={ isFormDisabled @@ -2042,6 +2076,7 @@ export function NewSession(props: { onReasoningEffortChange={setModelReasoningEffort} isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)} grokOptions={agent === 'grok' ? grokEffortOptions : undefined} + claudeOptions={claudeEffortOptions} codexReasoningOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined} opencodeVariantOptions={agent === 'opencode' ? opencodeVariantOptions : undefined} piSelectedModel={agent === 'pi' ? piSelectedModel : null} diff --git a/web/src/components/NewSession/preferences.test.ts b/web/src/components/NewSession/preferences.test.ts index 147517f4..9b4c6faa 100644 --- a/web/src/components/NewSession/preferences.test.ts +++ b/web/src/components/NewSession/preferences.test.ts @@ -133,14 +133,16 @@ describe('NewSession preferences', () => { expect(loadPreferredLaunchSettings('machine-1', 'codex')).toBeNull() }) - it('falls back when remembered static Claude options are no longer available', () => { + it('keeps a remembered Claude model the built-in list lacks, but drops an unknown effort', () => { + // Claude models come from the machine's catalog; a pinned model it no + // longer lists is shown as such rather than silently replaced. expect(resolvePreferredLaunchSettings('claude', { - model: 'retired-model', + model: 'claude-opus-4-1', cursorSelectedBase: 'auto', effort: 'ultra', modelReasoningEffort: 'default' })).toEqual({ - model: 'auto', + model: 'claude-opus-4-1', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'default', diff --git a/web/src/components/NewSession/preferences.ts b/web/src/components/NewSession/preferences.ts index 05a770a1..896cc4a3 100644 --- a/web/src/components/NewSession/preferences.ts +++ b/web/src/components/NewSession/preferences.ts @@ -137,7 +137,9 @@ export function resolvePreferredLaunchSettings( // Kimi's catalog is dynamic (machine discovery); validating a saved alias // against the static list would reset it to 'auto'. The settled-catalog // effect in NewSession validates it once the real catalog has arrived. - const model = staticModelValues.length > 0 && agent !== 'codex' && agent !== 'copilot' && agent !== 'kimi' + // Claude's catalog is dynamic too, and a saved Claude model is kept even + // when the catalog drops it (it is offered as "no longer listed"). + const model = staticModelValues.length > 0 && agent !== 'codex' && agent !== 'copilot' && agent !== 'kimi' && agent !== 'claude' ? resolvePreferredOptionValue(preferredModel, staticModelValues, 'auto') : preferredModel const effort = agent === 'claude' diff --git a/web/src/components/SessionChat.test.ts b/web/src/components/SessionChat.test.ts index 2bd48bac..b11626cc 100644 --- a/web/src/components/SessionChat.test.ts +++ b/web/src/components/SessionChat.test.ts @@ -10,6 +10,7 @@ import { mergeStagedAttachmentsInOrder, opencodeEffortOptionsInvalidationKey, resolvePiContextWindow, + resolveEffortForModelChange, resolveLatestCompletedBoundaryIdForView, shouldAutoClearPendingSchedule, shouldClearReasoningEffortForModelChange, @@ -582,3 +583,30 @@ describe('buildAgyComposerModelOptions', () => { expect(buildAgyComposerModelOptions([])).toBeUndefined() }) }) + +describe('resolveEffortForModelChange', () => { + const claudeModels = [ + { value: 'opus', effortLevels: ['low', 'high', 'xhigh'] }, + { value: 'haiku', effortLevels: [] }, + ] + + it('clears an effort the next Claude model does not offer', () => { + expect(resolveEffortForModelChange({ currentEffort: 'xhigh', model: 'haiku', claudeModels })).toEqual({ effort: null }) + }) + + it('leaves the effort alone when the next model keeps it', () => { + expect(resolveEffortForModelChange({ currentEffort: 'high', model: 'claude-opus-5-5', claudeModels })).toBeUndefined() + }) + + it('leaves the effort alone without a catalog (non-Claude sessions, or discovery unavailable)', () => { + expect(resolveEffortForModelChange({ currentEffort: 'xhigh', model: 'haiku', claudeModels: [] })).toBeUndefined() + }) + + it('ignores provider-qualified selections', () => { + expect(resolveEffortForModelChange({ + currentEffort: 'xhigh', + model: { provider: 'p', modelId: 'haiku' }, + claudeModels + })).toBeUndefined() + }) +}) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 81c6a9cd..01432894 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -9,6 +9,7 @@ import { ApiError, type ApiClient } from '@/api/client' import type { AgyModelSummary, AttachmentMetadata, + ClaudeModelSummary, CodexCollaborationMode, CodexModelSummary, CopilotAgentMode, @@ -61,6 +62,8 @@ import { } from '@/lib/messageDelivery' import type { MessageDeliveryMode } from '@hapi/protocol' import { isSteeringSupportedForSession } from '@hapi/protocol' +import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' +import { getClaudeCatalogOptions } from '@/components/AssistantChat/modelOptions' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { rewindMessageWindow, type OlderLoadOutcome } from '@/lib/message-window-store' import { ShareSeedConsumer } from '@/components/ShareSeedConsumer' @@ -97,6 +100,7 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useAgyModels } from '@/hooks/queries/useAgyModels' +import { useClaudeModels } from '@/hooks/queries/useClaudeModels' import { mergeCursorCliModelSkus, resolveCursorBaseFromWire @@ -182,6 +186,23 @@ export function buildAgyComposerModelOptions( })) } +/** + * The effort to send along with a model change, or undefined to leave it. + * Only a Claude catalog carries per-model effort levels, so other flavors + * (whose catalog here is empty) never get one. + */ +export function resolveEffortForModelChange(args: { + currentEffort: string | null + model: SessionModelSelection + claudeModels: readonly ClaudeModelSummary[] +}): { effort: string | null } | undefined { + if (typeof args.model === 'object' && args.model !== null) { + return undefined + } + const effort = resolveClaudeEffortForModel(args.currentEffort, args.model, args.claudeModels) + return effort === args.currentEffort ? undefined : { effort } +} + export async function applyModelChangeWithReasoningRollback(args: { model: SessionModelSelection previousModelReasoningEffort: string | null @@ -1180,6 +1201,24 @@ function SessionChatInner(props: SessionChatProps) { ? buildAgyComposerModelOptions(agyModelsState.availableModels) : undefined ), [agentFlavor, agyModelsState.availableModels]) + const claudeModelsState = useClaudeModels({ + api: props.api, + machineId: sessionMachineId, + enabled: agentFlavor === 'claude' && props.session.active && Boolean(sessionMachineId) + }) + // Until the catalog arrives (or on a runner without discovery) this stays + // undefined and the composer keeps its built-in Claude presets. + const claudeModelOptions = useMemo(() => ( + agentFlavor === 'claude' && claudeModelsState.availableModels.length > 0 + ? getClaudeCatalogOptions(claudeModelsState.availableModels, t('misc.otherModels')) + : undefined + ), [agentFlavor, claudeModelsState.availableModels, t]) + // Levels the current Claude model accepts; undefined when unknown (no + // catalog, or a model it does not list), which keeps every level. + const claudeEffortOptions = useMemo(() => ( + getClaudeEffortLevelsForModel(props.session.model ?? null, claudeModelsState.availableModels) + ?.map((level) => ({ value: level })) + ), [props.session.model, claudeModelsState.availableModels]) const piModelsState = usePiModels({ api: props.api, sessionId: props.session.id, @@ -1608,13 +1647,20 @@ function SessionChatInner(props: SessionChatProps) { codexModels: codexModelsState.models, model }) + // Sent in the same request so the session never runs the new model + // with an effort it does not offer. + const effortChange = resolveEffortForModelChange({ + currentEffort: props.session.effort ?? null, + model, + claudeModels: claudeModelsState.availableModels + }) try { await applyModelChangeWithReasoningRollback({ model, previousModelReasoningEffort, shouldClearReasoningEffort, - setModel, + setModel: (nextModel) => setModel(nextModel, effortChange), setModelReasoningEffort }) haptic.notification('success') @@ -1626,6 +1672,8 @@ function SessionChatInner(props: SessionChatProps) { }, [ agentFlavor, codexModelsState.models, + claudeModelsState.availableModels, + props.session.effort, props.session.modelReasoningEffort, setModelReasoningEffort, setModel, @@ -2127,8 +2175,9 @@ function SessionChatInner(props: SessionChatProps) { // cycler (getNextModelForFlavor) post a bare modelId string, // which loses the provider and can pick the wrong cached // match or throw in runPi. undefined makes the shortcut a no-op - // so Pi model changes go through the settings sheet only. - : undefined + // so Pi model changes go through the settings sheet only; + // claudeModelOptions is undefined for every non-Claude flavor. + : claudeModelOptions } piModels={piModels} piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} @@ -2142,7 +2191,7 @@ function SessionChatInner(props: SessionChatProps) { availableEffortOptions={ agentFlavor === 'grok' && grokEffortState.options.length > 0 ? grokEffortState.options - : undefined + : claudeEffortOptions } active={props.session.active} allowSendWhenInactive diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 4c9ce03b..13657f3e 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' @@ -22,7 +23,7 @@ export function useSessionActions( setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise setCopilotAgentMode: (mode: CopilotAgentMode) => Promise - setModel: (model: { provider: string; modelId: string } | string | null) => Promise + setModel: (model: { provider: string; modelId: string } | string | null, options?: { effort: string | null }) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise setServiceTier: (serviceTier: string | null) => Promise @@ -173,11 +174,14 @@ export function useSessionActions( const modelMutation = useMutation({ mutationKey: sessionModelMutationKey(sessionId ?? ''), - mutationFn: async (model: { provider: string; modelId: string } | string | null) => { + mutationFn: async (args: { + model: { provider: string; modelId: string } | string | null + options?: { effort: string | null } + }) => { if (!api || !sessionId) { throw new Error('Session unavailable') } - await api.setModel(sessionId, model) + await api.setModel(sessionId, args.model, args.options) }, onSuccess: async () => { await invalidateSession() @@ -185,6 +189,12 @@ export function useSessionActions( }, }) + const setModel = useCallback( + (model: { provider: string; modelId: string } | string | null, options?: { effort: string | null }) => + modelMutation.mutateAsync({ model, options }), + [modelMutation.mutateAsync] + ) + const modelReasoningEffortMutation = useMutation({ mutationFn: async (modelReasoningEffort: string | null) => { if (!api || !sessionId) { @@ -288,7 +298,7 @@ export function useSessionActions( setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, setCopilotAgentMode: copilotAgentModeMutation.mutateAsync, - setModel: modelMutation.mutateAsync, + setModel, setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, setEffort: effortMutation.mutateAsync, setServiceTier: serviceTierMutation.mutateAsync, diff --git a/web/src/hooks/queries/useClaudeModels.test.tsx b/web/src/hooks/queries/useClaudeModels.test.tsx new file mode 100644 index 00000000..43a20467 --- /dev/null +++ b/web/src/hooks/queries/useClaudeModels.test.tsx @@ -0,0 +1,75 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' +import { useClaudeModels } from './useClaudeModels' + +function wrapper(queryClient: QueryClient) { + return ({ children }: PropsWithChildren) => ( + {children} + ) +} + +const catalog = { + success: true, + availableModels: [{ value: 'opus', effortLevels: ['low'] }] +} + +describe('useClaudeModels', () => { + it('returns the machine catalog while enabled', async () => { + const getMachineClaudeModels = vi.fn(async () => catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const { result } = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + + await waitFor(() => expect(result.current.availableModels).toHaveLength(1)) + }) + + it('does not hand a cached Claude catalog to a session of another agent on the same machine', () => { + const getMachineClaudeModels = vi.fn(async () => catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(queryKeys.machineClaudeModels('machine-1'), catalog) + + const { result } = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: false }), { + wrapper: wrapper(queryClient) + }) + + expect(result.current.availableModels).toEqual([]) + expect(getMachineClaudeModels).not.toHaveBeenCalled() + }) + + it('retries a failed discovery the next time a picker mounts', async () => { + // The machine answers a failed probe with success:false; caching that as + // a successful result would keep the built-in list for the stale time + // even after the user signs in. + const getMachineClaudeModels = vi.fn() + .mockResolvedValueOnce({ + success: false, + error: 'Claude model discovery timed out', + availableModels: catalog.availableModels + }) + .mockResolvedValueOnce(catalog) + const api = { getMachineClaudeModels } as unknown as ApiClient + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const first = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + await waitFor(() => expect(getMachineClaudeModels).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(queryClient.getQueryState(queryKeys.machineClaudeModels('machine-1'))?.fetchStatus).toBe('idle')) + expect(first.result.current.availableModels).toEqual([]) + first.unmount() + + const second = renderHook(() => useClaudeModels({ api, machineId: 'machine-1', enabled: true }), { + wrapper: wrapper(queryClient) + }) + await waitFor(() => expect(second.result.current.availableModels).toHaveLength(1)) + expect(getMachineClaudeModels).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/src/hooks/queries/useClaudeModels.ts b/web/src/hooks/queries/useClaudeModels.ts new file mode 100644 index 00000000..d148691a --- /dev/null +++ b/web/src/hooks/queries/useClaudeModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { ClaudeModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +const NO_MODELS: ClaudeModelSummary[] = [] + +/** + * The models a machine's `claude` CLI offers. While loading, on failure, or on + * a runner without discovery the list is empty and callers keep their + * built-in presets, so a selection is never lost to a missing catalog. + */ +export function useClaudeModels(args: { + api: ApiClient | null + machineId?: string | null + enabled?: boolean +}): { + availableModels: ClaudeModelSummary[] +} { + const { api, machineId } = args + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: machineId + ? queryKeys.machineClaudeModels(machineId) + : ['machine-claude-models', 'unknown'] as const, + queryFn: async () => { + if (!api || !machineId) { + throw new Error('Claude models target unavailable') + } + const response = await api.getMachineClaudeModels(machineId) + // A failed probe arrives as success:false; as a query error it is + // not cached as an answer, so the next picker to open asks again. + if (!response.success) { + throw new Error(response.error ?? 'Failed to load Claude models') + } + return response + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + // Gated on `enabled` too: the query key is per machine, so a catalog + // cached by a Claude session would otherwise reach other agents' sessions. + availableModels: enabled ? (query.data?.availableModels ?? NO_MODELS) : NO_MODELS, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7d6a8070..49157d5f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -375,6 +375,7 @@ export default { 'newSession.agyModel.retry': 'Retry', 'newSession.agyModel.notListed': 'no longer listed', 'newSession.agyModel.noModels': 'No Antigravity models available', + 'newSession.claudeModel.notListed': 'no longer listed', 'newSession.opencodeModel.loading': 'Discovering OpenCode models…', 'newSession.opencodeModel.loadFailed': 'Failed to load OpenCode models', 'newSession.opencodeModel.retry': 'Retry', @@ -1144,6 +1145,7 @@ export default { 'misc.copilotAgentMode': 'Agent Mode', 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', + 'misc.otherModels': 'Other models', 'misc.reasoningEffort': 'Reasoning Effort', 'misc.effort': 'Effort', 'misc.fastMode': 'Fast Mode', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ad6bb153..61410342 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -371,6 +371,7 @@ export default { 'newSession.model.optional': '可选', 'newSession.model.default': '默认', 'newSession.model.loadFailed': '加载模型失败', + 'newSession.claudeModel.notListed': '已不在列表中', 'newSession.model.selectVariant': '选择变体', 'newSession.model.cursorUnavailable': '暂无 Cursor 模型列表。请先运行一次 Cursor 会话,或使用 Auto。', 'newSession.opencodeModel.loading': '正在发现 OpenCode 模型…', @@ -1142,6 +1143,7 @@ export default { 'misc.copilotAgentMode': '代理模式', 'misc.permissionMode': '权限模式', 'misc.model': '模型', + 'misc.otherModels': '其他模型', 'misc.reasoningEffort': '推理强度', 'misc.effort': '思考强度', 'misc.fastMode': '快速模式', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 68c9df93..1624ba81 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -39,6 +39,7 @@ export const queryKeys = { machineDshModels: (machineId: string) => ['machine-dsh-models', machineId] as const, sessionDshModels: (sessionId: string) => ['session-dsh-models', sessionId] as const, machinePiModels: (machineId: string) => ['machine-pi-models', machineId] as const, + machineClaudeModels: (machineId: string) => ['machine-claude-models', machineId] as const, skills: (sessionId: string) => ['skills', sessionId] as const, scratchlist: (sessionId: string) => ['scratchlist', sessionId] as const, } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index a4efd13d..54d85121 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,8 @@ export type { MachineListDirectoryResponse, MachinePathsExistsResponse, AuthResponse, + ClaudeModelSummary, + ClaudeModelsResponse, MachinesResponse, MessagesResponse, OpencodeModelsResponse,