diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 593462dd..a38ae2c3 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -70,6 +70,149 @@ describe('AcpSdkBackend', () => { expect(capturedRequestId).toBe('tool-approve'); }); + it('uses session/set_model by default (gemini flavor)', async () => { + const backend = new AcpSdkBackend({ command: 'gemini' }); + 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', 'gemini-2.5-pro'); + + expect(calls).toEqual([ + { method: 'session/set_model', params: { sessionId: 'session-1', modelId: 'gemini-2.5-pro' } } + ]); + }); + + it('uses session/set_model when flavor is opencode', 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; + }; + 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: [] } } + }; + }, + close: async () => {} + }; + + await backend.setModel('session-1', 'ollama/exaone:4.5-33b-q8', { flavor: 'opencode' }); + + expect(calls).toEqual([ + { + method: 'session/set_model', + params: { + sessionId: 'session-1', + modelId: 'ollama/exaone:4.5-33b-q8' + } + } + ]); + }); + + it('captures availableModels and currentModelId from session/new response', async () => { + const backend = new AcpSdkBackend({ command: 'opencode' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + const fixtureModels = [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama (SER8)/EXAONE 4.5 33B Q8' }, + { modelId: 'mlx/qwen3:0.6b', name: 'MLX/Qwen3 0.6B' } + ]; + backendInternal.transport = { + sendRequest: async (method) => { + if (method === 'session/new') { + return { + sessionId: 'opencode-session-7', + models: { + availableModels: fixtureModels, + currentModelId: 'ollama/exaone:4.5-33b-q8' + } + }; + } + return null; + }, + close: async () => {} + }; + + const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + expect(sessionId).toBe('opencode-session-7'); + expect(backend.getSessionModelsMetadata(sessionId)).toEqual({ + availableModels: fixtureModels, + currentModelId: 'ollama/exaone:4.5-33b-q8' + }); + }); + + it('returns undefined session metadata when session/new omits models', async () => { + const backend = new AcpSdkBackend({ command: 'gemini' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method) => { + if (method === 'session/new') { + return { sessionId: 'gemini-session-3' }; + } + return null; + }, + close: async () => {} + }; + + const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + expect(sessionId).toBe('gemini-session-3'); + expect(backend.getSessionModelsMetadata(sessionId)).toBeUndefined(); + }); + + it('optimistically updates currentModelId after a successful opencode setModel call', async () => { + const backend = new AcpSdkBackend({ command: 'opencode' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + const fixtureModels = [ + { modelId: 'ollama/a', name: 'a' }, + { modelId: 'ollama/b', name: 'b' } + ]; + backendInternal.transport = { + sendRequest: async (method) => { + if (method === 'session/new') { + return { + sessionId: 's1', + models: { availableModels: fixtureModels, currentModelId: 'ollama/a' } + }; + } + if (method === 'session/set_model') { + // OpenCode 1.14.30: response carries only an opaque _meta block. + return { _meta: { opencode: { modelId: 'ollama/b' } } }; + } + return null; + }, + close: async () => {} + }; + + await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await backend.setModel('s1', 'ollama/b', { flavor: 'opencode' }); + + // availableModels list is preserved from session/new; currentModelId is + // optimistically updated from the requested modelId. + expect(backend.getSessionModelsMetadata('s1')).toEqual({ + availableModels: fixtureModels, + currentModelId: 'ollama/b' + }); + }); + it('emits turn_complete after trailing tool updates from the same turn', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 8; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index b0fab77d..48afdb15 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -1,3 +1,4 @@ +import type { AgentFlavor } from '@hapi/protocol'; import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest, PermissionResponse, PromptContent } from '@/agent/types'; import { asString, isObject } from '@hapi/protocol'; import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport'; @@ -10,11 +11,22 @@ type PendingPermission = { resolve: (result: { outcome: { outcome: string; optionId?: string } }) => void; }; +export type AcpModelDescriptor = { + modelId: string; + name?: string; +}; + +export type AcpSessionModelsMetadata = { + availableModels: AcpModelDescriptor[]; + currentModelId: string | null; +}; + export class AcpSdkBackend implements AgentBackend { private transport: AcpStdioTransport | null = null; private permissionHandler: ((request: PermissionRequest) => void) | null = null; private stderrErrorHandler: ((error: AcpStderrError) => void) | null = null; private readonly pendingPermissions = new Map(); + private readonly sessionModelsMetadata = new Map(); private messageHandler: AcpMessageHandler | null = null; private activeSessionId: string | null = null; private isProcessingMessage = false; @@ -108,6 +120,7 @@ export class AcpSdkBackend implements AgentBackend { } this.activeSessionId = sessionId; + this.captureSessionModelsMetadata(sessionId, response); return sessionId; } @@ -133,10 +146,15 @@ export class AcpSdkBackend implements AgentBackend { const loadedSessionId = isObject(response) ? asString(response.sessionId) : null; const sessionId = loadedSessionId ?? config.sessionId; this.activeSessionId = sessionId; + this.captureSessionModelsMetadata(sessionId, response); return sessionId; } - async setModel(sessionId: string, modelId: string): Promise { + async setModel( + sessionId: string, + modelId: string, + opts?: { flavor?: AgentFlavor } + ): Promise { if (!this.transport) { throw new Error('ACP transport not initialized'); } @@ -145,12 +163,37 @@ export class AcpSdkBackend implements AgentBackend { // in-flight prompt to drain so we never interleave a switch with a session/prompt. await this.waitForResponseComplete(); - // Errors (including JSON-RPC 'method not found') propagate as rejections - // from the transport; the launcher's catch block handles them. - await this.transport.sendRequest('session/set_model', { + // ACP defines `session/set_model` ({ sessionId, modelId }) for inline model + // switching — see ACP SDK schema `x-method: session/set_model`. OpenCode + // 1.14.30 implements this exact wire name (the SDK's TypeScript helper is + // 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 }); + + if (opts?.flavor === 'opencode') { + // 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); + } else { + // For other flavors (e.g. Gemini), if the response carries metadata, + // capture it. Missing fields are silently ignored. + this.captureSessionModelsMetadata(sessionId, response); + } + } + + /** + * Returns the per-session models metadata captured from session/new (or + * session/load, or session/set_model). Returns undefined if the agent did + * not include the optional `models` block in its response. + */ + getSessionModelsMetadata(sessionId: string): AcpSessionModelsMetadata | undefined { + return this.sessionModelsMetadata.get(sessionId); } async prompt( @@ -279,6 +322,7 @@ export class AcpSdkBackend implements AgentBackend { this.messageHandler = null; this.activeSessionId = null; this.isProcessingMessage = false; + this.sessionModelsMetadata.clear(); this.notifyResponseComplete(); await this.transport.close(); this.transport = null; @@ -375,4 +419,74 @@ export class AcpSdkBackend implements AgentBackend { resolve(); } } + + /** + * Optimistically update the cached `currentModelId` for a session after a + * successful `session/set_model` call whose response does not echo the + * model metadata (OpenCode 1.14.30 returns only `_meta.opencode.modelId`). + * The previously captured `availableModels` list is preserved. + */ + private updateCurrentModelOptimistic(sessionId: string, modelId: string): void { + const existing = this.sessionModelsMetadata.get(sessionId); + this.sessionModelsMetadata.set(sessionId, { + availableModels: existing?.availableModels ?? [], + currentModelId: modelId + }); + } + + /** + * Extract `availableModels` and `currentModelId` from an ACP response and + * store them keyed by sessionId. Both top-level and nested-under-`models` + * shapes are accepted because different agents use different conventions. + * Missing or malformed fields are silently ignored — flavors that do not + * expose model metadata (e.g. current Gemini ACP build) simply leave the + * cache untouched. + */ + private captureSessionModelsMetadata(sessionId: string, response: unknown): void { + if (!isObject(response)) return; + + const directList = response.availableModels; + const directCurrent = response.currentModelId; + const nested = isObject(response.models) ? response.models : null; + const nestedList = nested?.availableModels; + const nestedCurrent = nested?.currentModelId; + + const rawModels = Array.isArray(directList) + ? directList + : Array.isArray(nestedList) + ? nestedList + : null; + const rawCurrent = typeof directCurrent === 'string' + ? directCurrent + : typeof nestedCurrent === 'string' + ? nestedCurrent + : null; + + if (rawModels === null && rawCurrent === null) { + return; + } + + const availableModels: AcpModelDescriptor[] = []; + if (Array.isArray(rawModels)) { + for (const entry of rawModels) { + if (!isObject(entry)) continue; + const modelId = asString(entry.modelId); + if (!modelId) continue; + const name = asString(entry.name) ?? undefined; + availableModels.push(name ? { modelId, name } : { modelId }); + } + } else { + // Preserve previously-captured availableModels when the response only + // updates currentModelId (e.g. a setModel response from some agents). + const existing = this.sessionModelsMetadata.get(sessionId); + if (existing) { + availableModels.push(...existing.availableModels); + } + } + + this.sessionModelsMetadata.set(sessionId, { + availableModels, + currentModelId: rawCurrent + }); + } } diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 8a049b81..9011937a 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -1,3 +1,5 @@ +import type { AgentFlavor } from '@hapi/protocol'; + export type McpEnvVar = { name: string; value: string; @@ -56,10 +58,21 @@ export type PermissionResponse = | { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' }; +export type AgentSessionModelDescriptor = { + modelId: string; + name?: string; +}; + +export type AgentSessionModelsMetadata = { + availableModels: AgentSessionModelDescriptor[]; + currentModelId: string | null; +}; + export interface AgentBackend { initialize(): Promise; newSession(config: AgentSessionConfig): Promise; - setModel?(sessionId: string, modelId: string): Promise; + setModel?(sessionId: string, modelId: string, opts?: { flavor?: AgentFlavor }): Promise; + getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined; prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise; cancelPrompt(sessionId: string): Promise; respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise; diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts new file mode 100644 index 00000000..9edfa62b --- /dev/null +++ b/cli/src/api/apiMachine.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const ioMock = vi.hoisted(() => vi.fn()) +const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) + +vi.mock('socket.io-client', () => ({ + io: ioMock +})) + +vi.mock('@/api/auth', () => ({ + getAuthToken: () => 'cli-token' +})) + +vi.mock('../modules/common/opencodeModels', () => ({ + listOpencodeModelsForCwd: listOpencodeModelsForCwdMock +})) + +import { ApiMachineClient } from './apiMachine' +import type { Machine } from './types' + +function makeMachine(id: string): Machine { + return { + id, + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: null, + metadataVersion: 0, + runnerState: null, + runnerStateVersion: 0 + } +} + +async function callListOpencodeModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { + // Reach into the private rpc handler manager to dispatch a request. + // Mirrors how the on-socket 'rpc-request' listener invokes handleRequest. + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listOpencodeModelsForCwd`, + params: JSON.stringify({ cwd }) + }) + return JSON.parse(raw) as unknown +} + +describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { + let workspaceRoot: string + + beforeEach(() => { + ioMock.mockReset() + listOpencodeModelsForCwdMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-')) + }) + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + it('rejects cwd outside the workspace root with the standard error shape', async () => { + const machine = makeMachine('machine-1') + const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-outside-')) + try { + const result = await callListOpencodeModels(client, machine.id, outsideCwd) + expect(result).toEqual({ success: false, error: 'Path is outside workspace root' }) + expect(listOpencodeModelsForCwdMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) + + it('rejects empty cwd with cwd-required error', async () => { + const machine = makeMachine('machine-2') + const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + + try { + const result = await callListOpencodeModels(client, machine.id, '') + expect(result).toEqual({ success: false, error: 'cwd is required' }) + expect(listOpencodeModelsForCwdMock).not.toHaveBeenCalled() + } finally { + client.shutdown() + } + }) + + it('forwards a workspace-internal cwd to listOpencodeModelsForCwd', async () => { + const machine = makeMachine('machine-3') + const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + + const innerDir = join(workspaceRoot, 'inner-project') + mkdirSync(innerDir) + + listOpencodeModelsForCwdMock.mockResolvedValueOnce({ + success: true, + availableModels: [{ modelId: 'a/b' }], + currentModelId: 'a/b' + }) + + try { + const result = await callListOpencodeModels(client, machine.id, innerDir) + expect(result).toEqual({ + success: true, + availableModels: [{ modelId: 'a/b' }], + currentModelId: 'a/b' + }) + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledTimes(1) + // The handler should pass the resolved (realpath'd) cwd to the lower layer. + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(expect.stringContaining('inner-project')) + } finally { + client.shutdown() + } + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index cebf3c0e..99a7d334 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -15,6 +15,11 @@ import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' +import { + listOpencodeModelsForCwd, + type ListOpencodeModelsForCwdRequest, + type ListOpencodeModelsForCwdResponse +} from '../modules/common/opencodeModels' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' @@ -206,6 +211,30 @@ export class ApiMachineClient { return { success: false, error: error instanceof Error ? error.message : 'Failed to list directory' } } }) + + // OpenCode model discovery spawns an `opencode acp` subprocess scoped to the + // requested cwd, so it must obey the same workspace-root containment as + // `list-directory` and `spawn-happy-session`. Re-register the handler that + // `registerCommonHandlers` installed unguarded with a guarded version that + // resolves symlinks and rejects paths outside the configured root before + // delegating to the lower-level probe. This intentionally overwrites the + // earlier registration on the same scoped method name. + this.rpcHandlerManager.registerHandler( + 'listOpencodeModelsForCwd', + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) { + return { success: false, error: 'cwd is required' } + } + + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoot(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace root' } + } + + return await listOpencodeModelsForCwd(resolvedCwd) + } + ) } private isWithinWorkspaceRoot(absolutePath: string): boolean { diff --git a/cli/src/commands/opencode.ts b/cli/src/commands/opencode.ts index 796c6aca..67a7f58e 100644 --- a/cli/src/commands/opencode.ts +++ b/cli/src/commands/opencode.ts @@ -15,6 +15,7 @@ export const opencodeCommand: CommandDefinition = { startedBy?: 'runner' | 'terminal' startingMode?: 'local' | 'remote' permissionMode?: OpencodePermissionMode + model?: string resumeSessionId?: string } = {} @@ -46,6 +47,12 @@ export const opencodeCommand: CommandDefinition = { throw new Error('Missing --resume value') } options.resumeSessionId = sessionId + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model } } diff --git a/cli/src/modules/common/handlers/opencodeModels.ts b/cli/src/modules/common/handlers/opencodeModels.ts new file mode 100644 index 00000000..73657a8e --- /dev/null +++ b/cli/src/modules/common/handlers/opencodeModels.ts @@ -0,0 +1,25 @@ +import { logger } from '@/ui/logger'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listOpencodeModelsForCwd, + type ListOpencodeModelsForCwdRequest, + type ListOpencodeModelsForCwdResponse +} from '../opencodeModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerOpencodeModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + 'listOpencodeModelsForCwd', + async (data) => { + logger.debug('List OpenCode models for cwd request', { cwd: data?.cwd }); + + try { + const cwd = typeof data?.cwd === 'string' ? data.cwd : ''; + return await listOpencodeModelsForCwd(cwd); + } catch (error) { + logger.debug('Failed to list OpenCode models:', error); + return rpcError(getErrorMessage(error, 'Failed to list OpenCode models')); + } + } + ); +} diff --git a/cli/src/modules/common/opencodeModels.test.ts b/cli/src/modules/common/opencodeModels.test.ts new file mode 100644 index 00000000..6af153e2 --- /dev/null +++ b/cli/src/modules/common/opencodeModels.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendRequestMock = vi.fn() +const closeMock = vi.fn().mockResolvedValue(undefined) +const transportConstructor = vi.fn() + +vi.mock('@/agent/backends/acp/AcpStdioTransport', () => ({ + AcpStdioTransport: class { + sendRequest = sendRequestMock + close = closeMock + constructor(opts: { command: string; args?: string[] }) { + transportConstructor(opts) + } + } +})) + +import { listOpencodeModelsForCwd, _resetOpencodeModelsCacheForTests } from './opencodeModels' + +describe('listOpencodeModelsForCwd', () => { + beforeEach(() => { + _resetOpencodeModelsCacheForTests() + sendRequestMock.mockReset() + closeMock.mockClear() + transportConstructor.mockClear() + }) + + afterEach(() => { + _resetOpencodeModelsCacheForTests() + }) + + it('returns success false when cwd is empty', async () => { + const result = await listOpencodeModelsForCwd('') + expect(result).toEqual({ success: false, error: 'cwd is required' }) + expect(sendRequestMock).not.toHaveBeenCalled() + }) + + it('spawns opencode acp, runs initialize and session/new, returns availableModels', async () => { + sendRequestMock + .mockResolvedValueOnce({ protocolVersion: 1 }) + .mockResolvedValueOnce({ + sessionId: 'sess-1', + models: { + availableModels: [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama/EXAONE 4.5 33B Q8' }, + { modelId: 'mlx/qwen3:32b', name: 'MLX/Qwen 3.6 32B Q8' } + ], + currentModelId: 'ollama/exaone:4.5-33b-q8' + } + }) + + const result = await listOpencodeModelsForCwd('/home/user/project') + + expect(transportConstructor).toHaveBeenCalledWith( + expect.objectContaining({ command: 'opencode', args: ['acp'] }) + ) + expect(sendRequestMock).toHaveBeenNthCalledWith( + 1, + 'initialize', + expect.objectContaining({ protocolVersion: 1 }), + expect.any(Object) + ) + expect(sendRequestMock).toHaveBeenNthCalledWith( + 2, + 'session/new', + expect.objectContaining({ cwd: '/home/user/project', mcpServers: [] }), + expect.any(Object) + ) + expect(result.success).toBe(true) + expect(result.availableModels).toEqual([ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama/EXAONE 4.5 33B Q8' }, + { modelId: 'mlx/qwen3:32b', name: 'MLX/Qwen 3.6 32B Q8' } + ]) + expect(result.currentModelId).toBe('ollama/exaone:4.5-33b-q8') + expect(closeMock).toHaveBeenCalled() + }) + + it('returns empty availableModels when session/new omits the models block', async () => { + sendRequestMock + .mockResolvedValueOnce({ protocolVersion: 1 }) + .mockResolvedValueOnce({ sessionId: 'sess-2' }) + + const result = await listOpencodeModelsForCwd('/tmp/proj') + + expect(result.success).toBe(true) + expect(result.availableModels).toEqual([]) + expect(result.currentModelId).toBeNull() + }) + + it('reads availableModels from top-level fields too (alternate response shape)', async () => { + sendRequestMock + .mockResolvedValueOnce({ protocolVersion: 1 }) + .mockResolvedValueOnce({ + sessionId: 'sess-3', + availableModels: [ + { modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' } + ], + currentModelId: 'opencode/big-pickle' + }) + + const result = await listOpencodeModelsForCwd('/p/another') + + expect(result.success).toBe(true) + expect(result.availableModels).toEqual([ + { modelId: 'opencode/big-pickle', name: 'OpenCode Zen/Big Pickle' } + ]) + }) + + it('caches the result for the same cwd within the TTL', async () => { + sendRequestMock + .mockResolvedValueOnce({ protocolVersion: 1 }) + .mockResolvedValueOnce({ + models: { availableModels: [{ modelId: 'a/b', name: 'A/B' }], currentModelId: 'a/b' } + }) + + await listOpencodeModelsForCwd('/cache/cwd') + await listOpencodeModelsForCwd('/cache/cwd') + + expect(transportConstructor).toHaveBeenCalledTimes(1) + expect(sendRequestMock).toHaveBeenCalledTimes(2) + }) + + it('coalesces concurrent probes for the same cwd into a single transport spawn', async () => { + let resolveSecond: (value: unknown) => void = () => undefined + sendRequestMock + .mockResolvedValueOnce({ protocolVersion: 1 }) + .mockImplementationOnce(() => new Promise((res) => { resolveSecond = res })) + + const inflight1 = listOpencodeModelsForCwd('/inflight/cwd') + const inflight2 = listOpencodeModelsForCwd('/inflight/cwd') + + // Allow microtasks to schedule the second sendRequest + await new Promise((resolve) => setImmediate(resolve)) + resolveSecond({ + models: { availableModels: [{ modelId: 'a/b' }], currentModelId: 'a/b' } + }) + + const [r1, r2] = await Promise.all([inflight1, inflight2]) + + expect(transportConstructor).toHaveBeenCalledTimes(1) + expect(r1).toEqual(r2) + expect(r1.success).toBe(true) + }) + + it('reports a failure when the spawn rejects', async () => { + sendRequestMock.mockRejectedValueOnce(new Error('Failed to spawn opencode: ENOENT')) + + const result = await listOpencodeModelsForCwd('/missing/binary') + + expect(result.success).toBe(false) + expect(result.error).toContain('Failed to spawn opencode') + expect(closeMock).toHaveBeenCalled() + }) +}) diff --git a/cli/src/modules/common/opencodeModels.ts b/cli/src/modules/common/opencodeModels.ts new file mode 100644 index 00000000..79902b4b --- /dev/null +++ b/cli/src/modules/common/opencodeModels.ts @@ -0,0 +1,174 @@ +import { asString, isObject } from '@hapi/protocol'; +import { AcpStdioTransport } from '@/agent/backends/acp/AcpStdioTransport'; +import packageJson from '../../../package.json'; +import { getErrorMessage } from './rpcResponses'; + +export interface OpencodeModelSummary { + modelId: string; + name?: string; +} + +export interface ListOpencodeModelsForCwdRequest { + cwd?: string; +} + +export interface ListOpencodeModelsForCwdResponse { + success: boolean; + availableModels?: OpencodeModelSummary[]; + currentModelId?: string | null; + error?: string; +} + +interface CacheEntry { + expiresAt: number; + response: ListOpencodeModelsForCwdResponse; +} + +const CACHE_TTL_MS = 60_000; +const PROBE_TIMEOUT_MS = 30_000; +const cache = new Map(); +const inflight = new Map>(); + +function normalizeAvailableModels(rawModels: unknown): OpencodeModelSummary[] { + if (!Array.isArray(rawModels)) return []; + const out: OpencodeModelSummary[] = []; + for (const entry of rawModels) { + if (!isObject(entry)) continue; + const modelId = asString(entry.modelId); + if (!modelId) continue; + const name = asString(entry.name) ?? undefined; + out.push(name ? { modelId, name } : { modelId }); + } + return out; +} + +function extractModelsFromResponse(response: unknown): { + availableModels: OpencodeModelSummary[]; + currentModelId: string | null; +} { + if (!isObject(response)) { + return { availableModels: [], currentModelId: null }; + } + + const directList = response.availableModels; + const directCurrent = response.currentModelId; + const nested = isObject(response.models) ? response.models : null; + const nestedList = nested?.availableModels; + const nestedCurrent = nested?.currentModelId; + + const rawModels = Array.isArray(directList) + ? directList + : Array.isArray(nestedList) + ? nestedList + : null; + const rawCurrent = typeof directCurrent === 'string' + ? directCurrent + : typeof nestedCurrent === 'string' + ? nestedCurrent + : null; + + return { + availableModels: normalizeAvailableModels(rawModels), + currentModelId: rawCurrent + }; +} + +async function runOpencodeProbe(cwd: string): Promise { + const transport = new AcpStdioTransport({ + command: 'opencode', + args: ['acp'] + }); + + try { + const initResponse = await transport.sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false + }, + clientInfo: { + name: 'hapi-opencode-models', + version: packageJson.version + } + }, { timeoutMs: PROBE_TIMEOUT_MS }); + + if (!isObject(initResponse) || typeof initResponse.protocolVersion !== 'number') { + return { success: false, error: 'Invalid initialize response from opencode acp' }; + } + + const newResponse = await transport.sendRequest('session/new', { + cwd, + mcpServers: [] + }, { timeoutMs: PROBE_TIMEOUT_MS }); + + const { availableModels, currentModelId } = extractModelsFromResponse(newResponse); + + return { + success: true, + availableModels, + currentModelId + }; + } finally { + await transport.close().catch(() => undefined); + } +} + +/** + * Discover available OpenCode models for a given working directory by spawning + * a short-lived `opencode acp` subprocess, sending `initialize` + `session/new`, + * and capturing the `availableModels` / `currentModelId` snapshot from the + * response. The subprocess is torn down immediately afterwards. + * + * Results are cached per cwd for 60 seconds; concurrent requests for the same + * cwd are coalesced via a single-flight promise so we never spawn more than + * one probe at a time per cwd. + */ +export async function listOpencodeModelsForCwd( + cwd: string +): Promise { + const trimmed = cwd?.trim(); + if (!trimmed) { + return { success: false, error: 'cwd is required' }; + } + + const cached = cache.get(trimmed); + if (cached && cached.expiresAt > Date.now()) { + return cached.response; + } + + const existing = inflight.get(trimmed); + if (existing) { + return existing; + } + + const promise = (async () => { + try { + const response = await runOpencodeProbe(trimmed); + if (response.success) { + cache.set(trimmed, { + expiresAt: Date.now() + CACHE_TTL_MS, + response + }); + } + return response; + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to discover OpenCode models') + } satisfies ListOpencodeModelsForCwdResponse; + } finally { + inflight.delete(trimmed); + } + })(); + + inflight.set(trimmed, promise); + return promise; +} + +/** + * Clear the in-process cache. Exposed for tests. + */ +export function _resetOpencodeModelsCacheForTests(): void { + cache.clear(); + inflight.clear(); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index fa8ba0b6..b72fd1b2 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' +import { registerOpencodeModelHandlers } from './handlers/opencodeModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -13,6 +14,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/cli/src/opencode/loop.ts b/cli/src/opencode/loop.ts index 79e99ed2..afb3701b 100644 --- a/cli/src/opencode/loop.ts +++ b/cli/src/opencode/loop.ts @@ -17,6 +17,7 @@ interface OpencodeLoopOptions { session: ApiSessionClient; api: ApiClient; permissionMode?: PermissionMode; + model?: string; resumeSessionId?: string; hookServer: OpencodeHookServer; hookUrl: string; diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts new file mode 100644 index 00000000..02fc9b2b --- /dev/null +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import type { OpencodeMode, PermissionMode } from './types'; + +const harness = vi.hoisted(() => ({ + setModelArgs: [] as Array<{ sessionId: string; modelId: string; flavor?: string }>, + promptCount: 0, + events: [] as string[], + setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise) +})); + +vi.mock('./utils/opencodeBackend', () => ({ + createOpencodeBackend: vi.fn(() => ({ + initialize: vi.fn(async () => {}), + newSession: vi.fn(async () => 'acp-session-1'), + loadSession: vi.fn(async () => 'acp-session-1'), + setModel: vi.fn(async (sessionId: string, modelId: string, opts?: { flavor?: string }) => { + harness.events.push(`setModel:${modelId}`); + harness.setModelArgs.push({ sessionId, modelId, flavor: opts?.flavor }); + if (harness.setModelImpl) { + await harness.setModelImpl(sessionId, modelId); + } + }), + prompt: vi.fn(async () => { + harness.events.push('prompt:start'); + harness.promptCount++; + await new Promise((resolve) => setImmediate(resolve)); + harness.events.push('prompt:end'); + }), + cancelPrompt: vi.fn(async () => {}), + respondToPermission: vi.fn(async () => {}), + onStderrError: vi.fn(), + onPermissionRequest: vi.fn(), + disconnect: vi.fn(async () => {}), + getSessionModelsMetadata: vi.fn(() => undefined) + })) +})); + +vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ + buildHapiMcpBridge: async () => ({ + server: { stop: () => {} }, + mcpServers: {} + }) +})); + +vi.mock('./utils/permissionHandler', () => ({ + OpencodePermissionHandler: class { + async cancelAll(): Promise {} + } +})); + +vi.mock('@/ui/ink/OpencodeDisplay', () => ({ + OpencodeDisplay: () => null +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn() + } +})); + +import { opencodeRemoteLauncher } from './opencodeRemoteLauncher'; + +function createMode(model?: string): OpencodeMode { + return { + permissionMode: 'default' as PermissionMode, + model + }; +} + +function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>) { + const queue = new MessageQueue2((mode) => JSON.stringify(mode)); + items.forEach(({ message, mode }, index) => { + if (index === 0 && items.length > 1) { + queue.pushIsolateAndClear(message, mode); + } else { + queue.push(message, mode); + } + }); + queue.close(); + + const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; + const rpcHandlers = new Map unknown>(); + + const client = { + rpcHandlerManager: { + registerHandler(method: string, handler: (params: unknown) => unknown) { + rpcHandlers.set(method, handler); + } + }, + sendAgentMessage(_message: unknown) {}, + sendUserMessage(_text: string) {}, + sendSessionEvent(event: { type: string; [key: string]: unknown }) { + sessionEvents.push(event); + } + }; + + const session = { + path: '/tmp/hapi-opencode-test', + logPath: '/tmp/hapi-opencode-test/test.log', + client, + queue, + sessionId: null as string | null, + thinking: false, + getPermissionMode() { + return 'default' as const; + }, + setModel(_model: string | null) {}, + onThinkingChange(thinking: boolean) { + session.thinking = thinking; + }, + onSessionFound(id: string) { + session.sessionId = id; + }, + sendAgentMessage(_message: unknown) {}, + sendSessionEvent(event: { type: string; [key: string]: unknown }) { + client.sendSessionEvent(event); + }, + sendUserMessage(_text: string) {} + }; + + return { session, sessionEvents, rpcHandlers }; +} + +describe('opencodeRemoteLauncher inline model switch', () => { + afterEach(() => { + harness.setModelArgs = []; + harness.promptCount = 0; + harness.events = []; + harness.setModelImpl = null; + }); + + 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') }, + { message: 'second', mode: createMode('mlx/qwen3:0.6b') } + ]); + + await opencodeRemoteLauncher(session as never); + + expect(harness.setModelArgs).toEqual([ + { sessionId: 'acp-session-1', modelId: 'mlx/qwen3:0.6b', flavor: 'opencode' } + ]); + expect(harness.promptCount).toBe(2); + }); + + it('does not call setModel when the model is unchanged across turns', async () => { + const { session } = createSessionStub([ + { message: 'first', mode: createMode('ollama/exaone:4.5-33b-q8') }, + { message: 'second', mode: createMode('ollama/exaone:4.5-33b-q8') } + ]); + + await opencodeRemoteLauncher(session as never); + + expect(harness.setModelArgs).toEqual([]); + expect(harness.promptCount).toBe(2); + }); + + it('latches inline switching off after a method-not-found response and notifies the user once', async () => { + harness.setModelImpl = async () => { + throw new Error('Method not found: session/set_model'); + }; + const { session, sessionEvents } = createSessionStub([ + { message: 'first', mode: createMode('ollama/a') }, + { message: 'second', mode: createMode('ollama/b') }, + { message: 'third', mode: createMode('ollama/c') } + ]); + + await opencodeRemoteLauncher(session as never); + + // Only one setModel attempt — latched off after the first method-not-found + expect(harness.setModelArgs).toEqual([ + { sessionId: 'acp-session-1', modelId: 'ollama/b', flavor: 'opencode' } + ]); + const unsupportedMessages = sessionEvents.filter( + (event) => + event.type === 'message' && + typeof event.message === 'string' && + event.message.includes('does not support inline model switching') + ); + expect(unsupportedMessages.length).toBe(1); + expect(harness.promptCount).toBe(3); + }); + + it('reports a transient setModel error and continues with the previous model', async () => { + let attempts = 0; + harness.setModelImpl = async () => { + attempts++; + throw new Error('Transient backend failure'); + }; + const { session, sessionEvents } = createSessionStub([ + { message: 'first', mode: createMode('ollama/a') }, + { message: 'second', mode: createMode('ollama/b') } + ]); + + await opencodeRemoteLauncher(session as never); + + expect(attempts).toBe(1); + const failureMessages = sessionEvents.filter( + (event) => + event.type === 'message' && + typeof event.message === 'string' && + event.message.includes('Failed to switch model') + ); + expect(failureMessages.length).toBe(1); + expect(failureMessages[0]?.message).toContain('ollama/b'); + expect(harness.promptCount).toBe(2); + }); + + it('registers a listOpencodeModels RPC handler that returns the backend cache', async () => { + // Override getSessionModelsMetadata for this run only. + const fixtureModels = [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama EXAONE' }, + { modelId: 'mlx/qwen3:0.6b', name: 'MLX Qwen3' } + ]; + const opencodeBackendModule = await import('./utils/opencodeBackend'); + const factory = (opencodeBackendModule as unknown as { createOpencodeBackend: ReturnType }).createOpencodeBackend; + factory.mockImplementationOnce(() => ({ + initialize: vi.fn(async () => {}), + newSession: vi.fn(async () => 'acp-session-1'), + loadSession: vi.fn(async () => 'acp-session-1'), + setModel: vi.fn(async () => {}), + prompt: vi.fn(async () => {}), + cancelPrompt: vi.fn(async () => {}), + respondToPermission: vi.fn(async () => {}), + onStderrError: vi.fn(), + onPermissionRequest: vi.fn(), + disconnect: vi.fn(async () => {}), + getSessionModelsMetadata: vi.fn((sessionId: string) => { + if (sessionId === 'acp-session-1') { + return { availableModels: fixtureModels, currentModelId: 'ollama/exaone:4.5-33b-q8' }; + } + return undefined; + }) + })); + + const { session, rpcHandlers } = createSessionStub([ + { message: 'first', mode: createMode('ollama/exaone:4.5-33b-q8') } + ]); + await opencodeRemoteLauncher(session as never); + + const handler = rpcHandlers.get('listOpencodeModels'); + expect(handler).toBeDefined(); + const result = await handler!(undefined) as Record; + expect(result).toEqual({ + success: true, + availableModels: fixtureModels, + currentModelId: 'ollama/exaone:4.5-33b-q8' + }); + }); + + it('listOpencodeModels handler returns empty cache when backend has no metadata', async () => { + const { session, rpcHandlers } = createSessionStub([ + { message: 'first', mode: createMode() } + ]); + await opencodeRemoteLauncher(session as never); + + const handler = rpcHandlers.get('listOpencodeModels'); + expect(handler).toBeDefined(); + const result = await handler!(undefined) as Record; + expect(result).toEqual({ + success: true, + availableModels: [], + currentModelId: null + }); + }); + + it('serializes setModel after the previous prompt resolves', async () => { + const { session } = createSessionStub([ + { message: 'first', mode: createMode('ollama/a') }, + { message: 'second', mode: createMode('ollama/b') } + ]); + + await opencodeRemoteLauncher(session as never); + + // Order must be: prompt(1) start/end → setModel → prompt(2) start/end + expect(harness.events).toEqual([ + 'prompt:start', + 'prompt:end', + 'setModel:ollama/b', + 'prompt:start', + 'prompt:end' + ]); + }); +}); diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 0be617ef..f28f6af0 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -19,6 +19,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private abortController = new AbortController(); private displayPermissionMode: PermissionMode | null = null; private instructionsSent = false; + private currentBackendModel: string | null = null; + private setModelSupported: boolean | undefined = undefined; constructor(session: OpencodeSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -85,6 +87,26 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } session.onSessionFound(acpSessionId); + // Seed currentBackendModel from the ACP session metadata so the first + // batch — whose model the hub mirrors from the just-discovered session — + // does not trigger a redundant setModel on the very first turn. + const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId); + this.currentBackendModel = initialMetadata?.currentModelId ?? null; + + // Expose the cached models metadata via per-session RPC so the hub can + // forward it to the web UI's model selector without round-tripping ACP. + session.client.rpcHandlerManager.registerHandler('listOpencodeModels', async () => { + const metadata = backend.getSessionModelsMetadata?.(acpSessionId); + if (!metadata) { + return { success: true, availableModels: [], currentModelId: null }; + } + return { + success: true, + availableModels: metadata.availableModels, + currentModelId: metadata.currentModelId + }; + }); + this.permissionHandler = new OpencodePermissionHandler( session.client, backend, @@ -111,6 +133,47 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { break; } + // Inline model change via ACP RPC (session/set_model — see ACP SDK + // schema `x-method: session/set_model`). Mirrors the Gemini pattern + // from PR #543: if the running OpenCode build does not implement the + // RPC, we learn that from the first method-not-found response and stop + // attempting it for the rest of this session. + // + // The very first batch seeds currentBackendModel — the OpenCode CLI was + // launched with that model via --model and there is nothing to switch yet. + if (batch.mode.model && this.currentBackendModel === null) { + this.currentBackendModel = batch.mode.model; + } else if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { + if (!backend.setModel || this.setModelSupported === false) { + batch.mode.model = this.currentBackendModel ?? undefined; + } else { + logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`); + try { + await backend.setModel(acpSessionId, batch.mode.model, { flavor: 'opencode' }); + this.currentBackendModel = batch.mode.model; + this.setModelSupported = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const methodNotFound = /method not found/i.test(message); + if (methodNotFound && this.setModelSupported === undefined) { + this.setModelSupported = false; + logger.warn('[opencode-remote] OpenCode build does not support session/set_model; inline switching disabled for this session'); + session.sendSessionEvent({ + type: 'message', + message: 'This OpenCode build does not support inline model switching. Restart the session to apply a different model.' + }); + } else { + logger.warn('[opencode-remote] Inline model switch failed', error); + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel ?? '(default)'}.` + }); + } + batch.mode.model = this.currentBackendModel ?? undefined; + } + } + } + this.applyDisplayMode(batch.mode.permissionMode); messageBuffer.addMessage(batch.message, 'user'); diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts new file mode 100644 index 00000000..35aba81d --- /dev/null +++ b/cli/src/opencode/runOpencode.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockOpencodeSession = vi.hoisted(() => ({ + setModel: vi.fn(), + setPermissionMode: vi.fn(), + pushKeepAlive: vi.fn(), + thinking: false, + stopKeepAlive: vi.fn() +})); + +const harness = vi.hoisted(() => ({ + bootstrapArgs: [] as Array>, + opencodeLoopArgs: [] as Array>, + opencodeLoopError: null as Error | null, + session: { + onUserMessage: vi.fn(), + rpcHandlerManager: { + registerHandler: vi.fn() + } + } +})); + +vi.mock('@/agent/sessionFactory', () => ({ + bootstrapSession: vi.fn(async (options: Record) => { + harness.bootstrapArgs.push(options); + return { + api: {}, + session: harness.session + }; + }) +})); + +vi.mock('./loop', () => ({ + opencodeLoop: vi.fn(async (options: Record) => { + harness.opencodeLoopArgs.push(options); + if (harness.opencodeLoopError) { + throw harness.opencodeLoopError; + } + const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; + if (onSessionReady) { + onSessionReady(mockOpencodeSession); + } + }) +})); + +vi.mock('@/claude/registerKillSessionHandler', () => ({ + registerKillSessionHandler: vi.fn() +})); + +const lifecycleMock = vi.hoisted(() => ({ + registerProcessHandlers: vi.fn(), + cleanupAndExit: vi.fn(async () => {}), + markCrash: vi.fn(), + setExitCode: vi.fn(), + setArchiveReason: vi.fn(), + setSessionEndReason: vi.fn() +})); + +vi.mock('@/agent/runnerLifecycle', () => ({ + createModeChangeHandler: vi.fn(() => vi.fn()), + createRunnerLifecycle: vi.fn(() => lifecycleMock), + setControlledByUser: vi.fn() +})); + +vi.mock('./utils/startOpencodeHookServer', () => ({ + startOpencodeHookServer: vi.fn(async () => ({ + port: 4242, + stop: vi.fn() + })) +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +vi.mock('@/utils/attachmentFormatter', () => ({ + formatMessageWithAttachments: vi.fn((text: string) => text) +})); + +import { runOpencode } from './runOpencode'; + +describe('runOpencode set-session-config handler', () => { + beforeEach(() => { + harness.bootstrapArgs.length = 0; + harness.opencodeLoopArgs.length = 0; + harness.opencodeLoopError = null; + mockOpencodeSession.setModel.mockReset(); + mockOpencodeSession.setPermissionMode.mockReset(); + mockOpencodeSession.pushKeepAlive.mockReset(); + harness.session.onUserMessage.mockReset(); + harness.session.rpcHandlerManager.registerHandler.mockReset(); + lifecycleMock.registerProcessHandlers.mockClear(); + lifecycleMock.cleanupAndExit.mockClear(); + lifecycleMock.markCrash.mockClear(); + lifecycleMock.setExitCode.mockClear(); + lifecycleMock.setArchiveReason.mockClear(); + lifecycleMock.setSessionEndReason.mockClear(); + }); + + function getConfigHandler(): (payload: unknown) => Promise { + const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls; + const configHandler = registerCalls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + expect(configHandler).toBeDefined(); + return configHandler![1] as (payload: unknown) => Promise; + } + + it('applies model change via set-session-config RPC', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + const result = await handler({ model: 'ollama/exaone:4.5-33b-q8' }) as Record; + const applied = result.applied as Record; + expect(applied.model).toBe('ollama/exaone:4.5-33b-q8'); + }); + + it('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => { + await runOpencode({}); + + // Reset to ignore pushKeepAlive fired from initial onSessionReady setup + mockOpencodeSession.pushKeepAlive.mockClear(); + + const handler = getConfigHandler(); + await handler({ model: 'ollama/exaone:4.5-33b-q8' }); + + expect(mockOpencodeSession.pushKeepAlive).toHaveBeenCalledTimes(1); + }); + + it('stores the chosen model on the session for keepalive runtime metadata', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + await handler({ model: 'mlx/qwen3:0.6b' }); + + expect(mockOpencodeSession.setModel).toHaveBeenLastCalledWith('mlx/qwen3:0.6b'); + }); + + it('accepts null model (Default) and forwards null to the session', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + const result = await handler({ model: null }) as Record; + const applied = result.applied as Record; + + expect(applied.model).toBeNull(); + expect(mockOpencodeSession.setModel).toHaveBeenLastCalledWith(null); + }); + + it('rejects non-string, non-null model values', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + await expect(handler({ model: 123 })).rejects.toThrow(); + await expect(handler({ model: '' })).rejects.toThrow(); + await expect(handler({ model: ' ' })).rejects.toThrow(); + }); + + it('only includes changed fields in applied response', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + const result = await handler({ permissionMode: 'default' }) as Record; + const applied = result.applied as Record; + expect(applied.permissionMode).toBe('default'); + expect(applied).not.toHaveProperty('model'); + }); + + it('still applies permissionMode-only payloads (no model field)', async () => { + await runOpencode({}); + + const handler = getConfigHandler(); + const result = await handler({ permissionMode: 'yolo' }) as Record; + const applied = result.applied as Record; + expect(applied.permissionMode).toBe('yolo'); + }); + + it('passes initial model from opts through to the loop', async () => { + await runOpencode({ model: 'ollama/exaone:4.5-33b-q8' }); + + expect(harness.opencodeLoopArgs[0]?.model).toBe('ollama/exaone:4.5-33b-q8'); + }); +}); diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index 0fc781a3..fd45b8b0 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -18,6 +18,7 @@ export async function runOpencode(opts: { startedBy?: 'runner' | 'terminal'; startingMode?: 'local' | 'remote'; permissionMode?: PermissionMode; + model?: string; resumeSessionId?: string; } = {}): Promise { const workingDirectory = getInvokedCwd(); @@ -34,11 +35,17 @@ export async function runOpencode(opts: { controlledByUser: false }; + // Persist only when the user (or runner) explicitly chose a model on launch. + // Mid-session selections are persisted by the hub via the set-session-config RPC, + // not by this initial bootstrap. + const initialModel = opts.model ?? null; + const { api, session } = await bootstrapSession({ flavor: 'opencode', startedBy, workingDirectory, - agentState: initialState + agentState: initialState, + model: initialModel ?? undefined }); const startingMode: 'local' | 'remote' = opts.startingMode @@ -47,11 +54,13 @@ export async function runOpencode(opts: { setControlledByUser(session, startingMode); const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode + permissionMode: mode.permissionMode, + model: mode.model ?? null })); const sessionWrapperRef: { current: OpencodeSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + let sessionModel: string | null = initialModel; const hookServer = await startOpencodeHookServer({ onEvent: (event) => { const currentSession = sessionWrapperRef.current; @@ -81,13 +90,20 @@ export async function runOpencode(opts: { return; } sessionInstance.setPermissionMode(currentPermissionMode); - logger.debug(`[opencode] Synced session permission mode for keepalive: ${currentPermissionMode}`); + sessionInstance.setModel(sessionModel); + + // Notify hub immediately so the UI reflects the change without + // waiting for the next 2s keepalive tick. + sessionInstance.pushKeepAlive(); + + logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}`); }; session.onUserMessage((message, localId) => { const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); const mode: OpencodeMode = { - permissionMode: currentPermissionMode + permissionMode: currentPermissionMode, + model: sessionModel ?? undefined }; messageQueue.push(formattedText, mode, localId); }); @@ -100,18 +116,35 @@ export async function runOpencode(opts: { return parsed.data as PermissionMode; }; + const resolveModel = (value: unknown): string | null => { + if (value === null) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model'); + } + return value.trim(); + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown }; + const config = payload as { permissionMode?: unknown; model?: unknown }; + const applied: Record = {}; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); + applied.permissionMode = currentPermissionMode; + } + + if (config.model !== undefined) { + sessionModel = resolveModel(config.model); + applied.model = sessionModel; } syncSessionMode(); - return { applied: { permissionMode: currentPermissionMode } }; + return { applied }; }); let crashed = false; @@ -125,6 +158,7 @@ export async function runOpencode(opts: { session, api, permissionMode: currentPermissionMode, + model: sessionModel ?? undefined, resumeSessionId: opts.resumeSessionId, hookServer, hookUrl, diff --git a/cli/src/opencode/session.ts b/cli/src/opencode/session.ts index 1b013854..8ffae6d3 100644 --- a/cli/src/opencode/session.ts +++ b/cli/src/opencode/session.ts @@ -73,6 +73,10 @@ export class OpencodeSession extends AgentSessionBase { this.permissionMode = mode; }; + setModel = (model: string | null): void => { + this.model = model; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/opencode/types.ts b/cli/src/opencode/types.ts index a91f2292..800292a6 100644 --- a/cli/src/opencode/types.ts +++ b/cli/src/opencode/types.ts @@ -4,6 +4,7 @@ export type PermissionMode = OpencodePermissionMode; export interface OpencodeMode { permissionMode: PermissionMode; + model?: string; } export type OpencodeHookEvent = { diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 612e2d1d..16122c7d 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -51,6 +51,15 @@ describe('buildCliArgs', () => { expect(args).not.toContain('--permission-mode') }) + it('passes --model through for opencode (mid-session model change support)', () => { + const args = buildCliArgs('opencode', { + directory: '/tmp', + model: 'ollama/exaone:4.5-33b-q8', + }) + expect(args).toContain('--model') + expect(args).toContain('ollama/exaone:4.5-33b-q8') + }) + it('validates all known permission modes', () => { for (const mode of ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 2e636110..c0f02a4d 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -925,7 +925,7 @@ export function buildCliArgs( } } args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); - if (options.model && agent !== 'opencode') { + if (options.model) { args.push('--model', options.model); } if (options.effort && agent === 'claude') { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7899261d..977b4ead 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -58,6 +58,18 @@ export type RpcListCodexModelsResponse = { error?: string } +export type RpcOpencodeModel = { + modelId: string + name?: string +} + +export type RpcListOpencodeModelsResponse = { + success: boolean + availableModels?: RpcOpencodeModel[] + currentModelId?: string | null + error?: string +} + export class RpcGateway { constructor( private readonly io: Server, @@ -262,6 +274,14 @@ export class RpcGateway { return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse } + async listOpencodeModelsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, 'listOpencodeModels', {}) as RpcListOpencodeModelsResponse + } + + async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise { + return await this.machineRpc(machineId, 'listOpencodeModelsForCwd', { cwd }) as RpcListOpencodeModelsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 4c561f04..c3c59bd3 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -22,6 +22,8 @@ import { type RpcDeleteUploadResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListOpencodeModelsResponse, + type RpcOpencodeModel, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse @@ -37,6 +39,8 @@ export type { RpcDeleteUploadResponse, RpcListDirectoryResponse, RpcListCodexModelsResponse, + RpcListOpencodeModelsResponse, + RpcOpencodeModel, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse @@ -611,4 +615,12 @@ export class SyncEngine { async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + + async listOpencodeModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listOpencodeModelsForSession(sessionId) + } + + async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise { + return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) + } } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index f616adaa..90348d5b 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -56,4 +56,68 @@ describe('machines routes', () => { ] }) }) + + it('returns 400 when /opencode-models is called without cwd', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listOpencodeModelsForCwd: async () => ({ success: true, availableModels: [] }) + } 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-models') + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + success: false, + error: 'cwd query parameter is required' + }) + }) + + it('forwards cwd to listOpencodeModelsForCwd and returns availableModels', async () => { + const machine = createMachine() + const calls: Array<{ machineId: string; cwd: string }> = [] + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listOpencodeModelsForCwd: async (machineId: string, cwd: string) => { + calls.push({ machineId, cwd }) + return { + success: true, + availableModels: [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama/EXAONE 4.5 33B Q8' } + ], + currentModelId: 'ollama/exaone:4.5-33b-q8' + } + } + } 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-models?cwd=' + encodeURIComponent('/home/user/proj') + ) + + expect(response.status).toBe(200) + expect(calls).toEqual([{ machineId: 'machine-1', cwd: '/home/user/proj' }]) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama/EXAONE 4.5 33B Q8' } + ], + currentModelId: 'ollama/exaone:4.5-33b-q8' + }) + }) }) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index cf4a4605..9f201fbe 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -146,5 +146,33 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/opencode-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const cwd = (c.req.query('cwd') ?? '').trim() + if (!cwd) { + return c.json({ success: false, error: 'cwd query parameter is required' }, 400) + } + + try { + const result = await engine.listOpencodeModelsForCwd(machineId, cwd) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list OpenCode models' + }, 500) + } + }) + return app } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index ce8e9864..fc29e293 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -63,11 +63,20 @@ function createApp(session: Session, opts?: { { id: 'gpt-5.5', displayName: 'GPT-5.5', isDefault: true } ] }) + const listOpencodeModelsForSession = async () => ({ + success: true, + availableModels: [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama (SER8)/EXAONE 4.5 33B Q8' }, + { modelId: 'mlx/qwen3:0.6b', name: 'MLX/Qwen3 0.6B' } + ], + currentModelId: 'ollama/exaone:4.5-33b-q8' + }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const engine = { resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), applySessionConfig, listCodexModelsForSession, + listOpencodeModelsForSession, resumeSession } as Partial @@ -245,6 +254,71 @@ describe('sessions routes', () => { expect(applySessionConfigCalls).toEqual([]) }) + it('applies model changes for OpenCode sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'opencode' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'ollama/exaone:4.5-33b-q8' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'ollama/exaone:4.5-33b-q8' }] + ]) + }) + + it('applies model changes for Gemini sessions (regression: opencode addition does not break Gemini)', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'gemini' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gemini-2.5-pro' }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'gemini-2.5-pro' }] + ]) + }) + + it('rejects model changes for Cursor sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'sonnet' }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) @@ -298,6 +372,33 @@ describe('sessions routes', () => { }) }) + it('returns OpenCode models for active OpenCode sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'opencode' } + }) + const { app } = createApp(session) + + const response = await app.request('/api/sessions/session-1/opencode-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { modelId: 'ollama/exaone:4.5-33b-q8', name: 'Ollama (SER8)/EXAONE 4.5 33B Q8' }, + { modelId: 'mlx/qwen3:0.6b', name: 'MLX/Qwen3 0.6B' } + ], + currentModelId: 'ollama/exaone:4.5-33b-q8' + }) + }) + + it('rejects opencode-models for non-OpenCode sessions', async () => { + const { app } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/opencode-models') + + expect(response.status).toBe(400) + }) + it('applies permission mode changes for inactive sessions', async () => { const session = createSession({ active: false, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index d3d28e25..96148e38 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,4 +1,4 @@ -import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol' +import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, supportsModelChange, toSessionSummary } from '@hapi/protocol' import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' @@ -342,8 +342,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude' && flavor !== 'gemini' && flavor !== 'codex') { - return c.json({ error: 'Model selection is only supported for Claude, Gemini, and Codex sessions' }, 400) + if (!supportsModelChange(flavor)) { + return c.json({ error: 'Model selection is not supported for this session' }, 400) } if (flavor === 'codex' && sessionResult.session.agentState?.controlledByUser === true) { return c.json({ error: 'Model selection can only be changed for remote Codex sessions' }, 409) @@ -562,5 +562,35 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/opencode-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'opencode') { + return c.json({ + success: false, + error: 'OpenCode models are only available for OpenCode sessions' + }, 400) + } + + try { + const result = await engine.listOpencodeModelsForSession(sessionResult.sessionId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list OpenCode models' + }, 500) + } + }) + return app } diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 60d27e9b..9ab14daf 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -32,8 +32,8 @@ describe('hasCapability', () => { expect(hasCapability('cursor', Capabilities.Effort)).toBe(false) }) - test('opencode has no capabilities', () => { - expect(hasCapability('opencode', Capabilities.ModelChange)).toBe(false) + test('opencode supports model-change but not effort', () => { + expect(hasCapability('opencode', Capabilities.ModelChange)).toBe(true) expect(hasCapability('opencode', Capabilities.Effort)).toBe(false) }) @@ -87,6 +87,7 @@ describe('convenience functions', () => { expect(supportsModelChange('claude')).toBe(true) expect(supportsModelChange('gemini')).toBe(true) expect(supportsModelChange('codex')).toBe(true) + expect(supportsModelChange('opencode')).toBe(true) expect(supportsModelChange('cursor')).toBe(false) expect(supportsModelChange(null)).toBe(false) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index ab283292..219f6831 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -14,7 +14,7 @@ const FLAVOR_CAPS: Record> = { gemini: new Set([Capabilities.ModelChange]), codex: new Set([Capabilities.ModelChange]), cursor: new Set([]), - opencode: new Set([]), + opencode: new Set([Capabilities.ModelChange]), } // --- Flavor display names --- diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b5903f2a..2b12e81f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -12,6 +12,7 @@ import type { MachinesResponse, MessagesResponse, CodexModelsResponse, + OpencodeModelsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -453,6 +454,18 @@ export class ApiClient { ) } + async getSessionOpencodeModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/opencode-models` + ) + } + + async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}` + ) + } + async getSlashCommands(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index c3001c96..7aa4b6f8 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -36,6 +36,32 @@ describe('getModelOptionsForFlavor', () => { { value: 'gpt-5.5', label: 'GPT-5.5' } ]) }) + + it('returns only the supplied custom options for opencode flavor (no claude fallback)', () => { + const options = getModelOptionsForFlavor('opencode', null, [ + { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama (SER8)/EXAONE 4.5 33B Q8' }, + { value: 'mlx/qwen3:0.6b', label: 'MLX/Qwen3 0.6B' } + ]) + expect(options).toEqual([ + { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama (SER8)/EXAONE 4.5 33B Q8' }, + { value: 'mlx/qwen3:0.6b', label: 'MLX/Qwen3 0.6B' } + ]) + }) + + it('returns an empty list for opencode flavor before models are discovered (no claude fallback)', () => { + const options = getModelOptionsForFlavor('opencode', null) + expect(options).toEqual([]) + }) + + it('includes the current opencode model when it is missing from explicit options', () => { + const options = getModelOptionsForFlavor('opencode', 'ollama/legacy', [ + { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } + ]) + expect(options).toEqual([ + { value: 'ollama/legacy', label: 'ollama/legacy' }, + { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -64,4 +90,19 @@ describe('getNextModelForFlavor', () => { ]) expect(next).toBe('gpt-5.5') }) + + it('keeps the current opencode model when the dynamic list has not loaded (undefined customOptions)', () => { + const next = getNextModelForFlavor('opencode', 'ollama/exaone:4.5-33b-q8') + expect(next).toBe('ollama/exaone:4.5-33b-q8') + }) + + it('keeps the current opencode model when the dynamic list is empty', () => { + const next = getNextModelForFlavor('opencode', 'ollama/exaone:4.5-33b-q8', []) + expect(next).toBe('ollama/exaone:4.5-33b-q8') + }) + + it('returns null for opencode without a current model and without dynamic options (no Claude fallback)', () => { + const next = getNextModelForFlavor('opencode', null, []) + expect(next).toBeNull() + }) }) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index b640cca9..bdbf8167 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -56,6 +56,12 @@ export function getModelOptionsForFlavor( if (flavor === 'gemini') { return getGeminiModelOptions(currentModel) } + // OpenCode discovers models dynamically via the listOpencodeModels RPC. Until + // those options arrive, render an empty list rather than the Claude fallback — + // the latter would surface unrelated Claude models in an OpenCode session. + if (flavor === 'opencode') { + return [] + } return getClaudeComposerModelOptions(currentModel) } @@ -75,5 +81,13 @@ export function getNextModelForFlavor( if (flavor === 'gemini') { return getNextGeminiModel(currentModel) } + // OpenCode discovers models dynamically via the listOpencodeModels RPC. Until + // those options arrive, pressing the Ctrl/Cmd+M shortcut must not fall through + // to the Claude preset cycler — that would post `sonnet`/`opus` into an + // OpenCode session and the next turn would attempt `session/set_model` with a + // Claude id. Keep the current model unchanged instead. + if (flavor === 'opencode') { + return normalizeCurrentModel(currentModel) + } return getNextClaudeComposerModel(currentModel) } diff --git a/web/src/components/NewSession/OpencodeModelSelector.tsx b/web/src/components/NewSession/OpencodeModelSelector.tsx new file mode 100644 index 00000000..0b1b1dbd --- /dev/null +++ b/web/src/components/NewSession/OpencodeModelSelector.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from '@/lib/use-translation' +import type { OpencodeModelSummary } from '@/types/api' + +export type OpencodeModelSelectorProps = { + cwd: string + machineId: string | null + isLoading: boolean + error: string | null + availableModels: OpencodeModelSummary[] + currentModelId: string | null + selectedModel: string | null + onModelChange: (modelId: string | null) => void + onRetry?: () => void +} + +export function OpencodeModelSelector(props: OpencodeModelSelectorProps) { + const { t } = useTranslation() + + if (!props.cwd || !props.machineId) { + return null + } + + return ( +
+ + + {props.isLoading ? ( +
+
+ + {t('newSession.opencodeModel.loading')} +
+ + ) : props.error ? ( +
+
+ {t('newSession.opencodeModel.loadFailed')}: {props.error} +
+ {props.onRetry ? ( + + ) : null} +
+ ) : props.availableModels.length === 0 ? ( +
+ {t('newSession.opencodeModel.empty')} +
+ ) : ( +
+ {props.availableModels.map((model) => { + const isSelected = props.selectedModel === model.modelId + const isDefault = props.currentModelId === model.modelId + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 721596b8..41e32c10 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useSessions } from '@/hooks/queries/useSessions' import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' @@ -16,7 +17,9 @@ import { AgentSelector } from './AgentSelector' import { DirectorySection } from './DirectorySection' import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' +import { OpencodeModelSelector } from './OpencodeModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' +import { shouldEnableOpencodeModelDiscovery } from './opencodeModelsGate' import { ReasoningEffortSelector } from './ReasoningEffortSelector' import { loadPreferredAgent, @@ -106,6 +109,7 @@ export function NewSession(props: { machineId, enabled: agent === 'codex' && Boolean(machineId) }) + const [opencodeSelectedModel, setOpencodeSelectedModel] = useState(null) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), [selectedMachine] @@ -148,6 +152,40 @@ export function NewSession(props: { [allPaths, pathExistence] ) + const deferredDirectoryExists = deferredDirectory + ? pathExistence[deferredDirectory] + : undefined + const opencodeModelsState = useOpencodeModelsForCwd({ + api: props.api, + machineId, + cwd: deferredDirectory, + // Gate on positive existence: typing partial paths must not spawn an + // expensive `opencode acp` probe for a non-existent cwd while the + // existence check is in flight. + enabled: shouldEnableOpencodeModelDiscovery({ + agent, + machineId, + cwd: deferredDirectory, + cwdExists: deferredDirectoryExists, + }) + }) + useEffect(() => { + // Auto-pick the OpenCode default model when discovery finishes, so the + // form has a sensible value if the user hits Enter without scrolling. + if (agent !== 'opencode') return + if (opencodeSelectedModel !== null) return + const fallback = opencodeModelsState.currentModelId + ?? opencodeModelsState.availableModels[0]?.modelId + ?? null + if (fallback) { + setOpencodeSelectedModel(fallback) + } + }, [agent, opencodeSelectedModel, opencodeModelsState.currentModelId, opencodeModelsState.availableModels]) + useEffect(() => { + // Reset selection when agent / machine / directory changes; new probe = new defaults. + setOpencodeSelectedModel(null) + }, [agent, machineId, deferredDirectory]) + const currentDirectoryExists = trimmedDirectory ? pathExistence[trimmedDirectory] : undefined const needsDirectoryCreationWarning = sessionType === 'simple' && trimmedDirectory !== '' && currentDirectoryExists === false const missingWorktreeDirectory = sessionType === 'worktree' && trimmedDirectory !== '' && currentDirectoryExists === false @@ -277,7 +315,9 @@ export function NewSession(props: { return } - const resolvedModel = model !== 'auto' && agent !== 'opencode' ? model : undefined + const resolvedModel = agent === 'opencode' + ? (opencodeSelectedModel ?? undefined) + : (model !== 'auto' ? model : undefined) const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default' ? modelReasoningEffort @@ -355,17 +395,31 @@ export function NewSession(props: { isDisabled={isFormDisabled} onAgentChange={setAgent} /> - + {agent === 'opencode' ? ( + + ) : ( + + )} { + const baseArgs = { + agent: 'opencode' as const, + machineId: 'machine-1', + cwd: '/home/user/project', + cwdExists: true, + } + + it('enables discovery when agent, machine, and existing cwd are present', () => { + expect(shouldEnableOpencodeModelDiscovery(baseArgs)).toBe(true) + }) + + it('disables discovery when cwd existence has not been confirmed yet', () => { + // pathExistence[cwd] is undefined while the existence probe is in flight + expect( + shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwdExists: undefined }) + ).toBe(false) + }) + + it('disables discovery when cwd does not exist on the machine', () => { + // typing partial paths must not spawn an opencode acp probe for non-existent dirs + expect( + shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwdExists: false }) + ).toBe(false) + }) + + it('disables discovery when agent is not opencode', () => { + expect( + shouldEnableOpencodeModelDiscovery({ ...baseArgs, agent: 'claude' }) + ).toBe(false) + }) + + it('disables discovery when machineId is missing', () => { + expect( + shouldEnableOpencodeModelDiscovery({ ...baseArgs, machineId: null }) + ).toBe(false) + }) + + it('disables discovery when cwd is empty', () => { + expect( + shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwd: '' }) + ).toBe(false) + }) +}) diff --git a/web/src/components/NewSession/opencodeModelsGate.ts b/web/src/components/NewSession/opencodeModelsGate.ts new file mode 100644 index 00000000..5b8c2184 --- /dev/null +++ b/web/src/components/NewSession/opencodeModelsGate.ts @@ -0,0 +1,23 @@ +import type { AgentType } from './types' + +/** + * Decide whether the new-session form should fire OpenCode model discovery + * for the current input state. + * + * Discovery is gated on the cwd having been *positively* confirmed to exist + * on the target machine. While `cwdExists` is undefined (existence probe in + * flight) or false (typing through a partial path), we suppress discovery so + * the CLI does not spawn an `opencode acp` subprocess for a non-existent + * directory only to time out 30 seconds later. + */ +export function shouldEnableOpencodeModelDiscovery(args: { + agent: AgentType + machineId: string | null + cwd: string + cwdExists: boolean | undefined +}): boolean { + if (args.agent !== 'opencode') return false + if (!args.machineId) return false + if (args.cwd.length === 0) return false + return args.cwdExists === true +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 12c840cb..a6fe892b 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -28,6 +28,7 @@ import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' import { useVoiceOptional } from '@/lib/voice-context' import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' import { isRemoteTerminalSupported } from '@/utils/terminalSupport' @@ -97,6 +98,21 @@ export function SessionChat(props: { } return options }, [agentFlavor, codexModelsState.models]) + const opencodeModelsState = useOpencodeModels({ + api: props.api, + sessionId: props.session.id, + enabled: agentFlavor === 'opencode' && props.session.active + }) + const opencodeModelOptions = useMemo(() => { + if (agentFlavor !== 'opencode') { + return undefined + } + + return opencodeModelsState.availableModels.map((opencodeModel) => ({ + value: opencodeModel.modelId, + label: opencodeModel.name ?? opencodeModel.modelId + })) + }, [agentFlavor, opencodeModelsState.availableModels]) const { abortSession, switchSession, @@ -451,7 +467,13 @@ export function SessionChat(props: { modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined} effort={props.session.effort} agentFlavor={agentFlavor} - availableModelOptions={agentFlavor === 'codex' ? codexModelOptions : undefined} + availableModelOptions={ + agentFlavor === 'codex' + ? codexModelOptions + : agentFlavor === 'opencode' + ? opencodeModelOptions + : undefined + } active={props.session.active} allowSendWhenInactive thinking={props.session.thinking} diff --git a/web/src/hooks/queries/useOpencodeModels.ts b/web/src/hooks/queries/useOpencodeModels.ts new file mode 100644 index 00000000..6a790223 --- /dev/null +++ b/web/src/hooks/queries/useOpencodeModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { OpencodeModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useOpencodeModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: OpencodeModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionOpencodeModels(sessionId) + : ['session-opencode-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('OpenCode models target unavailable') + } + return await api.getSessionOpencodeModels(sessionId) + }, + enabled, + staleTime: 30_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load OpenCode models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load OpenCode models' + : null, + } +} diff --git a/web/src/hooks/queries/useOpencodeModelsForCwd.ts b/web/src/hooks/queries/useOpencodeModelsForCwd.ts new file mode 100644 index 00000000..7043dbd1 --- /dev/null +++ b/web/src/hooks/queries/useOpencodeModelsForCwd.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { OpencodeModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useOpencodeModelsForCwd(args: { + api: ApiClient | null + machineId?: string | null + cwd?: string | null + enabled?: boolean +}): { + availableModels: OpencodeModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null + refetch: () => void +} { + const { api, machineId, cwd } = args + const trimmedCwd = typeof cwd === 'string' ? cwd.trim() : '' + const enabled = Boolean(args.enabled && api && machineId && trimmedCwd) + + const query = useQuery({ + queryKey: machineId && trimmedCwd + ? queryKeys.machineOpencodeModelsForCwd(machineId, trimmedCwd) + : ['machine-opencode-models', 'unknown', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!machineId || !trimmedCwd) { + throw new Error('OpenCode models target unavailable') + } + return await api.getMachineOpencodeModelsForCwd(machineId, trimmedCwd) + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load OpenCode models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load OpenCode models' + : null, + refetch: () => { + void query.refetch() + } + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 88403d7e..9b9fdbee 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -123,6 +123,11 @@ export default { 'newSession.effort': 'Effort', 'newSession.model.optional': 'optional', 'newSession.model.loadFailed': 'Failed to load Codex models', + 'newSession.opencodeModel.loading': 'Discovering OpenCode models…', + 'newSession.opencodeModel.loadFailed': 'Failed to load OpenCode models', + 'newSession.opencodeModel.retry': 'Retry', + 'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory', + 'newSession.opencodeModel.default': 'Default', 'newSession.reasoningEffort': 'Reasoning effort', 'newSession.yolo': 'YOLO mode', 'newSession.yolo.title': 'Bypass approvals and sandbox', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0d6fbc15..02759693 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -125,6 +125,11 @@ export default { 'newSession.effort': '思考强度', 'newSession.model.optional': '可选', 'newSession.model.loadFailed': '加载 Codex 模型失败', + 'newSession.opencodeModel.loading': '正在发现 OpenCode 模型…', + 'newSession.opencodeModel.loadFailed': '加载 OpenCode 模型失败', + 'newSession.opencodeModel.retry': '重试', + 'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型', + 'newSession.opencodeModel.default': '默认', 'newSession.reasoningEffort': '推理强度', 'newSession.yolo': 'YOLO 模式', 'newSession.yolo.title': '跳过审批和沙箱', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 2c723ff4..3222a820 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -16,5 +16,7 @@ export const queryKeys = { ] as const, slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const, + sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, + machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, skills: (sessionId: string) => ['skills', sessionId] as const, } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 71f34792..2133a250 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -225,6 +225,18 @@ export type CodexModelsResponse = { error?: string } +export type OpencodeModelSummary = { + modelId: string + name?: string +} + +export type OpencodeModelsResponse = { + success: boolean + availableModels?: OpencodeModelSummary[] + currentModelId?: string | null + error?: string +} + export type PushSubscriptionKeys = { p256dh: string auth: string