From 7031d60eb4a72ddd76037a459902f2f3d13b7632 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Wed, 9 Sep 2026 10:31:30 +0900 Subject: [PATCH] fix(opencode): expose model-specific reasoning effort options (#1716) * fix(cli): discover opencode thought_level via set_config_option on model switch * fix(cli): apply and refresh opencode model switches so thought_level stays discoverable * fix(web): track opencode effort options across model switches * feat(cli,hub,web): dynamic opencode effort options in new-session form * test(cli): avoid platform-specific process event narrowing * fix(opencode): address variant discovery review findings * fix(opencode): synchronize effort options with model targets * fix(opencode): roll back rejected model targets * fix(cli): guard opencode variant probe workspace paths * fix(web): clear stale opencode effort on model switch * fix(cli): clear stale opencode effort metadata * fix(web): reset stale effort options on model switch * fix(web): reset opencode effort poll budget * test(web): enforce opencode effort poll budget --- .../agent/backends/acp/AcpSdkBackend.test.ts | 108 ++++++- cli/src/agent/backends/acp/AcpSdkBackend.ts | 50 +++- cli/src/api/apiMachine.test.ts | 66 ++++- cli/src/api/apiMachine.ts | 21 ++ .../common/handlers/opencodeModelVariants.ts | 21 ++ .../common/opencodeModelVariants.test.ts | 270 +++++++++++++++++ .../modules/common/opencodeModelVariants.ts | 272 ++++++++++++++++++ .../modules/common/registerCommonHandlers.ts | 2 + cli/src/opencode/loop.ts | 2 + .../opencode/opencodeRemoteLauncher.test.ts | 140 ++++++++- cli/src/opencode/opencodeRemoteLauncher.ts | 68 ++++- cli/src/opencode/runOpencode.ts | 3 + hub/src/sync/rpcGateway.ts | 6 + hub/src/sync/syncEngine.ts | 6 + hub/src/web/routes/machines.test.ts | 86 ++++++ hub/src/web/routes/machines.ts | 30 ++ shared/src/apiTypes.ts | 11 + shared/src/rpcMethods.ts | 1 + web/src/api/client.ts | 7 + .../NewSession/EffortField.test.tsx | 27 ++ web/src/components/NewSession/EffortField.tsx | 22 +- web/src/components/NewSession/index.test.tsx | 55 +++- web/src/components/NewSession/index.tsx | 59 ++++ web/src/components/NewSession/types.test.ts | 20 +- web/src/components/NewSession/types.ts | 19 ++ web/src/components/SessionChat.test.ts | 38 +++ web/src/components/SessionChat.tsx | 72 ++++- .../hooks/queries/useOpencodeModelVariants.ts | 47 +++ ...codeReasoningEffortOptions.render.test.tsx | 135 +++++++++ .../useOpencodeReasoningEffortOptions.test.ts | 35 +++ .../useOpencodeReasoningEffortOptions.ts | 83 +++++- web/src/lib/query-keys.ts | 1 + web/src/types/api.ts | 1 + 33 files changed, 1729 insertions(+), 55 deletions(-) create mode 100644 cli/src/modules/common/handlers/opencodeModelVariants.ts create mode 100644 cli/src/modules/common/opencodeModelVariants.test.ts create mode 100644 cli/src/modules/common/opencodeModelVariants.ts create mode 100644 web/src/hooks/queries/useOpencodeModelVariants.ts create mode 100644 web/src/hooks/queries/useOpencodeReasoningEffortOptions.render.test.tsx diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 4d903601..7222e6d4 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -210,7 +210,7 @@ describe('AcpSdkBackend', () => { ]); }); - it('uses session/set_model when flavor is opencode', async () => { + it('uses session/set_config_option when flavor is opencode and captures thought_level', async () => { const backend = new AcpSdkBackend({ command: 'opencode' }); const calls: Array<{ method: string; params: unknown }> = []; const backendInternal = backend as unknown as { @@ -219,25 +219,50 @@ describe('AcpSdkBackend', () => { backendInternal.transport = { sendRequest: async (method, params) => { calls.push({ method, params }); - // OpenCode 1.14.30's set_model response: only an opaque _meta block. - return { - _meta: { opencode: { modelId: 'ollama/exaone:4.5-33b-q8', variant: null, availableVariants: [] } } - }; + if (method === 'session/set_config_option') { + return { + models: { + currentModelId: 'opencode/hy3-free', + availableModels: [] + }, + configOptions: [{ + id: 'effort', + category: 'thought_level', + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' } + ] + }] + }; + } + return null; }, close: async () => {} }; - await backend.setModel('session-1', 'ollama/exaone:4.5-33b-q8', { flavor: 'opencode' }); + await backend.setModel('session-1', 'opencode/hy3-free', { flavor: 'opencode' }); expect(calls).toEqual([ { - method: 'session/set_model', + method: 'session/set_config_option', params: { sessionId: 'session-1', - modelId: 'ollama/exaone:4.5-33b-q8' + configId: 'model', + value: 'opencode/hy3-free' } } ]); + expect(backend.getThoughtLevelConfigOption('session-1')).toMatchObject({ + id: 'effort', + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' } + ] + }); }); it('captures availableModels and currentModelId from session/new response', async () => { @@ -449,8 +474,9 @@ describe('AcpSdkBackend', () => { expect(backend.getSessionModelsMetadata(sessionId)).toBeUndefined(); }); - it('optimistically updates currentModelId after a successful opencode setModel call', async () => { + it('falls back to session/set_model when opencode set_config_option is not found', async () => { const backend = new AcpSdkBackend({ command: 'opencode' }); + const calls: Array<{ method: string; params: unknown }> = []; const backendInternal = backend as unknown as { transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; }; @@ -459,13 +485,23 @@ describe('AcpSdkBackend', () => { { modelId: 'ollama/b', name: 'b' } ]; backendInternal.transport = { - sendRequest: async (method) => { + sendRequest: async (method, params) => { + calls.push({ method, params }); if (method === 'session/new') { return { sessionId: 's1', - models: { availableModels: fixtureModels, currentModelId: 'ollama/a' } + models: { availableModels: fixtureModels, currentModelId: 'ollama/a' }, + configOptions: [{ + id: 'effort', + category: 'thought_level', + currentValue: 'high', + options: [{ value: 'high', name: 'High' }] + }] }; } + if (method === 'session/set_config_option') { + throw new Error('Method not found'); + } if (method === 'session/set_model') { // OpenCode 1.14.30: response carries only an opaque _meta block. return { _meta: { opencode: { modelId: 'ollama/b' } } }; @@ -476,14 +512,60 @@ describe('AcpSdkBackend', () => { }; await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + expect(backend.getThoughtLevelConfigOption('s1')).toBeDefined(); await backend.setModel('s1', 'ollama/b', { flavor: 'opencode' }); - // availableModels list is preserved from session/new; currentModelId is - // optimistically updated from the requested modelId. + expect(calls.slice(1)).toEqual([ + { + method: 'session/set_config_option', + params: { sessionId: 's1', configId: 'model', value: 'ollama/b' } + }, + { method: 'session/set_model', params: { sessionId: 's1', modelId: 'ollama/b' } } + ]); expect(backend.getSessionModelsMetadata('s1')).toEqual({ availableModels: fixtureModels, currentModelId: 'ollama/b' }); + expect(backend.getThoughtLevelConfigOption('s1')).toBeUndefined(); + }); + + it('rethrows non method-not-found errors from opencode set_config_option without falling back', async () => { + const backend = new AcpSdkBackend({ command: 'opencode' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method) => { + if (method === 'session/set_config_option') { + throw new Error('Invalid params'); + } + return null; + }, + close: async () => {} + }; + + await expect(backend.setModel('session-1', 'm/b', { flavor: 'opencode' })).rejects.toThrow('Invalid params'); + }); + + it('uses session/set_model when flavor is grok', async () => { + const backend = new AcpSdkBackend({ command: 'grok' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + return null; + }, + close: async () => {} + }; + + await backend.setModel('session-1', 'grok-4.5', { flavor: 'grok' }); + + expect(calls).toEqual([ + { method: 'session/set_model', params: { sessionId: 'session-1', modelId: 'grok-4.5' } } + ]); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index dca43137..ee388e6d 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -393,17 +393,57 @@ export class AcpSdkBackend implements AgentBackend { // exposed as `unstable_setSessionModel` but the JSON-RPC method on the wire // is unprefixed). Errors (including JSON-RPC 'method not found') propagate // as rejections from the transport; the launcher's catch block handles them. - const response = await this.transport.sendRequest('session/set_model', { - sessionId, - modelId - }); + let configOptionResponse: unknown; + let usedConfigOption = false; + if (opts?.flavor === 'opencode') { + // OpenCode's `session/set_model` response only carries an opaque + // `_meta` block with no `configOptions`, so the per-session + // thought_level options captured at session/new go stale after an + // inline switch. `session/set_config_option` with configId "model" + // (OpenCode's fixed id for the model picker) echoes fresh + // `configOptions` including thought_level for the new model. + try { + configOptionResponse = await this.transport.sendRequest('session/set_config_option', { + sessionId, + configId: 'model', + value: modelId + }); + usedConfigOption = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Older OpenCode builds predate set_config_option — fall back to + // the legacy set_model path below. Any other error propagates to + // the launcher's existing catch handling. + if (!/method not found/i.test(message)) { + throw error; + } + } + } - if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { + const response = usedConfigOption + ? configOptionResponse + : await this.transport.sendRequest('session/set_model', { + sessionId, + modelId + }); + + if (usedConfigOption) { + this.captureSessionMetadata(sessionId, response); + } else if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { // OpenCode's set_model response only carries an opaque `_meta` block, // not `availableModels`/`currentModelId`. Optimistically update the // cached currentModelId (the call succeeded, so the agent has switched) // while preserving the availableModels list captured from session/new. this.updateCurrentModelOptimistic(sessionId, modelId); + if (opts.flavor === 'opencode') { + const options = this.sessionConfigOptions.get(sessionId); + if (options) { + this.sessionConfigOptions.set( + sessionId, + options.filter((option) => option.category !== 'thought_level') + ); + } + } } else { // For other flavors (e.g. Gemini), if the response carries metadata, // capture it. Missing fields are silently ignored. diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index a442be11..def544e6 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' const ioMock = vi.hoisted(() => vi.fn()) const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) +const listOpencodeModelVariantsMock = vi.hoisted(() => vi.fn()) const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn()) const listCopilotModelsForCwdMock = vi.hoisted(() => vi.fn()) const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn()) @@ -21,6 +22,10 @@ vi.mock('../modules/common/opencodeModels', () => ({ listOpencodeModelsForCwd: listOpencodeModelsForCwdMock })) +vi.mock('../modules/common/opencodeModelVariants', () => ({ + listOpencodeModelVariants: listOpencodeModelVariantsMock +})) + vi.mock('../modules/common/grokModels', () => ({ listGrokModelsForCwd: listGrokModelsForCwdMock })) @@ -75,6 +80,15 @@ async function callListOpencodeModels(client: ApiMachineClient, machineId: strin return JSON.parse(raw) as unknown } +async function callListOpencodeModelVariants(client: ApiMachineClient, machineId: string, cwd: string): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listOpencodeModelVariants`, + params: JSON.stringify({ cwd }) + }) + return JSON.parse(raw) as unknown +} + async function callListGrokModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager const raw = await manager.handleRequest({ @@ -311,6 +325,56 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { }) }) +describe('ApiMachineClient listOpencodeModelVariants handler', () => { + let workspaceRoot: string + + beforeEach(() => { + ioMock.mockReset() + listOpencodeModelVariantsMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-ws-')) + }) + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + it('rejects cwd outside workspace roots before running the variant probe', async () => { + const machine = makeMachine('opencode-variants-machine-1') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-outside-')) + + try { + expect(await callListOpencodeModelVariants(client, machine.id, outsideCwd)).toEqual({ + success: false, + error: 'Path is outside workspace roots' + }) + expect(listOpencodeModelVariantsMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) + + it('rejects a workspace symlink that escapes the configured roots', async () => { + const machine = makeMachine('opencode-variants-machine-2') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-opencode-variants-symlink-outside-')) + const escape = join(workspaceRoot, 'escape') + symlinkSync(outsideCwd, escape, 'dir') + + try { + expect(await callListOpencodeModelVariants(client, machine.id, escape)).toEqual({ + success: false, + error: 'Path is outside workspace roots' + }) + expect(listOpencodeModelVariantsMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) +}) + describe('ApiMachineClient listCopilotModelsForCwd handler', () => { let workspaceRoot: string diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 4d12c027..75d07a1a 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -34,6 +34,10 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { + listOpencodeModelVariants, + type ListOpencodeModelVariantsResponse +} from '../modules/common/opencodeModelVariants' import { listGrokModelsForCwd, type ListGrokModelsForCwdRequest, @@ -256,6 +260,23 @@ export class ApiMachineClient { } ) + this.rpcHandlerManager.registerHandler<{ cwd?: string | null }, ListOpencodeModelVariantsResponse>( + RPC_METHODS.ListOpencodeModelVariants, + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) { + return { success: false, error: 'cwd is required' } + } + + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + + return await listOpencodeModelVariants({ cwd: resolvedCwd }) + } + ) + this.rpcHandlerManager.registerHandler( RPC_METHODS.ListGrokModelsForCwd, async (params) => { diff --git a/cli/src/modules/common/handlers/opencodeModelVariants.ts b/cli/src/modules/common/handlers/opencodeModelVariants.ts new file mode 100644 index 00000000..84eb5d95 --- /dev/null +++ b/cli/src/modules/common/handlers/opencodeModelVariants.ts @@ -0,0 +1,21 @@ +import { logger } from '@/ui/logger'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listOpencodeModelVariants, + type ListOpencodeModelVariantsResponse +} from '../opencodeModelVariants'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerOpencodeModelVariantsHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler<{ cwd?: string | null } | undefined, ListOpencodeModelVariantsResponse>(RPC_METHODS.ListOpencodeModelVariants, async (data) => { + logger.debug('List OpenCode model variants request'); + + try { + return await listOpencodeModelVariants({ cwd: data?.cwd ?? null }); + } catch (error) { + logger.debug('Failed to list OpenCode model variants:', error); + return rpcError(getErrorMessage(error, 'Failed to list OpenCode model variants')); + } + }); +} diff --git a/cli/src/modules/common/opencodeModelVariants.test.ts b/cli/src/modules/common/opencodeModelVariants.test.ts new file mode 100644 index 00000000..929727c7 --- /dev/null +++ b/cli/src/modules/common/opencodeModelVariants.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { extractOpencodeAcpPorts, extractOpencodeAcpServers, parseOpencodeProviderVariants, listOpencodeModelVariants } from './opencodeModelVariants' + +describe('parseOpencodeProviderVariants', () => { + it('collects variants for models that have at least one variant keyed by providerID/modelId', () => { + const payload = { + all: [ + { + id: 'opencode-go', + models: { + 'ox-alpha-free': { + id: 'ox-alpha-free', + providerID: 'opencode-go', + variants: { low: {}, high: {}, max: {} } + }, + 'bare-model': { + id: 'bare-model', + providerID: 'opencode-go' + } + } + }, + { + id: 'other', + models: { + 'muse-spark': { + id: 'muse-spark', + providerID: 'other', + variants: { minimal: null } + } + } + } + ] + } + + expect(parseOpencodeProviderVariants(payload)).toEqual({ + 'opencode-go/ox-alpha-free': ['low', 'high', 'max'], + 'other/muse-spark': ['minimal'] + }) + }) + + it('skips models with empty or invalid variant maps and malformed entries', () => { + const payload = { + all: [ + { + id: 'p1', + models: { + 'no-variants': { id: 'no-variants', providerID: 'p1', variants: {} }, + 'bad-variants': { id: 'bad-variants', providerID: 'p1', variants: 'nope' }, + 'missing-id': { providerID: 'p1', variants: { low: {} } }, + 'missing-provider': { id: 'x', variants: { low: {} } }, + ok: { id: 'ok', providerID: 'p1', variants: { high: {} } } + } + }, + 'not-an-object', + { noModels: true } + ] + } + + expect(parseOpencodeProviderVariants(payload)).toEqual({ + 'p1/ok': ['high'] + }) + }) + + it('returns an empty map for non-object payloads', () => { + expect(parseOpencodeProviderVariants(null)).toEqual({}) + expect(parseOpencodeProviderVariants('string')).toEqual({}) + expect(parseOpencodeProviderVariants({})).toEqual({}) + expect(parseOpencodeProviderVariants({ all: 'nope' })).toEqual({}) + }) +}) + +describe('extractOpencodeAcpPorts', () => { + it('extracts ports from live opencode acp process args', () => { + const ps = [ + '/home/user/.local/bin/hapi-runner opencode --hapi-starting-mode remote --model opencode-go/ox-alpha-free', + 'opencode acp --cwd /home/user --port 42495 --hostname 127.0.0.1', + 'opencode acp --cwd /tmp --port 38971 --hostname 127.0.0.1', + 'opencode serve --port 46123', + 'bun run hub/src/index.ts' + ].join('\n'); + expect(extractOpencodeAcpPorts(ps)).toEqual([42495, 38971]); + }); + + it('dedupes and rejects malformed ports', () => { + const ps = [ + 'opencode acp --port 70000', + 'opencode acp --port 42495', + 'opencode acp --port 42495 --hostname 127.0.0.1', + 'opencode acp --cwd /x' + ].join('\n'); + expect(extractOpencodeAcpPorts(ps)).toEqual([42495]); + }); +}); + + +describe('listOpencodeModelVariants resident lifecycle', () => { + const providerPayload = { + all: [{ id: 'p', models: { m: { id: 'm', providerID: 'p', variants: { low: {}, high: {} } } } }] + }; + + function makeProc(argv: string[]) { + return { + argv, + kill: vi.fn(), + exitCode: null, + stdout: new ReadableStream({ start(c) { c.close(); } }) + }; + } + + let procs: ReturnType[]; + let mod: typeof import('./opencodeModelVariants'); + + beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + procs = []; + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(providerPayload), { status: 200 }))); + vi.stubGlobal('Bun', { + spawn: vi.fn((argv: string[], opts?: { cwd?: string }) => { + const proc = makeProc(argv); + (proc as unknown as { spawnCwd?: string }).spawnCwd = opts?.cwd; + if (argv[0] === 'ps') { + proc.stdout = new ReadableStream({ + start(c) { c.enqueue(new TextEncoder().encode('')); c.close(); } + }); + } + procs.push(proc); + return proc; + }) + }); + mod = await import('./opencodeModelVariants'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const serveProcs = () => procs.filter((p) => p.argv[0] === 'opencode'); + + it('spawns serve once, keeps it resident, and answers subsequent calls without respawn', async () => { + const first = mod.listOpencodeModelVariants(); + await vi.advanceTimersByTimeAsync(300); + const r1 = await first; + expect(r1.success).toBe(true); + expect(serveProcs().length).toBe(1); + + await mod.listOpencodeModelVariants(); + await mod.listOpencodeModelVariants(); + expect(serveProcs().length).toBe(1); + }); + + it('reaps the resident serve after the idle timeout and respawns on the next call', async () => { + const first = mod.listOpencodeModelVariants(); + await vi.advanceTimersByTimeAsync(300); + await first; + const initialServe = serveProcs()[0]; + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(initialServe.kill).toHaveBeenCalled(); + + const second = mod.listOpencodeModelVariants(); + await vi.advanceTimersByTimeAsync(300); + const r2 = await second; + expect(r2.success).toBe(true); + expect(serveProcs().length).toBe(2); + expect(serveProcs()[1].kill).not.toHaveBeenCalled(); + }); + + it('does not reuse a project server when no cwd was requested', async () => { + // Seed the ps scan with a live acp server in /a, then request with cwd=null. + vi.stubGlobal('Bun', { + spawn: vi.fn((argv: string[]) => { + const proc = makeProc(argv); + if (argv[0] === 'ps') { + proc.stdout = new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode('opencode acp --cwd /a --port 42495 --hostname 127.0.0.1')); + c.close(); + } + }); + } + procs.push(proc); + return proc; + }) + }); + mod = await import('./opencodeModelVariants'); + const result = mod.listOpencodeModelVariants(); + await vi.advanceTimersByTimeAsync(300); + const r = await result; + // The /a server must be skipped; the answer comes from a fresh spawn. + expect(r.success).toBe(true); + const liveFetchProc = procs.find((p) => p.argv[0] === 'ps'); + void liveFetchProc; + expect(serveProcs().length).toBe(1); + }); + + it('kills the resident and respawns when the requested cwd changes', async () => { + const first = mod.listOpencodeModelVariants({ cwd: '/a' }); + await vi.advanceTimersByTimeAsync(300); + await first; + const firstServe = serveProcs()[0]; + + const second = mod.listOpencodeModelVariants({ cwd: '/b' }); + await vi.advanceTimersByTimeAsync(300); + const r2 = await second; + expect(r2.success).toBe(true); + expect(firstServe.kill).toHaveBeenCalled(); + const newServe = serveProcs()[1]; + expect(newServe).toBeDefined(); + expect(newServe.kill).not.toHaveBeenCalled(); + }); + + it('kills an unresponsive resident before replacing it', async () => { + const first = mod.listOpencodeModelVariants({ cwd: '/a' }); + await vi.advanceTimersByTimeAsync(300); + await first; + const firstServe = serveProcs()[0]; + + const fetchMock = vi.mocked(fetch); + fetchMock.mockRejectedValueOnce(new Error('server unavailable')); + const second = mod.listOpencodeModelVariants({ cwd: '/a' }); + await vi.advanceTimersByTimeAsync(300); + await second; + + expect(firstServe.kill).toHaveBeenCalled(); + expect(serveProcs().length).toBe(2); + }); + + it('does not coalesce concurrent calls for different cwds', async () => { + const first = mod.listOpencodeModelVariants({ cwd: '/a' }); + const second = mod.listOpencodeModelVariants({ cwd: '/b' }); + await vi.advanceTimersByTimeAsync(300); + const [r1, r2] = await Promise.all([first, second]); + expect(r1.success).toBe(true); + expect(r2.success).toBe(true); + // One serve per cwd — the two requests must not share a catalog. + expect(serveProcs().length).toBe(2); + // The loser of the resident slot must not leak: the replaced serve is + // killed once the second spawn takes over. + expect(serveProcs()[0].kill).toHaveBeenCalled(); + }); + + it('registers an exit hook that kills the resident serve', async () => { + const exitSpy = vi.spyOn(process, 'on'); + const first = mod.listOpencodeModelVariants(); + await vi.advanceTimersByTimeAsync(300); + await first; + const calls = exitSpy.mock.calls as unknown as Array<[string, (...args: unknown[]) => void]>; + const hook = calls.find(([event]) => event === 'exit')?.[1]; + expect(hook).toBeDefined(); + const residentServe = serveProcs()[0]; + hook?.(); + expect(residentServe.kill).toHaveBeenCalled(); + exitSpy.mockRestore(); + }); +}); + +describe('extractOpencodeAcpServers', () => { + it('extracts port and cwd pairs', () => { + const ps = [ + 'opencode acp --cwd /home/user --port 42495 --hostname 127.0.0.1', + 'opencode acp --port 38971' + ].join('\n'); + expect(extractOpencodeAcpServers(ps)).toEqual([ + { port: 42495, cwd: '/home/user' }, + { port: 38971, cwd: null } + ]); + }); +}); diff --git a/cli/src/modules/common/opencodeModelVariants.ts b/cli/src/modules/common/opencodeModelVariants.ts new file mode 100644 index 00000000..6b75fb16 --- /dev/null +++ b/cli/src/modules/common/opencodeModelVariants.ts @@ -0,0 +1,272 @@ +import { isObject } from '@hapi/protocol'; +import type { OpencodeModelVariantsResponse } from '@hapi/protocol/apiTypes'; + +export type ListOpencodeModelVariantsResponse = OpencodeModelVariantsResponse; + +const PROBE_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 300; + +/** + * Extract a `{ 'providerId/modelId': string[] }` map of model variants from an + * OpenCode server `GET /provider` payload. Only models with at least one + * variant are collected; malformed entries are skipped defensively. + */ +export function parseOpencodeProviderVariants(payload: unknown): Record { + if (!isObject(payload) || !Array.isArray(payload.all)) { + return {}; + } + + const out: Record = {}; + for (const provider of payload.all) { + if (!isObject(provider) || !isObject(provider.models)) { + continue; + } + for (const [, rawModel] of Object.entries(provider.models)) { + if (!isObject(rawModel)) continue; + const modelId = typeof rawModel.id === 'string' && rawModel.id.trim() ? rawModel.id : ''; + const providerID = typeof rawModel.providerID === 'string' ? rawModel.providerID : ''; + if (!providerID || !modelId) continue; + if (!isObject(rawModel.variants)) continue; + const variants = Object.keys(rawModel.variants).filter((key) => key.length > 0); + if (variants.length === 0) continue; + out[`${providerID}/${modelId}`] = variants; + } + } + return out; +} + +/** + * Parse `ps` args output for live `opencode acp --port ` servers. Pure so + * the process-scan path is testable without a real process list. + */ +export function extractOpencodeAcpPorts(psArgsOutput: string): number[] { + return extractOpencodeAcpServers(psArgsOutput).map((server) => server.port); +} + +/** + * Extract port + cwd from live `opencode acp --cwd --port ` process + * args. The cwd lets callers skip servers whose project-level opencode config + * would produce a different provider catalog than the requesting directory. + */ +export function extractOpencodeAcpServers(psArgsOutput: string): Array<{ port: number; cwd: string | null }> { + const servers = new Map(); + for (const line of psArgsOutput.split('\n')) { + if (!/\bopencode acp\b/.test(line)) continue; + const portMatch = /--port (\d+)/.exec(line); + if (!portMatch) continue; + const port = Number(portMatch[1]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) continue; + const cwdMatch = /--cwd (.+?)(?= --|$)/.exec(line); + servers.set(port, cwdMatch ? cwdMatch[1].trim() : null); + } + return [...servers.entries()].map(([port, cwd]) => ({ port, cwd })); +} + +async function listRunningOpencodeServers(): Promise> { + try { + const proc = Bun.spawn(['ps', '-eo', 'args='], { stdout: 'pipe', stderr: 'ignore' }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + return extractOpencodeAcpServers(output); + } catch { + return []; + } +} + +async function fetchVariantsFromServer(port: number): Promise | null> { + try { + const response = await fetch(`http://127.0.0.1:${port}/provider`, { signal: AbortSignal.timeout(1000) }); + if (!response.ok) return null; + return parseOpencodeProviderVariants(await response.json()); + } catch { + return null; + } +} + +async function getEphemeralPort(): Promise { + const net = await import('node:net'); + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : null; + server.close(() => { + if (port !== null) { + resolve(port); + } else { + reject(new Error('Failed to acquire ephemeral port')); + } + }); + }); + }); +} + +/** + * Spawned-serve cache: the catalog only changes when the opencode binary or + * user config changes, so the natural cache unit is the server process + * lifetime, not an arbitrary TTL. A lazily spawned headless `opencode serve` + * stays resident and answers subsequent calls instantly; an idle reaper kills + * it after IDLE_REAP_MS without a request, and an `exit` hook kills it when + * the CLI exits gracefully. The reaper timer is unref'd so it never keeps the + * CLI process alive. + */ +type ResidentServe = { proc: ReturnType; port: number; cwd: string | null }; +let resident: ResidentServe | null = null; +let idleReapTimer: ReturnType | null = null; +// In-flight coalescing is keyed by cwd: concurrent requests for different +// directories must not share a catalog (project-level opencode config differs). +const inFlightByCwd = new Map>(); + +const IDLE_REAP_MS = 10 * 60 * 1000; + +let exitCleanupRegistered = false; + +/** + * Kill the resident serve when the CLI exits gracefully (same pattern as + * autoStartServer's hub cleanup). Signal ownership stays with the runner so + * its asynchronous shutdown can finish before the process exits. + */ +function registerExitCleanup(): void { + if (exitCleanupRegistered) return; + exitCleanupRegistered = true; + const killResident = () => { + resident?.proc.kill(); + resident = null; + }; + process.on('exit', killResident); +} + +function scheduleIdleReap(): void { + if (idleReapTimer) { + clearTimeout(idleReapTimer); + } + const target = resident; + idleReapTimer = setTimeout(() => { + // Only reap the serve this timer was scheduled for — a newer call may + // have replaced the resident between scheduling and firing. + if (resident === target && resident) { + resident.proc.kill(); + resident = null; + } + idleReapTimer = null; + }, IDLE_REAP_MS); + idleReapTimer.unref?.(); +} + +export function listOpencodeModelVariants(request?: { cwd?: string | null }): Promise { + const cwd = request?.cwd ?? null; + const existing = inFlightByCwd.get(cwd); + if (existing) { + return existing; + } + const promise = listOpencodeModelVariantsUncached(cwd).finally(() => { + inFlightByCwd.delete(cwd); + }); + inFlightByCwd.set(cwd, promise); + return promise; +} + +async function listOpencodeModelVariantsUncached(cwd: string | null): Promise { + // Prefer a live OpenCode session's server — its /provider catalog is + // identical and answering from it skips any spawn entirely. Only servers + // running in the same cwd are used: project-level opencode config can + // override the provider catalog per directory. + for (const server of await listRunningOpencodeServers()) { + if (server.cwd !== cwd) continue; + const variants = await fetchVariantsFromServer(server.port); + if (variants) { + return { success: true, variants }; + } + } + + // Resident serve from a previous call — still the same opencode install + // and config, so the cached catalog is current by construction. A cwd + // mismatch means a different project config: kill and respawn targeted. + if (resident) { + if (resident.cwd !== cwd) { + resident.proc.kill(); + resident = null; + } else { + const variants = await fetchVariantsFromServer(resident.port); + if (variants) { + scheduleIdleReap(); + return { success: true, variants }; + } + // Server died (e.g. reaped externally) — fall through and respawn. + resident.proc.kill(); + resident = null; + } + } + + const spawned = await spawnServeWithCatalog(cwd); + if (!spawned.success) { + return spawned; + } + // A concurrent different-cwd call may have installed its own resident in + // parallel — it is no longer referenced by anyone once we overwrite it. + // (Read through an explicit type: TS narrows the module singleton to null + // on this path, but a concurrent call may have assigned it.) + const previousResident = resident as ResidentServe | null; + if (previousResident) { + previousResident.proc.kill(); + } + resident = { proc: spawned.proc, port: spawned.port, cwd }; + registerExitCleanup(); + scheduleIdleReap(); + return { success: true, variants: spawned.variants }; +} + +async function spawnServeWithCatalog(cwd: string | null): Promise< + { success: false; error: string } | { success: true; proc: ReturnType; port: number; variants: Record } +> { + let port: number; + try { + port = await getEphemeralPort(); + } catch { + return { success: false, error: 'Failed to acquire ephemeral port' }; + } + + let proc: ReturnType | null = null; + try { + proc = Bun.spawn(['opencode', 'serve', '--port', String(port)], { + stdout: 'ignore', + stderr: 'ignore', + ...(cwd ? { cwd } : {}), + }); + + const deadline = Date.now() + PROBE_TIMEOUT_MS; + let payload: unknown = null; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + // Bail out early instead of burning the full deadline when serve + // died on bind (e.g. the ephemeral port was claimed in the gap). + if (proc.exitCode !== null) { + break; + } + try { + const response = await fetch(`http://127.0.0.1:${port}/provider`, { signal: AbortSignal.timeout(2000) }); + if (response.ok) { + payload = await response.json(); + break; + } + } catch { + // Server not ready yet — keep polling until the deadline. + } + } + + if (!payload || proc.exitCode !== null) { + proc.kill(); + return { success: false, error: 'OpenCode provider catalog did not become available in time' }; + } + + return { success: true, proc, port, variants: parseOpencodeProviderVariants(payload) }; + } catch (error) { + proc?.kill(); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list OpenCode model variants' + }; + } +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 4cc1351b..88f4ee2e 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -4,6 +4,7 @@ import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' +import { registerOpencodeModelVariantsHandlers } from './handlers/opencodeModelVariants' import { registerPiModelHandlers } from './handlers/piModels' import { registerGrokModelHandlers } from './handlers/grokModels' import { registerCopilotModelHandlers } from './handlers/copilotModels' @@ -22,6 +23,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerCodexModelHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) + registerOpencodeModelVariantsHandlers(rpcHandlerManager) registerPiModelHandlers(rpcHandlerManager) registerGrokModelHandlers(rpcHandlerManager) registerCopilotModelHandlers(rpcHandlerManager) diff --git a/cli/src/opencode/loop.ts b/cli/src/opencode/loop.ts index 1b7aefb8..5e9b691f 100644 --- a/cli/src/opencode/loop.ts +++ b/cli/src/opencode/loop.ts @@ -23,6 +23,7 @@ interface OpencodeLoopOptions { hookServer: OpencodeHookServer; hookUrl: string; onSessionReady?: (session: OpencodeSession) => void; + onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; onCompactAvailabilityChange?: (available: boolean) => void; onClearRequested?: () => Promise; @@ -81,6 +82,7 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise { hookUrl: opts.hookUrl }), runRemote: (instance) => opencodeRemoteLauncher(instance, { + onModelRollback: opts.onModelRollback, onReasoningEffortRollback: opts.onReasoningEffortRollback, onCompactAvailabilityChange: opts.onCompactAvailabilityChange, isLocalIdCancelled: opts.isLocalIdCancelled, diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 92235905..3f8f3dec 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -14,6 +14,10 @@ const harness = vi.hoisted(() => ({ setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise), setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise), thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> }, + // Records the events-array length at each getThoughtLevelConfigOption call, + // so tests can order lookups against setModel/prompt events without + // polluting the events list other assertions compare exactly. + thoughtLevelLookups: [] as number[], stderrHandler: null as null | ((error: { type: string; message: string; raw: string }) => void), hangPrompt: false, resolvePrompt: null as null | (() => void), @@ -140,7 +144,10 @@ vi.mock('./utils/opencodeBackend', () => ({ } }), getSessionModelsMetadata: vi.fn(() => harness.sessionModelsMetadata), - getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined), + getThoughtLevelConfigOption: vi.fn(() => { + harness.thoughtLevelLookups.push(harness.events.length); + return harness.thoughtLevelOption ?? undefined; + }), // Real AcpSdkBackend.suppressUpdatesDuring swaps out the message // handler around `fn`; that detail is irrelevant to these // launcher-level tests (which never assert on ACP session/update @@ -310,6 +317,9 @@ function createSessionStub( const claudeSessionMessages: unknown[] = []; const rpcHandlers = new Map unknown>(); const setModelReasoningEffort = vi.fn(); + const setModel = vi.fn((model: string | null) => { + session.model = model; + }); const pushKeepAlive = vi.fn(); const emitMessagesConsumedCalls: Array<{ localIds: string[]; options?: { clearQueuedThinkingGrace?: boolean } }> = []; const thinkingChangeCalls: boolean[] = []; @@ -340,10 +350,14 @@ function createSessionStub( queue, sessionId: null as string | null, thinking: false, + model: null as string | null, getPermissionMode() { return 'default' as const; }, - setModel(_model: string | null) {}, + getModel() { + return session.model; + }, + setModel, setModelReasoningEffort, pushKeepAlive, onThinkingChange(thinking: boolean) { @@ -362,7 +376,7 @@ function createSessionStub( sendUserMessage(_text: string) {} }; - return { session, sessionEvents, sentAgentMessages, agentMessages: sentAgentMessages, claudeSessionMessages, rpcHandlers, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls }; + return { session, sessionEvents, sentAgentMessages, agentMessages: sentAgentMessages, claudeSessionMessages, rpcHandlers, setModel, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls }; } function createCompactMode(model?: string): OpencodeMode { @@ -393,6 +407,7 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.setModelImpl = null; harness.setConfigOptionImpl = null; harness.thoughtLevelOption = null; + harness.thoughtLevelLookups = []; harness.stderrHandler = null; harness.hangPrompt = false; harness.resolvePrompt = null; @@ -1646,6 +1661,89 @@ describe('opencodeRemoteLauncher inline model switch', () => { expect(lastCall.hostname).toBe('127.0.0.1'); }); + it('applies the requested startup model eagerly so thought_level is discoverable before the first turn', async () => { + // The OpenCode CLI was launched with --model hy3-free, but the ACP + // session's own default (mirrored into the metadata) is big-pickle. + harness.sessionModelsMetadata = { currentModelId: 'opencode/big-pickle', availableModels: [] }; + harness.thoughtLevelOption = { + id: 'effort', + currentValue: 'low', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] + }; + const { session } = createSessionStub([ + { message: 'first', mode: createModeWithEffort(undefined, 'high') } + ]); + session.model = 'opencode/hy3-free'; + + await opencodeRemoteLauncher(session as never); + + expect(harness.setModelArgs).toEqual([ + { sessionId: 'acp-session-1', modelId: 'opencode/hy3-free', flavor: 'opencode' } + ]); + // The effort lookup that seeds currentBackendEffort must run *after* + // the eager setModel, so it observes the new model's thought_level. + const eagerModelIndex = harness.events.indexOf('setModel:opencode/hy3-free'); + expect(eagerModelIndex).toBeGreaterThanOrEqual(0); + const lookupAfterEager = harness.thoughtLevelLookups.find((at) => at > eagerModelIndex); + expect(lookupAfterEager).toBeDefined(); + expect(lookupAfterEager!).toBeLessThan(harness.events.indexOf('prompt:start')); + }); + + it('ignores an eager startup model failure and lets the first batch retry inline', async () => { + let eagerFailed = false; + harness.setModelImpl = async () => { + if (!eagerFailed) { + eagerFailed = true; + throw new Error('Transient backend failure'); + } + }; + harness.sessionModelsMetadata = { currentModelId: 'opencode/big-pickle', availableModels: [] }; + harness.thoughtLevelOption = { + id: 'effort', + currentValue: 'low', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] + }; + const { session, sessionEvents } = createSessionStub([ + { message: 'first', mode: createMode('opencode/hy3-free') } + ]); + session.model = 'opencode/hy3-free'; + + await opencodeRemoteLauncher(session as never); + + // Eager attempt happened once and failed; the first batch then retried + // via the existing inline switch path. + expect(harness.setModelArgs).toEqual([ + { sessionId: 'acp-session-1', modelId: 'opencode/hy3-free', flavor: 'opencode' }, + { sessionId: 'acp-session-1', modelId: 'opencode/hy3-free', flavor: 'opencode' } + ]); + // The inline switch must also refresh the cached effort from the fresh + // thought_level options (set_config_option changes the backend's + // effort currentValue). + const inlineSwitchIndex = harness.events.lastIndexOf('setModel:opencode/hy3-free'); + const lookupAfterInline = harness.thoughtLevelLookups.find((at) => at > inlineSwitchIndex); + expect(lookupAfterInline).toBeDefined(); + // The eager attempt failed, but the inline retry succeeded — so the + // user never sees a "Failed to switch model" notice. + const failureNotices = sessionEvents.filter( + (event) => event.type === 'message' && typeof event.message === 'string' && event.message.includes('Failed to switch model') + ); + expect(failureNotices.length).toBe(0); + expect(harness.promptCount).toBe(1); + }); + + it('does not call setModel when the requested startup model matches the session default', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] }; + const { session } = createSessionStub([ + { message: 'first', mode: createMode('ollama/x') } + ]); + session.model = 'ollama/x'; + + await opencodeRemoteLauncher(session as never); + + expect(harness.setModelArgs).toEqual([]); + expect(harness.promptCount).toBe(1); + }); + it('calls setModel with opencode flavor between turns when the queued model differs', async () => { const { session } = createSessionStub([ { message: 'first', mode: createMode('ollama/exaone:4.5-33b-q8') }, @@ -1708,17 +1806,21 @@ describe('opencodeRemoteLauncher inline model switch', () => { }); it('reports a transient setModel error and continues with the previous model', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/a', availableModels: [] }; let attempts = 0; harness.setModelImpl = async () => { attempts++; throw new Error('Transient backend failure'); }; - const { session, sessionEvents } = createSessionStub([ + const { session, sessionEvents, setModel, pushKeepAlive } = createSessionStub([ { message: 'first', mode: createMode('ollama/a') }, { message: 'second', mode: createMode('ollama/b') } ]); + const rollbacks: Array = []; - await opencodeRemoteLauncher(session as never); + await opencodeRemoteLauncher(session as never, { + onModelRollback: (model) => rollbacks.push(model) + }); expect(attempts).toBe(1); const failureMessages = sessionEvents.filter( @@ -1729,6 +1831,10 @@ describe('opencodeRemoteLauncher inline model switch', () => { ); expect(failureMessages.length).toBe(1); expect(failureMessages[0]?.message).toContain('ollama/b'); + expect(setModel).toHaveBeenCalledWith('ollama/a'); + expect(session.model).toBe('ollama/a'); + expect(pushKeepAlive).toHaveBeenCalledTimes(1); + expect(rollbacks).toEqual(['ollama/a']); expect(harness.promptCount).toBe(2); }); @@ -1962,11 +2068,14 @@ describe('opencodeRemoteLauncher inline model switch', () => { { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' } ], - currentValue: 'low' + currentValue: 'low', + currentModelId: null, + targetModelId: null }); }); it('listOpencodeReasoningEffortOptions handler returns unavailable when backend has no thought level option', async () => { + harness.sessionModelsMetadata = { currentModelId: 'opencode/big-pickle', availableModels: [] }; const { session, rpcHandlers } = createSessionStub([ { message: 'first', mode: createMode() } ]); @@ -1977,7 +2086,24 @@ describe('opencodeRemoteLauncher inline model switch', () => { const result = await handler!(undefined) as Record; expect(result).toEqual({ success: false, - error: 'OpenCode reasoning effort options are not available' + error: 'OpenCode reasoning effort options are not available', + currentModelId: 'opencode/big-pickle', + targetModelId: 'opencode/big-pickle' + }); + }); + + it('reports the resolved default target while the backend still uses the previous model', async () => { + harness.sessionModelsMetadata = { currentModelId: 'opencode/default', availableModels: [] }; + const { session, rpcHandlers } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + await opencodeRemoteLauncher(session as never); + + harness.sessionModelsMetadata = { currentModelId: 'opencode/previous', availableModels: [] }; + const result = await rpcHandlers.get('listOpencodeReasoningEffortOptions')!(undefined) as Record; + expect(result).toMatchObject({ + currentModelId: 'opencode/previous', + targetModelId: 'opencode/default' }); }); diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index d67aee49..97c5c46d 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -26,6 +26,7 @@ import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; type OpencodeRemoteLauncherOptions = { + onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; // Called with `true` once the ACP backend + internal HTTP baseUrl are // ready (so /compact can actually run) and with `false` whenever this @@ -251,6 +252,34 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null; this.defaultBackendEffort = this.currentBackendEffort; + // The CLI may have been launched with an explicit --model that differs + // from the ACP session's own default. Apply it eagerly right here so + // the new model's thought_level config options are captured (via + // setModel's set_config_option round-trip) *before* the web UI's first + // effort-options poll — otherwise a variant-capable startup model looks + // unsupported until after the first turn. On failure just warn: the + // first batch's existing inline switch path retries the same model. + const requestedStartupModel = this.session.getModel?.(); + if ( + !this.shouldExit + && typeof requestedStartupModel === 'string' + && requestedStartupModel.length > 0 + && requestedStartupModel !== this.defaultBackendModel + && typeof backend.setModel === 'function' + ) { + try { + await backend.setModel(acpSessionId, requestedStartupModel, { flavor: 'opencode' }); + this.currentBackendModel = requestedStartupModel; + // The lookup above ran before the switch — re-query so the + // seeded effort reflects the eagerly applied model. + const refreshedThoughtLevel = backend.getThoughtLevelConfigOption?.(acpSessionId); + this.currentBackendEffort = refreshedThoughtLevel?.currentValue ?? null; + this.defaultBackendEffort = this.currentBackendEffort; + } catch (error) { + logger.warn('[opencode-remote] Eager startup model application failed; first batch will retry inline', error); + } + } + // Let the caller (runOpencode.ts) know native /compact can actually // run now that the ACP backend + internal HTTP baseUrl exist. The // dequeue loop below (not an externally-invoked trigger) is what @@ -295,13 +324,28 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeReasoningEffortOptions, async () => { const effortOption = backend.getThoughtLevelConfigOption?.(acpSessionId); + const currentModelId = backend.getSessionModelsMetadata?.(acpSessionId)?.currentModelId ?? null; + const requestedModel = this.session.getModel?.(); + const targetModelId = requestedModel === null + ? this.defaultBackendModel + : requestedModel ?? currentModelId; if (!effortOption) { - return { success: false, error: 'OpenCode reasoning effort options are not available' }; + return { + success: false, + error: 'OpenCode reasoning effort options are not available', + currentModelId, + targetModelId + }; } return { success: true, options: effortOption.options, - currentValue: effortOption.currentValue ?? null + currentValue: effortOption.currentValue ?? null, + // Lets the web client detect "options still belong to the + // previous model" while a requested switch has not been + // applied by the backend yet. + currentModelId, + targetModelId }; }); @@ -388,13 +432,22 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.currentBackendModel = requestedModel; } else if (requestedModel && requestedModel !== this.currentBackendModel) { if (!backend.setModel || this.setModelSupported === false) { - batch.mode.model = this.currentBackendModel ?? undefined; + this.rollbackModel(batch, this.currentBackendModel); } else { logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`); try { await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' }); this.currentBackendModel = requestedModel; this.setModelSupported = true; + // set_config_option("model") also switches the backend's + // effort currentValue — refresh both cached efforts so + // a subsequent request equal to the stale value still + // performs the round-trip instead of being skipped, + // and an unset effort falls back to the *new* model's + // default rather than reapplying the old model's. + const refreshedInlineEffort = backend.getThoughtLevelConfigOption?.(acpSessionId); + this.currentBackendEffort = refreshedInlineEffort?.currentValue ?? null; + this.defaultBackendEffort = this.currentBackendEffort; // Reflect the resolved model back into the batch so // downstream display logic sees the concrete id rather // than a `null` placeholder. @@ -416,7 +469,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.` }); } - batch.mode.model = this.currentBackendModel ?? undefined; + this.rollbackModel(batch, this.currentBackendModel); } } } @@ -806,6 +859,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.options.onReasoningEffortRollback?.(effort); } + private rollbackModel(batch: { mode: OpencodeMode }, model: string | null): void { + batch.mode.model = model ?? undefined; + this.session.setModel(model); + this.session.pushKeepAlive(); + this.options.onModelRollback?.(model); + } + /** * Executes the /compact operation for a queued `operation:'compact'` * batch. Reached only through the main dequeue loop (so it never runs diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index e0a838f3..134a4c36 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -530,6 +530,9 @@ export async function runOpencode(opts: { } notifyHubModeChange(mode); }, + onModelRollback: (model) => { + sessionModel = model; + }, onReasoningEffortRollback: (effort) => { sessionModelReasoningEffort = effort; }, diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index b749647f..e44e6edb 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -29,6 +29,7 @@ import type { ArchiveCodexSessionRpcResponse, OpencodeModelsResponse, OpencodeModelSummary, + OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PathExistsResponse, PiModelsResponse, @@ -86,6 +87,7 @@ export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcCursorChatStoreStatus = CursorChatStoreStatus export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse +export type RpcListOpencodeModelVariantsResponse = OpencodeModelVariantsResponse export type RpcListGrokModelsResponse = GrokModelsResponse export type RpcListCopilotModelsResponse = CopilotModelsResponse export type RpcListGrokReasoningEffortOptionsResponse = GrokReasoningEffortResponse @@ -379,6 +381,10 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse } + async listOpencodeModelVariantsForMachine(machineId: string, cwd?: string | null): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelVariants, { cwd: cwd ?? null }, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListOpencodeModelVariantsResponse + } + async listCodexModelsForSession(sessionId: string): Promise { return await this.sessionRpc( sessionId, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f5f2ba17..91a247ae 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -48,6 +48,7 @@ import { type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcListOpencodeModelVariantsResponse, type RpcListGrokModelsResponse, type RpcListCopilotModelsResponse, type RpcListGrokReasoningEffortOptionsResponse, @@ -81,6 +82,7 @@ export type { RpcListPiSessionsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcListOpencodeModelVariantsResponse, RpcListGrokModelsResponse, RpcListCopilotModelsResponse, RpcListGrokReasoningEffortOptionsResponse, @@ -3928,6 +3930,10 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + async listOpencodeModelVariantsForMachine(machineId: string, cwd?: string | null): Promise { + return await this.rpcGateway.listOpencodeModelVariantsForMachine(machineId, cwd) + } + async listCodexModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCodexModelsForSession(sessionId) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 3795fc76..44f5f417 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -248,6 +248,92 @@ describe('machines routes', () => { }) }) + it('returns OpenCode model variants for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listOpencodeModelVariantsForMachine: async () => ({ + success: true, + variants: { + 'opencode-go/ox-alpha-free': ['low', 'high', 'max'] + } + }) + } 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/opencode-model-variants') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + variants: { + 'opencode-go/ox-alpha-free': ['low', 'high', 'max'] + } + }) + }) + + it('forwards the cwd query parameter to the machine RPC', async () => { + let receivedCwd: string | null | undefined = 'unset' + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listOpencodeModelVariantsForMachine: async (machineId: string, cwd?: string | null) => { + void machineId + receivedCwd = cwd + return { success: true as const, variants: {} } + } + } 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/opencode-model-variants?cwd=%2Ftmp') + expect(response.status).toBe(200) + expect(receivedCwd).toBe('/tmp') + }) + + it('returns a stable code when the OpenCode variants machine RPC target is absent', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listOpencodeModelVariantsForMachine: async () => { + throw new RpcTargetMissingError( + 'machine-1:listOpencodeModelVariants', + '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/opencode-model-variants') + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + success: false, + error: 'RPC handler not registered: machine-1:listOpencodeModelVariants', + code: 'rpc_target_missing' + }) + }) + it('forwards startingMode "pty" to SyncEngine.spawnSession in the startingMode slot', async () => { const machine = createMachine() let captured: unknown[] | null = null diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index c7f55186..a84bd1e2 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -309,6 +309,36 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/opencode-model-variants', 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.listOpencodeModelVariantsForMachine(machineId, c.req.query('cwd') || null) + 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 OpenCode model variants' + }, 500) + } + }) + app.get('/machines/:id/grok-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 50fd75b3..3020e0ab 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -782,6 +782,13 @@ export type OpencodeModelsResponse = { export type ListOpencodeModelsResponse = OpencodeModelsResponse +/** Variant values keyed by `providerId/modelId` from the OpenCode server catalog. */ +export type OpencodeModelVariantsResponse = { + success: boolean + variants?: Record + error?: string +} + export type GrokModelSummary = { modelId: string name?: string @@ -833,6 +840,10 @@ export type OpencodeReasoningEffortResponse = { success: boolean options?: OpencodeReasoningEffortOption[] currentValue?: string | null + /** Backend-side model the options belong to — lets clients detect a pending model switch. */ + currentModelId?: string | null + /** Concrete backend model requested by the session, including a resolved Default selection. */ + targetModelId?: string | null error?: string } diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 78893518..824fc65a 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -36,6 +36,7 @@ export const RPC_METHODS = { ListPiModels: 'listPiModels', ListPiSessions: 'listPiSessions', ListOpencodeModels: 'listOpencodeModels', + ListOpencodeModelVariants: 'listOpencodeModelVariants', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', ListGrokModelsForCwd: 'listGrokModelsForCwd', ListGrokModels: 'listGrokModels', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0a3c3090..551352c8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PiModelsResponse, QueuedStateResponse, @@ -919,6 +920,12 @@ export class ApiClient { ) } + async getMachineOpencodeModelVariants(machineId: string, cwd?: string | null): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/opencode-model-variants${cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''}` + ) + } + async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` diff --git a/web/src/components/NewSession/EffortField.test.tsx b/web/src/components/NewSession/EffortField.test.tsx index b519f7b8..e9fca811 100644 --- a/web/src/components/NewSession/EffortField.test.tsx +++ b/web/src/components/NewSession/EffortField.test.tsx @@ -111,4 +111,31 @@ describe('EffortField', () => { ) expect(container.querySelector('select')).toBeNull() }) + + it('renders OpenCode dynamic variants with a default entry', () => { + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + expect(Array.from(select.options).map((option) => option.value)).toEqual([ + 'default', 'low', 'high', 'max' + ]) + }) + + it('hides the OpenCode effort field when variants are an empty array', () => { + const { container } = render( + + ) + expect(container.querySelector('select')).toBeNull() + }) + + it('falls back to static OpenCode presets when variants are not provided', () => { + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + expect(Array.from(select.options).map((option) => option.value)).toEqual([ + 'default', 'low', 'medium', 'high', 'max' + ]) + }) }) diff --git a/web/src/components/NewSession/EffortField.tsx b/web/src/components/NewSession/EffortField.tsx index fa42bf87..f684fac0 100644 --- a/web/src/components/NewSession/EffortField.tsx +++ b/web/src/components/NewSession/EffortField.tsx @@ -19,6 +19,8 @@ export type EffortFieldProps = { grokOptions?: 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. */ + opencodeVariantOptions?: string[] | null /** Selected Pi model — hides effort when the model cannot reason and filters levels via thinkingLevelMap. */ piSelectedModel?: { reasoning?: boolean; thinkingLevelMap?: PiThinkingLevelMap } | null } @@ -64,9 +66,23 @@ export function EffortField(props: EffortFieldProps) { })) : undefined) : undefined - options = modelOptions ?? CODEX_REASONING_EFFORT_OPTIONS.filter( - (option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max' - ) + if (props.agent === 'opencode') { + if (props.opencodeVariantOptions !== undefined && props.opencodeVariantOptions !== null) { + if (props.opencodeVariantOptions.length === 0) { + return null + } + options = [ + { value: 'default', label: t('newSession.model.default') }, + ...props.opencodeVariantOptions.map((variant) => ({ value: variant, label: variant })) + ] + } else { + options = CODEX_REASONING_EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh') + } + } else { + options = modelOptions ?? CODEX_REASONING_EFFORT_OPTIONS.filter( + (option) => option.value !== 'max' + ) + } } else { options = CLAUDE_EFFORT_OPTIONS } diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index 27d6057b..690c15f6 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -25,7 +25,11 @@ const mocks = vi.hoisted(() => ({ copilotModels: [] as Array<{ modelId: string; name?: string }>, copilotModelsLoading: false, opencodeModels: [] as Array<{ modelId: string; name?: string }>, + opencodeCurrentModelId: null as string | null, opencodeModelsLoading: false, + opencodeVariants: null as Record | null, + opencodeVariantsLoading: false, + opencodeVariantsEnabled: false, piDialogSelection: ['pi-native-1'] as string[], piModels: [] as PiModelSummary[], piModelsLoading: false, @@ -126,12 +130,22 @@ vi.mock('@/hooks/queries/useCursorModelsForMachine', () => ({ vi.mock('@/hooks/queries/useOpencodeModelsForCwd', () => ({ useOpencodeModelsForCwd: () => ({ availableModels: mocks.opencodeModels, - currentModelId: null, + currentModelId: mocks.opencodeCurrentModelId, isLoading: mocks.opencodeModelsLoading, error: null, refetch: vi.fn() }) })) +vi.mock('@/hooks/queries/useOpencodeModelVariants', () => ({ + useOpencodeModelVariants: (args: { enabled: boolean }) => { + mocks.opencodeVariantsEnabled = args.enabled + return { + variants: mocks.opencodeVariants, + isLoading: mocks.opencodeVariantsLoading, + error: null + } + } +})) vi.mock('@/hooks/queries/useGrokModelsForCwd', () => ({ useGrokModelsForCwd: () => ({ availableModels: [], @@ -225,9 +239,10 @@ vi.mock('./AgyModelSelector', () => ({ ) })) vi.mock('./EffortField', () => ({ - EffortField: (props: { effort: string; reasoningEffort: string; onReasoningEffortChange: (v: string) => void }) => ( + EffortField: (props: { effort: string; reasoningEffort: string; opencodeVariantOptions?: string[] | null; onReasoningEffortChange: (v: string) => void }) => ( <>
{props.effort}
+
{props.opencodeVariantOptions?.join(',') ?? 'static'}
@@ -286,7 +301,11 @@ describe('NewSession launch preferences', () => { mocks.copilotModels = [] mocks.copilotModelsLoading = false mocks.opencodeModels = [{ modelId: 'provider/current', name: 'Current' }] + mocks.opencodeCurrentModelId = 'provider/current' mocks.opencodeModelsLoading = false + mocks.opencodeVariants = null + mocks.opencodeVariantsLoading = false + mocks.opencodeVariantsEnabled = false mocks.piDialogSelection = ['pi-native-1'] mocks.piModels = [] mocks.piModelsLoading = false @@ -695,6 +714,38 @@ describe('NewSession launch preferences', () => { expect(screen.getByTestId('opencode-model')).toHaveTextContent('default') }) + it('uses the probed current model variants for an explicit OpenCode Default selection', async () => { + savePreferredAgent('opencode') + mocks.opencodeVariants = { 'provider/current': ['low', 'high'] } + render( {}} />) + + fireEvent.click(screen.getByTestId('opencode-model-default')) + await waitFor(() => expect(screen.getByTestId('opencode-variants')).toHaveTextContent('low,high')) + expect(mocks.opencodeVariantsEnabled).toBe(true) + }) + + it('waits for OpenCode variants before launching a non-default effort', async () => { + savePreferredAgent('opencode') + mocks.opencodeVariantsLoading = true + const view = render( {}} />) + + fireEvent.click(screen.getByTestId('reasoning')) + expect(screen.getByTestId('create')).toBeDisabled() + + mocks.opencodeVariantsLoading = false + mocks.opencodeVariants = { 'provider/current': ['max'] } + view.rerender( {}} />) + await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) + }) + + it('does not probe OpenCode variants until the working directory is verified', () => { + savePreferredAgent('opencode') + mocks.directoryExists = undefined + render( {}} />) + + expect(mocks.opencodeVariantsEnabled).toBe(false) + }) + it('restores a remembered OpenCode model when it is still advertised', async () => { savePreferredAgent('opencode') savePreferredLaunchSettings('machine-1', 'opencode', { model: 'provider/model', cursorSelectedBase: 'auto', effort: 'auto', modelReasoningEffort: 'high' }) diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 18ce79d6..62931cde 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -10,6 +10,7 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useAgyModels } from '@/hooks/queries/useAgyModels' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' +import { useOpencodeModelVariants } from '@/hooks/queries/useOpencodeModelVariants' import { useGrokModelsForCwd } from '@/hooks/queries/useGrokModelsForCwd' import { useCopilotModelsForCwd } from '@/hooks/queries/useCopilotModelsForCwd' import { usePiModelsForMachine } from '@/hooks/queries/usePiModelsForMachine' @@ -38,6 +39,7 @@ import { saveNewSessionFormDraft, shouldRestoreNewSessionFormDraft } from './newSessionFormDraft' +import { isOpencodeReasoningEffortValid } from './types' import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types' import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' @@ -346,6 +348,7 @@ export function NewSession(props: { setModelReasoningEffort('default') }, [agent, codexSupportedReasoningEfforts, modelReasoningEffort]) + useEffect(() => { if ( agent !== 'codex' @@ -558,6 +561,60 @@ export function NewSession(props: { cwdExists: deferredDirectoryExists, }) }) + const opencodeVariantsState = useOpencodeModelVariants({ + api: props.api, + machineId, + cwd: deferredDirectory || null, + enabled: shouldEnableOpencodeModelDiscovery({ + agent, + machineId, + cwd: deferredDirectory, + cwdExists: deferredDirectoryExists, + }) + }) + // OpenCode model option values are provider-qualified (`provider/model`), + // matching the variant catalog keys from the OpenCode server `/provider` + // endpoint. undefined = not applicable / no selection; null = loading or + // failed (static fallback); [] = catalog loaded and the selected model + // has no variants (hide the field — matches mid-session behavior). + const opencodeVariantOptions = useMemo(() => { + if (agent !== 'opencode' || !machineId) { + return undefined + } + const effectiveModelId = !opencodeSelectedModel || opencodeSelectedModel === 'auto' + ? opencodeModelsState.currentModelId + : opencodeSelectedModel + if (!effectiveModelId) { + return null + } + if (opencodeVariantsState.isLoading || opencodeVariantsState.error || !opencodeVariantsState.variants) { + return null + } + return opencodeVariantsState.variants[effectiveModelId] ?? [] + // Primitive/state-slice deps: the hook returns a fresh object per render, + // and a per-render options array would retrigger the reset effect below. + }, [agent, machineId, opencodeSelectedModel, opencodeModelsState.currentModelId, opencodeVariantsState.variants, opencodeVariantsState.isLoading, opencodeVariantsState.error]) + const opencodeCatalogPending = agent === 'opencode' + && deferredDirectory !== '' + && ( + deferredDirectoryExists === undefined + || (deferredDirectoryExists === true + && (opencodeModelsState.isLoading || opencodeVariantsState.isLoading)) + ) + + useEffect(() => { + if ( + agent !== 'opencode' + || modelReasoningEffort === 'default' + || opencodeCatalogPending + ) { + return + } + const dynamicVariants = opencodeVariantOptions ?? null + if (!isOpencodeReasoningEffortValid(modelReasoningEffort, dynamicVariants)) { + setModelReasoningEffort('default') + } + }, [agent, modelReasoningEffort, opencodeVariantOptions, opencodeCatalogPending]) const grokModelsState = useGrokModelsForCwd({ api: props.api, machineId, @@ -1625,6 +1682,7 @@ export function NewSession(props: { deferredDirectoryExists === undefined || (deferredDirectoryExists === true && opencodeModelsState.isLoading) )) + || (opencodeCatalogPending && modelReasoningEffort !== 'default') || (agent === 'copilot' && model !== 'auto' && ( @@ -1851,6 +1909,7 @@ export function NewSession(props: { isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)} grokOptions={agent === 'grok' ? grokEffortOptions : undefined} codexReasoningOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined} + opencodeVariantOptions={agent === 'opencode' ? opencodeVariantOptions : undefined} piSelectedModel={agent === 'pi' ? piSelectedModel : null} /> ) : null} diff --git a/web/src/components/NewSession/types.test.ts b/web/src/components/NewSession/types.test.ts index cd89351d..f2f74510 100644 --- a/web/src/components/NewSession/types.test.ts +++ b/web/src/components/NewSession/types.test.ts @@ -1,6 +1,6 @@ import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol' import { describe, expect, it } from 'vitest' -import { CLAUDE_EFFORT_OPTIONS, GROK_EFFORT_OPTIONS, MODEL_OPTIONS } from './types' +import { CLAUDE_EFFORT_OPTIONS, GROK_EFFORT_OPTIONS, MODEL_OPTIONS, isOpencodeReasoningEffortValid } from './types' describe('Claude model options', () => { it('derives options from shared Claude model presets', () => { @@ -44,3 +44,21 @@ describe('Grok effort options', () => { ]) }) }) + +describe('isOpencodeReasoningEffortValid', () => { + it('accepts default always', () => { + expect(isOpencodeReasoningEffortValid('default', ['low'])).toBe(true) + expect(isOpencodeReasoningEffortValid('default', null)).toBe(true) + }) + + it('validates against dynamic variants when loaded', () => { + expect(isOpencodeReasoningEffortValid('low', ['low', 'high', 'max'])).toBe(true) + expect(isOpencodeReasoningEffortValid('medium', ['low', 'high', 'max'])).toBe(false) + }) + + it('falls back to the static opencode list (xhigh excluded) when catalog is unavailable', () => { + expect(isOpencodeReasoningEffortValid('medium', null)).toBe(true) + expect(isOpencodeReasoningEffortValid('xhigh', null)).toBe(false) + expect(isOpencodeReasoningEffortValid('max', null)).toBe(true) + }) +}) diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 74dd9e81..4bd5fac7 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -64,6 +64,25 @@ export const CODEX_REASONING_EFFORT_OPTIONS: { value: CodexReasoningEffort; labe { value: 'max', label: 'Max' }, ] +/** + * Whether a saved/selected opencode reasoning effort is still valid for the + * current option set. Effort values are arbitrary model-provided strings + * (`CodexReasoningEffort` is a `string` alias, and dynamic variant lists from + * the OpenCode catalog may contain values beyond the static presets, e.g. + * `minimal` or `none`). `dynamicVariants` is the selected model's real variant + * list when the catalog is loaded (empty = model has none), or null when the + * catalog is unavailable and the static fallback list applies. + */ +export function isOpencodeReasoningEffortValid( + effort: CodexReasoningEffort, + dynamicVariants: string[] | null +): boolean { + if (effort === 'default') return true + const allowed = dynamicVariants + ?? CODEX_REASONING_EFFORT_OPTIONS.map((option) => option.value).filter((value) => value !== 'xhigh') + return allowed.includes(effort) +} + export const CLAUDE_EFFORT_OPTIONS: { value: LaunchEffort; label: string }[] = [ { value: 'auto', label: 'Auto' }, ...CLAUDE_EFFORT_LEVELS.map((value) => ({ value, label: CLAUDE_EFFORT_LABELS[value] })), diff --git a/web/src/components/SessionChat.test.ts b/web/src/components/SessionChat.test.ts index cc653a3b..0ba30c31 100644 --- a/web/src/components/SessionChat.test.ts +++ b/web/src/components/SessionChat.test.ts @@ -7,9 +7,11 @@ import { isScratchlistToggleHotkey, isSelectAllTargetBlocked, mergeStagedAttachmentsInOrder, + opencodeEffortOptionsInvalidationKey, resolvePiContextWindow, resolveLatestCompletedBoundaryIdForView, shouldAutoClearPendingSchedule, + shouldClearReasoningEffortForModelChange, shouldRouteToScratchlist, isRewindForkFallbackError, } from './SessionChat' @@ -72,6 +74,42 @@ describe('applyModelChangeWithReasoningRollback', () => { }) }) +describe('shouldClearReasoningEffortForModelChange', () => { + it('preserves an OpenCode effort for backend validation and rollback', () => { + expect(shouldClearReasoningEffortForModelChange({ + agentFlavor: 'opencode', + previousModelReasoningEffort: 'high', + codexModels: [], + model: 'provider/model-b' + })).toBe(false) + }) + + it('does not clear an unset OpenCode effort', () => { + expect(shouldClearReasoningEffortForModelChange({ + agentFlavor: 'opencode', + previousModelReasoningEffort: null, + codexModels: [], + model: 'provider/model-b' + })).toBe(false) + }) +}) + +describe('opencodeEffortOptionsInvalidationKey', () => { + it('returns the effort options query key for opencode sessions', () => { + expect(opencodeEffortOptionsInvalidationKey('opencode', 'session-1')).toEqual([ + 'session-opencode-reasoning-effort-options', + 'session-1', + ]) + }) + + it('returns null for other flavors and missing flavor', () => { + expect(opencodeEffortOptionsInvalidationKey('codex', 'session-1')).toBeNull() + expect(opencodeEffortOptionsInvalidationKey('grok', 'session-1')).toBeNull() + expect(opencodeEffortOptionsInvalidationKey(null, 'session-1')).toBeNull() + expect(opencodeEffortOptionsInvalidationKey(undefined, 'session-1')).toBeNull() + }) +}) + describe('resolvePiContextWindow', () => { const models = [ { provider: 'provider-a', modelId: 'shared-model', contextWindow: 100_000 }, diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 30679410..61730446 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { useNavigate } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' import { PRESERVE_SESSION_SIDEBAR_SCROLL } from '@/lib/sessionNavigation' import { AssistantRuntimeProvider, useAui, useAuiState } from '@assistant-ui/react' import { DragDropZone } from '@/components/AssistantChat/DragDropZone' @@ -8,6 +9,7 @@ import { ApiError, type ApiClient } from '@/api/client' import type { AttachmentMetadata, CodexCollaborationMode, + CodexModelSummary, CopilotAgentMode, DecryptedMessage, PermissionMode, @@ -108,6 +110,7 @@ import { useCopilotModels } from '@/hooks/queries/useCopilotModels' import { useGrokReasoningEffortOptions } from '@/hooks/queries/useGrokReasoningEffortOptions' import { usePiModels } from '@/hooks/queries/usePiModels' import { useOpencodeReasoningEffortOptions } from '@/hooks/queries/useOpencodeReasoningEffortOptions' +import { queryKeys } from '@/lib/query-keys' import { useVoiceOptional } from '@/lib/voice-context' import { AgentTerminalView } from '@/components/AgentTerminal/AgentTerminalView' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' @@ -116,6 +119,22 @@ import { isRemoteTerminalSupported } from '@/utils/terminalSupport' type SessionModelSelection = { provider: string; modelId: string } | string | null +/** + * Query key to invalidate after a successful model switch on an opencode + * session, or null for other flavors. The effort-options query caches per + * session (not per model), so without invalidation a stale option list from + * the previous model survives the switch. + */ +export function opencodeEffortOptionsInvalidationKey( + agentFlavor: string | null | undefined, + sessionId: string +): readonly unknown[] | null { + if (agentFlavor !== 'opencode') { + return null + } + return queryKeys.sessionOpencodeReasoningEffortOptions(sessionId) +} + export function isRewindForkFallbackError(error: unknown): boolean { return error instanceof ApiError && error.code === 'ambiguous_native_boundary_fork_safe' } @@ -160,6 +179,26 @@ export async function applyModelChangeWithReasoningRollback(args: { } } +export function shouldClearReasoningEffortForModelChange(args: { + agentFlavor: string | null | undefined + previousModelReasoningEffort: string | null + codexModels: readonly CodexModelSummary[] + model: SessionModelSelection +}): boolean { + if (!args.previousModelReasoningEffort) { + return false + } + if (args.agentFlavor === 'opencode') { + return false + } + return args.agentFlavor === 'codex' + && supportsCodexReasoningEffort( + args.codexModels, + args.model, + args.previousModelReasoningEffort + ) === false +} + /** * Returns whether a PendingSchedule should trigger an auto-clear timer. * @@ -658,6 +697,7 @@ function SessionChatInner(props: SessionChatProps) { const enqueueCursorModelApply = useMemo(() => createSerialAsyncQueue(), []) const lastSyncedCursorModelRef = useRef(undefined) const scratchlist = useHubScratchlist(props.session.id, props.api) + const queryClient = useQueryClient() const { sessions: allSessions } = useSessions(props.api) const resolveSessionMentionTooltip = useCallback((id: string, title: string) => { const hit = allSessions.find((s) => s.id === id) ?? null @@ -883,6 +923,22 @@ function SessionChatInner(props: SessionChatProps) { [props.onSend, props.api, props.session.id, scratchlist, scratchlistMode], ) const agentFlavor = props.session.metadata?.flavor ?? null + // The effort-options query is keyed by session only, so a stale option + // list from the previous model would survive a switch. Reset when the + // session model changes. `session.model` is updated by the hub at REST-ack + // time, ahead of the CLI's inline ACP switch — the invalidation alone + // would refetch the old model's options, and the hook's pending-switch + // polling (currentModelId mismatch) is what actually converges the picker. + // The key is built inside the effect: computing it during render yields a + // fresh array every render, and putting that in the deps would invalidate + // on every streaming re-render. + const sessionModel = props.session.model + const sessionId = props.session.id + useEffect(() => { + const effortInvalidationKey = opencodeEffortOptionsInvalidationKey(agentFlavor, sessionId) + if (!effortInvalidationKey || sessionModel === undefined) return + void queryClient.resetQueries({ queryKey: effortInvalidationKey, exact: true }) + }, [agentFlavor, sessionId, sessionModel, queryClient]) const controlledByUser = props.session.agentState?.controlledByUser === true const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser const codexModelsState = useCodexModels({ @@ -930,7 +986,8 @@ function SessionChatInner(props: SessionChatProps) { const opencodeReasoningEffortState = useOpencodeReasoningEffortOptions({ api: props.api, sessionId: props.session.id, - enabled: agentFlavor === 'opencode' && props.session.active + enabled: agentFlavor === 'opencode' && props.session.active, + sessionModel: props.session.model }) const opencodeModelOptions = useMemo(() => { if (agentFlavor !== 'opencode') { @@ -1437,13 +1494,12 @@ function SessionChatInner(props: SessionChatProps) { // Model mode change handler const handleModelChange = useCallback(async (model: SessionModelSelection) => { const previousModelReasoningEffort = props.session.modelReasoningEffort - const shouldClearReasoningEffort = agentFlavor === 'codex' - && Boolean(previousModelReasoningEffort) - && supportsCodexReasoningEffort( - codexModelsState.models, - model, - previousModelReasoningEffort - ) === false + const shouldClearReasoningEffort = shouldClearReasoningEffortForModelChange({ + agentFlavor, + previousModelReasoningEffort, + codexModels: codexModelsState.models, + model + }) try { await applyModelChangeWithReasoningRollback({ diff --git a/web/src/hooks/queries/useOpencodeModelVariants.ts b/web/src/hooks/queries/useOpencodeModelVariants.ts new file mode 100644 index 00000000..86a17990 --- /dev/null +++ b/web/src/hooks/queries/useOpencodeModelVariants.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { OpencodeModelVariantsResponse } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useOpencodeModelVariants(args: { + api: ApiClient | null + machineId?: string | null + /** Requesting directory — the variant catalog respects project-level opencode config, so only same-cwd servers are reused. */ + cwd?: string | null + enabled?: boolean +}): { + variants: Record | null + isLoading: boolean + error: string | null +} { + const { api, machineId, cwd } = args + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: [...queryKeys.machineOpencodeModelVariants(machineId ?? 'unknown'), cwd ?? null], + queryFn: async (): Promise => { + if (!api) { + throw new Error('API unavailable') + } + if (!machineId) { + throw new Error('OpenCode model variants target unavailable') + } + return await api.getMachineOpencodeModelVariants(machineId, cwd) + }, + enabled, + staleTime: 5 * 60_000, + retry: false, + }) + + return { + variants: query.data?.success && query.data.variants ? query.data.variants : null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load OpenCode model variants') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load OpenCode model variants' + : null, + } +} diff --git a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.render.test.tsx b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.render.test.tsx new file mode 100644 index 00000000..83f9f9fb --- /dev/null +++ b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.render.test.tsx @@ -0,0 +1,135 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { useOpencodeReasoningEffortOptions } from './useOpencodeReasoningEffortOptions' + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +describe('useOpencodeReasoningEffortOptions pending-switch polling', () => { + it('keeps polling while the backend reports the previous model, and continues after a model change', async () => { + const queryClient = new QueryClient() + const getSessionOpencodeReasoningEffortOptions = vi.fn(async () => ({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low', + // Backend still reports the previous model → mismatch polling. + currentModelId: 'opencode/big-pickle' + })) + const api = { getSessionOpencodeReasoningEffortOptions } as never + + const wrapper = ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result, rerender } = renderHook( + ({ model }) => useOpencodeReasoningEffortOptions({ + api, + sessionId: 'session-1', + enabled: true, + sessionModel: model + }), + { wrapper, initialProps: { model: 'opencode/hy3-free' } } + ) + + await waitFor(() => expect(getSessionOpencodeReasoningEffortOptions).toHaveBeenCalledTimes(1)) + expect(result.current.options).toEqual([]) + expect(result.current.currentValue).toBeNull() + await sleep(2300) + // Mismatch polling: at least two intervals' worth of refetches. + expect(getSessionOpencodeReasoningEffortOptions.mock.calls.length).toBeGreaterThanOrEqual(2) + + const callsBeforeSwitch = getSessionOpencodeReasoningEffortOptions.mock.calls.length + rerender({ model: 'opencode-go/ox-alpha-free' }) + await sleep(2300) + // Budget reset on model change: polling continues on the new model. + expect(getSessionOpencodeReasoningEffortOptions.mock.calls.length).toBeGreaterThan(callsBeforeSwitch) + }) + + it('hides cached options immediately when the session model changes', async () => { + const queryClient = new QueryClient() + let resolveRefetch: (() => void) | undefined + const refetchPending = new Promise((resolve) => { + resolveRefetch = resolve + }) + const getSessionOpencodeReasoningEffortOptions = vi.fn() + .mockResolvedValueOnce({ + success: true, + options: [{ value: 'high', name: 'High' }], + currentValue: 'high', + currentModelId: 'provider/model-a', + targetModelId: 'provider/model-a' + }) + .mockImplementationOnce(async () => { + await refetchPending + return { + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low', + currentModelId: 'provider/model-b', + targetModelId: 'provider/model-b' + } + }) + const api = { getSessionOpencodeReasoningEffortOptions } as never + const wrapper = ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result, rerender } = renderHook( + ({ model }) => useOpencodeReasoningEffortOptions({ + api, + sessionId: 'session-1', + enabled: true, + sessionModel: model + }), + { wrapper, initialProps: { model: 'provider/model-a' } } + ) + + await waitFor(() => expect(result.current.options).toEqual([{ value: 'high', name: 'High' }])) + rerender({ model: 'provider/model-b' }) + + expect(result.current.options).toEqual([]) + expect(result.current.currentValue).toBeNull() + resolveRefetch?.() + }) + + it('resets the mismatch poll budget when query counters reset', async () => { + const queryClient = new QueryClient() + const getSessionOpencodeReasoningEffortOptions = vi.fn(async () => ({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low', + currentModelId: 'provider/model-a' + })) + const api = { getSessionOpencodeReasoningEffortOptions } as never + const wrapper = ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { rerender } = renderHook( + ({ model }) => useOpencodeReasoningEffortOptions({ + api, + sessionId: 'session-1', + enabled: true, + sessionModel: model + }), + { wrapper, initialProps: { model: 'provider/model-b' } } + ) + + await waitFor(() => expect(getSessionOpencodeReasoningEffortOptions).toHaveBeenCalled()) + const queryKey = ['session-opencode-reasoning-effort-options', 'session-1'] as const + for (let i = 0; i < 65; i += 1) { + await queryClient.refetchQueries({ queryKey, exact: true }) + } + + await queryClient.resetQueries({ queryKey, exact: true }) + rerender({ model: 'provider/model-c' }) + + while ((queryClient.getQueryState(queryKey)?.dataUpdateCount ?? 0) <= 60) { + await queryClient.refetchQueries({ queryKey, exact: true }) + } + const callsAfterBudget = getSessionOpencodeReasoningEffortOptions.mock.calls.length + await sleep(1100) + + expect(getSessionOpencodeReasoningEffortOptions).toHaveBeenCalledTimes(callsAfterBudget) + }) +}) diff --git a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts index aae7a7ca..808e075c 100644 --- a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts +++ b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.test.ts @@ -21,4 +21,39 @@ describe('useOpencodeReasoningEffortOptions retry policy', () => { expect(getOpencodeReasoningEffortRefetchInterval(false, undefined, 0)).toBe(false) expect(getOpencodeReasoningEffortRefetchInterval(true, undefined, 10)).toBe(false) }) + + it('keeps polling while the reported options belong to a previous model, bounded', () => { + const staleData = { + success: true, + options: [{ value: 'low', name: 'Low' }], + currentModelId: 'opencode/big-pickle' + } + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 12, 'opencode/hy3-free')).toBe(1000) + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 60, 'opencode/hy3-free')).toBe(30_000) + // Matching model: options are current, no mismatch polling. + expect(getOpencodeReasoningEffortRefetchInterval(true, { ...staleData, currentModelId: 'opencode/hy3-free' }, 12, 'opencode/hy3-free')).toBe(false) + // No target model to compare against: fall back to discovery behavior. + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 12)).toBe(false) + }) + + it('polls toward the resolved Default target when the session model is null', () => { + const staleData = { + success: true, + options: [{ value: 'low', name: 'Low' }], + currentModelId: 'opencode/previous', + targetModelId: 'opencode/default' + } + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 12)).toBe(1000) + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 60)).toBe(30_000) + }) + + it('keeps polling when a variant-less previous model reports no options', () => { + const staleData = { + success: false, + error: 'not available', + currentModelId: 'opencode/big-pickle' + } + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 12, 'opencode-go/ox-alpha-free')).toBe(1000) + expect(getOpencodeReasoningEffortRefetchInterval(true, staleData, 60, 'opencode-go/ox-alpha-free')).toBe(30_000) + }) }) diff --git a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts index 7f13c09c..83ab1e35 100644 --- a/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts +++ b/web/src/hooks/queries/useOpencodeReasoningEffortOptions.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' import type { OpencodeReasoningEffortResponse } from '@hapi/protocol/apiTypes' import type { ApiClient } from '@/api/client' import { queryKeys } from '@/lib/query-keys' @@ -8,13 +9,33 @@ export function shouldRetryOpencodeReasoningEffortQuery(failureCount: number): b } const MAX_OPENCODE_REASONING_EFFORT_DISCOVERY_POLLS = 10 +// While the reported options still belong to a previous model (a requested +// switch has not been applied by the backend yet), keep polling so the picker +// converges once the switch lands — but bounded, so an idle session that +// never turns again does not poll forever. +const MAX_OPENCODE_REASONING_EFFORT_MISMATCH_POLLS = 60 export function getOpencodeReasoningEffortRefetchInterval( enabled: boolean, data: OpencodeReasoningEffortResponse | undefined, - pollCount: number -): 1000 | false { - if (!enabled || pollCount >= MAX_OPENCODE_REASONING_EFFORT_DISCOVERY_POLLS) { + pollCount: number, + sessionModel?: string | null +): 1000 | 30_000 | false { + if (!enabled) { + return false + } + const targetModelId = data?.targetModelId ?? sessionModel + if ( + data + && data.currentModelId + && targetModelId + && data.currentModelId !== targetModelId + ) { + // The backend still reports the previous model. This includes a + // variant-less previous model whose response has no options. + return pollCount < MAX_OPENCODE_REASONING_EFFORT_MISMATCH_POLLS ? 1000 : 30_000 + } + if (pollCount >= MAX_OPENCODE_REASONING_EFFORT_DISCOVERY_POLLS) { return false } if (!data) { @@ -30,15 +51,33 @@ export function useOpencodeReasoningEffortOptions(args: { api: ApiClient | null sessionId?: string | null enabled?: boolean + /** Server-confirmed session model — lets polling continue while the backend still reports the previous model's options. */ + sessionModel?: string | null }): { options: Array<{ value: string; name?: string }> currentValue: string | null isLoading: boolean error: string | null } { - const { api, sessionId } = args + const { api, sessionId, sessionModel } = args const enabled = Boolean(args.enabled && api && sessionId) + // The mismatch poll budget is per model switch: query.state.dataUpdateCount + // is cumulative for the query's whole life, so earlier switches would + // permanently exhaust the budget for later ones. Capture the update count + // as a baseline when the session model changes and budget by the diff. + // (Counting inside the refetchInterval callback is wrong: TanStack + // evaluates that callback on every setOptions, i.e. every render.) + const lastSeenModelRef = useRef(undefined) + const latestUpdateCountRef = useRef(0) + const mismatchBaselineRef = useRef(0) + useEffect(() => { + if (lastSeenModelRef.current !== undefined && lastSeenModelRef.current !== sessionModel) { + mismatchBaselineRef.current = latestUpdateCountRef.current + } + lastSeenModelRef.current = sessionModel + }, [sessionModel]) + const query = useQuery({ queryKey: sessionId ? queryKeys.sessionOpencodeReasoningEffortOptions(sessionId) @@ -55,16 +94,38 @@ export function useOpencodeReasoningEffortOptions(args: { enabled, staleTime: 30_000, retry: (failureCount) => shouldRetryOpencodeReasoningEffortQuery(failureCount), - refetchInterval: (query) => getOpencodeReasoningEffortRefetchInterval( - enabled, - query.state.data as OpencodeReasoningEffortResponse | undefined, - query.state.dataUpdateCount + query.state.errorUpdateCount - ), + refetchInterval: (query) => { + const data = query.state.data as OpencodeReasoningEffortResponse | undefined + const totalUpdateCount = query.state.dataUpdateCount + query.state.errorUpdateCount + if (totalUpdateCount < latestUpdateCountRef.current) { + mismatchBaselineRef.current = 0 + } + latestUpdateCountRef.current = totalUpdateCount + const targetModelId = data?.targetModelId ?? sessionModel + const mismatchActive = Boolean( + data + && data.currentModelId + && targetModelId + && data.currentModelId !== targetModelId + ) + return getOpencodeReasoningEffortRefetchInterval( + enabled, + data, + mismatchActive + ? totalUpdateCount - mismatchBaselineRef.current + : totalUpdateCount, + sessionModel + ) + }, }) + const targetModelId = sessionModel ?? query.data?.targetModelId + const optionsAreCurrent = !query.data?.currentModelId + || !targetModelId + || query.data.currentModelId === targetModelId return { - options: query.data?.options ?? [], - currentValue: query.data?.currentValue ?? null, + options: optionsAreCurrent ? (query.data?.options ?? []) : [], + currentValue: optionsAreCurrent ? (query.data?.currentValue ?? null) : null, isLoading: query.isLoading, error: query.data?.success === false ? (query.data.error ?? 'Failed to load OpenCode reasoning effort options') diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index c865c658..8b647432 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -27,6 +27,7 @@ export const queryKeys = { sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, + machineOpencodeModelVariants: (machineId: string) => ['machine-opencode-model-variants', machineId] as const, machineGrokModelsForCwd: (machineId: string, cwd: string) => ['machine-grok-models', machineId, cwd] as const, sessionGrokModels: (sessionId: string) => ['session-grok-models', sessionId] as const, sessionCopilotModels: (sessionId: string) => ['session-copilot-models', sessionId] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 66f009ef..b2abaf81 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,7 @@ export type { MessagesResponse, OpencodeModelsResponse, OpencodeModelSummary, + OpencodeModelVariantsResponse, PathExistsResponse, PiModelSummary, PiModelsResponse,