diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index 7c1dfa93..07ba01f1 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -673,7 +673,7 @@ describe('pingPeer delivery modes', () => { it.each([ { ...active, active: false }, { ...active, thinking: false, agentState: { steeringActive: false } }, - { ...active, metadata: { flavor: 'opencode' } }, + { ...active, metadata: { flavor: 'gemini' } }, { ...active, metadata: { flavor: 'cursor', cursorSessionProtocol: 'stream-json' as const } }, { ...active, metadata: { flavor: 'cursor', cursorSessionId: 'legacy' } }, { ...active, agentState: { controlledByUser: true } } diff --git a/cli/src/opencode/loop.ts b/cli/src/opencode/loop.ts index 5e9b691f..0946f23b 100644 --- a/cli/src/opencode/loop.ts +++ b/cli/src/opencode/loop.ts @@ -1,3 +1,4 @@ +import type { OpencodeSteerCancelHandler } from './utils/opencodeV2Steer'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { logger } from '@/ui/logger'; import { runLocalRemoteSession } from '@/agent/loopBase'; @@ -25,6 +26,7 @@ interface OpencodeLoopOptions { onSessionReady?: (session: OpencodeSession) => void; onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; + onSteerCancelHandlerChange?: (handler: OpencodeSteerCancelHandler | null) => void; onCompactAvailabilityChange?: (available: boolean) => void; onClearRequested?: () => Promise; onClearCleanupComplete?: () => Promise; @@ -85,6 +87,7 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise { onModelRollback: opts.onModelRollback, onReasoningEffortRollback: opts.onReasoningEffortRollback, onCompactAvailabilityChange: opts.onCompactAvailabilityChange, + onSteerCancelHandlerChange: opts.onSteerCancelHandlerChange, isLocalIdCancelled: opts.isLocalIdCancelled, onClearRequested: opts.onClearRequested, onClearCleanupComplete: opts.onClearCleanupComplete, diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index c5868ab3..4d2474e5 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { MessageBuffer } from '@/ui/ink/messageBuffer'; import type { OpencodeMode, PermissionMode } from './types'; +import type { PromptContent } from '@/agent/types'; +import { ACP_INDETERMINATE_SYMBOL } from '@/agent/backends/acp/AcpStdioTransport'; const harness = vi.hoisted(() => ({ servePid: null as number | null, @@ -9,7 +11,7 @@ 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[], + promptContents: [] as PromptContent[][], refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>, bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null, events: [] as string[], @@ -56,6 +58,25 @@ const harness = vi.hoisted(() => ({ onEvent?: (type: string, properties: Record, eventId?: string) => void; }>, eventStreamCloseCount: 0, + // Soft-steer (mid-turn inject) controls. + softSteerCalls: [] as Array<{ sessionId: string; content: unknown[] }>, + softSteerDispatchError: null as Error | null, + // Lets a test hold beginSoftSteerPrompt()'s dispatched promise pending. + deferSoftSteerDispatch: null as Promise | null, + // Lets a test hold the concurrent prompt's completion pending, so + // "dispatch returned but completion pending" is observable. + deferSoftSteer: null as Promise | null, + softSteerThrow: null as Error | null, + // Lets a test hold the durable dispatching-state write pending, so the + // foreground prompt can finish (or an abort can land) mid-persistence. + deferSteerState: null as Promise | null, + steerStateCalls: [] as Array<{ localIds: string[]; state: string }>, + steerStateResult: true as boolean, + promptGeneration: 1, + abortSoftSteersCalls: 0, + // Explicit override for isPromptRequestInFlight(); null keeps the + // events-derived default (true between prompt:start and prompt:end). + promptRequestInFlight: null as null | boolean, // v2-compat harness: flavor detection + port/password discovery stubs. detectedVersion: null as null | 'v1' | 'v2', loopbackListeners: [] as number[], @@ -122,7 +143,7 @@ vi.mock('./utils/opencodeBackend', () => ({ harness.thoughtLevelOption = { ...harness.thoughtLevelOption, currentValue: value }; } }), - prompt: vi.fn(async (_sessionId: string, content: unknown[], onMessage?: (message: unknown) => void) => { + prompt: vi.fn(async (_sessionId: string, content: PromptContent[], onMessage?: (message: unknown) => void) => { harness.promptOnMessage = onMessage ?? null; harness.promptContents.push(content); harness.events.push('prompt:start'); @@ -139,7 +160,11 @@ vi.mock('./utils/opencodeBackend', () => ({ harness.events.push('prompt:end'); }), isPromptRequestInFlight: vi.fn(() => - harness.events.lastIndexOf('prompt:start') > harness.events.lastIndexOf('prompt:end') + // The override models the real backend's window where ACP already + // answered session/prompt but prompt() is still draining updates. + harness.promptRequestInFlight !== null + ? harness.promptRequestInFlight + : harness.events.lastIndexOf('prompt:start') > harness.events.lastIndexOf('prompt:end') ), cancelPrompt: vi.fn(async (sessionId: string) => { await harness.cancelPrompt(sessionId); @@ -147,6 +172,18 @@ vi.mock('./utils/opencodeBackend', () => ({ await harness.cancelPromptImpl(); } }), + getPromptGeneration: vi.fn(() => harness.promptGeneration), + beginSoftSteerPrompt: vi.fn((sessionId: string, content: unknown[]) => { + harness.softSteerCalls.push({ sessionId, content }); + if (harness.softSteerThrow) throw harness.softSteerThrow; + return { + dispatched: harness.softSteerDispatchError + ? Promise.reject(harness.softSteerDispatchError) + : (harness.deferSoftSteerDispatch ?? Promise.resolve()), + completed: harness.deferSoftSteer ?? Promise.resolve() + }; + }), + abortSoftSteers: vi.fn(() => { harness.abortSoftSteersCalls++; }), respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn((handler: (error: { type: string; message: string; raw: string }) => void) => { harness.stderrHandler = handler; @@ -410,8 +447,18 @@ function createSessionStub( session.model = model; }); const pushKeepAlive = vi.fn(); - const emitMessagesConsumedCalls: Array<{ localIds: string[]; options?: { clearQueuedThinkingGrace?: boolean } }> = []; + const emitMessagesConsumedCalls: Array<{ localIds: string[]; options?: { clearQueuedThinkingGrace?: boolean; steered?: boolean } }> = []; const thinkingChangeCalls: boolean[] = []; + const steerIndeterminateCalls: string[][] = []; + const agentState: Record = {}; + const steeringActiveCalls: boolean[] = []; + const steerStateImpl = vi.fn(async (localIds: string[], state: 'queued' | 'dispatching') => { + harness.steerStateCalls.push({ localIds, state }); + if (harness.deferSteerState) { + await harness.deferSteerState; + } + return harness.steerStateResult; + }); const client = { rpcHandlerManager: { @@ -427,9 +474,17 @@ function createSessionStub( sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); }, - emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }) { + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean; steered?: boolean }) { emitMessagesConsumedCalls.push({ localIds, options }); - } + }, + emitSteerIndeterminate(localIds: string[]) { + steerIndeterminateCalls.push(localIds); + }, + updateAgentState(handler: (state: Record) => Record) { + Object.assign(agentState, handler(agentState)); + steeringActiveCalls.push(Boolean(agentState.steeringActive)); + }, + setSteerDeliveryState: steerStateImpl }; const session = { @@ -465,7 +520,7 @@ function createSessionStub( sendUserMessage(_text: string) {} }; - return { session, sessionEvents, sentAgentMessages, agentMessages: sentAgentMessages, claudeSessionMessages, rpcHandlers, setModel, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls }; + return { session, sessionEvents, sentAgentMessages, agentMessages: sentAgentMessages, claudeSessionMessages, rpcHandlers, setModel, setModelReasoningEffort, pushKeepAlive, emitMessagesConsumedCalls, thinkingChangeCalls, steeringActiveCalls, steerIndeterminateCalls, steerStateImpl }; } function createCompactMode(model?: string): OpencodeMode { @@ -528,6 +583,18 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.serverStopError = null; harness.eventStreamOptions = []; harness.eventStreamCloseCount = 0; + harness.stderrHandler = null; + harness.softSteerCalls = []; + harness.softSteerDispatchError = null; + harness.deferSoftSteerDispatch = null; + harness.deferSoftSteer = null; + harness.softSteerThrow = null; + harness.deferSteerState = null; + harness.steerStateCalls = []; + harness.steerStateResult = true; + harness.promptGeneration = 1; + harness.abortSoftSteersCalls = 0; + harness.promptRequestInFlight = null; harness.detectedVersion = null; harness.loopbackListeners = []; harness.discoveredPort = null; @@ -2551,6 +2618,2069 @@ describe('selectAbortStatusMessage', () => { }); }); +function indeterminateError(message: string): Error { + const error = new Error(message); + Object.defineProperty(error, ACP_INDETERMINATE_SYMBOL, { value: true }); + return error; +} + +describe('opencodeRemoteLauncher mid-turn steer', () => { + afterEach(async () => { + harness.events = []; + harness.cleanupEvents = []; + harness.promptCount = 0; + harness.promptContents = []; + harness.promptImpl = null; + harness.hangPrompt = false; + harness.resolvePrompt = null; + harness.cancelPrompt.mockClear(); + harness.cancelPromptImpl = null; + harness.disconnectImpl = null; + harness.thoughtLevelOption = null; + harness.sessionModelsMetadata = undefined; + harness.softSteerCalls = []; + harness.softSteerDispatchError = null; + harness.deferSoftSteerDispatch = null; + harness.deferSoftSteer = null; + harness.softSteerThrow = null; + harness.deferSteerState = null; + harness.steerStateCalls = []; + harness.steerStateResult = true; + harness.promptGeneration = 1; + harness.abortSoftSteersCalls = 0; + harness.promptRequestInFlight = null; + compactHarness.calls = []; + compactHarness.markerSnapshot = { markerIds: ['before'] }; + compactHarness.triggerImpl = null; + compactHarness.resultImpl = null; + compactHarness.snapshotImpl = null; + compactHarness.compactionResult = { status: 'success', text: '## Objective\n- Did the thing' }; + harness.detectedVersion = null; + harness.loopbackListeners = []; + harness.discoveredPort = null; + harness.discoveredPassword = null; + harness.backendPid = 4242; + harness.createBackendArgs = []; + harness.v2DiscoveryCalls = []; + harness.v2PasswordCalls = []; + harness.servePid = null; + harness.servePidCalls = []; + const backendModule = await import('./utils/opencodeBackend'); + for (const name of ['createOpencodeBackend', 'detectOpencodeFlavorVersion', 'discoverOpencodeV2RestPort', 'listLoopbackListeners'] as const) { + vi.mocked(backendModule[name]).mockClear(); + } + }); + + type SteerSession = ReturnType; + type SteerHandler = (payload: unknown) => Promise; + + async function startTurn(opts: { + firstMessage?: string; + firstMode?: OpencodeMode; + holdPrompt?: boolean; + launcherOptions?: Parameters[1]; + } = {}): Promise<{ + stub: SteerSession; + handlers: Map unknown>; + releasePrompt: () => void; + rejectPrompt: (error: Error) => void; + runPromise: Promise<'switch' | 'exit'>; + }> { + let releasePrompt!: () => void; + let rejectPrompt!: (error: Error) => void; + if (opts.holdPrompt !== false) { + harness.promptImpl = () => new Promise((resolve, reject) => { + releasePrompt = resolve; + rejectPrompt = reject; + }); + } + const mode = opts.firstMode ?? createMode(); + const stub = createSessionStub( + [{ message: opts.firstMessage ?? 'first', mode, localId: 'first' }], + { keepOpen: true } + ); + const runPromise = opencodeRemoteLauncher(stub.session as never, opts.launcherOptions); + await vi.waitFor(() => expect(harness.promptCount).toBe(1)); + // Later prompts fall back to the default auto-resolving prompt, so a + // release can never strand the loop on a second turn. + const release = () => { + harness.promptImpl = null; + releasePrompt(); + }; + return { stub, handlers: stub.rpcHandlers, releasePrompt: release, rejectPrompt: error => { + harness.promptImpl = null; + harness.events.push('prompt:end'); + rejectPrompt(error); + }, runPromise }; + } + + async function stopTurn( + stub: SteerSession, + releasePrompt: () => void, + runPromise: Promise<'switch' | 'exit'> + ): Promise { + releasePrompt(); + stub.session.queue.close(); + await runPromise; + } + + function steerHandlerOf(stub: SteerSession): SteerHandler { + return stub.rpcHandlers.get('steer-queued-message') as SteerHandler; + } + + async function startRejectedDispatchRestore(stub: SteerSession) { + let rejectDispatch!: (error: Error) => void; + const dispatched = new Promise((_, reject) => { rejectDispatch = reject; }); + harness.deferSoftSteerDispatch = dispatched; + harness.deferSoftSteer = dispatched; + const steering = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.softSteerCalls).toHaveLength(1)); + let releaseState!: () => void; + harness.deferSteerState = new Promise(resolve => { releaseState = resolve; }); + rejectDispatch(new Error('stdin rejected before transmission')); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual({ localIds: ['steer'], state: 'queued' })); + return { steering, releaseState }; + } + + it.each([true, false])('advertises v2 steering only with REST discovery=%s', async (discovered) => { + harness.detectedVersion = 'v2'; + harness.discoveredPort = discovered ? 48274 : null; + harness.discoveredPassword = discovered ? 'synthetic-password' : null; + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + expect(harness.createBackendArgs.at(-1)?.majorVersion).toBe('v2'); + expect(harness.events).toContain('prompt:start'); + expect(harness.events).not.toContain('prompt:end'); + expect(stub.steeringActiveCalls.includes(true)).toBe(discovered); + } finally { + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each([false])('rejects v2 steering before reserving the queued row with REST discovery=%s', async (discovered) => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + harness.detectedVersion = 'v2'; + harness.discoveredPort = discovered ? 48274 : null; + harness.discoveredPassword = discovered ? 'synthetic-password' : null; + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'steer'); + const queued = stub.session.queue.peekByLocalId('steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, error: 'OpenCode v2 internal API unavailable' + }); + expect(stub.session.queue.peekByLocalId('steer')).toBe(queued); + expect(harness.steerStateCalls).toEqual([]); + expect(harness.softSteerCalls).toEqual([]); + expect(receipt).not.toHaveBeenCalled(); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + expect(harness.cancelPrompt).not.toHaveBeenCalled(); + } finally { + receipt.mockRestore(); + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('v2 durably admits without ACP soft prompt and consumes only its delivered identity', async () => { + harness.detectedVersion = 'v2'; + harness.discoveredPort = 48274; + harness.discoveredPassword = 'synthetic-password'; + let inputId = ''; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + if (init?.method === 'POST') inputId = JSON.parse(String(init.body)).id; + const item = { id: inputId, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + return Response.json({ data: init?.method === 'POST' ? item : [item] }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await expect(steerHandlerOf(stub)({ localId: 'v2-steer' })).resolves.toEqual({ steered: true }); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + const event = harness.eventStreamOptions.at(-1)!.onEvent!; + event('session.inbox.delivery.changed', { sessionID: 'acp-session-1', inboxID: inputId, delivery: 'steer' }); + event('session.inbox.delivered', { sessionID: 'other-session', inboxID: inputId }); + event('session.inbox.delivered', { sessionID: 'acp-session-1', inboxID: 'other-input' }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + event('session.inbox.delivered', { sessionID: 'acp-session-1', inboxID: inputId }); + event('session.inbox.delivered', { sessionID: 'acp-session-1', inboxID: inputId }); + expect(stub.emitMessagesConsumedCalls).toEqual([{ localIds: ['v2-steer'], options: { steered: true } }]); + expect(harness.events).not.toContain('prompt:end'); + } finally { + nativeFetch.mockRestore(); + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('v2 recovers a missing delivered event by exact native projection readback', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') { + id = JSON.parse(String(init.body)).id; + return Response.json({ data: { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } } }); + } + return Response.json({ data: String(url).endsWith('/inbox') ? [] : { id, sessionID: 'acp-session-1', type: 'user', text: 'correction' } }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([{ localIds: ['v2-steer'], options: { steered: true } }])); + expect(harness.events).not.toContain('prompt:end'); + } finally { nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 keeps missing delivery evidence unknown and never blindly re-POSTs on Retry', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let posts = 0; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') { posts++; throw new Error('admission response lost'); } + return String(url).endsWith('/inbox') ? Response.json({ data: [] }) : new Response(null, { status: 404 }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + expect(stub.steerIndeterminateCalls).toContainEqual(['v2-steer']); + expect(stub.session.queue.cancelByLocalId('v2-steer')).toBe(true); + // Rehydration after explicit Retry retains original unknown origin. + stub.session.queue.push('correction', createMode(), 'v2-steer'); + const held = stub.session.queue.takeByLocalId('v2-steer')!; + stub.session.queue.beginReservationDispatch(held); + stub.session.queue.markReservationIndeterminate(held); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + expect(posts).toBe(1); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 confirms native cancellation before allowing explicit removal', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let cancelled = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') id = JSON.parse(String(init.body)).id; + if (init?.method === 'DELETE') { cancelled = true; return new Response(null, { status: 204 }); } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + if (init?.method === 'POST') return Response.json({ data: item }); + return String(url).endsWith('/inbox') ? Response.json({ data: cancelled ? [] : [item] }) : new Response(null, { status: 404 }); + }); + let cancel: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null = null; + const { stub, releasePrompt, runPromise } = await startTurn({ launcherOptions: { onSteerCancelHandlerChange: value => { cancel = value; } } }); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + await expect(cancel!('v2-steer')).resolves.toBe(true); + expect(cancelled).toBe(true); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.session.queue.cancelByLocalId('v2-steer')).toBe(false); + } finally { nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 restores a proven cancelled input after one reset for overlapping Stops', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let cancelled = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') id = JSON.parse(String(init.body)).id; + if (init?.method === 'DELETE') { cancelled = true; return new Response(null, { status: 204 }); } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + if (init?.method === 'POST') return Response.json({ data: item }); + return String(url).endsWith('/inbox') ? Response.json({ data: cancelled ? [] : [item] }) : new Response(null, { status: 404 }); + }); + let releaseAbort!: () => void; + harness.cancelPromptImpl = () => new Promise(resolve => { releaseAbort = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + const reset = vi.spyOn(stub.session.queue, 'reset'); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + const abort = stub.rpcHandlers.get('abort') as () => Promise; + const first = abort(); + await vi.waitFor(() => expect(cancelled).toBe(true)); + const second = abort(); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + releaseAbort(); + await Promise.all([first, second]); + expect(reset).toHaveBeenCalledTimes(1); + expect(stub.session.queue.peekByLocalId('v2-steer')?.message).toBe('correction'); + expect(harness.steerStateCalls).toContainEqual({ localIds: ['v2-steer'], state: 'queued' }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { + releaseAbort?.(); harness.cancelPromptImpl = null; nativeFetch.mockRestore(); + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('v2 lets delivery win a no-op native cancellation without restoring or double-ACKing', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let delivered = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') id = JSON.parse(String(init.body)).id; + if (init?.method === 'DELETE') { delivered = true; return new Response(null, { status: 204 }); } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + if (init?.method === 'POST') return Response.json({ data: item }); + return Response.json({ data: String(url).endsWith('/inbox') ? delivered ? [] : [item] : { id, sessionID: 'acp-session-1', type: 'user', text: 'correction' } }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + expect(stub.emitMessagesConsumedCalls).toEqual([{ localIds: ['v2-steer'], options: { steered: true } }]); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + expect(harness.steerStateCalls).not.toContainEqual({ localIds: ['v2-steer'], state: 'queued' }); + } finally { nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 accepts its delivered event before the admission HTTP response without waiting or double delivery', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let releasePost!: () => void; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + id = JSON.parse(String(init?.body)).id; + await new Promise(resolve => { releasePost = resolve; }); + return Response.json({ data: { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } } }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + const steering = steerHandlerOf(stub)({ localId: 'v2-steer' }); + await vi.waitFor(() => expect(id).not.toBe('')); + harness.eventStreamOptions.at(-1)!.onEvent!('session.inbox.delivered', { sessionID: 'acp-session-1', inboxID: id }); + await expect(steering).resolves.toEqual({ steered: true }); + expect(stub.emitMessagesConsumedCalls).toHaveLength(1); + releasePost(); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toHaveLength(1); + } finally { releasePost?.(); nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 Stop fences a delayed admission before native cancellation and FIFO restoration', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let releasePost!: () => void; let deleted = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') { + id = JSON.parse(String(init.body)).id; + await new Promise(resolve => { releasePost = resolve; }); + return Response.json({ data: { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } } }); + } + if (init?.method === 'DELETE') { deleted = true; return new Response(null, { status: 204 }); } + return String(url).endsWith('/inbox') ? Response.json({ data: [] }) : new Response(null, { status: 404 }); + }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + const steering = steerHandlerOf(stub)({ localId: 'v2-steer' }); + await vi.waitFor(() => expect(id).not.toBe('')); + const aborting = (stub.rpcHandlers.get('abort') as () => Promise)(); + await new Promise(resolve => setImmediate(resolve)); + expect(deleted).toBe(false); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + releasePost(); + await Promise.all([steering, aborting]); + expect(deleted).toBe(true); + expect(stub.session.queue.peekByLocalId('v2-steer')?.message).toBe('correction'); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(nativeFetch.mock.calls.filter(call => call[1]?.method === 'POST')).toHaveLength(1); + } finally { releasePost?.(); nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 pre-admission restoration waits for an overlapping Stop reset', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseState!: () => void; let releaseAbort!: () => void; + harness.deferSteerState = new Promise(resolve => { releaseState = resolve; }); + harness.cancelPromptImpl = () => new Promise(resolve => { releaseAbort = resolve; }); + let steering: Promise | undefined; let aborting: Promise | undefined; + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + steering = steerHandlerOf(stub)({ localId: 'v2-steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toHaveLength(1)); + aborting = (stub.rpcHandlers.get('abort') as () => Promise)(); + await vi.waitFor(() => expect(releaseAbort).toBeTypeOf('function')); + releaseState(); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + releaseAbort(); + await Promise.all([steering, aborting]); + expect(stub.session.queue.peekByLocalId('v2-steer')?.message).toBe('correction'); + expect(harness.softSteerCalls).toEqual([]); + } finally { + releaseState?.(); releaseAbort?.(); harness.deferSteerState = null; harness.cancelPromptImpl = null; + await Promise.all([steering, aborting]); await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('v2 retries uncertain native cancellation after later positive admission evidence', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let ready = false; let cancelled = false; let deletes = 0; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') { id = JSON.parse(String(init.body)).id; throw new Error('response lost before write is known'); } + if (init?.method === 'DELETE') { deletes++; cancelled = true; return new Response(null, { status: 204 }); } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + return String(url).endsWith('/inbox') ? Response.json({ data: ready && !cancelled ? [item] : [] }) : new Response(null, { status: 404 }); + }); + let cancel: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null = null; + const { stub, releasePrompt, runPromise } = await startTurn({ launcherOptions: { onSteerCancelHandlerChange: value => { cancel = value; } } }); + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + await expect(cancel!('v2-steer')).resolves.toBe('indeterminate'); + expect(deletes).toBe(0); + ready = true; + harness.eventStreamOptions.at(-1)!.onEvent!('session.inbox.enqueued', { + sessionID: 'acp-session-1', inboxID: id, item: { type: 'user', payload: { text: 'correction' } } + }); + await expect(cancel!('v2-steer')).resolves.toBe(true); + expect(deletes).toBe(1); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); } + }); + + it('v2 explicit Cancel during queued-state restoration cannot leave a runnable FIFO item', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let cancelled = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') id = JSON.parse(String(init.body)).id; + if (init?.method === 'DELETE') { cancelled = true; return new Response(null, { status: 204 }); } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + if (init?.method === 'POST') return Response.json({ data: item }); + return String(url).endsWith('/inbox') ? Response.json({ data: cancelled ? [] : [item] }) : new Response(null, { status: 404 }); + }); + let cancel: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null = null; + const { stub, releasePrompt, runPromise } = await startTurn({ launcherOptions: { onSteerCancelHandlerChange: value => { cancel = value; } } }); + let releaseState!: () => void; let aborting: Promise | undefined; + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + harness.deferSteerState = new Promise(resolve => { releaseState = resolve; }); + aborting = (stub.rpcHandlers.get('abort') as () => Promise)(); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual({ localIds: ['v2-steer'], state: 'queued' })); + const cancelling = cancel!('v2-steer'); + releaseState(); + await Promise.all([aborting, expect(cancelling).resolves.toBe(true)]); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { + releaseState?.(); harness.deferSteerState = null; await aborting; + nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('v2 stall restoration shares the reset barrier of an overlapping user Stop', async () => { + harness.detectedVersion = 'v2'; harness.discoveredPort = 48274; harness.discoveredPassword = 'test-only'; + let id = ''; let deleted = false; let releaseDelete!: () => void; let releaseStop!: () => void; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + if (init?.method === 'POST') id = JSON.parse(String(init.body)).id; + if (init?.method === 'DELETE') { + await new Promise(resolve => { releaseDelete = resolve; }); + deleted = true; return new Response(null, { status: 204 }); + } + const item = { id, sessionID: 'acp-session-1', type: 'user', delivery: 'steer', payload: { text: 'correction' } }; + if (init?.method === 'POST') return Response.json({ data: item }); + return String(url).endsWith('/inbox') ? Response.json({ data: deleted ? [] : [item] }) : new Response(null, { status: 404 }); + }); + let cancels = 0; + harness.cancelPromptImpl = async () => { if (++cancels > 1) await new Promise(resolve => { releaseStop = resolve; }); }; + const { stub, releasePrompt, runPromise } = await startTurn(); + let stopping: Promise | undefined; + try { + stub.session.queue.push('correction', createMode(), 'v2-steer'); + await steerHandlerOf(stub)({ localId: 'v2-steer' }); + harness.stderrHandler!({ type: 'quota_exceeded', message: 'API quota exceeded. Retrying in 30 seconds', raw: 'quota exceeded' }); + await vi.waitFor(() => expect(releaseDelete).toBeTypeOf('function')); + stopping = (stub.rpcHandlers.get('abort') as () => Promise)(); + await vi.waitFor(() => expect(releaseStop).toBeTypeOf('function')); + releaseDelete(); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.session.queue.peekByLocalId('v2-steer')).toBeNull(); + releaseStop(); await stopping; + expect(stub.session.queue.peekByLocalId('v2-steer')?.message).toBe('correction'); + } finally { + releaseDelete?.(); releaseStop?.(); harness.cancelPromptImpl = null; await stopping; + nativeFetch.mockRestore(); await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('injects a compatible queued message into the active turn without cancelling it', async () => { + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + const mode = createMode(); + + stub.session.queue.push('mid-turn correction', mode, 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + + // Injected into the live ACP session as a concurrent session/prompt. + expect(harness.softSteerCalls).toEqual([ + { sessionId: 'acp-session-1', content: [{ type: 'text', text: 'mid-turn correction' }] } + ]); + // The foreground turn was never cancelled or re-prompted. + expect(harness.cancelPrompt).not.toHaveBeenCalled(); + expect(harness.promptCount).toBe(1); + + // Consumed ack only after the concurrent prompt settles. + expect(stub.emitMessagesConsumedCalls).not.toContainEqual( + expect.objectContaining({ options: { steered: true } }) + ); + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual( + { localIds: ['steer'], options: { steered: true } } + )); + expect(stub.steerIndeterminateCalls).toEqual([]); + // Committed: the row is neither restored nor cancellable. + expect(stub.session.queue.cancelByLocalId('steer')).toBe('consumed'); + + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('acknowledges dispatch without waiting for a pending native receipt lookup', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + let releaseReceipt!: () => void; + const pendingReceipt = new Promise>(resolve => { + releaseReceipt = () => resolve([{ id: 'accepted', text: 'correction' }]); + }); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockResolvedValueOnce([]).mockReturnValue(pendingReceipt); + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let response: unknown; + let steering: Promise | undefined; + try { + stub.session.queue.push('correction', createMode(), 'steer'); + steering = steerHandlerOf(stub)({ localId: 'steer' }).then(result => { response = result; }); + await vi.waitFor(() => expect(receipt).toHaveBeenCalledTimes(2)); + expect(response).toEqual({ steered: true }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + releaseReceipt(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + } finally { + releaseReceipt(); + releaseSoftSteer(); + await steering; + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each(['delayed persistence', 'transient history failure'] as const)( + 'consumes a proven native receipt after %s before the turn finishes', async (scenario) => { + const receipts = await import('./utils/opencodeInputReceipt'); + const realNow = Date.now.bind(Date); + let time: ReturnType | undefined; + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockResolvedValueOnce([]) + .mockImplementationOnce(async () => { + if (scenario === 'delayed persistence') { + time = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + 4_000); + return []; + } + return null; + }) + .mockResolvedValue([{ id: 'accepted', text: 'correction' }]); + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + expect(harness.promptCount).toBe(1); + expect(harness.events).not.toContain('prompt:end'); + expect(stub.session.queue.cancelByLocalId('steer')).toBe('consumed'); + } finally { + time?.mockRestore(); + releaseSoftSteer(); + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + } + ); + + it('stops receipt polling when an unconfirmed native request fails', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockResolvedValueOnce([]).mockResolvedValue(null); + let rejectSoftSteer!: (error: Error) => void; + harness.deferSoftSteer = new Promise((_resolve, reject) => { rejectSoftSteer = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + await vi.waitFor(() => expect(receipt.mock.calls.length).toBeGreaterThan(1)); + rejectSoftSteer(new Error('provider failed without an input receipt')); + await vi.waitFor(() => expect(stub.steerIndeterminateCalls).toEqual([['steer']])); + const reads = receipt.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 150)); + expect(receipt).toHaveBeenCalledTimes(reads); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + } finally { + rejectSoftSteer(new Error('cleanup')); + await stub.rpcHandlers.get('abort')?.({}); + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('consumes an input accepted in native history before its concurrent prompt completes', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const text = 'mid-turn correction'; + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockResolvedValueOnce([{ id: 'existing-user', text: 'different earlier input' }]) + .mockResolvedValue([{ id: 'existing-user', text: 'different earlier input' }, { id: 'new-user', text }]); + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push(text, createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + expect(stub.emitMessagesConsumedCalls).toContainEqual({ localIds: ['steer'], options: { steered: true } }); + expect(stub.session.queue.cancelByLocalId('steer')).toBe('consumed'); + expect(harness.cancelPrompt).not.toHaveBeenCalled(); + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls.filter(c => c.localIds.includes('steer'))).toHaveLength(1)); + } finally { + releaseSoftSteer(); + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('does not restore an input accepted by native history when the prompt later fails', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockResolvedValueOnce([]) + .mockResolvedValue([{ id: 'native-user', text: 'correction' }]); + let rejectSoftSteer!: (error: Error) => void; + harness.deferSoftSteer = new Promise((_resolve, reject) => { rejectSoftSteer = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + rejectSoftSteer(new Error('provider failed after accepting input')); + await stopTurn(stub, releasePrompt, runPromise); + expect(harness.steerStateCalls).not.toContainEqual({ localIds: ['steer'], state: 'queued' }); + expect(stub.steerIndeterminateCalls).toEqual([]); + expect(stub.session.queue.cancelByLocalId('steer')).toBe('consumed'); + expect(stub.emitMessagesConsumedCalls.filter(c => c.localIds.includes('steer'))).toHaveLength(1); + } finally { + receipt.mockRestore(); + harness.deferSoftSteer = null; + releasePrompt(); + stub.session.queue.close(); + await runPromise; + } + }); + + it('does not correlate overlapping identical steers using native text alone', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + let releaseSnapshot!: (value: Array<{ id: string; text: string }>) => void; + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockImplementationOnce(() => new Promise(resolve => { releaseSnapshot = resolve; })) + .mockResolvedValue([{ id: 'new-user', text: 'same correction' }]); + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('same correction', createMode(), 'steer-1'); + const first = steerHandlerOf(stub)({ localId: 'steer-1' }); + await vi.waitFor(() => expect(receipt).toHaveBeenCalledOnce()); + stub.session.queue.push('same correction', createMode(), 'steer-2'); + await steerHandlerOf(stub)({ localId: 'steer-2' }); + releaseSnapshot([]); + await first; + expect(stub.emitMessagesConsumedCalls).toEqual([]); + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toHaveLength(2)); + } finally { + releaseSnapshot?.([]); + releaseSoftSteer(); + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('does not mistake a delayed foreground input for an identical steer receipt', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages'); + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + const foregroundText = harness.promptContents[0] + .filter(part => part.type === 'text').map(part => part.text).join(''); + expect(foregroundText.length).toBeGreaterThan(0); + receipt.mockResolvedValueOnce([]).mockResolvedValue([{ id: 'late-foreground', text: foregroundText }]); + try { + stub.session.queue.push(foregroundText, createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(receipt).not.toHaveBeenCalled(); + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + } finally { + releaseSoftSteer(); + receipt.mockRestore(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('rejects a steer while idle with no active turn', async () => { + const stub = createSessionStub([], { keepOpen: true }); + const runPromise = opencodeRemoteLauncher(stub.session as never); + await vi.waitFor(() => expect(stub.rpcHandlers.has('steer-queued-message')).toBe(true)); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'No active steerable turn' + }); + expect(harness.softSteerCalls).toEqual([]); + // No reservation, no durable steer state: the row is left entirely to + // the normal queue and delivered by an ordinary prompt. + expect(harness.steerStateCalls).toEqual([]); + await vi.waitFor(() => expect(harness.promptCount).toBe(1)); + expect(JSON.stringify(harness.promptContents[0])).toContain('mid-turn correction'); + + stub.session.queue.close(); + await runPromise; + }); + + it('rejects a missing localId and a localId that is not queued', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + await expect(steerHandlerOf(stub)({})).resolves.toEqual({ + steered: false, + error: 'Missing localId' + }); + await expect(steerHandlerOf(stub)({ localId: 'never-queued' })).resolves.toEqual({ + steered: false, + error: 'Message not in queue' + }); + expect(harness.softSteerCalls).toEqual([]); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('rejects a duplicate steer for the same queued row', async () => { + let releaseDispatch!: () => void; + const dispatchGate = new Promise((resolve) => { releaseDispatch = resolve; }); + harness.deferSoftSteerDispatch = dispatchGate; + const { stub, releasePrompt, runPromise } = await startTurn(); + const mode = createMode(); + + stub.session.queue.push('mid-turn correction', mode, 'steer'); + const first = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.softSteerCalls).toHaveLength(1)); + + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Message not in queue' + }); + expect(harness.softSteerCalls).toHaveLength(1); + + releaseDispatch(); + await expect(first).resolves.toEqual({ steered: true }); + harness.deferSoftSteerDispatch = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it.each([ + ['isolated', createMode(), true], + ['compact', createCompactMode(), false], + ['clear', createClearMode(), false] + ])('rejects an isolated %s item and restores its FIFO position', async (_label, mode, isolate) => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + if (isolate) { + stub.session.queue.pushIsolated('control', mode, 'control'); + } else { + stub.session.queue.push('control', mode, 'control'); + } + await expect(steerHandlerOf(stub)({ localId: 'control' })).resolves.toEqual({ + steered: false, + error: 'Control commands cannot be steered' + }); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.session.queue.peekByLocalId('control')).not.toBeNull(); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it.each([ + ['permission', createPlanMode()], + ['model', createMode('other-model')], + ['effort', createModeWithEffort(undefined, 'high')] + ])('rejects a queued message whose %s differs from the active turn', async (_label, mode) => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', mode, 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Queued message mode differs from the active turn' + }); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.session.queue.peekByLocalId('steer')?.message).toBe('mid-turn correction'); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it.each(['model rejection', 'effort rejection', 'unsupported effort', 'coerced effort'] as const)( + 'steers with the resolved settings after %s', + async (scenario) => { + const modelFailure = scenario === 'model rejection'; + const requested = modelFailure ? createMode('ollama/b') : createModeWithEffort(undefined, 'high'); + const resolved = modelFailure ? createMode('ollama/a') : createModeWithEffort(undefined, 'low'); + harness.sessionModelsMetadata = { currentModelId: 'ollama/a', availableModels: [] }; + if (modelFailure) { + harness.setModelImpl = async () => { throw new Error('Model unavailable'); }; + } else { + harness.thoughtLevelOption = { + id: 'effort', + currentValue: scenario === 'coerced effort' ? 'high' : 'low', + options: scenario === 'effort rejection' + ? [{ value: 'low' }, { value: 'high' }] + : [{ value: 'low' }, { value: 'medium' }] + }; + if (scenario === 'coerced effort') requested.modelReasoningEffort = 'max'; + if (scenario === 'effort rejection') { + harness.setConfigOptionImpl = async () => { throw new Error('Effort unavailable'); }; + } + } + const { stub, releasePrompt, runPromise } = await startTurn({ firstMode: requested }); + try { + if (modelFailure) { + expect(stub.setModel).toHaveBeenCalledWith('ollama/a'); + } else { + expect(stub.setModelReasoningEffort).toHaveBeenCalledWith('low'); + } + stub.session.queue.push('resolved correction', resolved, 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual({ + localIds: ['steer'], options: { steered: true } + })); + expect(harness.softSteerCalls).toEqual([{ + sessionId: 'acp-session-1', content: [{ type: 'text', text: 'resolved correction' }] + }]); + expect(harness.promptCount).toBe(1); + expect(stub.session.queue.cancelByLocalId('steer')).toBe('consumed'); + + // The original request still differs from the running turn. + const stale = modelFailure ? createMode('ollama/b') : createModeWithEffort(undefined, 'high'); + stub.session.queue.push('stale settings', stale, 'stale'); + await expect(steerHandlerOf(stub)({ localId: 'stale' })).resolves.toEqual({ + steered: false, + error: 'Queued message mode differs from the active turn' + }); + expect(harness.softSteerCalls).toHaveLength(1); + expect(stub.session.queue.cancelByLocalId('stale')).toBe(true); + } finally { + harness.setModelImpl = null; + harness.setConfigOptionImpl = null; + harness.setModelArgs = []; + harness.setConfigOptionArgs = []; + await stopTurn(stub, releasePrompt, runPromise); + } + } + ); + + it.each([false, true])('preserves Default model intent after a successful reset with effort rejection=%s', async (rejectEffort) => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/default', availableModels: [] }; + harness.thoughtLevelOption = { + id: 'effort', currentValue: 'low', options: [{ value: 'low' }, { value: 'high' }] + }; + if (rejectEffort) { + harness.setConfigOptionImpl = async () => { throw new Error('Effort unavailable'); }; + } + let releasePrompt!: () => void; + harness.promptImpl = async () => { + if (harness.promptCount === 2) await new Promise(resolve => { releasePrompt = resolve; }); + }; + const requested = rejectEffort ? { ...createResetMode(), modelReasoningEffort: 'high' } : createResetMode(); + const resolved = rejectEffort ? { ...createResetMode(), modelReasoningEffort: 'low' } : createResetMode(); + const stub = createSessionStub([ + { message: 'first', mode: createMode('ollama/custom'), localId: 'first' }, + { message: 'reset', mode: requested, localId: 'reset' } + ], { keepOpen: true }); + const runPromise = opencodeRemoteLauncher(stub.session as never); + try { + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(harness.setModelArgs).toEqual([ + { sessionId: 'acp-session-1', modelId: 'ollama/custom', flavor: 'opencode' }, + { sessionId: 'acp-session-1', modelId: 'ollama/default', flavor: 'opencode' } + ]); + if (rejectEffort) expect(stub.setModelReasoningEffort).toHaveBeenCalledWith('low'); + stub.session.queue.push('Default correction', resolved, 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual({ + localIds: ['steer'], options: { steered: true } + })); + expect(harness.softSteerCalls).toEqual([{ + sessionId: 'acp-session-1', content: [{ type: 'text', text: 'Default correction' }] + }]); + } finally { + harness.setConfigOptionImpl = null; + harness.setModelArgs = []; + harness.setConfigOptionArgs = []; + harness.promptImpl = null; + releasePrompt?.(); + stub.session.queue.close(); + await runPromise; + } + }); + + it('rejects a normal steer while a compaction is in flight', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] }; + let releaseCompact!: () => void; + compactHarness.triggerImpl = () => new Promise((resolve) => { + releaseCompact = () => resolve({ ok: true }); + }); + const stub = createSessionStub( + [ + { message: '', mode: createCompactMode('ollama/x'), localId: 'compact' }, + { message: 'mid-turn correction', mode: createCompactMode('ollama/x'), localId: 'steer' } + ], + { keepOpen: true } + ); + const runPromise = opencodeRemoteLauncher(stub.session as never, { + onCompactAvailabilityChange: () => {} + }); + await vi.waitFor(() => expect(compactHarness.calls.length).toBe(1)); + + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'No active steerable turn' + }); + expect(harness.softSteerCalls).toEqual([]); + + releaseCompact(); + compactHarness.triggerImpl = null; + stub.session.queue.close(); + await runPromise; + }); + + it('marks the steer indeterminate and sends nothing when the durable dispatching state fails', async () => { + harness.steerStateResult = false; + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Steer state is indeterminate' + }); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([['steer']]); + // Held outside the automatic queue — never silently replayed. + expect(stub.session.queue.peekByLocalId('steer')).toBeNull(); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('does not send into a finished turn, restores the row, and does not start the next prompt until bookkeeping finished', async () => { + let releaseSteerState!: () => void; + harness.deferSteerState = new Promise((resolve) => { releaseSteerState = resolve; }); + let releasePrompt!: () => void; + harness.promptImpl = () => new Promise((resolve) => { releasePrompt = resolve; }); + const stub = createSessionStub( + [ + { message: 'first', mode: createMode(), localId: 'first' }, + { message: 'second', mode: createMode(), localId: 'second' } + ], + { keepOpen: true } + ); + const runPromise = opencodeRemoteLauncher(stub.session as never); + await vi.waitFor(() => expect(harness.promptCount).toBe(1)); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + const steerPromise = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual( + { localIds: ['steer'], state: 'dispatching' } + )); + + // The foreground prompt finishes while persistence is still pending. + harness.promptImpl = null; + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + // No second prompt may start while the steer still owns the row. + expect(harness.promptCount).toBe(1); + + releaseSteerState(); + await expect(steerPromise).resolves.toEqual({ steered: false, error: 'Active turn changed' }); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + + // Restored in order and delivered by the next normal prompt — the row + // was neither lost nor dropped on the floor. + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(JSON.stringify(harness.promptContents[1])).toContain('mid-turn correction'); + stub.session.queue.close(); + harness.deferSteerState = null; + await runPromise; + }); + + it('restores the original FIFO position and emits no consumed ack when ACP rejects the steer explicitly', async () => { + harness.softSteerDispatchError = new Error('session/prompt rejected'); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('before', createMode(), 'before'); + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + stub.session.queue.push('after', createMode(), 'after'); + + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Failed to soft-steer into active turn' + }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + expect(harness.steerStateCalls).toEqual([ + { localIds: ['steer'], state: 'dispatching' }, + { localIds: ['steer'], state: 'queued' } + ]); + // Exact FIFO restoration: before, steer, after. + expect(stub.session.queue.queue.map((item: { localId?: string }) => item.localId)) + .toEqual(['before', 'steer', 'after']); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('holds an ambiguous dispatch failure for explicit resolution instead of replaying it', async () => { + harness.softSteerDispatchError = indeterminateError('ACP write callback failed'); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Steer outcome is being reconciled' + }); + expect(stub.steerIndeterminateCalls).toEqual([['steer']]); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + // No automatic replay, but an explicit cancel releases it. + expect(stub.session.queue.peekByLocalId('steer')).toBeNull(); + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('keeps a provider failure indeterminate when native receipts are unavailable', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + let rejectSteer!: (error: Error) => void; + harness.deferSoftSteer = new Promise((_, reject) => { rejectSteer = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('correction', createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + rejectSteer(new Error('provider failed after persisting input')); + await vi.waitFor(() => expect(stub.steerIndeterminateCalls).toEqual([['steer']])); + expect(stub.session.queue.peekByLocalId('steer')).toBeNull(); + expect(harness.steerStateCalls).not.toContainEqual({ localIds: ['steer'], state: 'queued' }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('never replays overlapping steers after an ordinary provider failure', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + let releaseSnapshot!: (value: Array<{ id: string; text: string }>) => void; + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages') + .mockImplementationOnce(() => new Promise(resolve => { releaseSnapshot = resolve; })) + .mockResolvedValue([{ id: 'native-new', text: 'correction' }]); + let rejectSteer!: (error: Error) => void; + harness.deferSoftSteer = new Promise((_, reject) => { rejectSteer = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let first: Promise | undefined; + try { + stub.session.queue.push('correction', createMode(), 'steer-1'); + first = steerHandlerOf(stub)({ localId: 'steer-1' }); + await vi.waitFor(() => expect(receipt).toHaveBeenCalledOnce()); + stub.session.queue.push('correction', createMode(), 'steer-2'); + await steerHandlerOf(stub)({ localId: 'steer-2' }); + releaseSnapshot([]); await first; + rejectSteer(new Error('provider failed after dispatch')); + await vi.waitFor(() => expect(stub.steerIndeterminateCalls.flat().sort()).toEqual(['steer-1', 'steer-2'])); + expect(harness.steerStateCalls.filter(call => call.state === 'queued')).toEqual([]); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + } finally { + releaseSnapshot?.([]); await first?.catch(() => {}); + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('keeps undispatched persistence gated after plain Stop before a newer message', async () => { + let releaseState!: () => void; + harness.deferSteerState = new Promise(resolve => { releaseState = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + stub.session.queue.push('older correction', createMode(), 'steer'); + const steering = steerHandlerOf(stub)({ localId: 'steer' }); + try { + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual({ localIds: ['steer'], state: 'dispatching' })); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + stub.session.queue.push('newer input', createMode(), 'newer'); + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + await new Promise(resolve => setImmediate(resolve)); + expect(harness.promptCount).toBe(1); + expect(stub.sessionEvents.filter(event => event.type === 'ready')).toEqual([]); + releaseState(); await steering; + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(harness.promptContents[1]).toEqual([{ type: 'text', text: 'older correction\nnewer input' }]); + expect(harness.softSteerCalls).toEqual([]); + } finally { + releaseState(); await steering.catch(() => {}); + harness.deferSteerState = null; releasePrompt(); stub.session.queue.close(); await runPromise; + } + }); + + it('holds an ambiguous completion failure and never replays it', async () => { + let rejectSoftSteer!: (error: Error) => void; + harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + rejectSoftSteer(indeterminateError('ACP transport closed')); + + // Delivery stays unproven and user-resolvable: held out of the queue, + // no consumed ack, and only an explicit cancel releases it. + await vi.waitFor(() => expect(stub.steerIndeterminateCalls).toEqual([['steer']])); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.session.queue.peekByLocalId('steer')).toBeNull(); + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + + // That delivery ambiguity is independent of the backend's logical + // request lifetime: `completed` settled, so the ACP counters are + // already down and an ordinary Stop must not force-settle them again + // (which would deactivate the foreground handler for nothing). + await (stub.rpcHandlers.get('abort') as () => Promise)(); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1'); + expect(harness.abortSoftSteersCalls).toBe(0); + + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('acknowledges dispatch before completion but does not start the next prompt until completion', async () => { + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const { stub, handlers, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + stub.session.queue.push('second', createMode(), 'second'); + + // The RPC returns as soon as stdin accepted the inject. + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + // Foreground turn still in flight: no ready, no next prompt. + expect(stub.sessionEvents.filter((event) => event.type === 'ready')).toEqual([]); + expect(harness.promptCount).toBe(1); + + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + // Completion still gates the next prompt. + expect(harness.promptCount).toBe(1); + expect(stub.sessionEvents.filter((event) => event.type === 'ready')).toEqual([]); + + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual( + { localIds: ['steer'], options: { steered: true } } + )); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + void handlers; + + harness.deferSoftSteer = null; + stub.session.queue.close(); + await runPromise; + }); + + it('prevents sending into a new backend when an abort lands during persistence', async () => { + let releaseSteerState!: () => void; + harness.deferSteerState = new Promise((resolve) => { releaseSteerState = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + const steerPromise = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual( + { localIds: ['steer'], state: 'dispatching' } + )); + + await (stub.rpcHandlers.get('abort') as () => Promise)(); + releaseSteerState(); + + await expect(steerPromise).resolves.toEqual({ steered: false, error: 'Active turn changed' }); + expect(harness.softSteerCalls).toEqual([]); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('restores the row when the ACP call fails synchronously, before anything was sent', async () => { + harness.softSteerThrow = new Error('no active ACP prompt to soft-steer into'); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('before', createMode(), 'before'); + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + stub.session.queue.push('after', createMode(), 'after'); + + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Failed to soft-steer into active turn' + }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + expect(stub.session.queue.queue.map((item: { localId?: string }) => item.localId)) + .toEqual(['before', 'steer', 'after']); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('prevents sending into a new backend when a switch lands during persistence', async () => { + let releaseSteerState!: () => void; + harness.deferSteerState = new Promise((resolve) => { releaseSteerState = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + const steerPromise = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual( + { localIds: ['steer'], state: 'dispatching' } + )); + + const switchPromise = (stub.rpcHandlers.get('switch') as () => Promise)(); + releaseSteerState(); + await switchPromise; + + await expect(steerPromise).resolves.toEqual({ steered: false, error: 'Active turn changed' }); + expect(harness.softSteerCalls).toEqual([]); + + harness.deferSteerState = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('advertises steerability only while a normal prompt runs, and drops it on abort and teardown', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + // Live prompt: the web's queued Steer button is reachable, and it was + // published as false before the first prompt ran. + await vi.waitFor(() => expect(stub.steeringActiveCalls.at(-1)).toBe(true)); + expect(stub.steeringActiveCalls[0]).toBe(false); + + releasePrompt(); + await vi.waitFor(() => expect(stub.steeringActiveCalls.at(-1)).toBe(false)); + + // Abort keeps it false, and the terminal teardown leaves it false too. + await (stub.rpcHandlers.get('abort') as () => Promise)(); + expect(stub.steeringActiveCalls.at(-1)).toBe(false); + stub.session.queue.close(); + await runPromise; + expect(stub.steeringActiveCalls.at(-1)).toBe(false); + }); + + it('does not advertise steerability during a compaction', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] }; + let releaseCompact!: () => void; + compactHarness.triggerImpl = () => new Promise((resolve) => { + releaseCompact = () => resolve({ ok: true }); + }); + const stub = createSessionStub( + [{ message: '', mode: createCompactMode('ollama/x'), localId: 'compact' }], + { keepOpen: true } + ); + const runPromise = opencodeRemoteLauncher(stub.session as never, { + onCompactAvailabilityChange: () => {} + }); + await vi.waitFor(() => expect(compactHarness.calls.length).toBe(1)); + expect(stub.steeringActiveCalls).not.toContain(true); + + releaseCompact(); + compactHarness.triggerImpl = null; + stub.session.queue.close(); + await runPromise; + }); + + it('lets a new normal turn finish ready even when an aborted steer never completes', async () => { + // The concurrent ACP request was cancelled by the abort and will never + // answer; its completion callback stays attached so an accepted steer + // can still be acknowledged later. + harness.deferSoftSteer = new Promise(() => {}); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + + releasePrompt(); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + // Abort resets the queue, so the next normal turn is queued afterwards. + stub.session.queue.push('second', createMode(), 'second'); + + // The new normal turn must reach ready instead of blocking forever on + // the old steer's unresolved completion. + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + await vi.waitFor(() => expect(stub.sessionEvents).toContainEqual({ type: 'ready' })); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steeringActiveCalls.at(-1)).toBe(false); + expect(stub.steerIndeterminateCalls).toEqual([['steer']]); + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + + harness.deferSoftSteer = null; + harness.promptImpl = null; + stub.session.queue.close(); + await runPromise; + }); + + it('does not consume a replacement after an abandoned steer completes late', async () => { + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push('old correction', createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + // Explicit Retry releases the old unknown reservation and queues + // a replacement with the same localId. + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + stub.session.queue.push('replacement', createMode(), 'steer'); + releaseSoftSteer(); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.session.queue.peekByLocalId('steer')?.message).toBe('replacement'); + } finally { + releaseSoftSteer(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each(['resolve', 'reject'] as const)('keeps a fresh attempt owned when an abandoned explicit steer retry reports %s', async outcome => { + let resolveOld!: () => void; + let rejectOld!: (error: Error) => void; + harness.deferSoftSteer = new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseSecond: (() => void) | undefined; + let resolveNew: (() => void) | undefined; + try { + stub.session.queue.push('correction', createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + releasePrompt(); + harness.promptImpl = () => new Promise(resolve => { releaseSecond = resolve; }); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + harness.deferSoftSteer = new Promise(resolve => { resolveNew = resolve; }); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + if (outcome === 'resolve') resolveOld(); + else rejectOld(new Error('old attempt failed late')); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([['steer']]); + expect(stub.session.queue.cancelByLocalId('steer')).toBe('in-flight'); + resolveNew!(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + } finally { + resolveOld(); resolveNew?.(); releaseSecond?.(); + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each(['foreground', 'steer', 'failed-steer', 'failed-foreground'] as const)('post-stop native receipts recover only after successful old %s responses', async writer => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + let resolveOld!: () => void; + let rejectOld!: (error: Error) => void; + harness.deferSoftSteer = new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject; }); + const { stub, releasePrompt, rejectPrompt, runPromise } = await startTurn(); + let releaseSecond: (() => void) | undefined; + let resolveNew: (() => void) | undefined; + try { + const text = 'same correction'; + if (writer === 'steer' || writer === 'failed-steer') { + stub.session.queue.push(text, createMode(), 'old-steer'); + await steerHandlerOf(stub)({ localId: 'old-steer' }); + } + await (stub.rpcHandlers.get('abort') as () => Promise)(); + if (writer === 'failed-foreground') rejectPrompt(indeterminateError('old foreground response lost')); + else releasePrompt(); + if (writer === 'failed-steer') rejectOld(indeterminateError('old native response lost')); + else resolveOld(); + await vi.waitFor(() => expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1)); + await vi.waitFor(() => expect(stub.session.thinking).toBe(false)); + await new Promise(resolve => setImmediate(resolve)); + stub.emitMessagesConsumedCalls.length = 0; + harness.promptImpl = () => new Promise(resolve => { releaseSecond = resolve; }); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + const oldMessage = { id: 'already-saved-old-input', text: 'earlier correction' }; + receipt.mockReset().mockResolvedValueOnce([oldMessage]).mockResolvedValue([ + oldMessage, { id: 'new-native-input', text } + ]); + harness.deferSoftSteer = new Promise(resolve => { resolveNew = resolve; }); + stub.session.queue.push(text, createMode(), 'new-steer'); + await steerHandlerOf(stub)({ localId: 'new-steer' }); + if (writer === 'failed-steer' || writer === 'failed-foreground') { + expect(receipt).not.toHaveBeenCalled(); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + resolveNew!(); + } + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['new-steer'], options: { steered: true } } + ])); + // The foreground and new soft prompt are still unresolved in the + // recovery cases: this is input acceptance, not output completion. + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1); + } finally { + resolveOld(); resolveNew?.(); releaseSecond?.(); + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each(['synthetic', 'compaction'])('does not mistake a native %s helper for a pending user input', async kind => { + const text = 'Summarize the task tool output above and continue with your task.'; + const helper = { info: { id: 'internal-native-row', role: 'user' }, parts: kind === 'synthetic' + ? [{ type: 'text', text, synthetic: true }] + : [{ type: 'compaction', auto: true }, { type: 'text', text }] }; + let calls = 0; + let actualInputSaved = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json( + calls++ === 0 ? [] : actualInputSaved + ? [helper, { info: { id: 'real-native-input', role: 'user' }, parts: [{ type: 'text', text }] }] + : [helper] + )); + let resolveSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push(text, createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + await new Promise(resolve => setImmediate(resolve)); + expect(calls).toBeGreaterThanOrEqual(2); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + actualInputSaved = true; + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(0); + } finally { + resolveSteer(); nativeFetch.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('does not mistake an unmarked native compaction replay for a repeated pending input', async () => { + const text = 'previous correction'; + const original = { info: { id: 'earlier-input', role: 'user' }, parts: [{ type: 'text', text }] }; + const replay = { info: { id: 'native-overflow-replay', role: 'user' }, parts: [{ type: 'text', text }] }; + let calls = 0; + let actualInputSaved = false; + const nativeFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json( + calls++ === 0 ? [original] : [original, replay, ...(actualInputSaved + ? [{ info: { id: 'genuine-repeated-input', role: 'user' }, parts: [{ type: 'text', text }] }] + : [])] + )); + let resolveSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + try { + stub.session.queue.push(text, createMode(), 'repeated-steer'); + await steerHandlerOf(stub)({ localId: 'repeated-steer' }); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + actualInputSaved = true; + await new Promise(resolve => setTimeout(resolve, 150)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + resolveSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['repeated-steer'], options: { steered: true } } + ])); + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(0); + } finally { + resolveSteer(); nativeFetch.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('waits for its own response while a native slash foreground may save expanded text', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValueOnce([]) + .mockResolvedValue([{ id: 'expanded-foreground', text: 'expanded command body' }]); + let resolveSteer!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseCommand: (() => void) | undefined; + try { + releasePrompt(); + await vi.waitFor(() => expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1)); + harness.promptImpl = () => new Promise(resolve => { releaseCommand = resolve; }); + stub.session.queue.push('/native-command arguments', createMode(), 'command'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + stub.session.queue.push('expanded command body', createMode(), 'steer'); + await steerHandlerOf(stub)({ localId: 'steer' }); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + resolveSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['steer'], options: { steered: true } } + ])); + } finally { + resolveSteer(); releaseCommand?.(); receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('post-stop keeps receipt recovery when a successful foreground is still refreshing its transcript', async () => { + const transcript = await import('./utils/opencodeNativeTranscript'); + let releaseRefresh!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + let refreshCalls = 0; + const refresh = vi.spyOn(transcript.OpencodeNativeTranscript.prototype, 'refresh').mockImplementation(() => { + refreshCalls++; + return refreshCalls === 2 ? heldRefresh : Promise.resolve(); + }); + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValueOnce([]).mockResolvedValue([ + { id: 'new-native-input', text: 'correction' } + ]); + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseSecond: (() => void) | undefined; + let resolveSteer: (() => void) | undefined; + try { + releasePrompt(); + await vi.waitFor(() => expect(refreshCalls).toBe(2)); + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + releaseRefresh(); + harness.promptImpl = () => new Promise(resolve => { releaseSecond = resolve; }); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + harness.deferSoftSteer = new Promise(resolve => { resolveSteer = resolve; }); + stub.session.queue.push('correction', createMode(), 'new-steer'); + await steerHandlerOf(stub)({ localId: 'new-steer' }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['new-steer'], options: { steered: true } } + ])); + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1); + } finally { + releaseRefresh(); resolveSteer?.(); releaseSecond?.(); + refresh.mockRestore(); receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('post-stop resumes receipts when an old writer succeeds while a newer foreground remains live', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + let resolveOld!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveOld = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseSecond: (() => void) | undefined; + let resolveNew: (() => void) | undefined; + try { + stub.session.queue.push('same correction', createMode(), 'old-steer'); + await steerHandlerOf(stub)({ localId: 'old-steer' }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + releasePrompt(); + harness.promptImpl = () => new Promise(resolve => { releaseSecond = resolve; }); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + resolveOld(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['old-steer'], options: { steered: true } } + ])); + stub.emitMessagesConsumedCalls.length = 0; + const oldMessage = { id: 'old-native-input', text: 'same correction' }; + receipt.mockReset().mockResolvedValueOnce([oldMessage]).mockResolvedValue([ + oldMessage, { id: 'new-native-input', text: 'new correction' } + ]); + harness.deferSoftSteer = new Promise(resolve => { resolveNew = resolve; }); + stub.session.queue.push('new correction', createMode(), 'new-steer'); + await steerHandlerOf(stub)({ localId: 'new-steer' }); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['new-steer'], options: { steered: true } } + ])); + expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(1); + } finally { + resolveOld(); resolveNew?.(); releaseSecond?.(); + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('post-stop does not use a late old write after the next foreground completed and drained', async () => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + let resolveOld!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveOld = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + let releaseThird: (() => void) | undefined; + let resolveNew: (() => void) | undefined; + try { + stub.session.queue.push('same correction', createMode(), 'old-steer'); + await steerHandlerOf(stub)({ localId: 'old-steer' }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + releasePrompt(); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + await vi.waitFor(() => expect(harness.events.filter(event => event === 'prompt:end')).toHaveLength(2)); + await vi.waitFor(() => expect(stub.session.thinking).toBe(false)); + harness.promptImpl = () => new Promise(resolve => { releaseThird = resolve; }); + stub.session.queue.push('third foreground', createMode(), 'third'); + await vi.waitFor(() => expect(harness.promptCount).toBe(3)); + receipt.mockReset().mockResolvedValueOnce([]).mockResolvedValue([ + { id: 'late-old-input-only', text: 'same correction' } + ]); + harness.deferSoftSteer = new Promise(resolve => { resolveNew = resolve; }); + stub.session.queue.push('same correction', createMode(), 'new-steer'); + await steerHandlerOf(stub)({ localId: 'new-steer' }); + await new Promise(resolve => setImmediate(resolve)); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(receipt).not.toHaveBeenCalled(); + resolveNew!(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['new-steer'], options: { steered: true } } + ])); + } finally { + resolveOld(); resolveNew?.(); releaseThird?.(); + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it.each(['foreground', 'steer'] as const)('does not treat an abandoned %s late native save as a newer steer receipt', async writer => { + const receipts = await import('./utils/opencodeInputReceipt'); + const receipt = vi.spyOn(receipts, 'fetchOpencodeUserMessages').mockResolvedValue(null); + let resolveOld!: () => void; + harness.deferSoftSteer = new Promise(resolve => { resolveOld = resolve; }); + const { stub, releasePrompt, rejectPrompt, runPromise } = await startTurn(); + let releaseSecond: (() => void) | undefined; + let resolveNew: (() => void) | undefined; + try { + const text = writer === 'foreground' + ? harness.promptContents[0].filter(part => part.type === 'text').map(part => part.text).join('') + : 'same correction'; + if (writer === 'steer') { + stub.session.queue.push(text, createMode(), 'old-steer'); + await steerHandlerOf(stub)({ localId: 'old-steer' }); + } + await (stub.rpcHandlers.get('abort') as () => Promise)(); + // A successful native response fences that request's input writes. + // To model an abandoned foreground still capable of a late save, + // its transport result must remain unknown, not report success. + if (writer === 'foreground') rejectPrompt(indeterminateError('foreground response lost')); + else releasePrompt(); + harness.promptImpl = () => new Promise(resolve => { releaseSecond = resolve; }); + stub.session.queue.push('second foreground', createMode(), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + receipt.mockReset().mockResolvedValueOnce([]).mockResolvedValue([{ id: 'old-attempt-native-id', text }]); + harness.deferSoftSteer = new Promise(resolve => { resolveNew = resolve; }); + stub.session.queue.push(text, createMode(), 'new-steer'); + await steerHandlerOf(stub)({ localId: 'new-steer' }); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(receipt).not.toHaveBeenCalled(); + resolveNew!(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toEqual([ + { localIds: ['new-steer'], options: { steered: true } } + ])); + } finally { + resolveOld(); resolveNew?.(); releaseSecond?.(); + receipt.mockRestore(); harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + } + }); + + it('keeps the next prompt gated until a definite dispatch rejection is fully restored', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + stub.session.queue.push('second', createMode(), 'second'); + const { steering, releaseState } = await startRejectedDispatchRestore(stub); + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + expect(harness.promptCount).toBe(1); + expect(stub.sessionEvents.filter(event => event.type === 'ready')).toEqual([]); + releaseState(); await steering; + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(JSON.stringify(harness.promptContents[1])).toContain('mid-turn correction'); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + harness.deferSteerState = null; harness.deferSoftSteerDispatch = null; harness.deferSoftSteer = null; + stub.session.queue.close(); await runPromise; + }); + + + it('refuses a new steer once a stalled runtime has been cancelled from stderr', async () => { + let releasePrompt!: () => void; + harness.promptImpl = () => new Promise((resolve) => { + releasePrompt = resolve; + }); + const stub = createSessionStub( + [{ message: 'first', mode: createMode(), localId: 'first' }], + { keepOpen: true } + ); + const runPromise = opencodeRemoteLauncher(stub.session as never); + await vi.waitFor(() => expect(harness.promptCount).toBe(1)); + await vi.waitFor(() => expect(stub.steeringActiveCalls.at(-1)).toBe(true)); + + // A dispatched steer whose concurrent prompt the runtime cancelled and + // will never answer. + harness.deferSoftSteer = new Promise(() => {}); + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + + harness.stderrHandler!({ + type: 'quota_exceeded', + message: 'API quota exceeded. Retrying in 30 seconds', + raw: 'quota exceeded' + }); + await vi.waitFor(() => expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1')); + + // The cancelled runtime accepts no further mid-turn steer, and the + // killed steer's bookkeeping no longer gates the next turn. + expect(stub.steeringActiveCalls.at(-1)).toBe(false); + stub.session.queue.push('second', createMode(), 'second'); + await expect(steerHandlerOf(stub)({ localId: 'second' })).resolves.toEqual({ + steered: false, + error: 'No active steerable turn' + }); + expect(harness.softSteerCalls).toHaveLength(1); + + harness.deferSoftSteer = null; + releasePrompt(); + harness.promptImpl = null; + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + stub.session.queue.close(); + await runPromise; + }); + + it('completes a definite pre-dispatch restore before a switch finishes cleanup', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + const { steering, releaseState } = await startRejectedDispatchRestore(stub); + releasePrompt(); + const switching = (stub.rpcHandlers.get('switch') as () => Promise)(); + await vi.waitFor(() => expect(stub.steeringActiveCalls.at(-1)).toBe(false)); + expect(harness.promptCount).toBe(1); + releaseState(); await steering; await switching; + await vi.waitFor(() => expect(harness.cleanupEvents).toContain('cleanup:disconnect')); + expect(harness.softSteerCalls).toHaveLength(1); + expect(harness.abortSoftSteersCalls).toBe(0); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + harness.deferSteerState = null; harness.deferSoftSteerDispatch = null; harness.deferSoftSteer = null; + stub.session.queue.close(); await runPromise; + }); + + + it('force-settles a killed soft steer so the next turn\'s model update is not blocked', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/first', availableModels: [] }; + // The steer was accepted on dispatch but the runtime never answers its + // concurrent prompt: the stall cancel kills that ACP request. + harness.deferSoftSteer = new Promise(() => {}); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + + harness.stderrHandler!({ + type: 'quota_exceeded', + message: 'API quota exceeded. Retrying in 30 seconds', + raw: 'quota exceeded' + }); + await vi.waitFor(() => expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1')); + // The killed steer's backend counters are reset, so the + // response-complete wait behind the next turn's model/effort update is + // not held open by a request that will never answer. + await vi.waitFor(() => expect(harness.abortSoftSteersCalls).toBe(1)); + + releasePrompt(); + harness.promptImpl = null; + // Next normal turn switches model (which waits for response-complete + // in the real backend) and must still run and reach ready. + stub.session.queue.push('second', createMode('ollama/second'), 'second'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(harness.setModelArgs).toContainEqual({ sessionId: 'acp-session-1', modelId: 'ollama/second', flavor: 'opencode' }); + await vi.waitFor(() => expect(stub.sessionEvents).toContainEqual({ type: 'ready' })); + expect(harness.softSteerCalls).toHaveLength(1); + + harness.deferSoftSteer = null; + stub.session.queue.close(); + await runPromise; + }); + + it('does not touch ACP soft-steer bookkeeping on an ordinary abort with no steer', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + // No soft request was ever dispatched by this session, so cancelling + // must leave the foreground handler alone — otherwise the cancelled + // tool's final tool_result and the trailing text would be dropped. + await (stub.rpcHandlers.get('abort') as () => Promise)(); + expect(harness.abortSoftSteersCalls).toBe(0); + expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1'); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('does not touch ACP soft-steer bookkeeping on an ordinary stall cancel with no steer', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + harness.stderrHandler!({ + type: 'quota_exceeded', + message: 'API quota exceeded. Retrying in 30 seconds', + raw: 'quota exceeded' + }); + await vi.waitFor(() => expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1')); + expect(harness.abortSoftSteersCalls).toBe(0); + + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('rejects a steer once the foreground ACP request answered but prompt() is still draining', async () => { + const { stub, releasePrompt, runPromise } = await startTurn(); + + // ACP has answered session/prompt; only the update drain remains, so + // the turn is over from the agent's point of view. + harness.promptRequestInFlight = false; + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'No active steerable turn' + }); + // No reservation was even taken: the row is untouched for its turn. + expect(harness.steerStateCalls).toEqual([]); + expect(harness.softSteerCalls).toEqual([]); + expect(stub.session.queue.peekByLocalId('steer')).not.toBeNull(); + + harness.promptRequestInFlight = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('restores the row when the foreground request answers during dispatching persistence', async () => { + let releaseSteerState!: () => void; + harness.deferSteerState = new Promise((resolve) => { releaseSteerState = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + const steerPromise = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual( + { localIds: ['steer'], state: 'dispatching' } + )); + + // The ACP request answers while the durable write is still pending. + harness.promptRequestInFlight = false; + releaseSteerState(); + + await expect(steerPromise).resolves.toEqual({ steered: false, error: 'Active turn changed' }); + expect(harness.softSteerCalls).toEqual([]); + expect(harness.steerStateCalls).toEqual([ + { localIds: ['steer'], state: 'dispatching' }, + { localIds: ['steer'], state: 'queued' } + ]); + expect(stub.session.queue.peekByLocalId('steer')).not.toBeNull(); + + harness.promptRequestInFlight = null; + harness.deferSteerState = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('stops advertising steerability before draining a deferred steer completion', async () => { + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + await vi.waitFor(() => expect(stub.steeringActiveCalls.at(-1)).toBe(true)); + + // The foreground turn returns while the steer is still pending: the + // drain waits, and the advertisement must already be false so no + // queued row offers a steer that would be rejected. + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + expect(stub.steeringActiveCalls.at(-1)).toBe(false); + expect(harness.promptCount).toBe(1); + + releaseSoftSteer(); + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual( + { localIds: ['steer'], options: { steered: true } } + )); + + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('keeps an unsent steer gating the queue after a stall cancel, and restores it in FIFO order', async () => { + let releaseSteerState!: () => void; + harness.deferSteerState = new Promise((resolve) => { releaseSteerState = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('steer row', createMode(), 'steer'); + stub.session.queue.push('newer row', createMode(), 'newer'); + const steerPromise = steerHandlerOf(stub)({ localId: 'steer' }); + await vi.waitFor(() => expect(harness.steerStateCalls).toContainEqual( + { localIds: ['steer'], state: 'dispatching' } + )); + + // Stall cancels the runtime while the steer is still only in + // persistence — nothing was transmitted yet. + harness.stderrHandler!({ + type: 'quota_exceeded', + message: 'API quota exceeded. Retrying in 30 seconds', + raw: 'quota exceeded' + }); + await vi.waitFor(() => expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1')); + expect(harness.abortSoftSteersCalls).toBe(0); + + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + // The held row still gates the turn: no ready, and the newer queued + // message must not overtake it. + expect(harness.promptCount).toBe(1); + expect(stub.sessionEvents.filter((event) => event.type === 'ready')).toEqual([]); + + releaseSteerState(); + await expect(steerPromise).resolves.toEqual({ steered: false, error: 'Active turn changed' }); + expect(harness.softSteerCalls).toEqual([]); + + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + // Restored at its original FIFO position: ahead of the newer row. + expect(harness.promptContents[1]).toEqual([{ type: 'text', text: 'steer row\nnewer row' }]); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + + harness.deferSteerState = null; + stub.session.queue.close(); + await runPromise; + }); + + it.each(['stall', 'plain Stop'])('keeps a settled pre-dispatch restoration gated after %s', async reason => { + const { stub, releasePrompt, runPromise } = await startTurn(); + stub.session.queue.push('steer row', createMode(), 'steer'); + const { steering, releaseState } = await startRejectedDispatchRestore(stub); + if (reason === 'stall') { + harness.stderrHandler!({ type: 'quota_exceeded', message: 'API quota exceeded. Retrying in 30 seconds', raw: 'quota exceeded' }); + await vi.waitFor(() => expect(harness.cancelPrompt).toHaveBeenCalledWith('acp-session-1')); + } else await (stub.rpcHandlers.get('abort') as () => Promise)(); + stub.session.queue.push('newer row', createMode(), 'newer'); + releasePrompt(); + await vi.waitFor(() => expect(harness.events).toContain('prompt:end')); + expect(harness.abortSoftSteersCalls).toBe(0); + expect(harness.promptCount).toBe(1); + expect(stub.sessionEvents.filter(event => event.type === 'ready')).toEqual([]); + releaseState(); await steering; + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + expect(harness.promptContents[1]).toEqual([{ type: 'text', text: 'steer row\nnewer row' }]); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + expect(stub.steerIndeterminateCalls).toEqual([]); + harness.deferSteerState = null; harness.deferSoftSteerDispatch = null; harness.deferSoftSteer = null; + stub.session.queue.close(); await runPromise; + }); + + + it('keeps a newer turn\'s soft-steer ownership when an older settled request reports late', async () => { + let settleFirst!: () => void; + const firstCompletion = new Promise((resolve) => { settleFirst = resolve; }); + harness.deferSoftSteer = firstCompletion; + const { stub, releasePrompt, runPromise } = await startTurn(); + + // Turn 1: a steer whose request this turn's Stop force-settles. + stub.session.queue.push('first steer', createMode(), 'steer-1'); + await expect(steerHandlerOf(stub)({ localId: 'steer-1' })).resolves.toEqual({ steered: true }); + await (stub.rpcHandlers.get('abort') as () => Promise)(); + expect(harness.abortSoftSteersCalls).toBe(1); + releasePrompt(); + + // Turn 2 runs and dispatches its own still-unanswered soft request. + let releaseSecond!: () => void; + harness.promptImpl = () => new Promise((resolve) => { releaseSecond = resolve; }); + stub.session.queue.push('second turn', createMode(), 'turn-2'); + await vi.waitFor(() => expect(harness.promptCount).toBe(2)); + harness.deferSoftSteer = new Promise(() => {}); + stub.session.queue.push('second steer', createMode(), 'steer-2'); + await expect(steerHandlerOf(stub)({ localId: 'steer-2' })).resolves.toEqual({ steered: true }); + expect(harness.softSteerCalls).toHaveLength(2); + + // Turn 1's request finally answers, long after its own ownership was + // settled. It may only clear its own record and flag. + harness.deferSoftSteer = null; + settleFirst(); + await new Promise((resolve) => setImmediate(resolve)); + + // Turn 2's ownership survived that late report, so its Stop still + // recognises the outstanding request and recovers the counters. + await (stub.rpcHandlers.get('abort') as () => Promise)(); + expect(harness.abortSoftSteersCalls).toBe(2); + + harness.promptImpl = null; + releaseSecond(); + stub.session.queue.close(); + await runPromise; + }); + + it('acknowledges an abandoned steer exactly once when completion later proves acceptance', async () => { + let releaseSoftSteer!: () => void; + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ steered: true }); + + await (stub.rpcHandlers.get('abort') as () => Promise)(); + // The still-pending steer's ACP bookkeeping is force-settled so it + // cannot hold the next turn's message handler. + expect(harness.abortSoftSteersCalls).toBe(1); + releaseSoftSteer(); + + await vi.waitFor(() => expect(stub.emitMessagesConsumedCalls).toContainEqual( + { localIds: ['steer'], options: { steered: true } } + )); + expect(stub.emitMessagesConsumedCalls.filter((call) => call.localIds.includes('steer'))).toHaveLength(1); + // Stop first exposes the unknown outcome for explicit resolution; + // late completion can still consume it if the user left it alone. + expect(stub.steerIndeterminateCalls).toEqual([['steer']]); + + harness.deferSoftSteer = null; + await stopTurn(stub, releasePrompt, runPromise); + }); + + it('does not resurrect a steer row the user explicitly cancelled after a restore', async () => { + harness.softSteerDispatchError = new Error('session/prompt rejected'); + const { stub, releasePrompt, runPromise } = await startTurn(); + + stub.session.queue.push('mid-turn correction', createMode(), 'steer'); + await expect(steerHandlerOf(stub)({ localId: 'steer' })).resolves.toEqual({ + steered: false, + error: 'Failed to soft-steer into active turn' + }); + expect(stub.session.queue.peekByLocalId('steer')).not.toBeNull(); + + // Explicit cancel of the restored row: it must not come back, and the + // next prompt must not be given the abandoned input. + expect(stub.session.queue.cancelByLocalId('steer')).toBe(true); + expect(stub.session.queue.peekByLocalId('steer')).toBeNull(); + expect(stub.emitMessagesConsumedCalls).toEqual([]); + + await stopTurn(stub, releasePrompt, runPromise); + expect(harness.promptContents.every((content) => JSON.stringify(content).includes('mid-turn correction') === false)) + .toBe(true); + }); +}); + function backendHarnessCreateArgs(): Record { return harness.createBackendArgs[0] ?? {}; } @@ -2561,6 +4691,10 @@ function backendCreateArgsIncludePortFlags(): boolean { } describe('opencodeRemoteLauncher v2 flavor wiring', () => { + beforeEach(() => { + harness.eventStreamOptions = []; + }); + afterEach(async () => { harness.detectedVersion = null; harness.loopbackListeners = []; diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 20fcaeb7..3a09610f 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -4,7 +4,9 @@ import { Buffer } from 'node:buffer'; import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; import { logger } from '@/ui/logger'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport'; +import { isAcpIndeterminateError } from '@/agent/backends/acp/AcpStdioTransport'; import { isAcpStallStderrError } from '@/agent/backends/acp/acpStderrErrors'; import { convertAgentMessage } from '@/agent/messageConverter'; import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; @@ -12,6 +14,8 @@ import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLaunc import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; import type { OpencodeSession } from './session'; import type { OpencodeMode, PermissionMode } from './types'; +import type { QueueReservation } from '@/utils/MessageQueue2'; +import { admitOpencodeV2Steer, cancelOpencodeV2Steer, opencodeV2InputId, readOpencodeV2Input, steerOpencodeV2Inbox, type OpencodeSteerCancelHandler, type OpencodeSteerCancelResult } from './utils/opencodeV2Steer'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { allocateFreePort, @@ -46,8 +50,10 @@ import { import { OpencodePermissionHandler } from './utils/permissionHandler'; import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; +import { fetchOpencodeUserMessages } from './utils/opencodeInputReceipt'; type OpencodeRemoteLauncherOptions = { + onSteerCancelHandlerChange?: (handler: OpencodeSteerCancelHandler | null) => void; onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; // Called with `true` once the ACP backend + internal HTTP baseUrl are @@ -79,6 +85,42 @@ export type AbortStatusDecision = { type CompactOperationPhase = 'idle' | 'snapshot' | 'summarize' | 'post-summarize' | 'verification'; +/** + * One steer operation the launcher still tracks. Deliberately distinct from + * the queue reservation (what a cancelled row does) and from backend + * ownership (whether the ACP backend is counting a request): + * + * - `settled` resolves only once completion *and* every restore/consumed + * bookkeeping finished. It is what the foreground prompt's drain awaits. + * - `transportPending` is true only while the backend may still be counting + * this steer's request: from `beginSoftSteerPrompt` returning (the backend + * counts it before `dispatched` resolves) until the real `completed` promise + * settles, or until a *definite* failure proved the request is done. Only a + * pending request can be *stranded* by a cancellation (one the agent killed + * that never answers), so only a pending one may be detached. It must be + * cleared as soon as the request settles — while this operation keeps owning + * the row's restore bookkeeping, which must continue to gate the drain. + * - `release()` ends the launcher's wait without touching the ACP callbacks, + * so an accepted steer can still commit and acknowledge itself later. + * - `detach()` also makes an unanswered delivery explicitly resolvable. A + * late callback may consume it only while it still owns that reservation. + */ +type OpencodeV2SteerEntry = { + inputId: string; + sessionId: string; + delivered: () => void; + enqueued: (properties: Record) => void; + cancel: (purpose: 'stop' | 'explicit' | 'teardown', resetFinished?: Promise) => Promise; + abandon: () => void; +}; + +type OpencodeSteerOperation = { + settled: Promise; + transportPending: boolean; + detach: () => void; + release: () => void; +}; + /** * Pure decision logic for handleAbort()'s final step: which status message * to show, and whether `thinking` should be cleared. Extracted out of the @@ -158,10 +200,57 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private eventStream: OpencodeEventSubscription | null = null; private nativeTranscript: OpencodeNativeTranscript | null = null; private stallErrorReportedForPrompt = false; + /** True while a normal (non-compact/clear) prompt turn is in flight — the only steerable state. */ + private promptInFlight = false; + /** Mode hash of the prompt turn currently in flight; steers must match it. */ + private activePromptModeHash: string | null = null; + private activePromptText: string | null = null; + /** Bumped synchronously by abort/cleanup/teardown; invalidates in-flight steer handlers. */ + private steerEpoch = 0; + /** + * Identity of the normal prompt turn currently running, advanced once per + * prompt. `steerEpoch` churns several times per turn (onLeavingRemote, + * abort, cleanup), so it cannot identify which turn dispatched a soft + * request; this can, and cancellation paths use it to settle exactly their + * own turn's requests. + */ + private steerTurnEpoch = 0; + /** + * Steer operations that still own queue/bookkeeping state. Registered the + * moment a reservation is taken — *before* the durable-state await — and + * retained until the ACP completion plus every restore/consumed ACK has + * settled. The foreground prompt's `finally` drains this before advancing, + * so a prompt that finishes mid-persistence cannot start the next turn + * while a steer is still restoring rows. + */ + private pendingSteerOperations = new Set(); + private pendingSteerAcceptances = new Set(); + private nativeSteerReceiptGeneration = 0; + // Detached requests outlive operation/counter bookkeeping. Quarantine all + // native inputs present at a cancellation or uncertain outcome until each + // returns successfully: only its own response fences its input writes. + // Rejections retain quarantine; a later foreground is not a writer fence. + private nativeInputWriters = new Set<{ uncertain: boolean }>(); + private nativeSteerReceiptCorrelationLost = false; + /** + * Soft requests the ACP backend is still counting, keyed by identity so a + * late completion callback can only release *its own* entry and never + * corrupt a newer turn's ownership. + * + * `AcpSdkBackend.abortSoftSteers()` deactivates the shared foreground + * message handler, which would swallow the cancelled turn's trailing + * tool_result/text — so it may only be called while this set holds a + * request for that backend. An ordinary Stop with no steer must behave + * exactly as before steering existed. + */ + private backendSoftSteerOwnership = new Set<{ backend: AcpSdkBackend; turnEpoch: number }>(); /** opencode major version detected once per session; v2 changes spawn args and baseUrl discovery. */ private majorVersion: OpencodeFlavorVersion | null = null; /** v2-only REST access discovered after spawn; null unless both port and OPENCODE_PASSWORD were found. */ private v2RestInfo: { baseUrl: string; password: string } | null = null; + private v2Steers = new Map(); + private v2AbortInProgress: Promise | null = null; + private v2ResetInProgress: Promise | null = null; constructor( session: OpencodeSession, @@ -185,6 +274,9 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { protected async runMainLoop(): Promise { const session = this.session; const messageBuffer = this.messageBuffer; + // Published up front so a session that never reaches a normal prompt + // advertises "not steerable" rather than a stale value. + this.setSteeringActive(false); const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { enableChangeTitle: false, @@ -371,7 +463,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { mode: this.majorVersion ?? 'v1', onRetry: (retry) => this.surfaceUpstreamRetry(retry), onConnected: () => { void this.nativeTranscript?.refresh(); }, - onEvent: (type, properties, eventId) => this.nativeTranscript?.event(type, properties, eventId) + onEvent: (type, properties, eventId) => { + this.nativeTranscript?.event(type, properties, eventId); + this.handleV2SteerEvent(type, properties); + } }); } @@ -497,6 +592,11 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { onSwitch: () => this.handleSwitchRequest() }); + this.installSteerHandler(session, acpSessionId); + if (isV2 && this.v2RestInfo) { + this.options.onSteerCancelHandlerChange?.(localId => this.v2Steers.get(localId)?.cancel('explicit')); + } + const sendReady = () => { session.sendSessionEvent({ type: 'ready' }); }; @@ -534,6 +634,14 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { // controller afterward — the compact's unbounded REST call would // then run to completion with no way to interrupt it, despite // the user having already pressed Stop/switch/exit. + + // No foreground turn is live here (the previous one settled and its + // drain completed), so this is the one safe place to force-settle a + // soft request a cancellation left counted: the inter-turn + // model/effort switch below awaits response-complete and would + // otherwise block on it forever. + this.settleBackendSoftSteers(backend, null, 'dequeue'); + const isCompactBatch = batch.mode.operation === 'compact'; const compactAbortController = isCompactBatch ? new AbortController() : null; if (compactAbortController) { @@ -556,6 +664,9 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { // `batch.mode.model` semantics: a string is a specific model id; // `null` means "reset to whatever model the backend launched with" // (emitted by `/model default`); `undefined` means "no change". + // Preserve Default's null queue intent when display mode resolves + // to a concrete model; only a rollback changes that intent. + let steeringModel = batch.mode.model; const requestedModel = batch.mode.model === null ? this.defaultBackendModel : batch.mode.model; @@ -566,6 +677,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } else if (requestedModel && requestedModel !== this.currentBackendModel) { if (!backend.setModel || this.setModelSupported === false) { this.rollbackModel(batch, this.currentBackendModel); + steeringModel = batch.mode.model; } else { logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`); try { @@ -603,6 +715,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { }); } this.rollbackModel(batch, this.currentBackendModel); + steeringModel = batch.mode.model; } } } @@ -794,17 +907,45 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.stallErrorReportedForPrompt = false; session.onThinkingChange(true); - + // Only a normal prompt is steerable: /compact and /clear leave this + // false, so a steer arriving during either is rejected rather than + // injected into a turn the agent is not running. + this.promptInFlight = true; + this.activePromptModeHash = session.queue.modeHasher({ ...batch.mode, model: steeringModel }); + this.activePromptText = messageText; + this.steerTurnEpoch += 1; + // Advertise steerability so the web's queued-message Steer button + // is reachable at all; only a normal active prompt may set it. + this.setSteeringActive(true); + const nativeInput = { uncertain: false }; + this.nativeInputWriters.add(nativeInput); try { await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { this.handleAgentMessage(message); }); + this.completeNativeInput(nativeInput); await this.nativeTranscript?.refresh(); void backend.refreshSessionInfo(acpSessionId, session.path); } catch (error) { + this.invalidateNativeReceiptCorrelation(); logger.warn('[opencode-remote] prompt failed', error); this.reportPromptFailure(error); } finally { + // Close the steer gate synchronously, before the first await: + // any steer still parked on its durable-state await must see + // the turn as gone and restore its row instead of injecting + // into a turn that has already returned. + this.promptInFlight = false; + this.activePromptModeHash = null; + // Publish "not steerable" together with the gate, *before* the + // drain: a steer whose completion is still pending would keep + // the web's Steer button visible for the whole drain, and every + // click in that window is a guaranteed rejection. + this.setSteeringActive(false); + // Drain before ready / thinking=false / the next batch, so a + // steer that settled (or is still restoring) cannot cross the + // turn boundary into the next prompt or a /compact. + await this.drainSteerOperations(); session.onThinkingChange(false); await this.permissionHandler?.cancelAll('Prompt finished'); if (session.queue.size() === 0 && !this.shouldExit) { @@ -814,6 +955,608 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } } + /** + * Registers the per-message Queue/Steer handler. A steer delivers one + * waiting-queue row into the *active* OpenCode turn as a concurrent ACP + * `session/prompt` (soft inject) — no cancel, no mode/model/effort + * mutation, and no plan-instruction re-application (that already rode the + * active prompt). + */ + private installSteerHandler( + session: OpencodeSession, + acpSessionId: string + ): void { + session.client.rpcHandlerManager.registerHandler( + RPC_METHODS.SteerQueuedMessage, + async (payload: unknown) => { + const localId = typeof (payload as { localId?: unknown } | null)?.localId === 'string' + ? (payload as { localId: string }).localId + : ''; + if (!localId) { + return { steered: false, error: 'Missing localId' }; + } + if (this.majorVersion === 'v2' && !this.v2RestInfo) { + return { steered: false, error: 'OpenCode v2 internal API unavailable' }; + } + const backend = this.backend; + // isPromptRequestInFlight(), not processingMessage: after ACP + // answers the foreground session/prompt, prompt() keeps + // draining updates for seconds while the backend still reports + // a processing message. A steer accepted in that window would + // start a brand-new turn instead of injecting mid-turn. + if (!this.promptInFlight + || !this.activeAcpSessionId + || !backend + || this.shouldExit + || !backend.isPromptRequestInFlight()) { + return { steered: false, error: 'No active steerable turn' }; + } + const steerEpoch = this.steerEpoch; + const targetPromptGeneration = backend.getPromptGeneration(); + + // takeByLocalId is atomic: a duplicate Steer for the same row + // finds no queue entry (the live one is held as a reservation) + // and is rejected. + const taken = session.queue.takeByLocalId(localId); + if (!taken) { + return { steered: false, error: 'Message not in queue' }; + } + + // Registered before the first await below, and released only + // after every restore/consumed ACK has settled. Without this + // a prompt finishing during the durable-state await would + // drain an empty set and advance to the next batch while this + // row is still being restored. + const markUncertainNativeDelivery = () => { + this.invalidateNativeReceiptCorrelation(); + if (session.queue.markReservationIndeterminate(taken)) { + session.client.emitSteerIndeterminate([localId]); + } + }; + const { operation, release: releaseOperation } = this.beginSteerOperation(markUncertainNativeDelivery); + const receiptGeneration = this.nativeSteerReceiptGeneration; + + const isControlItem = Boolean(taken.item.isolate) || taken.item.mode.operation !== undefined; + if (isControlItem) { + session.queue.restoreReservation(taken); + releaseOperation(); + return { steered: false, error: 'Control commands cannot be steered' }; + } + if (this.activePromptModeHash !== taken.item.modeHash) { + session.queue.restoreReservation(taken); + releaseOperation(); + return { steered: false, error: 'Queued message mode differs from the active turn' }; + } + if (!session.queue.beginReservationDispatch(taken)) { + releaseOperation(); + return { steered: false, error: 'Steer cancelled' }; + } + + const dispatchStatePersisted = await session.client.setSteerDeliveryState([localId], 'dispatching'); + if (!dispatchStatePersisted) { + // Never send without a durable dispatching state: a crash + // after this point must not look like a delivered row. + session.queue.markReservationIndeterminate(taken); + session.client.emitSteerIndeterminate([localId]); + releaseOperation(); + return { steered: false, error: 'Steer state is indeterminate' }; + } + + const restoreQueuedReservation = async (shouldRestore: () => boolean = () => true): Promise => { + // Includes pre-admission and stall restores, whose native + // entry may not yet exist or predate the active Stop. + while (this.v2ResetInProgress) await this.v2ResetInProgress; + if (!shouldRestore()) return false; + if (!taken.originIndeterminate) { + const persisted = await session.client.setSteerDeliveryState([localId], 'queued'); + if (!persisted) { + session.queue.markReservationIndeterminate(taken); + session.client.emitSteerIndeterminate([localId]); + return false; + } + } + while (this.v2ResetInProgress) await this.v2ResetInProgress; + if (!shouldRestore()) return false; + if (taken.state !== 'dispatching' || !session.queue.restoreReservation(taken)) { + session.client.emitSteerIndeterminate([localId]); + return false; + } + return true; + }; + + if (taken.state !== 'dispatching') { + session.client.emitSteerIndeterminate([localId]); + releaseOperation(); + return { steered: false, error: 'Steer cancelled' }; + } + // Snapshot before dispatch: an older identical prompt must not + // count as acceptance. Overlapping unproven steers cannot be + // correlated by text, so they retain completion-based ACKs. + // The foreground input itself may still be awaiting native + // persistence. If its text matches, a new id cannot prove + // which request was accepted; wait for completion instead. + const nativeBefore = this.majorVersion !== 'v2' && !this.nativeSteerReceiptCorrelationLost + && this.pendingSteerAcceptances.size === 1 + && taken.item.message !== this.activePromptText + // Native slash commands can persist an expanded body + // different from the foreground text sent over ACP. + && !this.activePromptText?.trimStart().startsWith('/') + ? await fetchOpencodeUserMessages({ baseUrl: this.baseUrl, sessionId: acpSessionId }) + : null; + // Recheck everything the durable/native-history awaits could + // have invalidated before sending to the backend. + if (!this.promptInFlight + || this.shouldExit + || this.steerEpoch !== steerEpoch + || this.backend !== backend + || this.activeAcpSessionId !== acpSessionId + || !backend.isPromptRequestInFlight() + || backend.getPromptGeneration() !== targetPromptGeneration) { + await restoreQueuedReservation(); + releaseOperation(); + return { steered: false, error: 'Active turn changed' }; + } + + if (this.majorVersion === 'v2') { + return this.dispatchV2Steer(session, acpSessionId, localId, taken, releaseOperation, restoreQueuedReservation); + } + + let steer: ReturnType; + let softSteerOwnership: { backend: AcpSdkBackend; turnEpoch: number } | null = null; + const nativeInput = { uncertain: false }; + this.nativeInputWriters.add(nativeInput); + try { + steer = backend.beginSoftSteerPrompt(acpSessionId, [{ + type: 'text', + text: taken.item.message + }]); + // The backend counts this request from here, before + // `dispatched` resolves, so ownership starts now — and a + // cancellation can strand it from here on. + softSteerOwnership = { backend, turnEpoch: this.steerTurnEpoch }; + this.backendSoftSteerOwnership.add(softSteerOwnership); + operation.transportPending = true; + // Attach the transport-ownership cleanup immediately, + // before awaiting dispatch. `AcpSdkBackend`'s `completed` + // runs finishPromptRequest in its finally, so the backend + // stops counting this request the moment that promise + // settles — for *every* outcome, definite or ambiguous. + // Leaving the record behind would let a cancellation detach + // the operation mid-restore (the FIFO inversion) and + // force-settle a counter already back down, deactivating the + // foreground handler for nothing. + // + // This is purely backend logical-request bookkeeping: an + // ambiguous outcome is still an unproven *delivery*, which + // the reservation policy below handles independently (held + // indeterminate, never auto-replayed). The handler swallows + // the rejection, so this adds no unhandled rejection. + void steer.completed.then(() => { + this.completeNativeInput(nativeInput); + this.settleSteerTransport(operation, softSteerOwnership); + }, () => { + this.invalidateNativeReceiptCorrelation(); + this.settleSteerTransport(operation, softSteerOwnership); + }); + } catch (error) { + if (isAcpIndeterminateError(error)) { + markUncertainNativeDelivery(); + logger.debug('[opencode-remote] soft-steer dispatch outcome unknown', error); + releaseOperation(); + return { steered: false, error: 'Steer outcome is being reconciled' }; + } + this.completeNativeInput(nativeInput); + logger.debug('[opencode-remote] soft-steer failed to start', error); + await restoreQueuedReservation(); + releaseOperation(); + return { steered: false, error: 'Failed to soft-steer into active turn' }; + } + + try { + // Ack the hub once ACP accepted the inject on stdin, not + // when the concurrent prompt finishes — that response can + // exceed the hub's RPC timeout while the inject is already + // under way. Completion still gates the next prompt. + await steer.dispatched; + } catch (error) { + if (isAcpIndeterminateError(error)) { + markUncertainNativeDelivery(); + logger.debug('[opencode-remote] soft-steer dispatch outcome unknown', error); + releaseOperation(); + return { steered: false, error: 'Steer outcome is being reconciled' }; + } + // A definite dispatch rejection means the request is + // finished, not stranded: clear its ownership before the + // restore so a cancellation during it cannot treat this + // operation as an unanswered runtime request. + this.settleSteerTransport(operation, softSteerOwnership); + this.completeNativeInput(nativeInput); + await restoreQueuedReservation(); + releaseOperation(); + logger.debug('[opencode-remote] soft-steer failed to start', error); + return { steered: false, error: 'Failed to soft-steer into active turn' }; + } + + let accepted = false; + const acknowledgeAcceptance = () => { + if (accepted) return; + // Explicit resolution can release an abandoned request's + // reservation before its late native completion arrives. + if (!session.queue.commitReservation(taken)) return; + accepted = true; + this.pendingSteerAcceptances.delete(operation); + session.client.emitMessagesConsumed([localId], { steered: true }); + this.messageBuffer.addMessage(taken.item.message, 'user'); + }; + // OpenCode persists the user input before the concurrent + // session/prompt response (which waits for the model's entire + // turn). A new native id can confirm a previously unseen input. + // Compaction can replay an earlier user row without a marker, + // so repeated text must wait for its own successful response. + // Never infer acceptance from stdin dispatch or elapsed time. + const nativeReceipt = (async () => { + if (!nativeBefore || nativeBefore.some(message => message.text === taken.item.message)) return; + const previousIds = new Set(nativeBefore.map(message => message.id)); + while (!accepted && operation.transportPending + && !this.nativeSteerReceiptCorrelationLost + && this.steerEpoch === steerEpoch + && this.backend === backend + && this.activeAcpSessionId === acpSessionId + && this.nativeSteerReceiptGeneration === receiptGeneration + && this.pendingSteerAcceptances.has(operation)) { + const nativeMessages = await fetchOpencodeUserMessages({ + baseUrl: this.baseUrl, + sessionId: acpSessionId + }); + if (this.nativeSteerReceiptCorrelationLost + || this.steerEpoch !== steerEpoch + || this.backend !== backend + || this.activeAcpSessionId !== acpSessionId + || this.nativeSteerReceiptGeneration !== receiptGeneration) return; + const matches = nativeMessages?.filter(message => + !previousIds.has(message.id) && message.text === taken.item.message + ) ?? []; + if (matches.length === 1) { + acknowledgeAcceptance(); + return; + } + if (matches.length > 1) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + })(); + // Completion still gates the next normal prompt and settles + // backend ownership. A failure after proven native acceptance + // must never restore/replay that input. If no receipt is + // available, hold a completion failure indeterminate. + void steer.completed.then(acknowledgeAcceptance, async (error) => { + await nativeReceipt; + if (accepted) return; + // ACP provider errors may arrive after native input acceptance. + // Without a receipt, dispatch completion cannot prove rejection. + markUncertainNativeDelivery(); + logger.debug('[opencode-remote] soft-steer outcome unknown after dispatch; row held for explicit resolution', error); + }).finally(() => { + releaseOperation(); + }); + // Native persistence is independent of the transport ACK. Keep + // reconciling while this request owns the active turn, without + // holding the hub RPC open or inferring acceptance from dispatch. + return { steered: true }; + } + ); + } + + private handleV2SteerEvent(type: string, properties: Record): void { + if (this.majorVersion !== 'v2' || typeof properties.inboxID !== 'string') return; + for (const entry of this.v2Steers.values()) { + if (properties.sessionID !== entry.sessionId || properties.inboxID !== entry.inputId) continue; + if (type === 'session.inbox.delivered') entry.delivered(); + if (type === 'session.inbox.enqueued') entry.enqueued(properties); + // delivery.changed is only a mode change. Neither it nor an + // enqueued fact can acknowledge model-visible input delivery. + } + } + + private async dispatchV2Steer( + session: OpencodeSession, + sessionId: string, + localId: string, + taken: QueueReservation, + release: () => void, + restore: (shouldRestore?: () => boolean) => Promise + ): Promise<{ steered: boolean; error?: string }> { + const rest = this.v2RestInfo!; + const opts = { ...rest, sessionId, text: taken.item.message, inputId: opencodeV2InputId({ + hapiSessionId: session.client.sessionId, sessionId, localId, text: taken.item.message + }) }; + let accepted = false; + let admissionKnown = false; + let cancelRequested = false; + let abandoned = false; + let purpose: 'stop' | 'explicit' | 'teardown' = 'stop'; + let cancellation: Promise | null = null; + const currentPurpose = () => purpose; + let resolveDelivered!: () => void; + const delivered = new Promise(resolve => { resolveDelivered = resolve; }); + const remove = () => { if (this.v2Steers.get(localId) === entry) this.v2Steers.delete(localId); }; + const unknown = () => { + if (accepted) return; + if (session.queue.markReservationIndeterminate(taken)) session.client.emitSteerIndeterminate([localId]); + release(); + }; + const consume = () => { + if (accepted) return true; + if (abandoned || !session.queue.commitReservation(taken)) return false; + accepted = true; + session.client.emitMessagesConsumed([localId], { steered: true }); + this.messageBuffer.addMessage(taken.item.message, 'user'); + resolveDelivered(); + release(); + remove(); + return true; + }; + const entry: OpencodeV2SteerEntry = { + inputId: opts.inputId, sessionId, delivered: consume, + enqueued: properties => { + const item = properties.item; + if (item && typeof item === 'object' && 'type' in item && item.type === 'user' + && 'payload' in item && item.payload && typeof item.payload === 'object' + && 'text' in item.payload && item.payload.text === opts.text) admissionKnown = true; + }, + abandon: () => { abandoned = true; unknown(); }, + cancel: (nextPurpose, resetFinished = Promise.resolve()) => { + cancelRequested = true; + if (nextPurpose === 'explicit' || purpose !== 'explicit' && nextPurpose === 'teardown') purpose = nextPurpose; + const activeCancellation: Promise = cancellation ?? (async (): Promise => { + const admission = await Promise.race([admitted, delivered.then(() => 'delivered' as const)]); + if (accepted) return 'consumed'; + if (!admissionKnown && admission !== 'rejected') { + // A lost POST may still prepare an input. Only a + // positive inbox/projection proves its write happened; + // a no-op DELETE before admission cannot fence it. + const state = await readOpencodeV2Input(opts); + if (state === 'delivered') return consume() ? 'consumed' : 'indeterminate'; + admissionKnown = state === 'pending'; + } + const outcome = admission === 'rejected' ? 'cancelled' + : admissionKnown ? await cancelOpencodeV2Steer(opts) : 'unknown'; + if (accepted || outcome === 'delivered') return consume() ? 'consumed' : 'indeterminate'; + await resetFinished; + if (outcome !== 'cancelled' || abandoned) { unknown(); return 'indeterminate'; } + if (purpose === 'explicit') { + session.queue.markReservationIndeterminate(taken); + const removed = session.queue.cancelByLocalId(localId); + release(); remove(); + return removed; + } + if (purpose === 'stop' && !taken.originIndeterminate) { + const restored = await restore(() => purpose === 'stop' && !abandoned); + // Explicit resolution can arrive during the durable + // queued-state await. Remove the actual reservation or + // restored FIFO item before acknowledging removal. + if (currentPurpose() === 'explicit') { + session.queue.markReservationIndeterminate(taken); + const removed = session.queue.cancelByLocalId(localId); + release(); remove(); + return removed; + } + if (!restored) unknown(); + release(); remove(); + return restored ? true : 'indeterminate'; + } + // Teardown or a previously unknown delivery never becomes + // an implicit retry. The durable HAPI row remains unknown. + unknown(); remove(); + return 'indeterminate'; + })().catch((error): OpencodeSteerCancelResult => { + logger.debug('[opencode-remote] v2 cancellation could not be verified', error); + unknown(); + return 'indeterminate'; + }).then((result): OpencodeSteerCancelResult => { + // Unknown is not terminal native evidence. A later user + // Cancel/Retry must be able to reconcile newly admitted input. + if (result === 'indeterminate') cancellation = null; + return result; + }); + cancellation = activeCancellation; + return activeCancellation; + } + }; + this.v2Steers.set(localId, entry); + const admitted = (async () => { + if (!taken.originIndeterminate) return admitOpencodeV2Steer(opts); + // Unknown retries never blindly POST again: a previous request + // might still admit later. Reconcile its stable native identity. + const state = await readOpencodeV2Input(opts); + if (state === 'delivered') { consume(); return 'admitted' as const; } + if (state !== 'pending' || cancelRequested) return 'unknown' as const; + return steerOpencodeV2Inbox(opts); + })().then(result => { + admissionKnown ||= result === 'admitted'; + return result; + }); + const result = await Promise.race([admitted, delivered.then(() => 'delivered' as const)]); + if (accepted) return { steered: true }; + if (cancelRequested) return { steered: false, error: 'Active turn changed' }; + if (result === 'rejected') { + const restored = await restore(); + release(); remove(); + return { steered: false, error: restored ? 'OpenCode rejected steering input' : 'Steer state is indeterminate' }; + } + if (result !== 'admitted') { + unknown(); + return { steered: false, error: 'Steer outcome is being reconciled' }; + } + // SSE owns prompt feedback. Identity-based readback closes missed + // events/reconnect gaps without treating elapsed time as acceptance. + void (async () => { + while (!accepted && !cancelRequested && !abandoned) { + const state = await readOpencodeV2Input(opts); + if (accepted || cancelRequested || abandoned) return; + if (state === 'delivered') { consume(); return; } + if (state !== 'pending') { unknown(); return; } + await new Promise(resolve => setTimeout(resolve, 100)); + } + })(); + return { steered: true }; + } + + /** + * Single source of the steer gate's lifecycle: bump the epoch so any steer + * parked on an await is invalid, close the normal-prompt gate, and stop + * advertising steerability. Every terminal or cancelling path (abort, + * switch/exit, teardown, and the stderr stall cancel) must run this + * *synchronously*, before its first await — otherwise a steer can still + * inject into a runtime that is being cancelled or already torn down. + * + * `detachStrandedSteers` additionally releases tracked operations whose + * ACP request may still be unanswered — a cancellation can strand those + * forever, and they must not block the next turn's drain. Everything else + * keeps gating the drain: an operation still on its durable write, or on + * the restore that follows a settled request, always finishes within the + * socket timeouts. Releasing those early would let the next prompt start + * (and `ready` fire) while the row is still held, and the row would then + * be restored *after* the next prompt, inverting FIFO. Their ACP callbacks + * stay attached either way. An unanswered delivery becomes indeterminate, + * so explicit Cancel/Retry can release it; late acceptance may commit only + * while that request still owns the reservation. + */ + private closeSteerGate(options?: { detachStrandedSteers?: boolean }): void { + if (options?.detachStrandedSteers && this.promptInFlight) { + // A normal foreground request can also still be awaiting native + // persistence when Stop/stall releases its turn. + this.invalidateNativeReceiptCorrelation(); + } + this.steerEpoch++; + this.promptInFlight = false; + this.activePromptModeHash = null; + this.activePromptText = null; + this.setSteeringActive(false); + if (options?.detachStrandedSteers === true) { + for (const operation of Array.from(this.pendingSteerOperations)) { + if (operation.transportPending) { + operation.detach(); + } + } + } + } + + private invalidateNativeReceiptCorrelation(): void { + this.nativeSteerReceiptCorrelationLost = this.nativeInputWriters.size > 0; + for (const input of this.nativeInputWriters) input.uncertain = true; + } + + private completeNativeInput(input: { uncertain: boolean }): void { + this.nativeInputWriters.delete(input); + if (!Array.from(this.nativeInputWriters).some(writer => writer.uncertain)) { + this.nativeSteerReceiptCorrelationLost = false; + } + } + + /** + * Publishes whether a queued message could be steered right now. The web + * only surfaces the queued Steer button for a session advertising this, + * so it must track the normal prompt turn exactly — true while one runs, + * false in its `finally`, on abort, on teardown, and the instant remote + * mode starts leaving. + */ + private setSteeringActive(active: boolean): void { + // v2 uses its experimental durable REST inbox, never concurrent ACP. + const steeringActive = active && (this.majorVersion !== 'v2' || this.v2RestInfo !== null); + this.session.client.updateAgentState?.((state) => ({ ...state, steeringActive })); + } + + /** + * Registers a steer operation that the foreground prompt must wait for. + * Released once its bookkeeping finished, or detached by a cancellation + * that can strand its ACP request — either way the ACP callbacks attached + * to that request stay alive. + */ + private beginSteerOperation(onDetach: () => void): { + operation: OpencodeSteerOperation; + release: () => void; + } { + let settle!: () => void; + const operation: OpencodeSteerOperation = { + settled: new Promise((resolve) => { settle = resolve; }), + transportPending: false, + detach: () => { + onDetach(); + operation.release(); + }, + release: () => {} + }; + this.pendingSteerOperations.add(operation); + this.pendingSteerAcceptances.add(operation); + this.nativeSteerReceiptGeneration++; + let released = false; + operation.release = () => { + if (released) return; + released = true; + this.pendingSteerOperations.delete(operation); + this.pendingSteerAcceptances.delete(operation); + settle(); + }; + return { operation, release: operation.release }; + } + + /** + * Marks a steer operation's ACP request as settled: the backend no longer + * counts it, so it is neither stranded-by-a-cancellation material nor a + * reason to force-settle counters. + * + * Identity-scoped on both sides — a late callback (an aborted turn's + * request settling long after) can only clear its own record and flag, and + * can never mark a newer turn's request as settled. + */ + private settleSteerTransport( + operation: OpencodeSteerOperation, + ownership: { backend: AcpSdkBackend; turnEpoch: number } | null + ): void { + operation.transportPending = false; + if (ownership) { + this.backendSoftSteerOwnership.delete(ownership); + } + } + + /** + * Force-settles this backend's soft-steer counters — and only when it + * actually owns an outstanding soft request. + * + * `AcpSdkBackend.abortSoftSteers()` deactivates the shared foreground + * message handler, so calling it on a plain Stop with no steer would + * swallow the cancelled turn's trailing tool_result/text. `turnEpoch` + * scopes the decision to the turn being cancelled; `null` means "any + * leftover", which is only safe where no foreground turn is live (the + * dequeue loop, after the previous turn settled). + */ + private settleBackendSoftSteers( + backend: AcpSdkBackend, + turnEpoch: number | null, + reason: string + ): void { + let owned = false; + for (const entry of Array.from(this.backendSoftSteerOwnership)) { + if (entry.backend !== backend) continue; + if (turnEpoch !== null && entry.turnEpoch !== turnEpoch) continue; + this.backendSoftSteerOwnership.delete(entry); + owned = true; + } + if (!owned) { + return; + } + logger.debug(`[opencode-remote] settling stranded soft-steer request (${reason})`); + backend.abortSoftSteers(); + } + + /** Wait for remaining durable writes/restores, including after a plain Stop. + * closeSteerGate detaches only stranded runtime requests; their callbacks + * remain attached, while undispatched/settled bookkeeping still gates FIFO. */ + private async drainSteerOperations(): Promise { + const pending = Array.from(this.pendingSteerOperations, operation => operation.settled); + await Promise.allSettled(pending); + } + /** * /compact must stop being offered the instant remote mode starts * leaving — not merely by the time it's actually torn down, and @@ -829,10 +1572,26 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { * comment on RemoteLauncherBase for exactly when this fires. */ protected onLeavingRemote(): void { + // Synchronous, before anything here could await: a steer parked on a + // durable-state await must not be able to inject into a backend that is + // already on its way out. + this.closeSteerGate(); this.options.onCompactAvailabilityChange?.(false); } protected async cleanup(): Promise { + // Before anything that can fail: a steer still parked on a + // durable-state await must not be able to send into the backend that is + // about to be torn down (or a replacement one). + this.closeSteerGate({ detachStrandedSteers: true }); + this.options.onSteerCancelHandlerChange?.(null); + await Promise.all(Array.from(this.v2Steers.values(), entry => entry.cancel('teardown'))); + for (const entry of this.v2Steers.values()) entry.abandon(); + this.v2Steers.clear(); + this.session.client.rpcHandlerManager.registerHandler(RPC_METHODS.SteerQueuedMessage, async () => ({ + steered: false, + error: 'Session ending' + })); // Before anything that can fail: a stream left open would keep // reconnecting to a subprocess that is on its way out. this.nativeTranscript?.close(); @@ -980,11 +1739,27 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { return; } + // Synchronous, before the cancel: this cancels the running runtime turn, + // so a cancelled runtime must never accept a new mid-turn steer, and a + // concurrent ACP request the cancel killed must not block the next + // turn's drain. Only already-transmitted requests are detached — a + // reservation still waiting on its durable write keeps gating the + // drain, so its row cannot be restored after the next prompt. + const v2Cancellations = Array.from(this.v2Steers.values(), entry => entry.cancel('stop')); + this.closeSteerGate({ detachStrandedSteers: true }); + this.session.onThinkingChange(false); try { await backend.cancelPrompt(sessionId); } catch (error) { logger.debug('[opencode-remote] cancelPrompt after stderr failed', error); + } finally { + // Same reasoning as handleAbort, and deliberately conditional: with + // no soft request of this turn in flight, abortSoftSteers() would + // deactivate the foreground handler and swallow the tail output + // OpenCode still emits while processing the cancel. + this.settleBackendSoftSteers(backend, this.steerTurnEpoch, 'stall-cancel'); + await Promise.all(v2Cancellations); } } @@ -1377,9 +2152,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { /** * `leavingRemote` distinguishes plain Stop (`false`, the default — stays * in the same remote session) from switch-to-local/exit (`true` — the - * session is being torn down). A 6th PR-review round rejected an earlier - * fix (always aborting `compactAbortController` here) because it broke - * this feature's core invariant: compact and a prompt must never touch + * session is being torn down). Compact and a prompt must never touch * the same OpenCode session concurrently (see * `compactResultSuppressed`'s field doc comment for the full * reasoning). Plain Stop aborts only read-only marker/result GETs. It @@ -1389,59 +2162,109 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { * disconnects the ACP subprocess right after. */ private async handleAbort(leavingRemote = false): Promise { - // A hostile-review sweep found that a plain Stop during an in-flight - // compact — which deliberately leaves the operation running for real - // (see compactResultSuppressed's doc comment) — still unconditionally - // flipped `thinking` off and reported "Turn aborted" below, telling - // the user the turn had stopped while the dequeue loop was actually - // still blocked inside runCompactOperation() for however long the - // real server-side compaction takes (potentially minutes). Track - // that specific case so the messaging stays honest: nothing has - // actually stopped yet from the user's perspective, and the dequeue - // loop's own `finally` (once runCompactOperation() genuinely - // returns) remains the sole source of truth for when this turn is - // done. - const compactAbortController = this.compactAbortController; - if (compactAbortController) { - this.compactResultSuppressed = true; - // Only summarize can still mutate the shared OpenCode session. - // Plain Stop aborts snapshot/verification reads, but deliberately - // waits for an in-flight POST to complete server-side. - if ( - leavingRemote - || this.compactOperationPhase === 'snapshot' - || this.compactOperationPhase === 'post-summarize' - || this.compactOperationPhase === 'verification' - ) { - compactAbortController.abort(); + if (this.majorVersion !== 'v2') return this.performAbort(leavingRemote); + if (this.v2AbortInProgress) { + // One overlapping Stop/switch owns queue.reset. A later reset + // must not erase an input the first cancellation just restored. + if (leavingRemote) { + this.closeSteerGate({ detachStrandedSteers: true }); + this.compactResultSuppressed = true; + this.compactAbortController?.abort(); } + return this.v2AbortInProgress; } - const backend = this.backend; - if (backend && this.session.sessionId) { - await backend.cancelPrompt(this.session.sessionId); + let resolve!: () => void; + let reject!: (error: unknown) => void; + const abort = new Promise((ok, failed) => { resolve = ok; reject = failed; }); + this.v2AbortInProgress = abort; + void this.performAbort(leavingRemote).then(resolve, reject); + try { await abort; } + finally { if (this.v2AbortInProgress === abort) this.v2AbortInProgress = null; } + } + + private async performAbort(leavingRemote: boolean): Promise { + let resolveReset!: () => void; + const resetFinished = new Promise(resolve => { resolveReset = resolve; }); + if (this.majorVersion === 'v2') this.v2ResetInProgress = resetFinished; + const finishReset = () => { + if (this.v2ResetInProgress === resetFinished) this.v2ResetInProgress = null; + resolveReset(); + }; + const v2Cancellations = Array.from(this.v2Steers.values(), entry => entry.cancel('stop', resetFinished)); + let resetDone = false; + try { + // Synchronous, before any await: a steer parked on its durable-state + // await must observe the turn as invalid immediately, so it restores + // its row instead of injecting into a cancelled turn or a new one. + this.closeSteerGate({ detachStrandedSteers: true }); + // A plain Stop during an in-flight compact deliberately leaves the + // operation running for real. Its dequeue-loop finally remains + // the boundary for thinking=false while the native POST can mutate + // this session; Stop must not report completion before that boundary. + const compactAbortController = this.compactAbortController; + if (compactAbortController) { + this.compactResultSuppressed = true; + // Only summarize can still mutate the shared OpenCode session. + // Plain Stop aborts snapshot/verification reads, but deliberately + // waits for an in-flight POST to complete server-side. + if ( + leavingRemote + || this.compactOperationPhase === 'snapshot' + || this.compactOperationPhase === 'post-summarize' + || this.compactOperationPhase === 'verification' + ) { + compactAbortController.abort(); + } + } + const backend = this.backend; + if (backend && this.session.sessionId) { + try { + await backend.cancelPrompt(this.session.sessionId); + } finally { + // A soft steer this turn dispatched can still be pending after + // an Abort and its ACP request may never answer, which would + // hold both the next turn's message handler and the inter-turn + // model/effort switches that wait for response-complete. Only + // settle counters this backend genuinely owns, so an ordinary + // Stop with no steer keeps the cancelled turn's trailing + // tool_result/text (abortSoftSteers deactivates the shared + // foreground handler). + this.settleBackendSoftSteers(backend, this.steerTurnEpoch, 'abort'); + } + } + await this.permissionHandler?.cancelAll('User aborted'); + // A steer that was already dispatched may still complete after the + // abort; preserve its reservation so the completion callback can + // still commit it (or mark it indeterminate) instead of losing the + // input. + this.session.queue.reset({ preserveDispatchingReservations: true }); + resetDone = true; + finishReset(); + await Promise.all(v2Cancellations); + this.abortController.abort(); + this.abortController = new AbortController(); + // Re-read here (not the snapshot taken above, before the awaits) in + // case a concurrent leavingRemote=true call for the same compact + // interleaved with this one and already aborted it — RPC dispatch + // doesn't serialize handleAbort() calls against each other, so a + // Stop immediately followed by a switch-to-local can genuinely + // overlap. Without this, the (now-stale) plain-Stop continuation + // could append its "still waiting" message after the switch's + // "Turn aborted" already ran, showing the two in a confusing order. + const activeCompactAbortController = this.compactAbortController; + const decision = selectAbortStatusMessage({ + hasCompactInFlight: activeCompactAbortController !== null && this.compactOperationPhase === 'summarize', + leavingRemote, + compactAborted: activeCompactAbortController?.signal.aborted ?? false + }); + if (decision.shouldClearThinking) { + this.session.onThinkingChange(false); + } + this.messageBuffer.addMessage(decision.message, 'status'); + } finally { + if (!resetDone) for (const entry of this.v2Steers.values()) void entry.cancel('teardown'); + finishReset(); } - await this.permissionHandler?.cancelAll('User aborted'); - this.session.queue.reset(); - this.abortController.abort(); - this.abortController = new AbortController(); - // Re-read here (not the snapshot taken above, before the awaits) in - // case a concurrent leavingRemote=true call for the same compact - // interleaved with this one and already aborted it — RPC dispatch - // doesn't serialize handleAbort() calls against each other, so a - // Stop immediately followed by a switch-to-local can genuinely - // overlap. Without this, the (now-stale) plain-Stop continuation - // could append its "still waiting" message after the switch's - // "Turn aborted" already ran, showing the two in a confusing order. - const activeCompactAbortController = this.compactAbortController; - const decision = selectAbortStatusMessage({ - hasCompactInFlight: activeCompactAbortController !== null && this.compactOperationPhase === 'summarize', - leavingRemote, - compactAborted: activeCompactAbortController?.signal.aborted ?? false - }); - if (decision.shouldClearThinking) { - this.session.onThinkingChange(false); - } - this.messageBuffer.addMessage(decision.message, 'status'); } private async handleExitFromUi(): Promise { diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index b04a592b..654915f4 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -2,6 +2,8 @@ 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(), @@ -471,6 +473,20 @@ describe('runOpencode set-session-config handler', () => { 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' }); @@ -894,4 +910,25 @@ describe('runOpencode set-session-config handler', () => { 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); + }); + }); diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index 3a471f0f..f79ac4fc 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -1,3 +1,4 @@ +import type { OpencodeSteerCancelHandler } from './utils/opencodeV2Steer'; import { logger } from '@/ui/logger'; import { randomUUID } from 'node:crypto'; import { opencodeLoop } from './loop'; @@ -438,9 +439,12 @@ export async function runOpencode(opts: { }); }); + let cancelNativeSteer: OpencodeSteerCancelHandler | null = null; session.onCancelQueuedMessage((localId) => { + const nativeCancellation = cancelNativeSteer?.(localId); + if (nativeCancellation !== undefined) return nativeCancellation; const removedFromQueue = messageQueue.cancelByLocalId(localId); - if (removedFromQueue) { + if (removedFromQueue === true) { if (queuedClearLocalId === localId) { queuedClearLocalId = null; clearTransitionLatched = false; @@ -457,6 +461,7 @@ export async function runOpencode(opts: { logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`); return true; } + if (removedFromQueue !== false) return removedFromQueue; if (preparingLocalIds.has(localId)) { cancelledBeforeEnqueue.add(localId); logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`); @@ -543,6 +548,7 @@ export async function runOpencode(opts: { sessionWrapperRef.current = instance; syncSessionMode(); }, + onSteerCancelHandlerChange: (handler) => { cancelNativeSteer = handler; }, onCompactAvailabilityChange: (available) => { compactSupported = available; if (!available) { diff --git a/cli/src/opencode/utils/opencodeInputReceipt.test.ts b/cli/src/opencode/utils/opencodeInputReceipt.test.ts new file mode 100644 index 00000000..c04f4a2e --- /dev/null +++ b/cli/src/opencode/utils/opencodeInputReceipt.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchOpencodeUserMessages } from './opencodeInputReceipt'; + +const context = { baseUrl: 'http://127.0.0.1:1234', sessionId: 'session/one' }; +const user = { info: { id: 'user-1', role: 'user' }, parts: [{ type: 'text', text: 'correction' }] }; + +describe('fetchOpencodeUserMessages', () => { + it('reads native ids and joins text parts, excluding assistant output', async () => { + const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => Response.json([ + { ...user, parts: [{ type: 'text', text: 'mid-' }, { type: 'text', text: 'turn' }] }, + { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'mid-turn' }] } + ])); + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl })).resolves.toEqual([{ id: 'user-1', text: 'mid-turn' }]); + expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:1234/session/session%2Fone/message', expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) })); + }); + + it.each([ + null, {}, [null], [{ info: null }], + [{ ...user, info: { role: 'user' } }], + [{ ...user, info: { id: '', role: 'user' } }], + [{ ...user, parts: undefined }], + [{ ...user, parts: [null] }], + [{ ...user, parts: [{ type: 'text', text: 42 }] }], + [{ ...user, parts: [{ type: 'text', text: 'correction', synthetic: 'true' }] }], + [{ ...user, parts: [{ type: 'text', text: 'correction', ignored: {} }] }], + [user, user] + ])('does not treat malformed or duplicate history as an input receipt: %j', async data => { + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json(data) })).resolves.toBeNull(); + }); + + it.each([ + { parts: [{ type: 'file', mime: 'image/png', filename: 'image.png' }] }, + { parts: [] } + ])('does not infer text receipt identity from media replay or incomplete user history', async ({ parts }) => { + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([{ ...user, parts }]) })) + .resolves.toBeNull(); + }); + + it('returns unknown for an unavailable native endpoint', async () => { + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => new Response(null, { status: 404 }) })).resolves.toBeNull(); + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => { throw new Error('offline'); } })).resolves.toBeNull(); + }); + + it.each(['synthetic', 'ignored'])('retains an internal %s row identity without using its text as input proof', async flag => { + const helper = { ...user, parts: [{ type: 'text', text: 'correction', [flag]: true }] }; + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([helper]) })) + .resolves.toEqual([{ id: 'user-1', text: null }]); + }); + + it('reads only submitted text beside synthetic or ignored helper parts', async () => { + const mixed = { ...user, parts: [ + { type: 'text', text: 'helper', synthetic: true }, + { type: 'text', text: 'correction' }, + { type: 'text', text: 'display only', ignored: true } + ] }; + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([mixed]) })) + .resolves.toEqual([{ id: 'user-1', text: 'correction' }]); + }); + + it.each([ + { parts: [{ type: 'compaction', auto: true }, { type: 'text', text: 'correction' }] }, + { parts: [{ type: 'text', text: 'correction', metadata: { compaction_continue: true } }] } + ])('does not use native compaction controls or continuations as input proof', async ({ parts }) => { + await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([{ ...user, parts }]) })) + .resolves.toEqual([{ id: 'user-1', text: null }]); + }); + + it('does not send without a native session context', async () => { + const fetchImpl = vi.fn(); + await expect(fetchOpencodeUserMessages({ ...context, baseUrl: null, fetchImpl })).resolves.toBeNull(); + await expect(fetchOpencodeUserMessages({ ...context, sessionId: null, fetchImpl })).resolves.toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('propagates caller cancellation and conservatively returns unknown', async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + })); + const receipt = fetchOpencodeUserMessages({ ...context, signal: controller.signal, fetchImpl }); + controller.abort(); + await expect(receipt).resolves.toBeNull(); + }); +}); diff --git a/cli/src/opencode/utils/opencodeInputReceipt.ts b/cli/src/opencode/utils/opencodeInputReceipt.ts new file mode 100644 index 00000000..76cbaa21 --- /dev/null +++ b/cli/src/opencode/utils/opencodeInputReceipt.ts @@ -0,0 +1,62 @@ +import type { FetchLike } from './opencodeCompactBridge'; + +/** Null text retains a native identity without treating internal content as input proof. */ +export type OpencodeUserMessage = { id: string; text: string | null }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Submitted native text can prove acceptance; internal rows retain identity only. */ +export async function fetchOpencodeUserMessages(opts: { + baseUrl: string | null; + sessionId: string | null; + signal?: AbortSignal; + fetchImpl?: FetchLike; +}): Promise { + if (!opts.baseUrl || !opts.sessionId) return null; + try { + const response = await (opts.fetchImpl ?? fetch)( + `${opts.baseUrl}/session/${encodeURIComponent(opts.sessionId)}/message`, + { method: 'GET', signal: AbortSignal.any([AbortSignal.timeout(1_000), ...(opts.signal ? [opts.signal] : [])]) } + ); + if (!response.ok) return null; + const data: unknown = await response.json(); + if (!Array.isArray(data)) return null; + const messages: OpencodeUserMessage[] = []; + const ids = new Set(); + for (const entry of data) { + if (!isRecord(entry) || !isRecord(entry.info)) return null; + if (entry.info.role !== 'user') continue; + const id = entry.info.id; + if (typeof id !== 'string' || !id || ids.has(id) || !Array.isArray(entry.parts)) return null; + let text = ''; + let hasSubmittedText = false; + let hasInternalText = false; + let internal = entry.info.type === 'compaction' || entry.info.summary === true; + for (const part of entry.parts) { + if (!isRecord(part)) return null; + // Overflow replay can turn a file into unmarked placeholder + // text, so this history cannot establish text-only identity. + if (part.type === 'file') return null; + if (part.type === 'compaction' + || (isRecord(part.metadata) && part.metadata.compaction_continue === true)) internal = true; + if (part.type !== 'text') continue; + if (typeof part.text !== 'string') return null; + if ((part.synthetic !== undefined && typeof part.synthetic !== 'boolean') + || (part.ignored !== undefined && typeof part.ignored !== 'boolean')) return null; + if (part.synthetic === true || part.ignored === true) { hasInternalText = true; continue; } + hasSubmittedText = true; + text += part.text; + } + // updateMessage precedes updatePart: an incomplete ordinary row + // may still acquire text that a later compaction can replay. + if (!internal && !hasSubmittedText && !hasInternalText) return null; + ids.add(id); + messages.push({ id, text: !internal && hasSubmittedText ? text : null }); + } + return messages; + } catch { + return null; + } +} diff --git a/cli/src/opencode/utils/opencodeV2Steer.test.ts b/cli/src/opencode/utils/opencodeV2Steer.test.ts new file mode 100644 index 00000000..b7c33d3b --- /dev/null +++ b/cli/src/opencode/utils/opencodeV2Steer.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { admitOpencodeV2Steer, cancelOpencodeV2Steer, opencodeV2InputId, readOpencodeV2Input } from './opencodeV2Steer'; + +const context = { baseUrl: 'http://127.0.0.1:1234', password: 'test-only', sessionId: 'ses_native', inputId: 'msg_input' }; +const item = { id: context.inputId, sessionID: context.sessionId, type: 'user', delivery: 'steer', payload: { text: 'correction' } }; +const message = { id: context.inputId, sessionID: context.sessionId, type: 'user', text: 'correction' }; + +describe('OpenCode v2 steer receipts', () => { + it('uses a stable input identity scoped to both sessions, local id and text', () => { + const scope = { hapiSessionId: 'hapi', sessionId: 'ses_native', localId: 'local', text: 'correction' }; + const id = opencodeV2InputId(scope); + expect(id).toMatch(/^msg_/); + expect(opencodeV2InputId(scope)).toBe(id); + for (const key of Object.keys(scope) as Array) { + expect(opencodeV2InputId({ ...scope, [key]: scope[key] + '-other' })).not.toBe(id); + } + }); + + it('posts durable native steer with exact identity and Basic auth', async () => { + const fetchImpl = vi.fn(async () => Response.json({ data: item })); + await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('admitted'); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('http://127.0.0.1:1234/api/session/ses_native/prompt'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ id: 'msg_input', text: 'correction', delivery: 'steer' }); + expect(init.headers).toMatchObject({ authorization: 'Basic ' + Buffer.from('opencode:test-only').toString('base64') }); + }); + + it('promotes an existing parked item instead of duplicating admission', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: { ...item, delivery: 'queue' } })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('admitted'); + expect(fetchImpl.mock.calls[1][1]).toMatchObject({ method: 'PATCH', body: JSON.stringify({ delivery: 'steer' }) }); + }); + + it.each([400, 401, 403, 404, 422])('distinguishes definite admission rejection HTTP%s', async status => { + await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl: async () => new Response(null, { status }) })) + .resolves.toBe('rejected'); + }); + + it.each([{}, { data: { ...item, id: 'other' } }, { data: { ...item, sessionID: 'other' } }, { data: { ...item, payload: { text: 'other' } } }])('holds an unverified admission unknown: %j', async data => { + await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl: async () => Response.json(data) })) + .resolves.toBe('unknown'); + }); + + it('retains a lost response as unknown without automatically posting again', async () => { + const fetchImpl = vi.fn(async () => { throw new Error('response lost'); }); + await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('unknown'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('keeps admitted inbox work pending, separate from actual delivery', async () => { + await expect(readOpencodeV2Input({ ...context, fetchImpl: async () => Response.json({ data: [item] }) })).resolves.toBe('pending'); + }); + + it('recovers a missed delivered event only from the exact projected user id', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(Response.json({ data: message })); + await expect(readOpencodeV2Input({ ...context, fetchImpl })).resolves.toBe('delivered'); + expect(fetchImpl.mock.calls[1][0]).toContain('/message/msg_input'); + }); + + it('does not treat missing inbox work alone as delivered or cancelled', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(new Response(null, { status: 404 })); + await expect(readOpencodeV2Input({ ...context, fetchImpl })).resolves.toBe('missing'); + }); + + it.each([{ data: {} }, { data: [{ ...item, sessionID: 'other' }] }])('rejects malformed or cross-session inbox readback: %j', async data => { + await expect(readOpencodeV2Input({ ...context, fetchImpl: async () => Response.json(data) })).resolves.toBe('unknown'); + }); + + it('confirms cancellation with ordered inbox and projection readback after DELETE', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(new Response(null, { status: 404 })); + await expect(cancelOpencodeV2Steer({ ...context, fetchImpl })).resolves.toBe('cancelled'); + expect(fetchImpl.mock.calls.map(call => call[1]?.method ?? 'GET')).toEqual(['DELETE', 'GET', 'GET']); + }); + + it('does not mistake native no-op DELETE204 for successful cancellation after delivery', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(Response.json({ data: message })); + await expect(cancelOpencodeV2Steer({ ...context, fetchImpl })).resolves.toBe('delivered'); + }); + + it('holds uncertain cancellation unknown', async () => { + await expect(cancelOpencodeV2Steer({ ...context, fetchImpl: async () => { throw new Error('lost cancel'); } })).resolves.toBe('unknown'); + }); +}); diff --git a/cli/src/opencode/utils/opencodeV2Steer.ts b/cli/src/opencode/utils/opencodeV2Steer.ts new file mode 100644 index 00000000..a02c4202 --- /dev/null +++ b/cli/src/opencode/utils/opencodeV2Steer.ts @@ -0,0 +1,127 @@ +import { createHash } from 'node:crypto'; +import { isObject } from '@hapi/protocol'; +import type { FetchLike } from './opencodeCompactBridge'; + +export type OpencodeSteerCancelResult = boolean | 'in-flight' | 'indeterminate' | 'consumed'; +export type OpencodeSteerCancelHandler = (localId: string) => Promise | undefined; + +type InputContext = { + baseUrl: string; + password: string; + sessionId: string; + inputId: string; + text?: string; + fetchImpl?: FetchLike; +}; + +export type OpencodeV2InputState = 'pending' | 'delivered' | 'missing' | 'unknown'; +export type OpencodeV2Admission = 'admitted' | 'rejected' | 'unknown'; +export type OpencodeV2Cancellation = 'cancelled' | 'delivered' | 'unknown'; + +/** Preserve one native identity across response loss/retry without matching text receipts. */ +export function opencodeV2InputId(scope: { + hapiSessionId: string; + sessionId: string; + localId: string; + text: string; +}): string { + return 'msg_' + createHash('sha256').update(JSON.stringify([ + scope.hapiSessionId, scope.sessionId, scope.localId, scope.text + ])).digest('hex'); +} + +function sessionUrl(opts: InputContext): string { + return `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}`; +} + +function request(opts: InputContext, url: string, init?: RequestInit): Promise { + return (opts.fetchImpl ?? fetch)(url, { + ...init, + headers: { + authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64'), + 'content-type': 'application/json' + }, + // Bound only HTTP admission/readback, never model execution. This is + // the native transcript's existing read budget, below the hub RPC budget. + signal: AbortSignal.timeout(10_000) + }); +} + +function matchesUser(value: unknown, opts: InputContext, inbox: boolean): boolean { + if (!isObject(value) || value.id !== opts.inputId || value.sessionID !== opts.sessionId || value.type !== 'user') return false; + const text = inbox && isObject(value.payload) ? value.payload.text : value.text; + return typeof text === 'string' && (opts.text === undefined || text === opts.text); +} + +/** OpenCode's experimental v2 API: admission is durable, but is not delivery. */ +export async function admitOpencodeV2Steer(opts: InputContext & { text: string }): Promise { + try { + const response = await request(opts, `${sessionUrl(opts)}/prompt`, { + method: 'POST', + body: JSON.stringify({ id: opts.inputId, text: opts.text, delivery: 'steer' }) + }); + if ([400, 401, 403, 404, 422].includes(response.status)) return 'rejected'; + if (!response.ok) return 'unknown'; + const body: unknown = await response.json(); + if (!isObject(body) || !matchesUser(body.data, opts, true) || !isObject(body.data)) return 'unknown'; + if (body.data.delivery === 'queue') { + // A retry may find the same durable identity parked by the native + // client. Change its mode; a second POST must not create a copy. + const changed = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, { + method: 'PATCH', body: JSON.stringify({ delivery: 'steer' }) + }); + // It may have been delivered before PATCH. Let exact readback + // settle that race rather than classifying a conflict as rejection. + if (!changed.ok && changed.status !== 409) return 'unknown'; + } else if (body.data.delivery !== 'steer') return 'unknown'; + return 'admitted'; + } catch { + return 'unknown'; + } +} + +/** Read inbox BEFORE projection: an absent pending item can already be delivered. */ +export async function readOpencodeV2Input(opts: InputContext): Promise { + try { + const inboxResponse = await request(opts, `${sessionUrl(opts)}/inbox`); + if (!inboxResponse.ok) return 'unknown'; + const inbox: unknown = await inboxResponse.json(); + if (!isObject(inbox) || !Array.isArray(inbox.data)) return 'unknown'; + for (const value of inbox.data) { + if (!isObject(value) || typeof value.id !== 'string' || value.sessionID !== opts.sessionId) return 'unknown'; + if (value.id === opts.inputId) return matchesUser(value, opts, true) ? 'pending' : 'unknown'; + } + const projection = await request(opts, `${sessionUrl(opts)}/message/${encodeURIComponent(opts.inputId)}`); + if (projection.status === 404) return 'missing'; + if (!projection.ok) return 'unknown'; + const body: unknown = await projection.json(); + return isObject(body) && matchesUser(body.data, opts, false) ? 'delivered' : 'unknown'; + } catch { + return 'unknown'; + } +} + +/** Caller must first know its admission POST settled; DELETE204 alone is a no-op too. */ +export async function cancelOpencodeV2Steer(opts: InputContext): Promise { + try { + const response = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, { method: 'DELETE' }); + if (!response.ok) return 'unknown'; + const state = await readOpencodeV2Input(opts); + if (state === 'delivered') return 'delivered'; + return state === 'missing' ? 'cancelled' : 'unknown'; + } catch { + return 'unknown'; + } +} + +/** Re-steer a positively identified existing inbox entry without resubmitting uncertain input. */ +export async function steerOpencodeV2Inbox(opts: InputContext): Promise { + try { + const response = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, { + method: 'PATCH', body: JSON.stringify({ delivery: 'steer' }) + }); + return response.ok || response.status === 409 ? 'admitted' : 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/cli/src/utils/MessageQueue2.test.ts b/cli/src/utils/MessageQueue2.test.ts index d1dee25e..24778e48 100644 --- a/cli/src/utils/MessageQueue2.test.ts +++ b/cli/src/utils/MessageQueue2.test.ts @@ -609,10 +609,18 @@ describe('MessageQueue2', () => { expect(queue.beginReservationDispatch(reservation!)).toBe(true); expect(queue.markReservationIndeterminate(reservation!)).toBe(true); expect(queue.size()).toBe(0); - expect(queue.takeByLocalId('b')).toBe(reservation); - expect(queue.beginReservationDispatch(reservation!)).toBe(true); - expect(queue.restoreReservation(reservation!)).toBe(true); - expect(queue.takeByLocalId('b')).toBe(reservation); + const retry = queue.takeByLocalId('b'); + expect(retry).not.toBeNull(); + expect(retry).not.toBe(reservation); + expect(queue.beginReservationDispatch(retry!)).toBe(true); + expect(queue.commitReservation(reservation!)).toBe(false); + expect(queue.markReservationIndeterminate(reservation!)).toBe(false); + expect(queue.cancelByLocalId('b')).toBe('in-flight'); + expect(queue.restoreReservation(retry!)).toBe(true); + const nextRetry = queue.takeByLocalId('b'); + expect(nextRetry).not.toBeNull(); + expect(nextRetry).not.toBe(retry); + expect(queue.commitReservation(retry!)).toBe(false); }); it('allows a positive reconciliation after a queue reset', () => { @@ -692,4 +700,4 @@ describe('MessageQueue2', () => { expect(batch3?.message).toBe('after-isolated'); expect(batch3?.mode.type).toBe('B'); }); -}); \ No newline at end of file +}); diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index b4423dd6..010ec777 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -357,13 +357,16 @@ export class MessageQueue2 { if (!localId) return null; // An indeterminate steer is deliberately held outside the normal queue. - // A later explicit Steer retries that same reservation; automatic queue - // drains never see it. + // A later explicit Steer owns a fresh attempt, so an abandoned + // request's late callbacks cannot mutate its replacement. const existing = this.reservations.get(localId); if (existing) { if (existing.state !== 'indeterminate') return null; - existing.state = 'reserved'; - return existing; + const retry: QueueReservation = { ...existing, state: 'reserved', cancelReason: undefined }; + existing.state = 'cancelled'; + existing.cancelReason = 'explicit'; + this.reservations.set(localId, retry); + return retry; } const idx = this.queue.findIndex(item => item.localId === localId); diff --git a/docs/api/client-contract/pagination.md b/docs/api/client-contract/pagination.md index 21f462e0..83c35ad1 100644 --- a/docs/api/client-contract/pagination.md +++ b/docs/api/client-contract/pagination.md @@ -145,9 +145,10 @@ Lifecycle: 1. Generate a client-side `localId` and append an **optimistic row**: `{id: localId, seq: null, localId, invokedAt: null, scheduledAt, createdAt: now, status:'sending', content: {role:'user', content:{type:'text', text, attachments?}, meta:{deliveryMode}}}`. A row is *optimistic* iff `id === localId`. 2. On POST success: status → `queued` if the session is currently thinking, else `sent`. On failure: drop the row and restore the composer (or keep it as `failed` with a retry affordance when attachments are involved). 3. **Echo**: the hub emits `message-received` carrying the stored row (server `id`, real `seq`, same `localId`). Merging a stored row whose `localId` matches an optimistic row **replaces** the optimistic one, preserving the client-side `status` and any already-known `invokedAt` the server row lacks. Fallback when no `localId` echo matches: drop an optimistic `sent` row when a server user message lands within **10 s** of the same position. -4. **`messages-consumed {localIds, invokedAt}`** (SSE): stamp `invokedAt` and flip status to `sent` on matching rows (skip `failed` ones). This is what moves a message out of the queued bar and into the thread at its invocation position. +4. **`messages-consumed {localIds, invokedAt}`** (SSE): stamp `invokedAt`, clear `deliveryState`/`deliveryPending`, and flip status to `sent` on matching rows (skip `failed` ones). This is what moves a message out of the queued bar and into the thread at its invocation position. 5. **`messages-indeterminate {localIds}`** (SSE): a native dispatch or queue mutation has an unknown outcome. Keep `invokedAt: null`, mark `deliveryState:'indeterminate'`, and exclude the row from automatic replay. Retry/Cancel are explicit resolution actions and may remain unavailable until the native outcome can be reconciled. -6. **`messages-requeued {localIds}`** (SSE): an explicit Retry restored normal queue delivery; clear `deliveryState`. + An optional `dispatching:true` identifies a live dispatch still awaiting confirmation. Preserve `deliveryState:'indeterminate'` and set `deliveryPending:true`; while the session is active and thinking, show confirmation pending without offering another Steer/Retry. This pending state can outlive Steer admission while the current request drains. Without the flag, clear `deliveryPending` and retain explicit unknown-outcome resolution. Neither state proves invocation. +6. **`messages-requeued {localIds}`** (SSE): an explicit Retry restored normal queue delivery; clear `deliveryState` and `deliveryPending`. 7. **`message-cancelled {messageId, localId?}`** (SSE): remove the row (match either id). **Queued semantics**: a user message is "queued" iff `invokedAt === null` **strictly**, `deliveryState !== 'indeterminate'`, and `status !== 'failed'`. An indeterminate row remains visible in the unresolved-delivery bar but is not eligible for automatic delivery. `undefined` means already-invoked (rows from pre-V8 hubs omit the field) — only rows explicitly carrying `null` belong in the queued bar. Server-side, a send without a `localId` gets a hub-minted one, so every REST send follows the queue/ack path; only agent-originated rows without a `localId` are stamped invoked at insert. @@ -158,8 +159,8 @@ After a reconnect whose handshake said `resume: 'gap'` (an `ok` resume replayed 1. Finish a tail sync. 2. Collect candidate `localId`s: user rows with `invokedAt === null`, excluding optimistic rows still `sending`/`failed`. -3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], indeterminateLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}`. -4. Apply `invokedLocalMessages` exactly like `messages-consumed`; mark `indeterminateLocalIds` as unresolved delivery. Retain both queued and indeterminate rows; drop only candidates absent from **all three** result groups. An in-flight native dispatch is reported as indeterminate, not as a deleted message. +3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], indeterminateLocalIds: string[], dispatchingLocalIds?: string[], invokedLocalMessages: [{localId, invokedAt}]}`. +4. Apply `invokedLocalMessages` exactly like `messages-consumed`; mark `indeterminateLocalIds` as unresolved delivery. Retain both queued and indeterminate rows; drop only candidates absent from **all three** result groups. The optional `dispatchingLocalIds` is a subset of `indeterminateLocalIds`: these rows are awaiting confirmation, not deleted or already invoked. Treat the remaining indeterminate rows as genuinely unknown. Nonterminal snapshots must not overwrite a newer SSE or local transition that occurred during the request. --- @@ -191,11 +192,11 @@ Response `{"ok": true}`. Sending to an inactive session returns `409 {"error":"S Other subscribers learn the same outcome via `message-cancelled` / `messages-consumed` SSE events. -**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` → `SteerQueuedMessageResponseSchema`. Unlike the send-time `deliveryMode` option above, this endpoint supports Claude (remote mode), Pi, Codex, and Cursor ACP sessions (`isSteeringSupportedForSession` in `shared/src/modes.ts`). It rejects all scheduled messages, and rejects terminal-controlled sessions unless they advertise `concurrentClients`. +**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` → `SteerQueuedMessageResponseSchema`. Unlike the send-time `deliveryMode` option above, this endpoint supports Claude (remote mode), Pi, Codex, OpenCode v1/v2, and Cursor ACP sessions (`isSteeringSupportedForSession` in `shared/src/modes.ts`, together with the agent's `steeringActive` state). OpenCode v2 requires access to its experimental native REST inbox. Durable admission keeps the row pending; an exact native inbox-delivered event or projected-input readback confirms consumption. If admission or cancellation cannot be verified, the row remains indeterminate without automatic resubmission. It rejects all scheduled messages, and rejects terminal-controlled sessions unless they advertise `concurrentClients`. | Response | Client action | |---|---| -| `{"status":"steered","localId"}` | Keep the row queued-side; it is being injected into the live turn. | +| `{"status":"steered","localId"}` | Keep the uninvoked row until confirmed by `messages-consumed` or readback; show confirmation pending when `deliveryPending:true`. If REST beats the pending SSE, retain a provisional confirmation hold and reconcile queued state before allowing another action. This does not stamp `invokedAt`; a newer unknown/consumed state wins over a stale readback. | | `{"status":"invoked","message"}` | Already consumed — ingest the message. | | `{"status":"failed","error","localId":string\|null}` | Surface the error. Do not infer delivery state from this alone; reconcile before retrying when the native outcome is unknown. | @@ -204,3 +205,6 @@ is user-initiated only. `retried` or `already-queued` means normal queue deliver `invoked` carries the authoritative message; `not-found` means the row is gone. `retry-unavailable` leaves the row unresolved: the hub could not prove that retrying would avoid duplicate work. Reconcile instead of automatically retrying. + + +OpenCode confirmation ownership ends when its worker exits, expires, or is replaced (machine/PID/process-start marker). The hub moves only that session's uninvoked dispatching rows to unknown delivery; queued and consumed rows remain unchanged. Closing Steer admission alone does not end an existing delivery. After a hub restart, stale persisted `steeringActive` is not sufficient to show a live confirmation wait. Unknown rows are never automatically replayed or stamped invoked; later positive consumption may still reconcile them. diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index c45b5ee2..68162b5a 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -523,8 +523,10 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session sessionAccess.value.namespace ) reply({ ok }) - if (ok && data.state === 'queued') { - onWebappEvent?.({ type: 'messages-requeued', sessionId: data.sid, localIds }) + if (ok) { + onWebappEvent?.(data.state === 'queued' + ? { type: 'messages-requeued', sessionId: data.sid, localIds } + : { type: 'messages-indeterminate', sessionId: data.sid, localIds, dispatching: true }) } } catch (err) { console.error('recordSteerDeliveryState failed', err) diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 9bc6ca3c..399fb8eb 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -31,6 +31,7 @@ import { countMessages, markMessagesInvoked, markMessagesIndeterminate, + markDispatchingMessagesIndeterminate, setMessagesDeliveryState, markUninvokedImmediateMessages, mergeSessionMessages, @@ -245,6 +246,10 @@ export class MessageStore { return markMessagesInvoked(this.db, sessionId, localIds, invokedAt) } + markDispatchingMessagesIndeterminate(sessionId: string): string[] { + return markDispatchingMessagesIndeterminate(this.db, sessionId) + } + markMessagesIndeterminate(sessionId: string, localIds: string[]): number { return markMessagesIndeterminate(this.db, sessionId, localIds) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index d8ae7e8d..cd5058a3 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -118,7 +118,8 @@ function toStoredMessage(row: DbMessageRow): StoredMessage { localId: row.local_id, invokedAt: row.invoked_at ?? null, scheduledAt: row.scheduled_at ?? null, - ...(row.delivery_state && row.delivery_state !== 'queued' ? { deliveryState: 'indeterminate' as const } : {}) + ...(row.delivery_state && row.delivery_state !== 'queued' ? { deliveryState: 'indeterminate' as const } : {}), + ...(row.delivery_state === 'dispatching' ? { deliveryPending: true as const } : {}) } } @@ -1036,6 +1037,17 @@ export function setMessagesDeliveryState( ).run(state, sessionId, ...localIds).changes } +/** Release confirmation-only state when the owning OpenCode worker ends or is replaced. */ +export function markDispatchingMessagesIndeterminate(db: Database, sessionId: string): string[] { + const rows = prepareCached(db, ` + UPDATE messages SET delivery_state = 'indeterminate' + WHERE session_id = ? AND delivery_state = 'dispatching' + AND invoked_at IS NULL AND local_id IS NOT NULL + RETURNING local_id + `).all(sessionId) as Array<{ local_id: string }> + return rows.map(row => row.local_id) +} + /** Hold an ambiguous steer out of automatic replay without claiming delivery. */ export function markMessagesIndeterminate( db: Database, diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 43dc5b48..4a57e9bb 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -46,6 +46,7 @@ export type StoredMachine = { export type MessageDeliveryState = 'indeterminate' export type StoredMessage = { + deliveryPending?: true contentRevision?: number id: string sessionId: string diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 9f1ffb00..7b5c7641 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -1978,6 +1978,23 @@ describe('MessageService.sweepImmediateQueuedOnSessionEnd — scheduled rows are }) }) +it('preserves live confirmation state when an idempotent send repeats the same durable input', async () => { + const store = makeStore() + const session = makeSession(store, 'duplicate-pending-input') + const events: SyncEvent[] = [] + const service = new MessageService(store, {} as Server, { emit(event: SyncEvent) { events.push(event) } } as EventPublisher) + const message = store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'correction' } }, 'same-local') + store.messages.setMessagesDeliveryState(session.id, ['same-local'], 'dispatching') + try { + await service.sendMessage(session.id, { text: 'correction', localId: 'same-local' }) + const event = events.find(item => item.type === 'message-received') + if (!event || event.type !== 'message-received') throw new Error('missing message echo') + expect(event.message).toMatchObject({ id: message.id, localId: 'same-local', invokedAt: null, deliveryState: 'indeterminate', deliveryPending: true }) + expect(store.messages.countMessages(session.id)).toBe(1) + expect(store.messages.lookupQueuedMessage(session.id, message.id).status).toBe('dispatching') + } finally { store.close() } +}) + describe('observed read revision receipts', () => { it('latest and completed catch-up receipt cover only their observed head', () => { const store = makeStore() diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 0bed0c1f..5b46c321 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -65,7 +65,8 @@ function toDecryptedMessage(message: StoredMessageForDelivery): DecryptedMessage createdAt: message.createdAt, invokedAt: message.invokedAt, scheduledAt: message.scheduledAt, - ...(message.deliveryState ? { deliveryState: message.deliveryState } : {}) + ...(message.deliveryState ? { deliveryState: message.deliveryState } : {}), + ...(message.deliveryPending ? { deliveryPending: true as const } : {}) } } @@ -257,7 +258,9 @@ export class MessageService { getQueuedState(sessionId: string, localIds: string[]): QueuedStateResponse { const states = this.store.messages.getLocalMessageStates(sessionId, localIds) + const dispatchingLocalIds = states.filter(state => state.invokedAt === null && state.deliveryState === 'dispatching').map(state => state.localId) return { + ...(dispatchingLocalIds.length ? { dispatchingLocalIds } : {}), queuedLocalIds: states .filter((state) => state.invokedAt === null && state.deliveryState !== 'indeterminate' && state.deliveryState !== 'dispatching') .map((state) => state.localId), @@ -537,7 +540,8 @@ export class MessageService { createdAt: message.createdAt, invokedAt: message.invokedAt, scheduledAt: message.scheduledAt, - ...(message.deliveryState ? { deliveryState: message.deliveryState } : {}) + ...(message.deliveryState ? { deliveryState: message.deliveryState } : {}), + ...(message.deliveryPending ? { deliveryPending: true as const } : {}) })) } @@ -1009,7 +1013,8 @@ export class MessageService { createdAt: msg.createdAt, invokedAt: msg.invokedAt, scheduledAt: msg.scheduledAt, - ...(msg.deliveryState ? { deliveryState: msg.deliveryState } : {}) + ...(msg.deliveryState ? { deliveryState: msg.deliveryState } : {}), + ...(msg.deliveryPending ? { deliveryPending: true as const } : {}) } }) return { actualSessionId, createdAt: msg.createdAt } diff --git a/hub/src/sync/steerDeliveryLifecycle.test.ts b/hub/src/sync/steerDeliveryLifecycle.test.ts new file mode 100644 index 00000000..066a1717 --- /dev/null +++ b/hub/src/sync/steerDeliveryLifecycle.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, spyOn } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +function createEngine(store = new Store(':memory:'), flavor: 'opencode' | 'claude' | 'codex' | 'cursor' = 'opencode') { + const events: SyncEvent[] = [] + const io = { of: () => ({ to: () => ({ emit() {} }) }) } + const engine = new SyncEngine(store, io as never, new RpcRegistry(), { broadcast(event: SyncEvent) { events.push(event) } } as never) + const session = engine.getOrCreateSession('delivery-owner', { path: '/tmp/project', host: 'localhost', flavor, hostPid: 100, hostStartMarker: 'worker-A' }, { steeringActive: true }, 'default') + engine.handleSessionAlive({ sid: session.id, time: Date.now(), thinking: true }) + for (const localId of ['pending', 'queued', 'consumed']) store.messages.addMessage(session.id, { text: localId }, localId) + store.messages.setMessagesDeliveryState(session.id, ['pending'], 'dispatching') + store.messages.markMessagesInvoked(session.id, ['consumed'], 1000) + return { store, engine, session, events } +} + +function expectUnknown(store: Store, engine: SyncEngine, sessionId: string) { + expect(engine.getQueuedState(sessionId, ['pending'])).toEqual({ queuedLocalIds: [], indeterminateLocalIds: ['pending'], invokedLocalMessages: [] }) + expect(store.messages.lookupQueuedMessage(sessionId, 'pending').status).toBe('indeterminate') + expect(store.messages.lookupQueuedMessage(sessionId, 'queued').status).toBe('queued') + expect(store.messages.lookupQueuedMessage(sessionId, 'consumed').status).toBe('invoked') + expect(store.messages.countMessages(sessionId)).toBe(3) + expect(engine.getDeliverableMessagesAfter(sessionId, { afterSeq: 0, limit: 100, now: Date.now() }).map(message => message.localId)).not.toContain('pending') +} + +describe.each(['opencode', 'claude'] as const)('%s pending delivery ownership', (flavor) => { + it('holds only unconfirmed dispatches as unknown when the session ends', () => { + const { store, engine, session, events } = createEngine(new Store(':memory:'), flavor) + const other = store.sessions.getOrCreateSession('other', { path: '/tmp/other', flavor: 'opencode' }, null, 'other-namespace') + store.messages.addMessage(other.id, { text: 'other' }, 'pending') + store.messages.setMessagesDeliveryState(other.id, ['pending'], 'dispatching') + try { + engine.handleSessionEnd({ sid: session.id, time: Date.now(), reason: 'error' }) + expectUnknown(store, engine, session.id) + expect(store.messages.lookupQueuedMessage(other.id, 'pending').status).toBe('dispatching') + expect(events).toContainEqual(expect.objectContaining({ type: 'messages-indeterminate', sessionId: session.id, localIds: ['pending'] })) + } finally { engine.stop(); store.close() } + }) + + it('turns abandoned dispatches unknown when a replacement worker identifies itself after hub restart', () => { + const path = join(mkdtempSync(join(tmpdir(), 'hapi-steer-restart-')), 'hapi.db') + const { store, engine, session } = createEngine(new Store(path), flavor) + const id = session.id + engine.stop(); store.close() + const reopened = new Store(path) + const io = { of: () => ({ to: () => ({ emit() {} }) }) } + const restarted = new SyncEngine(reopened, io as never, new RpcRegistry(), { broadcast() {} } as never) + try { + const before = reopened.sessions.getSession(id)! + const metadata = { ...(before.metadata as Record), hostPid: 101, hostStartMarker: 'worker-B' } + const update = reopened.sessions.updateSessionMetadata(id, metadata, before.metadataVersion, 'default') + if (update.result !== 'success') throw new Error('metadata update failed') + restarted.handleRealtimeEvent({ type: 'session-updated', sessionId: id, data: { metadata: { version: update.version, value: metadata as never } } }) + restarted.handleSessionAlive({ sid: id, time: Date.now(), thinking: true }) + expect(restarted.getSession(id)?.thinking).toBe(true) + expectUnknown(reopened, restarted, id) + const next = reopened.sessions.updateSessionAgentState(id, { steeringActive: true }, before.agentStateVersion, 'default') + if (next.result !== 'success') throw new Error('next state update failed') + restarted.handleRealtimeEvent({ type: 'session-updated', sessionId: id, data: { agentState: { version: next.version, value: { steeringActive: true } } } }) + expectUnknown(reopened, restarted, id) + reopened.messages.markMessagesInvoked(id, ['pending'], 2000) + expect(restarted.getQueuedState(id, ['pending']).invokedLocalMessages).toEqual([{ localId: 'pending', invokedAt: 2000 }]) + } finally { restarted.stop(); reopened.close() } + }) + + it('keeps pending delivery during a live reconnect but resolves an expired owner to unknown', () => { + const { store, engine, session } = createEngine(new Store(':memory:'), flavor) + const clock = spyOn(Date, 'now') + try { + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: session.id }) + expect(engine.getQueuedState(session.id, ['pending']).dispatchingLocalIds).toEqual(['pending']) + clock.mockReturnValue(session.activeAt + 300_001) + engine['expireInactive']() + expectUnknown(store, engine, session.id) + } finally { clock.mockRestore(); engine.stop(); store.close() } + }) +}) + + +it('does not confuse closing Steer admission with a live request finishing its drain', () => { + const { store, engine, session } = createEngine() + try { + const state = store.sessions.updateSessionAgentState(session.id, { steeringActive: false }, session.agentStateVersion, 'default') + if (state.result !== 'success') throw new Error('state update failed') + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: session.id, data: { agentState: { version: state.version, value: { steeringActive: false } } } }) + expect(engine.getQueuedState(session.id, ['pending']).dispatchingLocalIds).toEqual(['pending']) + expect(store.messages.lookupQueuedMessage(session.id, 'pending').status).toBe('dispatching') + } finally { engine.stop(); store.close() } +}) + + +it.each(['codex', 'cursor'] as const)('preserves the separate %s runtime delivery owner on metadata replacement', (flavor) => { + const { store, engine, session } = createEngine(new Store(':memory:'), flavor) + try { + const metadata = { ...session.metadata!, hostPid: 101, hostStartMarker: 'worker-B' } + const update = store.sessions.updateSessionMetadata(session.id, metadata, session.metadataVersion, 'default') + if (update.result !== 'success') throw new Error('metadata update failed') + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: session.id, data: { metadata: { version: update.version, value: metadata } } }) + expect(engine.getQueuedState(session.id, ['pending']).dispatchingLocalIds).toEqual(['pending']) + expect(store.messages.lookupQueuedMessage(session.id, 'pending').status).toBe('dispatching') + } finally { engine.stop(); store.close() } +}) diff --git a/hub/src/sync/steerQueuedMessage.test.ts b/hub/src/sync/steerQueuedMessage.test.ts index a9ce0925..48148841 100644 --- a/hub/src/sync/steerQueuedMessage.test.ts +++ b/hub/src/sync/steerQueuedMessage.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, spyOn } from 'bun:test' import { Store } from '../store' import { RpcRegistry } from '../socket/rpcRegistry' import { SyncEngine } from './syncEngine' +import { RpcGateway } from './rpcGateway' function createEngine() { const store = new Store(':memory:') @@ -15,6 +16,118 @@ function createEngine() { } describe('SyncEngine.steerQueuedMessage', () => { + it.each(['negative reply', 'lost reply'] as const)('returns the consumed row when a %s races native acceptance', async (outcome) => { + const { store, engine } = createEngine() + const session = engine.getOrCreateSession( + 'steer-racing-acceptance', + { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }, + { requests: {}, completedRequests: {} }, 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'correction' }, 'accepted-local') + const steer = spyOn(RpcGateway.prototype, 'steerQueuedMessage').mockImplementation(async () => { + store.messages.markMessagesInvoked(session.id, ['accepted-local'], 2_000) + if (outcome === 'lost reply') throw new Error('operation has timed out') + return { steered: false, error: 'Message not in queue' } + }) + try { + expect(await engine.steerQueuedMessage(session.id, message.id)).toMatchObject({ + status: 'invoked', message: { id: message.id, localId: 'accepted-local', invokedAt: 2_000 } + }) + expect(steer).toHaveBeenCalledTimes(1) + } finally { + steer.mockRestore() + engine.stop() + } + }) + + it.each(['opencode', 'claude'] as const)('routes a remote %s correction using its original localId', async (flavor) => { + const { store, engine } = createEngine() + const steer = spyOn(RpcGateway.prototype, 'steerQueuedMessage').mockResolvedValue({ steered: true }) + try { + const session = engine.getOrCreateSession( + `steer-${flavor}`, + { path: '/tmp/project', host: 'localhost', flavor }, + { requests: {}, completedRequests: {} }, + 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'correction' }, 'original-local') + + expect(await engine.steerQueuedMessage(session.id, message.id)).toEqual({ + status: 'steered', localId: 'original-local' + }) + expect(steer).toHaveBeenCalledWith(session.id, 'original-local') + } finally { + steer.mockRestore() + engine.stop() + } + }) + + it.each(['queued', 'dispatching', 'indeterminate'] as const)('does not infer invocation from a failed RPC while the row is %s', async (state) => { + const { store, engine } = createEngine() + const session = engine.getOrCreateSession( + 'steer-unconfirmed', + { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }, + { requests: {}, completedRequests: {} }, 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'correction' }, 'unconfirmed-local') + store.messages.setMessagesDeliveryState(session.id, ['unconfirmed-local'], state) + const steer = spyOn(RpcGateway.prototype, 'steerQueuedMessage').mockRejectedValue(new Error('operation has timed out')) + try { + expect(await engine.steerQueuedMessage(session.id, message.id)).toEqual({ + status: 'failed', error: 'operation has timed out', localId: 'unconfirmed-local' + }) + expect(store.messages.lookupQueuedMessage(session.id, message.id).status).not.toBe('invoked') + } finally { + steer.mockRestore() + engine.stop() + } + }) + + it('returns the persisted invoked row on a repeated OpenCode steer', async () => { + const { store, engine } = createEngine() + const steer = spyOn(RpcGateway.prototype, 'steerQueuedMessage') + try { + const session = engine.getOrCreateSession( + 'steer-opencode-invoked', + { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'correction' }, 'original-local') + store.messages.markMessagesInvoked(session.id, ['original-local'], 2_000) + + expect(await engine.steerQueuedMessage(session.id, 'original-local')).toMatchObject({ + status: 'invoked', message: { id: message.id, localId: 'original-local', invokedAt: 2_000 } + }) + expect(steer).not.toHaveBeenCalled() + } finally { + steer.mockRestore() + engine.stop() + } + }) + + it('rejects a locally controlled OpenCode session before invoking the CLI', async () => { + const { store, engine } = createEngine() + const steer = spyOn(RpcGateway.prototype, 'steerQueuedMessage') + try { + const session = engine.getOrCreateSession( + 'steer-opencode-local', + { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }, + { requests: {}, completedRequests: {}, controlledByUser: true }, + 'default' + ) + const message = store.messages.addMessage(session.id, { text: 'correction' }, 'original-local') + + expect(await engine.steerQueuedMessage(session.id, message.id)).toEqual({ + status: 'failed', error: 'Steering is only available for remote sessions', localId: null + }) + expect(steer).not.toHaveBeenCalled() + } finally { + steer.mockRestore() + engine.stop() + } + }) + it('rejects every scheduled row, mature ones included, without invoking the CLI', async () => { const { store, engine } = createEngine() try { @@ -52,8 +165,8 @@ describe('SyncEngine.steerQueuedMessage', () => { const { store, engine } = createEngine() try { const session = engine.getOrCreateSession( - 'steer-opencode', - { path: '/tmp/project', host: 'localhost', flavor: 'opencode' }, + 'steer-gemini', + { path: '/tmp/project', host: 'localhost', flavor: 'gemini' }, { requests: {}, completedRequests: {} }, 'default' ) @@ -63,7 +176,7 @@ describe('SyncEngine.steerQueuedMessage', () => { expect(result).toEqual({ status: 'failed', - error: 'Steering is only supported for Claude, Pi, Codex, and Cursor ACP sessions', + error: 'Steering is only supported for Claude, Pi, Codex, OpenCode, and Cursor ACP sessions', localId: null }) } finally { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c0346efa..61503824 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -604,6 +604,7 @@ export class SyncEngine { if (patchApplied) { this.eventPublisher.emit(event) const after = this.sessionCache.getSession(event.sessionId) + this.reconcileSteerDeliveryOwner(beforeMetadata, after) if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { if (!this.canRunCursorDedup(after)) { return @@ -620,6 +621,7 @@ export class SyncEngine { // legacy refresh-from-DB-and-broadcast path. this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) + this.reconcileSteerDeliveryOwner(beforeMetadata, after) if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { if (!this.canRunCursorDedup(after)) { return @@ -724,6 +726,28 @@ export class SyncEngine { this.sessionCache.clearQueuedThinkingGrace(sessionId) } + private reconcileSteerDeliveryOwner(before: Session['metadata'], after: Session | undefined): void { + const current = after?.metadata + if ((before?.flavor !== 'opencode' && before?.flavor !== 'claude') + || current?.flavor !== before.flavor) return + // Steer admission closes before an existing request finishes draining. + // Only replacement of the owning worker ends its confirmation lease. + if (before.machineId !== current.machineId || before.hostPid !== current.hostPid + || before.hostStartMarker !== current.hostStartMarker) { + this.markSteerDispatchesUncertain(after) + } + } + + private markSteerDispatchesUncertain(session: Session | undefined): void { + // These launchers own confirmation through a single worker. Shared-client + // runtimes have separate delivery ownership and are not fenced here. + if (session?.metadata?.flavor !== 'opencode' && session?.metadata?.flavor !== 'claude') return + const localIds = this.store.messages.markDispatchingMessagesIndeterminate(session.id) + if (localIds.length > 0) { + this.eventPublisher.emit({ type: 'messages-indeterminate', sessionId: session.id, localIds }) + } + } + handleSessionEnd(payload: { sid: string; time: number; reason?: SessionEndReason }): void { const before = this.sessionCache.getSession(payload.sid) if (before?.metadata?.opencodeClearOperation?.state === 'reserved' && payload.reason !== 'cleared') { @@ -744,6 +768,7 @@ export class SyncEngine { const shouldRetryDedup = !ownsPiAttempt && !isPiAttemptChild && (!isCursorAcp || this.sessionReadyIds.has(payload.sid)) this.sessionCache.handleSessionEnd(payload) + this.markSteerDispatchesUncertain(this.sessionCache.getSession(payload.sid)) if (payload.reason === 'completed') { this.titleTurnCompletedIds.add(payload.sid) void this.automaticTitleSuggestionService.onTurnCompleted(payload.sid) @@ -1130,6 +1155,7 @@ export class SyncEngine { .filter((s): s is NonNullable => s != null) .sort((a, b) => (b.activeAt - a.activeAt) || (b.updatedAt - a.updatedAt)) for (const session of sorted) { + this.markSteerDispatchesUncertain(session) this.triggerDedupIfNeeded(session.id) } this.machineCache.expireInactive?.() @@ -1265,8 +1291,8 @@ export class SyncEngine { /** * Ask the CLI to deliver one waiting-queue message into the active turn - * (native steer). Supported for Claude, Pi, Codex, and Cursor ACP sessions; the - * CLI's `steer-queued-message` handler is registered per flavor. Legacy + * (native steer). Supported for Claude, Pi, Codex, OpenCode, and Cursor + * ACP sessions; the CLI's handler is registered per flavor. Legacy * stream-json Cursor sessions and other flavors are rejected by the * capability gate. */ @@ -1279,30 +1305,30 @@ export class SyncEngine { return { status: 'failed', error: 'Session not found', localId: null } } if (!isSteeringSupportedForSession(session.metadata)) { - return { status: 'failed', error: 'Steering is only supported for Claude, Pi, Codex, and Cursor ACP sessions', localId: null } + return { status: 'failed', error: 'Steering is only supported for Claude, Pi, Codex, OpenCode, and Cursor ACP sessions', localId: null } } if (session.agentState?.controlledByUser === true && !session.metadata?.capabilities?.concurrentClients) { return { status: 'failed', error: 'Steering is only available for remote sessions', localId: null } } + const invokedResponse = (message: DecryptedMessage): SteerQueuedMessageResponse => ({ + status: 'invoked', + message: { + id: message.id, + seq: message.seq, + localId: message.localId, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + } + }) const lookup = this.store.messages.lookupQueuedMessage(sessionId, messageId) if (lookup.status === 'absent') { return { status: 'failed', error: 'Message not found', localId: null } } if (lookup.status === 'invoked') { - const message = lookup.message - return { - status: 'invoked', - message: { - id: message.id, - seq: message.seq, - localId: message.localId, - content: message.content, - createdAt: message.createdAt, - invokedAt: message.invokedAt, - scheduledAt: message.scheduledAt - } - } + return invokedResponse(lookup.message) } const { localId, scheduledAt } = lookup if (!localId) { @@ -1315,20 +1341,23 @@ export class SyncEngine { return { status: 'failed', error: 'Scheduled messages cannot be steered', localId } } + let failure: string try { const result = await this.rpcGateway.steerQueuedMessage(sessionId, localId) + const settled = this.store.messages.lookupQueuedMessage(sessionId, messageId) + if (settled.status === 'invoked') return { status: 'invoked', message: settled.message } if (result.steered) { return { status: 'steered', localId } } - return { - status: 'failed', - error: result.error ?? 'Steer failed', - localId - } + failure = result.error ?? 'Steer failed' } catch (error) { - const message = error instanceof Error ? error.message : 'Steer failed' - return { status: 'failed', error: message, localId } + failure = error instanceof Error ? error.message : 'Steer failed' } + // The consumed event can win the race against an RPC error or stale + // negative reply. Persisted invocation is authoritative in either case. + const current = this.store.messages.lookupQueuedMessage(sessionId, messageId) + if (current.status === 'invoked') return invokedResponse(current.message) + return { status: 'failed', error: failure, localId } } sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 24cb766c..56491bd6 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -746,6 +746,8 @@ export type QueuedStateRequest = z.infer export type QueuedStateResponse = { queuedLocalIds: string[] indeterminateLocalIds?: string[] + /** Subset of indeterminateLocalIds still awaiting delivery confirmation. */ + dispatchingLocalIds?: string[] invokedLocalMessages: Array<{ localId: string invokedAt: number diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 8381ff7c..11d214c0 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -145,20 +145,22 @@ describe('claude auto permission mode', () => { }) describe('isSteeringSupportedForFlavor', () => { - it('supports codex, cursor and pi', () => { + it('supports claude, codex, cursor, opencode and pi', () => { expect(isSteeringSupportedForFlavor('codex')).toBe(true) expect(isSteeringSupportedForFlavor('cursor')).toBe(true) + expect(isSteeringSupportedForFlavor('opencode')).toBe(true) expect(isSteeringSupportedForFlavor('pi')).toBe(true) expect(isSteeringSupportedForFlavor('claude')).toBe(true) - expect(isSteeringSupportedForFlavor('opencode')).toBe(false) + expect(isSteeringSupportedForFlavor('gemini')).toBe(false) expect(isSteeringSupportedForFlavor(undefined)).toBe(false) expect(isSteeringSupportedForFlavor(null)).toBe(false) }) }) describe('isSteeringSupportedForSession', () => { - it('supports codex and pi sessions', () => { + it('supports claude, codex, opencode and pi sessions', () => { expect(isSteeringSupportedForSession({ flavor: 'codex' })).toBe(true) + expect(isSteeringSupportedForSession({ flavor: 'opencode' })).toBe(true) expect(isSteeringSupportedForSession({ flavor: 'pi' })).toBe(true) expect(isSteeringSupportedForSession({ flavor: 'claude' })).toBe(true) }) @@ -185,7 +187,7 @@ describe('isSteeringSupportedForSession', () => { }) it('rejects non-steerable flavors', () => { - expect(isSteeringSupportedForSession({ flavor: 'opencode' })).toBe(false) + expect(isSteeringSupportedForSession({ flavor: 'gemini' })).toBe(false) expect(isSteeringSupportedForSession(null)).toBe(false) }) }) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index eba6964c..8c374f10 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -191,6 +191,8 @@ export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption * Steer = soft mid-turn delivery (same idea as Cursor GUI default "Send"): * - Pi: native steer over the Pi runtime (first-class since #1466) * - Codex: app-server `turn/steer` (true mid-turn inject) + * - OpenCode v1: concurrent ACP `session/prompt`; v2: durable REST inbox + * steering at a native step boundary. Both require live steeringActive. * - Cursor ACP: concurrent `session/prompt` soft-send (no cancel). Legacy * stream-json Cursor sessions are NOT steerable — gate with * {@link isSteeringSupportedForSession}. @@ -199,7 +201,7 @@ export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption * * Others: not supported — UI hides Steer. */ -export const STEERING_SUPPORTED_FLAVORS = ['claude', 'codex', 'cursor', 'pi'] as const +export const STEERING_SUPPORTED_FLAVORS = ['claude', 'codex', 'cursor', 'opencode', 'pi'] as const export function isSteeringSupportedForFlavor(flavor?: string | null): boolean { return (STEERING_SUPPORTED_FLAVORS as readonly string[]).includes(flavor ?? '') @@ -218,7 +220,10 @@ export function isSteeringSupportedForSession(metadata?: { cursorSessionId?: string | null cursorSessionProtocol?: 'acp' | 'stream-json' | null } | null): boolean { - if (metadata?.flavor === 'claude' || metadata?.flavor === 'codex' || metadata?.flavor === 'pi') { + if (metadata?.flavor === 'claude' + || metadata?.flavor === 'codex' + || metadata?.flavor === 'opencode' + || metadata?.flavor === 'pi') { return true } if (metadata?.flavor !== 'cursor') { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 04d1ff21..865447e0 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -352,6 +352,8 @@ export const DecryptedMessageSchema = z.object({ // The agent was sent the steer but its final outcome could not be proven. // The row stays uninvoked and requires an explicit user resolution. deliveryState: z.literal('indeterminate').optional(), + // A live dispatch is still unconfirmed, but is not an invitation to resend. + deliveryPending: z.literal(true).optional(), // Live signal via messages-consumed (steered:true); not persisted by the hub. steered: z.boolean().optional() }) @@ -667,7 +669,9 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ }), SessionChangedSchema.extend({ type: z.literal('messages-indeterminate'), - localIds: z.array(z.string()) + localIds: z.array(z.string()), + // Older clients conservatively retain the unresolved row. + dispatching: z.literal(true).optional() }), SessionChangedSchema.extend({ type: z.literal('messages-requeued'), diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index 176cb7b7..8e095958 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -47,6 +47,17 @@ vi.mock('@/lib/message-window-store', () => ({ getMessageWindowState: () => mocks.messageWindowState, subscribeMessageWindow: () => () => {}, markMessagesConsumed: mocks.markMessagesConsumed, + markMessagesDispatching: (_sessionId: string, ids: string[]) => { + mocks.messageWindowState = { messages: mocks.messageWindowState.messages.map(item => { + const message = item as DecryptedMessage + return message.localId && ids.includes(message.localId) + ? { ...message, deliveryState: 'indeterminate', deliveryPending: true } + : message + }) } + }, + markMessagesIndeterminate: vi.fn(), + markMessagesRequeued: vi.fn(), + reconcileQueuedLocalIds: vi.fn(), })) vi.mock('@/hooks/mutations/useCancelQueuedMessage', () => ({ @@ -96,10 +107,12 @@ function renderQueuedMessage( pendingScheduleRevision = 0, canSteer = false, api: ApiClient | null = null, + delivery: Partial = {}, + deliveryActive = canSteer, ) { const onEdit = vi.fn() let currentPendingScheduleRevision = pendingScheduleRevision - mocks.messageWindowState = { messages: [makeQueuedMessage(scheduledAt)] } + mocks.messageWindowState = { messages: [makeQueuedMessage(scheduledAt, 'server-message-id', delivery)] } // The real useSteerQueuedMessage hook runs inside the bar, so every render // needs a QueryClient (its mutations use the tanstack defaults). const queryClient = new QueryClient({ @@ -114,6 +127,7 @@ function renderQueuedMessage( pendingScheduleRevision={currentPendingScheduleRevision} onEdit={onEdit} canSteer={canSteer} + deliveryActive={deliveryActive} /> ) @@ -131,6 +145,7 @@ function renderQueuedMessage( pendingScheduleRevision={currentPendingScheduleRevision} onEdit={onEdit} canSteer={canSteer} + deliveryActive={deliveryActive} /> ) @@ -904,3 +919,45 @@ describe('QueuedMessagesBar steer action', () => { expect(mocks.addToast).not.toHaveBeenCalled() }) }) + + +describe('unconfirmed delivery actions', () => { + it('shows confirmation pending without Steer or Retry while the turn owns the delivery', () => { + renderQueuedMessage(null, null, 0, true, null, { deliveryState: 'indeterminate', deliveryPending: true }) + expect(screen.getByText('queuedMessages.deliveryPending')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull() + expect(screen.queryByRole('button', { name: 'queuedMessages.retryOutcome' })).toBeNull() + expect(screen.getByRole('button', { name: 'Cancel queued message' })).toBeDisabled() + }) + + it.each([false, true])('keeps explicit resolution for real uncertainty when steer capability is %s', (canSteer) => { + renderQueuedMessage(null, null, 0, canSteer, null, { deliveryState: 'indeterminate' }) + expect(screen.getByText('queuedMessages.steerOutcomeUnknown')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'queuedMessages.retryOutcome' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Cancel queued message' })).toBeEnabled() + }) + + it('offers explicit resolution when the pending owner is no longer steerable', () => { + renderQueuedMessage(null, null, 0, false, null, { deliveryState: 'indeterminate', deliveryPending: true }) + expect(screen.getByText('queuedMessages.steerOutcomeUnknown')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'queuedMessages.retryOutcome' })).toBeEnabled() + expect(screen.queryByText('queuedMessages.deliveryPending')).toBeNull() + }) +}) + + +it('keeps a live confirmation pending during normal drain after new Steer admission closes', () => { + renderQueuedMessage(null, null, 0, false, null, { deliveryState: 'indeterminate', deliveryPending: true }, true) + expect(screen.getByText('queuedMessages.deliveryPending')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'queuedMessages.retryOutcome' })).toBeNull() +}) + + +it('does not expose a second Steer when REST success beats the pending SSE', async () => { + const api = { steerMessage: vi.fn().mockResolvedValue({ status: 'steered', localId: 'local-server-message-id' }), getQueuedState: vi.fn().mockResolvedValue({ queuedLocalIds: [], indeterminateLocalIds: ['local-server-message-id'], dispatchingLocalIds: ['local-server-message-id'], invokedLocalMessages: [] }) } as unknown as ApiClient + renderQueuedMessage(null, null, 0, true, api) + fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + await waitFor(() => expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull()) + expect(screen.getByText('queuedMessages.deliveryPending')).toBeInTheDocument() + expect(mocks.addToast).not.toHaveBeenCalled() +}) diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index 16490798..abc98ef4 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -199,6 +199,7 @@ export function QueuedMessagesBar({ pendingScheduleRevision, onEdit, canSteer, + deliveryActive = canSteer, compactionPendingMessageIds, }: { sessionId: string @@ -216,10 +217,11 @@ export function QueuedMessagesBar({ onEdit?: (params: { text: string; pendingSchedule: PendingSchedule | null }) => void /** * When true, each queued row gets a Steer button that delivers that - * message into the active turn (Pi native steer). The parent computes it - * as: pi flavor && session thinking && remote-controlled. + * message into a supported active remote turn. */ canSteer?: boolean + /** An existing dispatch may still be confirming after new Steer admission closes. */ + deliveryActive?: boolean }) { const queued = useQueuedMessages(sessionId) const assistantApi = useAui() @@ -355,7 +357,9 @@ export function QueuedMessagesBar({ const localId = msg.localId ?? msg.id const waitingForCompaction = msg.deliveryState === 'indeterminate' && compactionPendingMessageIds?.includes(localId) === true - const isPending = cancelMutation.isPending || queuedOperationPending + const deliveryPending = msg.deliveryPending === true && deliveryActive === true + const needsResolution = msg.deliveryState === 'indeterminate' && !deliveryPending + const isPending = cancelMutation.isPending || queuedOperationPending || deliveryPending const canCancel = computeCanCancel({ id: msg.id, localId: msg.localId, isPending }) const handleCancel = () => { @@ -403,7 +407,7 @@ export function QueuedMessagesBar({ const retryPending = retryMutation.isPending && retryMutation.variables?.messageId === msg.id const handleRetry = () => { - if (msg.deliveryState !== 'indeterminate' || !canCancel) return + if (!needsResolution || !canCancel) return const token = beginQueuedOperation(sessionId) if (!token) return void retryMutation.mutateAsync({ @@ -524,7 +528,7 @@ export function QueuedMessagesBar({ ) : null} {msg.deliveryState === 'indeterminate' ? (
- {t(waitingForCompaction ? 'queuedMessages.compactionPending' : 'queuedMessages.steerOutcomeUnknown')} + {t(waitingForCompaction ? 'queuedMessages.compactionPending' : deliveryPending ? 'queuedMessages.deliveryPending' : 'queuedMessages.steerOutcomeUnknown')}
) : null} {hasAttachments ? ( @@ -551,7 +555,7 @@ export function QueuedMessagesBar({ )}
- {msg.deliveryState === 'indeterminate' && !waitingForCompaction ? ( + {needsResolution && !waitingForCompaction ? (