import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildCliArgs } from '@/runner/run'; import { parseRemoteAgentCommandOptions } from '@/commands/agentCommandOptions'; import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes'; import type { MessageQueue2 } from '@/utils/MessageQueue2'; import type { OpencodeMode } from './types'; const mockOpencodeSession = vi.hoisted(() => ({ setModel: vi.fn(), setPermissionMode: vi.fn(), setModelReasoningEffort: vi.fn(), pushKeepAlive: vi.fn(), thinking: false, stopKeepAlive: vi.fn(), onThinkingChange: vi.fn(), // Mirrors AgentSessionBase's own `mode` field ('local' | 'remote', // flipped synchronously by onModeChange before either launcher // starts/finishes) — settable per test to simulate a session that // started in remote mode and is still initializing (ACP backend not // ready yet, so onCompactAvailabilityChange(true) hasn't fired), as // opposed to a genuinely local-mode session. This becomes // sessionWrapperRef.current in runOpencode.ts via the mocked // opencodeLoop's onSessionReady callback below. mode: 'local' as 'local' | 'remote' })); const harness = vi.hoisted(() => ({ bootstrapArgs: [] as Array>, bootstrapExistingArgs: [] as Array>, opencodeLoopArgs: [] as Array>, opencodeLoopError: null as Error | null, triggerClear: false, triggerCleanupFailure: false, clearOpenCodeSession: vi.fn(async () => 'fresh-session'), reserveOpenCodeClearSession: vi.fn(async () => 'fresh-session'), confirmOpenCodeClearCleanup: vi.fn(async () => 'fresh-session'), abortOpenCodeClearSession: vi.fn(async () => 'source-session'), listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array), session: { sessionId: 'source-session', onUserMessage: vi.fn(), onCancelQueuedMessage: vi.fn(), sendAgentMessage: vi.fn(), sendSessionEvent: vi.fn(), emitMessagesConsumed: vi.fn(), // Needed for createModeChangeHandler(session) (real, unmocked) to // run without throwing when a test invokes the real onModeChange // wrapper passed to opencodeLoop. updateAgentState: vi.fn(), rpcHandlerManager: { registerHandler: vi.fn() } } })); vi.mock('@/agent/sessionFactory', () => ({ bootstrapSession: vi.fn(async (options: Record) => { harness.bootstrapArgs.push(options); return { api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession }, session: harness.session }; }), bootstrapExistingSession: vi.fn(async (options: Record) => { harness.bootstrapExistingArgs.push(options); return { api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession }, 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); } if (harness.triggerClear) { const onClearRequested = options.onClearRequested as (() => void) | undefined; await onClearRequested?.(); const onClearCleanupComplete = options.onClearCleanupComplete as (() => Promise) | undefined; await onClearCleanupComplete?.(); } if (harness.triggerCleanupFailure) { await (options.onClearRequested as (() => Promise))(); await (options.onClearCleanupFailed as (() => Promise))(); throw new Error('disconnect failed'); } }) })); vi.mock('@/claude/registerKillSessionHandler', () => ({ registerKillSessionHandler: vi.fn() })); const lifecycleMock = vi.hoisted(() => ({ registerProcessHandlers: vi.fn(), cleanup: vi.fn(async () => {}), cleanupConfirmed: vi.fn(async () => {}), cleanupAndExit: vi.fn(async () => {}), markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), setSessionEndReason: vi.fn(), hasExplicitSessionEndReason: vi.fn(() => false) })); 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) })); vi.mock('@/modules/common/slashCommands', () => ({ listSlashCommands: (agent: string, projectDir?: string) => harness.listSlashCommands(agent, projectDir) })); import { runOpencode } from './runOpencode'; describe('runOpencode set-session-config handler', () => { beforeEach(() => { harness.bootstrapArgs.length = 0; harness.bootstrapExistingArgs.length = 0; harness.opencodeLoopArgs.length = 0; harness.opencodeLoopError = null; harness.triggerClear = false; harness.triggerCleanupFailure = false; harness.clearOpenCodeSession.mockReset(); harness.clearOpenCodeSession.mockResolvedValue('fresh-session'); harness.reserveOpenCodeClearSession.mockReset(); harness.reserveOpenCodeClearSession.mockResolvedValue('fresh-session'); harness.abortOpenCodeClearSession.mockReset(); harness.abortOpenCodeClearSession.mockResolvedValue('source-session'); mockOpencodeSession.setModel.mockReset(); mockOpencodeSession.setPermissionMode.mockReset(); mockOpencodeSession.setModelReasoningEffort.mockReset(); mockOpencodeSession.pushKeepAlive.mockReset(); mockOpencodeSession.onThinkingChange.mockReset(); mockOpencodeSession.mode = 'local'; harness.session.onUserMessage.mockReset(); harness.session.onCancelQueuedMessage.mockReset(); harness.session.sendAgentMessage.mockReset(); harness.session.sendSessionEvent.mockReset(); harness.session.emitMessagesConsumed.mockReset(); harness.session.updateAgentState.mockReset(); harness.session.rpcHandlerManager.registerHandler.mockReset(); harness.listSlashCommands.mockReset(); harness.listSlashCommands.mockResolvedValue([]); lifecycleMock.registerProcessHandlers.mockClear(); lifecycleMock.cleanup.mockClear(); lifecycleMock.cleanupConfirmed.mockReset(); lifecycleMock.cleanupConfirmed.mockResolvedValue(undefined); 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('routes reservedSessionId from buildCliArgs into bootstrapSession (adopt, not reopen)', async () => { const runnerArgs = buildCliArgs('opencode', { directory: '/tmp/project', reservedSessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' }); const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES); expect(parsed.reservedSessionId).toBe('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'); expect(parsed.existingSessionId).toBeUndefined(); await runOpencode({ ...parsed, workingDirectory: '/tmp/project' }); expect(harness.bootstrapExistingArgs).toEqual([]); expect(harness.bootstrapArgs).toEqual([expect.objectContaining({ flavor: 'opencode', startedBy: 'runner', workingDirectory: '/tmp/project', reservedSessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' })]); }); it('routes --existing-session-id reopen into bootstrapExistingSession', async () => { const runnerArgs = buildCliArgs('opencode', { directory: '/tmp/project', existingSessionId: 'live-hub-row' }); const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES); await runOpencode({ ...parsed, workingDirectory: '/tmp/project' }); expect(harness.bootstrapArgs).toEqual([]); expect(harness.bootstrapExistingArgs).toEqual([{ sessionId: 'live-hub-row', flavor: 'opencode', startedBy: 'runner', workingDirectory: '/tmp/project' }]); }); it('rejects plan mode for local OpenCode startup', async () => { await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow( 'OpenCode plan mode is only supported in remote mode' ); expect(harness.opencodeLoopArgs).toEqual([]); }); it('allows plan mode for remote OpenCode startup', async () => { await runOpencode({ permissionMode: 'plan', startingMode: 'remote' }); expect(harness.opencodeLoopArgs[0]?.permissionMode).toBe('plan'); expect(harness.opencodeLoopArgs[0]?.startingMode).toBe('remote'); }); 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('accepts plan mode via set-session-config RPC', async () => { await runOpencode({}); const handler = getConfigHandler(); const result = await handler({ permissionMode: 'plan' }) as Record; const applied = result.applied as Record; expect(applied.permissionMode).toBe('plan'); expect(mockOpencodeSession.setPermissionMode).toHaveBeenLastCalledWith('plan'); }); it('accepts model reasoning effort via set-session-config RPC', async () => { await runOpencode({}); const handler = getConfigHandler(); const result = await handler({ modelReasoningEffort: 'high' }) as Record; const applied = result.applied as Record; expect(applied.modelReasoningEffort).toBe('high'); expect(mockOpencodeSession.setModelReasoningEffort).toHaveBeenLastCalledWith('high'); }); 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'); }); it('opts in to clearQueuedThinkingGrace when acking a handled slash command', async () => { await runOpencode({}); const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; expect(userMessageHandler).toBeDefined(); userMessageHandler!({ content: { text: '/status' } }, 'local-status'); // Drain microtasks so the chain runs and acks the slash command. await new Promise((resolve) => setTimeout(resolve, 0)); expect(harness.session.emitMessagesConsumed).toHaveBeenCalledWith( ['local-status'], { clearQueuedThinkingGrace: true } ); // The slash reply should still have gone out as a separate message. expect(harness.session.sendAgentMessage).toHaveBeenCalled(); }); it('queues a /compact request (isolated, with operation:"compact") once compact becomes available', async () => { await runOpencode({}); const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as ((available: boolean) => void) | undefined; expect(onCompactAvailabilityChange).toBeDefined(); onCompactAvailabilityChange!(true); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; expect(userMessageHandler).toBeDefined(); userMessageHandler!({ content: { text: '/compact' } }, 'local-compact'); // Drain microtasks across the async chain: listSlashCommands -> slash // resolve -> messageQueue.pushIsolated(...). for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } // No manual emitMessagesConsumed call here (unlike the synchronous // 'handled' branch) — the ack now happens automatically at dequeue // time via MessageQueue2's onBatchConsumed (wired in // AgentSessionBase's constructor onto session.queue, same as any // regular prompt), not synchronously when queuing. A manual call // right here used to exist and fire immediately regardless of FIFO // position — that's what let the hub mark a still-queued /compact // "invoked" before it was actually dequeued, breaking cancellation // of it while queued (see the comment on this branch in // runOpencode.ts and opencodeRemoteLauncher.test.ts's "cancelling a // /compact operation while it is still queued behind another // prompt" test for the fix this enables). expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled(); // The actual REST call, "Compaction started/completed" status events, // and Reasoning-block summary now all happen inside // opencodeRemoteLauncher.ts's dequeue loop once this item reaches the // front of the queue (covered by opencodeRemoteLauncher.test.ts) — // runOpencode.ts's job for a supported /compact is only to queue it // in its correct FIFO position, never to run it directly. expect(messageQueue.queue).toEqual([ { message: '', mode: expect.objectContaining({ operation: 'compact' }), modeHash: expect.any(String), localId: 'local-compact', isolate: true } ]); expect(harness.session.sendSessionEvent).not.toHaveBeenCalled(); expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); }); it('queues runner-backed /clear as its own FIFO operation without acknowledging it early', async () => { await runOpencode({ startedBy: 'runner' }); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; userMessageHandler!({ content: { text: '/clear' } }, 'local-clear'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(messageQueue.queue).toEqual([ { message: '', mode: expect.objectContaining({ operation: 'clear' }), modeHash: expect.any(String), localId: 'local-clear', isolate: true } ]); expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled(); }); it('leaves a prompt uninvoked when it arrives during the clear latch so scheduled rows can transfer', async () => { let resolveCommands: ((commands: Array) => void) | undefined; harness.listSlashCommands.mockImplementationOnce(() => new Promise((resolve) => { resolveCommands = resolve; })); await runOpencode({ startedBy: 'runner' }); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ mode: { operation?: string }; localId?: string }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void); userMessageHandler({ content: { text: '/clear' } }, 'first-clear'); userMessageHandler({ content: { text: 'must not reach the source session' } }, 'follow-up'); userMessageHandler({ content: { text: 'redelivered scheduled prompt' } }, 'follow-up'); await Promise.resolve(); resolveCommands?.([]); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(messageQueue.queue).toEqual([expect.objectContaining({ mode: expect.objectContaining({ operation: 'clear' }), localId: 'first-clear' })]); expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith( ['follow-up'], expect.anything() ); expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); }); it('delegates native v2 cancellation before generic queue removal and clears the delegate on teardown', async () => { await runOpencode({ startedBy: 'runner' }); const bind = harness.opencodeLoopArgs[0].onSteerCancelHandlerChange as (handler: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null) => void; const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as (id: string) => boolean | Promise; const native = vi.fn((id: string) => id === 'native' ? Promise.resolve('in-flight' as const) : undefined); bind(native); await expect(cancel('native')).resolves.toBe('in-flight'); expect(native).toHaveBeenCalledWith('native'); bind(null); expect(cancel('missing')).toBe(false); }); it('releases the clear transition latch when the queued /clear is cancelled', async () => { await runOpencode({ startedBy: 'runner' }); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ message: string; mode: { operation?: string }; localId?: string }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void); const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean); userMessageHandler({ content: { text: '/clear' } }, 'queued-clear'); userMessageHandler({ content: { text: 'rejected while clear is queued' } }, 'rejected-before-cancel'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(cancelHandler('queued-clear')).toBe(true); expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith( ['rejected-before-cancel'], expect.anything() ); userMessageHandler({ content: { text: 'continue in the source session' } }, 'after-cancel'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(messageQueue.queue).toEqual([ expect.objectContaining({ message: 'rejected while clear is queued', localId: 'rejected-before-cancel' }), expect.objectContaining({ message: 'continue in the source session', localId: 'after-cancel' }) ]); expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); }); it('removes an individually cancelled prompt held behind queued clear', async () => { await runOpencode({ startedBy: 'runner' }); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ localId?: string }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string } }, localId?: string) => void); const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean); userMessageHandler({ content: { text: '/clear' } }, 'queued-clear'); userMessageHandler({ content: { text: 'keep me' } }, 'held-keep'); userMessageHandler({ content: { text: 'cancel me' } }, 'held-cancel'); for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); expect(cancelHandler('held-cancel')).toBe(true); expect(cancelHandler('queued-clear')).toBe(true); expect(messageQueue.queue.map((item) => item.localId)).toEqual(['held-keep']); }); it('archives the source before asking the hub to spawn the fresh OpenCode process', async () => { harness.triggerClear = true; const order: string[] = []; lifecycleMock.cleanupConfirmed.mockImplementationOnce(async () => { order.push('cleanup'); }); harness.reserveOpenCodeClearSession.mockImplementationOnce(async () => { order.push('reserve'); return 'fresh-session'; }); harness.clearOpenCodeSession.mockImplementationOnce(async () => { order.push('spawn'); return 'fresh-session'; }); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { await runOpencode({ startedBy: 'runner' }); } finally { exit.mockRestore(); } expect(lifecycleMock.setArchiveReason).toHaveBeenCalledWith('Cleared by /clear'); expect(lifecycleMock.setSessionEndReason).toHaveBeenCalledWith('cleared'); expect(harness.clearOpenCodeSession).toHaveBeenCalledWith('source-session'); expect(harness.confirmOpenCodeClearCleanup).toHaveBeenCalledWith('source-session', 'fresh-session'); expect(order).toEqual(['reserve', 'cleanup', 'spawn']); }); it('keeps archive-confirmation ownership beyond the old finite budget', async () => { vi.useFakeTimers(); harness.triggerClear = true; const timeout = Object.assign(new Error('archive acknowledgement timed out'), { code: 'ETIMEDOUT' }); for (let i = 0; i < 13; i++) { lifecycleMock.cleanupConfirmed.mockRejectedValueOnce(timeout); } lifecycleMock.cleanupConfirmed.mockResolvedValueOnce(undefined); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { const run = runOpencode({ startedBy: 'runner' }); await vi.runAllTimersAsync(); await run; expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(14); expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(1); } finally { exit.mockRestore(); vi.useRealTimers(); } }); it('retries a lost durable-reservation response before native teardown', async () => { vi.useFakeTimers(); harness.triggerClear = true; const transient = Object.assign(new Error('connection reset after commit'), { code: 'ECONNRESET' }); harness.reserveOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('fresh-session'); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { const run = runOpencode({ startedBy: 'runner' }); await vi.runAllTimersAsync(); await run; expect(harness.reserveOpenCodeClearSession).toHaveBeenCalledTimes(2); expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(1); } finally { exit.mockRestore(); vi.useRealTimers(); } }); it('surfaces a fresh-session handoff failure instead of exiting cleanly after archival', async () => { harness.triggerClear = true; harness.clearOpenCodeSession.mockRejectedValueOnce(new Error('replacement link failed')); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { await expect(runOpencode({ startedBy: 'runner' })).rejects.toThrow('replacement link failed'); } finally { exit.mockRestore(); } expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); }); it('retries a transient abort notification before releasing cleanup-failure ownership', async () => { vi.useFakeTimers(); harness.triggerCleanupFailure = true; const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); harness.abortOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('source-session'); try { const run = runOpencode({ startedBy: 'runner' }); await vi.runAllTimersAsync(); await run; expect(harness.abortOpenCodeClearSession).toHaveBeenCalledTimes(2); expect(harness.abortOpenCodeClearSession).toHaveBeenLastCalledWith('source-session', 'fresh-session'); expect(harness.clearOpenCodeSession).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it('keeps retry ownership beyond the old finite budget until the archived-source handoff succeeds', async () => { vi.useFakeTimers(); harness.triggerClear = true; const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); harness.clearOpenCodeSession .mockRejectedValueOnce(transient) .mockRejectedValueOnce(transient) .mockRejectedValueOnce(transient) .mockRejectedValueOnce(transient) .mockRejectedValueOnce(transient) .mockRejectedValueOnce(transient) .mockResolvedValueOnce('fresh-session'); const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); try { const run = runOpencode({ startedBy: 'runner' }); await vi.runAllTimersAsync(); await run; expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(7); expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(1, 'source-session'); expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(7, 'source-session'); expect(exit).toHaveBeenCalledWith(0); } finally { exit.mockRestore(); vi.useRealTimers(); } }); it('keeps terminal-backed /clear explicit rather than archiving a session with no runner machine', async () => { await runOpencode({ startedBy: 'terminal' }); const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; userMessageHandler!({ content: { text: '/clear' } }, 'local-terminal-clear'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(harness.session.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ message: '/clear is available only for runner-backed OpenCode sessions.' })); }); it('queues /compact like a prompt while a remote-mode session is still initializing (ACP backend not ready yet), instead of rejecting it as not-yet-supported', async () => { // Reproduces a hostile-review finding: compactSupported alone // conflates "genuinely local mode" with "remote mode, but ACP // initialize + session load/new hasn't finished yet" — a regular // prompt sent in that exact same startup window queues normally and // just waits, but /compact used to get an immediate not-yet-supported // reply instead, even though the session is (or is about to be) in // remote mode. sessionWrapperRef.current?.mode (mocked here via // mockOpencodeSession.mode, delivered through onSessionReady) is what // now distinguishes the two — deliberately never call // onCompactAvailabilityChange(true) here, since the whole point is // that this must queue even while it's still false. mockOpencodeSession.mode = 'remote'; await runOpencode({}); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; expect(userMessageHandler).toBeDefined(); userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-pending'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } // Must not get the not-yet-supported reply. expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); // Must be queued exactly like the compactSupported===true case above. expect(messageQueue.queue).toEqual([ { message: '', mode: expect.objectContaining({ operation: 'compact' }), modeHash: expect.any(String), localId: 'local-compact-pending', isolate: true } ]); }); it('falls back to a not-yet-supported message for /compact when compact is not available (e.g. local mode)', async () => { await runOpencode({}); // Deliberately do not call onCompactAvailabilityChange(true) — this // is the state a local-mode session stays in (loop.ts resets it to // false on every local entry and opencodeLocalLauncher never sets it // true). const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-none'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } const messages = harness.session.sendAgentMessage.mock.calls.map((call) => (call[0] as { message: string }).message); expect(messages).toEqual(['/compact is not yet supported in HAPI OpenCode sessions.']); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: unknown[] }; expect(messageQueue.queue).toEqual([]); }); it('stops queuing /compact once availability is reset to false (e.g. a remote->local handoff mid-session)', async () => { await runOpencode({}); // Faithful to real production timing: session.mode stays 'remote' // throughout the whole teardown window (onLeavingRemote firing // synchronously as the very first action of requestExit(), long // before runMainLoop() actually returns and mode flips back to // 'local') — see AgentSessionBase.onModeChange and // OpencodeRemoteLauncher.onLeavingRemote's doc comments. A hostile // review found that omitting this from the mock let a real // regression slip through: the mode-based /compact queuing fix // for the *startup* window (mode:'remote' but not yet ready) also // accidentally re-opened queuing during *this* teardown window, // since both look identical if you only check `mode !== 'remote'`. mockOpencodeSession.mode = 'remote'; const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as ((available: boolean) => void) | undefined; onCompactAvailabilityChange!(true); onCompactAvailabilityChange!(false); const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-reset'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } const messages = harness.session.sendAgentMessage.mock.calls.map((call) => (call[0] as { message: string }).message); expect(messages).toEqual(['/compact is not yet supported in HAPI OpenCode sessions.']); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: unknown[] }; expect(messageQueue.queue).toEqual([]); }); it('resumes queuing /compact once a torn-down session re-enters remote mode (compactTeardownInProgress resets on the next remote entry)', async () => { // Locks in the other half of compactTeardownInProgress's contract: // it must not get stuck true forever after one teardown, or every // later remote re-entry's startup window (the case the "queues // /compact like a prompt while..." test above covers) would // incorrectly reject /compact too. // // Round 2 of the review-cycle that produced this test found the // first version didn't actually discriminate the fix: it left // `mockOpencodeSession.mode` at 'remote' throughout, so the gate's // `mode !== 'remote'` clause was permanently false and the test // would have passed identically even if compactTeardownInProgress // never reset (or didn't exist at all). Faithfully modeling the // real local interlude between the two remote attempts — mode // actually flips to 'local' once the first remote launcher's // runMainLoop() fully unwinds, per AgentSessionBase.onModeChange — // is what makes this test sensitive to the reset specifically. mockOpencodeSession.mode = 'remote'; await runOpencode({}); const onCompactAvailabilityChange = harness.opencodeLoopArgs[0]?.onCompactAvailabilityChange as ((available: boolean) => void) | undefined; const onModeChange = harness.opencodeLoopArgs[0]?.onModeChange as ((mode: 'local' | 'remote') => void) | undefined; expect(onModeChange).toBeDefined(); const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> }; const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; // First remote attempt becomes ready, then tears down. onCompactAvailabilityChange!(true); onCompactAvailabilityChange!(false); // Local interlude — mode genuinely flips to 'local' here in // production. Sanity-check /compact is still correctly rejected // during it (proves the interlude is real, not cosmetic). mockOpencodeSession.mode = 'local'; userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-interlude'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } expect(harness.session.sendAgentMessage).toHaveBeenCalledTimes(1); expect(messageQueue.queue).toEqual([]); // The next remote attempt begins: mode flips back to 'remote' and // onModeChange fires on that exact transition — this is what // compactTeardownInProgress's reset actually depends on. mockOpencodeSession.mode = 'remote'; onModeChange!('remote'); userMessageHandler!({ content: { text: '/compact' } }, 'local-compact-reentry'); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); } // Still only the one not-yet-supported reply from the interlude // above — the re-entry /compact must queue, not get a second reply. expect(harness.session.sendAgentMessage).toHaveBeenCalledTimes(1); expect(messageQueue.queue).toEqual([ { message: '', mode: expect.objectContaining({ operation: 'compact' }), modeHash: expect.any(String), localId: 'local-compact-reentry', isolate: true } ]); }); it('cancels a slash command that is cancelled before listSlashCommands resolves', async () => { let releaseListSlashCommands: () => void = () => {}; const slashCommandsPromise = new Promise((resolve) => { releaseListSlashCommands = () => resolve([]); }); harness.listSlashCommands.mockReset(); harness.listSlashCommands.mockReturnValue(slashCommandsPromise); await runOpencode({}); const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) | undefined; const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean) | undefined; expect(userMessageHandler).toBeDefined(); expect(cancelHandler).toBeDefined(); userMessageHandler!({ content: { text: '/status' } }, 'local-1'); // Cancel arrives while listSlashCommands is still pending — the queue // is empty, so without the preparing-localIds bookkeeping the cancel // would return false and the slash reply would still fire when the // chain resumes. expect(cancelHandler!('local-1')).toBe(true); releaseListSlashCommands(); // Drain microtasks so the chain runs the cancellation short-circuit. await new Promise((resolve) => setTimeout(resolve, 0)); expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled(); }); it('bounds the unmatched-cancel tracking Set so it cannot grow unboundedly over a long session', async () => { await runOpencode({}); const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean) | undefined; const isLocalIdCancelled = harness.opencodeLoopArgs[0]?.isLocalIdCancelled as ((localId: string) => boolean) | undefined; expect(cancelHandler).toBeDefined(); expect(isLocalIdCancelled).toBeDefined(); // None of these localIds are in the queue or in the pre-enqueue // preparing window, so every call falls into the fallback branch // that records it as a possible dequeued-compact cancel. Simulate // far more of these than could ever realistically be in flight at // once (see the comment on `cancelledDequeuedLocalIds` in // runOpencode.ts for why this branch is only reachable during a // brief per-message ack race) to prove the tracking Set evicts its // oldest entries instead of growing forever. const localIds = Array.from({ length: 200 }, (_, i) => `unmatched-${i}`); for (const localId of localIds) { cancelHandler!(localId); } // The earliest entries must have been evicted... expect(isLocalIdCancelled!('unmatched-0')).toBe(false); // ...while a recent one is still tracked (delete-and-return: true // once, then gone). const lastLocalId = localIds[localIds.length - 1]!; expect(isLocalIdCancelled!(lastLocalId)).toBe(true); expect(isLocalIdCancelled!(lastLocalId)).toBe(false); }); it('preserves dispatch and consumption outcomes through the registered cancel callback', async () => { await runOpencode({}); const queue = harness.opencodeLoopArgs[0]?.messageQueue as MessageQueue2; const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as (id: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed'; queue.push('correction', { permissionMode: 'default', model: null }, 'reserved'); const reservation = queue.takeByLocalId('reserved')!; expect(queue.beginReservationDispatch(reservation)).toBe(true); expect(cancel('reserved')).toBe('in-flight'); expect(cancel('reserved')).toBe('in-flight'); expect(queue.commitReservation(reservation)).toBe(true); expect(cancel('reserved')).toBe('consumed'); vi.spyOn(queue, 'cancelByLocalId').mockReturnValueOnce('indeterminate'); expect(cancel('unresolved')).toBe('indeterminate'); queue.push('user-resolvable', { permissionMode: 'default', model: null }, 'unknown'); const unknown = queue.takeByLocalId('unknown')!; queue.beginReservationDispatch(unknown); queue.markReservationIndeterminate(unknown); expect(cancel('unknown')).toBe(true); }); });