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 }>, setConfigOptionArgs: [] as Array<{ sessionId: string; configId: string; value: string }>, promptCount: 0, promptContents: [] as unknown[], refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>, bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null, events: [] as string[], 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 }> } })); 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); } }), setConfigOption: vi.fn(async (sessionId: string, configId: string, value: string) => { harness.events.push(`setConfigOption:${value}`); harness.setConfigOptionArgs.push({ sessionId, configId, value }); if (harness.setConfigOptionImpl) { await harness.setConfigOptionImpl(sessionId, configId, value); } if (harness.thoughtLevelOption) { harness.thoughtLevelOption = { ...harness.thoughtLevelOption, currentValue: value }; } }), prompt: vi.fn(async (_sessionId: string, content: unknown[]) => { harness.promptContents.push(content); 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(), setSessionInfoUpdateListener: vi.fn(), refreshSessionInfo: vi.fn(async (sessionId: string, cwd: string) => { harness.refreshSessionInfoCalls.push({ sessionId, cwd }); }), onPermissionRequest: vi.fn(), disconnect: vi.fn(async () => {}), getSessionModelsMetadata: vi.fn(() => undefined), getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined) })) })); vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async (_client: unknown, options?: { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } }) => { harness.bridgeOptions = options ?? null; return { 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 createPlanMode(model?: string): OpencodeMode { return { permissionMode: 'plan' as PermissionMode, model }; } function createModeWithEffort(model: string | undefined, modelReasoningEffort: string | null): OpencodeMode { return { permissionMode: 'default' as PermissionMode, model, modelReasoningEffort }; } function createResetMode(): OpencodeMode { return { permissionMode: 'default' as PermissionMode, model: null }; } 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 setModelReasoningEffort = vi.fn(); const pushKeepAlive = vi.fn(); const client = { rpcHandlerManager: { registerHandler(method: string, handler: (params: unknown) => unknown) { rpcHandlers.set(method, handler); } }, sendAgentMessage(_message: unknown) {}, sendClaudeSessionMessage(_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) {}, setModelReasoningEffort, pushKeepAlive, 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, setModelReasoningEffort, pushKeepAlive }; } describe('opencodeRemoteLauncher inline model switch', () => { afterEach(() => { harness.setModelArgs = []; harness.setConfigOptionArgs = []; harness.promptCount = 0; harness.promptContents = []; harness.refreshSessionInfoCalls = []; harness.bridgeOptions = null; harness.events = []; harness.setModelImpl = null; harness.setConfigOptionImpl = null; harness.thoughtLevelOption = null; }); it('injects the skill lookup instruction only on the first prompt', async () => { const { session } = createSessionStub([ { message: 'first', mode: createMode() }, { message: 'second', mode: createMode() } ]); await opencodeRemoteLauncher(session as never); expect(JSON.stringify(harness.promptContents[0])).toContain('$name'); expect(JSON.stringify(harness.promptContents[0])).toContain('skill_lookup'); expect(JSON.stringify(harness.promptContents[0])).toContain('hapi_display_image'); expect(JSON.stringify(harness.promptContents[0])).not.toContain('hapi_change_title'); expect(JSON.stringify(harness.promptContents[1])).not.toContain('skill_lookup'); }); 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.bridgeOptions).toEqual({ enableChangeTitle: false, skillLookup: { workingDirectory: '/tmp/hapi-opencode-test', flavor: 'opencode' } }); expect(harness.refreshSessionInfoCalls).toEqual([ { sessionId: 'acp-session-1', cwd: '/tmp/hapi-opencode-test' }, { sessionId: 'acp-session-1', cwd: '/tmp/hapi-opencode-test' } ]); 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('rejects unsupported reasoning effort values before calling setConfigOption', async () => { harness.thoughtLevelOption = { id: 'effort', currentValue: 'low', options: [ { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' } ] }; const { session, setModelReasoningEffort } = createSessionStub([ { message: 'first', mode: createModeWithEffort(undefined, 'high') } ]); await opencodeRemoteLauncher(session as never); expect(harness.setConfigOptionArgs).toEqual([]); expect(setModelReasoningEffort).toHaveBeenCalledWith('low'); expect(harness.promptCount).toBe(1); }); it('syncs hub effort state after coercing an unsupported request to a different supported value', async () => { harness.thoughtLevelOption = { id: 'effort', currentValue: 'high', options: [ { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' } ] }; const { session, setModelReasoningEffort, pushKeepAlive } = createSessionStub([ { message: 'first', mode: createModeWithEffort(undefined, 'max') } ]); await opencodeRemoteLauncher(session as never); expect(harness.setConfigOptionArgs).toEqual([ { sessionId: 'acp-session-1', configId: 'effort', value: 'low' } ]); expect(setModelReasoningEffort).toHaveBeenCalledWith('low'); expect(pushKeepAlive).toHaveBeenCalledTimes(1); expect(harness.promptCount).toBe(1); }); it('resets to the backend launch-time default model when the queued mode.model is null', async () => { // Seed the backend with a launch-time default model so the launcher // captures it as `defaultBackendModel`. Without that, `/model default` // resolves to null and the launcher has nothing to switch back to. const opencodeBackendModule = await import('./utils/opencodeBackend'); const factory = (opencodeBackendModule as unknown as { createOpencodeBackend: ReturnType }).createOpencodeBackend; const originalImpl = factory.getMockImplementation(); factory.mockImplementationOnce(() => { const backend = (originalImpl as () => Record)(); backend.getSessionModelsMetadata = vi.fn(() => ({ currentModelId: 'ollama/launch-default', availableModels: [] })); return backend; }); const { session } = createSessionStub([ { message: 'first', mode: createMode('ollama/custom') }, { message: 'second', mode: createResetMode() } ]); await opencodeRemoteLauncher(session as never); // Switch to custom on turn 1, then back to the launch-time default on turn 2. expect(harness.setModelArgs).toEqual([ { sessionId: 'acp-session-1', modelId: 'ollama/custom', flavor: 'opencode' }, { sessionId: 'acp-session-1', modelId: 'ollama/launch-default', flavor: 'opencode' } ]); expect(harness.promptCount).toBe(2); }); it('calls setConfigOption for OpenCode reasoning effort changes', async () => { harness.thoughtLevelOption = { id: 'effort', currentValue: 'low', options: [ { value: 'low', name: 'Low' }, { value: 'high', name: 'High' } ] }; const { session } = createSessionStub([ { message: 'first', mode: createModeWithEffort(undefined, 'high') } ]); await opencodeRemoteLauncher(session as never); expect(harness.setConfigOptionArgs).toEqual([ { sessionId: 'acp-session-1', configId: 'effort', value: 'high' } ]); expect(harness.promptCount).toBe(1); }); it('rolls back session reasoning effort when OpenCode rejects the switch', async () => { harness.thoughtLevelOption = { id: 'effort', currentValue: 'low', options: [ { value: 'low', name: 'Low' }, { value: 'high', name: 'High' } ] }; harness.setConfigOptionImpl = async () => { throw new Error('Transient backend failure'); }; const { session, sessionEvents, setModelReasoningEffort, pushKeepAlive } = createSessionStub([ { message: 'first', mode: createModeWithEffort(undefined, 'high') } ]); const rollbacks: Array = []; await opencodeRemoteLauncher(session as never, { onReasoningEffortRollback: (effort) => rollbacks.push(effort) }); expect(harness.setConfigOptionArgs).toEqual([ { sessionId: 'acp-session-1', configId: 'effort', value: 'high' } ]); expect(setModelReasoningEffort).toHaveBeenCalledWith('low'); expect(pushKeepAlive).toHaveBeenCalledTimes(1); expect(rollbacks).toEqual(['low']); expect(sessionEvents.some( (event) => event.type === 'message' && typeof event.message === 'string' && event.message.includes('Failed to switch reasoning effort') )).toBe(true); expect(harness.promptCount).toBe(1); }); it('injects plan-mode instructions into plan turns', async () => { const { session } = createSessionStub([ { message: 'design the fix', mode: createPlanMode() } ]); await opencodeRemoteLauncher(session as never); const content = harness.promptContents[0] as Array<{ type: string; text: string }>; expect(content[0]?.text).toContain('You are in plan mode'); expect(content[0]?.text).toContain('Do not execute tools'); expect(content[0]?.text).toContain('design the fix'); expect(content[0]?.text).not.toContain('hapi_change_title'); }); 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(), setSessionInfoUpdateListener: vi.fn(), refreshSessionInfo: vi.fn(async () => {}), 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 unavailable 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: false, error: 'OpenCode model metadata is not available' }); }); it('registers a listOpencodeReasoningEffortOptions RPC handler that returns ACP options', async () => { harness.thoughtLevelOption = { id: 'effort', currentValue: 'low', options: [ { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' } ] }; const { session, rpcHandlers } = createSessionStub([ { message: 'first', mode: createMode() } ]); await opencodeRemoteLauncher(session as never); const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions'); expect(handler).toBeDefined(); const result = await handler!(undefined) as Record; expect(result).toEqual({ success: true, options: [ { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' } ], currentValue: 'low' }); }); it('listOpencodeReasoningEffortOptions handler returns unavailable when backend has no thought level option', async () => { const { session, rpcHandlers } = createSessionStub([ { message: 'first', mode: createMode() } ]); await opencodeRemoteLauncher(session as never); const handler = rpcHandlers.get('listOpencodeReasoningEffortOptions'); expect(handler).toBeDefined(); const result = await handler!(undefined) as Record; expect(result).toEqual({ success: false, error: 'OpenCode reasoning effort options are not available' }); }); 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' ]); }); });