feat(cursor): mid-turn Steer via concurrent ACP session/prompt (#888) (#1609)

* feat(shared): steer capability gates and live steered signal schemas

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
  agents can deliver queued messages into the active turn (pi, codex,
  cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
  messages-consumed  live signal (never persisted by the hub)

* feat(cli): queue reservations and steered messages-consumed option

- MessageQueue2 gains takeByLocalId/restoreReservation/
  beginReservationDispatch/commitReservation so an async steer can reserve
  a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery

* feat(codex): mid-turn steer via app-server turn/steer (#888)

- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, validates it against the active turn (no
  control commands, matching mode hash), injects via turn/steer with an
  epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered

* feat(web): Steered badge and steer gating for codex sessions

- HappyUserMessage shows a ↳ Steered badge fed by the live
  messages-consumed steered signal, preserved across server echoes and
  refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
  the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
  (upstream typecheck breakage)

* feat(acp): split request dispatch from completion and add soft steer

- AcpStdioTransport.sendRequestWithDispatch separates stdin-accepted
  dispatch from the JSON-RPC response, keeping sendRequest behavior
  unchanged
- AcpSdkBackend tracks concurrent session/prompt requests with an
  activePromptRequests counter (main prompt + soft steers); response
  completion stays pending until every concurrent prompt settles
- beginSoftSteerPrompt kicks off a concurrent session/prompt (Cursor GUI
  Send semantics — no cancel, no handler swap) returning {dispatched,
  completed}; softSteerPrompt awaits the full response for direct callers

* feat(cursor): mid-turn soft steer via concurrent session/prompt (#888)

- CursorAcpRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, rejects control commands and mode mismatches,
  then soft-injects via beginSoftSteerPrompt without canceling the
  in-flight turn
- Acks the hub once stdin accepts the inject (not on turn completion) to
  stay inside the 30s RPC window; the launcher stays busy until the
  concurrent prompt settles so handlers are not swapped mid-inject
- steeringActive agent state mirrors the active-turn window; abort and
  cleanup reset it and invalidate pending steers
- Legacy stream-json Cursor sessions register a steer handler that
  reports unsupported

* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
  codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
  finished): the hub RPC acks once dispatch succeeds — never on the
  concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
  restores the row so the message still delivers via turn/start, and a
  dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
  ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure

* feat(shared): advertise cursor in the steer gate now that its handler lands

Cursor ACP sessions pass the web and hub steer gates; legacy stream-json
cursor sessions stay excluded.

* fix(codex): reconcile dispatched steers before restoring; align error copy

- A dispatched turn/steer whose completion fails (disconnect / protocol
  error) is now reconciled via thread/read by clientUserMessageId before
  the queued row is restored — the instruction is only re-delivered by
  turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
  current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
  (Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
  and reconcile-rejected outcomes

* fix(codex): consume the row at dispatch; drop background reconcile

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  turn/steer; completion is background-only logging. A dispatched steer is
  never restored, so the same localId cannot be re-delivered via turn/start
  after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
  failure
- steer.completed rejection is always handled (no unhandled rejection on
  the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
  dispatch failure restores it

* fix(cursor): consume the row at dispatch; keep waiters for prompt gating

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  the concurrent session/prompt; completion is background-only. A
  dispatched steer is never restored (no duplicate via the next prompt)
- softSteerWaiters are registered before awaiting dispatch so the main
  loop's finally cannot start the next prompt mid-inject; they still gate
  prompt handover on completion
- tests updated: post-dispatch ACP rejection keeps the row consumed

* fix(cursor,hub): never hang teardown on unresolved soft steer; align diagnostics

- Prompt-finally waits for soft-steer completion only when not exiting;
  the outer finally no longer waits at all — cleanup() disconnects the
  ACP transport, which rejects pending requests and settles the waiters
- syncEngine gate diagnostics and JSDoc name all supported flavors
  (Pi, Codex, Cursor ACP)
- regression test: Switch with an unresolved soft-steer completion still
  reaches teardown

* fix(codex): distinguish definite rejection from indeterminate completion

- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
  carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
  a definite app-server rejection restores the row (instruction was never
  accepted, so turn/start cannot duplicate it); an indeterminate outcome
  leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
  dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
  outcome (row stays reserved) and dispatch failure

* fix(codex): reconcile indeterminate steers instead of a permanent reservation

- After an indeterminate completion (disconnect/protocol), reconcile the
  thread by clientUserMessageId immediately: accepted → commit + consumed,
  provably rejected → restore, still unreadable → keep the reservation and
  retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
  while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
  reconciliation consumes; rejected path restores

* fix(cursor): abort drops soft-steer waiters so the next prompt never blocks

- Ordinary Abort (shouldExit false) now clears softSteerWaiters: the
  prompt finally cannot wait forever on a soft steer whose completion is
  unbounded; the ACP cancel rejects in-flight requests, and cleanup()
  settles leftovers on session end
- regression test: unresolved soft-steer completion after Abort no longer
  blocks the next prompt

* fix(codex): accept all thread item shapes; retry reconcile; ack through abort

- Reconcile matcher accepts userMessage/user_message with clientId/
  client_id, matching the shapes the thread parser supports — an accepted
  steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
  app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
  reported steered on dispatch, so commit + messages-consumed must reach
  it even when an abort resets the queue in between

* fix(cursor,acp): abort force-settles soft-steer bookkeeping

- AcpSdkBackend.abortSoftSteers() drops the concurrent-prompt counter and
  notifies response-complete so the next turn's waitForResponseComplete()
  cannot block on a soft steer that will never settle after abort
- handleAbort calls it before clearing the waiters; the main prompt's own
  finishPromptRequest stays guarded by Math.max(0, ...)
- unit tests cover counter release and no-op when idle

* fix(codex): reinit reconnected app-server; keep reconcile retries alive

- thread/read after a disconnect auto-connects a fresh app-server, which
  must be initialized before any request — reconcile now ensures
  connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
  so recovery without external traffic is eventually observed
- launcher mock gains isConnected

* test(acp): match finishPromptRequest epoch signature in whitebox test

* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK

- Reconciliation runs on a self-rescheduling 1s timer independent of the
  main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
  observe app-server recovery; abort clears nothing implicitly — the ACK
  path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
  'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
  so ensureAppServerInitialized re-initializes a fresh process before
  thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
  keeps reserved, explicit rejection restores

* fix(codex): bind reconciliation to the launcher lifecycle

- runSteerReconciliation clears any armed retry timer on entry and never
  installs a second one, so loop-top and timer-driven passes cannot
  multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
  pending map is dropped, so an unresolved steer can never respawn an
  app-server after cleanup (remote-to-local switch included)

* fix(cursor): abort releases an in-progress soft-steer wait

- The prompt-finally wait races Promise.allSettled against the abort
  signal: an Abort that clears the waiters now also releases a wait that
  already started, so the launcher always reaches the next queued prompt

* fix(codex): report steered only after app-server acceptance

- The handler now awaits steer.completed (the inject-acceptance response):
  an explicit JSON-RPC rejection surfaces as failed and restores the row
  for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
  reconciled' and keeps the row reserved while the timer-driven thread
  reconciliation runs
- dispatch-failure path also swallows the paired completion rejection

* fix(cursor,acp): commit on ACP acceptance; distinguish transport failures

- AcpStdioTransport marks transport-level failures (timeout, closed,
  stdin write) as indeterminate; explicit JSON-RPC error responses are not
- The steer handler commits + consumes on completion (ACP acceptance) and
  restores the row on an explicit rejection; an indeterminate transport
  failure keeps the row reserved so a delivered instruction is never
  re-sent, and the ACK reaches the hub even when abort reset the queue
- launcher/transport tests updated for the three outcomes

* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait

- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
  steer reservation: the hub neither deletes the row nor stamps invoked_at
  (new CancelMessageResponse 'busy' status; web restores the optimistic
  row); pushIsolateAndClear and reset/close share cancelReservations so
  /clear-style commands cannot have a rejected steer resurrect a discarded
  prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
  lost response is indeterminate and funnels into thread reconciliation
  instead of stranding the reservation
- tests updated for the tri-state cancel contract

* test(cursor): match tri-state cancel contract for dispatching steers

* fix(cursor): drop duplicate promptInFlight declaration after upstream merge

* fix(codex,web): busy-aware edit flow; bound reconciliation reads

- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
  never prefills the composer when the row is inside an async steer, so a
  second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
  connected-but-silent app-server cannot hold the reservation in-flight
  indefinitely

* fix(steer): inFlight-dominated cancel acks; bounded reconciliation

- hub cancel-queued-message acks check inFlight before removed: a stale
  duplicate socket reporting removed can no longer delete the durable row
  while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
  rejection window, a dispatched steer that the app-server never proved
  (client ids dropped on restart) is committed instead of polling
  thread/read forever
- pre-dispatch failures (abort before write included) never enter
  reconciliation — they restore the row and report failure

* fix(cursor,acp,web): indeterminate close marks, steer gating precision

- AcpStdioTransport.rejectAllPending marks close/protocol failures
  indeterminate, so an accepted-but-close-interrupted soft steer restores
  nothing (no duplicate delivery)
- the abort race in the soft-steer wait removes its listener in finally
  (no accumulation across repeated waits)
- SessionChat gates the Steer button on agentState.steeringActive for
  codex/cursor instead of the queued-grace thinking flag, so Steer is not
  exposed before the launcher can accept it
- codex pre-dispatch abort never enters reconciliation (merged from #1606)

* fix(steer): persist indeterminate outcomes without replay

* fix(cursor): hold ambiguous steers for explicit resolution

* fix(steer): make ambiguous delivery restart-safe

* fix(cursor): make ambiguous delivery restart-safe

* fix(steer): recover crash-held rows and preserve retry dedup

* fix(steer): ack retries and bound stdin dispatch

* fix(cursor): reject steers when prompt generation changes

* fix(steer): reconcile indeterminate dispatches and serialize retries

* fix(cursor): preserve soft-steer reservations across abort

* fix(codex): classify stdin callback failures as indeterminate

* fix(steer): recheck indeterminate cancels after ACK

* fix(steer): close retry and abort races

* fix(cursor): hold ambiguous dispatch failures

* fix(steer): serialize live retries and abort admission

* fix(steer): distinguish live dispatching from unknown

* fix(cursor): bound ACP dispatch acknowledgements

* fix(steer): keep ACK failures held and reconcile busy cancel

* fix(cursor): preserve state when dispatch ACK is uncertain

* fix(steer): distinguish held cancel from removal

* fix(store): combine schema v24 migrations

* fix(cursor): distinguish held cancel from removal

* fix(store): reserve schema v25 for steer delivery state

* fix(cursor): suppress late ACP updates after abort

* fix(steer): keep held cancel state and notify requeue

* fix(cursor): isolate late updates after abort

* fix(steer): release explicitly cancelled unknown reservations

* test(cursor): cover explicit held cancellation

* fix(codex): reject cancelled reservations before native steer

* fix(cursor): reject cancelled reservations before ACP steer

* fix(codex): make reservation restore atomic with state

* fix(cursor): make reservation restore atomic with state

* fix(codex): terminate abandoned transport writes

* fix(cursor): hard-stop abandoned writes and update native queue state

* fix(steer): own abandoned app-server lifecycle and consume races

* fix(cursor): isolate aborts and add native retry resolution

* fix(codex): confirm dispatch and recover abandoned turns

* test(codex): mock abandoned transport callback

* fix(native): reconcile retry responses

* fix(codex): clear visible turn state on transport loss

* fix(steer): claim retries and cover native delivery state

* fix(native): resync busy cancel outcomes

* fix(native): preserve indeterminate state on Android hydration

* fix(steer): make retry claims single-winner

* fix(cursor): hold restore failures for explicit resolution

* fix(steer): serialize concurrent retry claims

* fix(socket): tolerate missing steer-state ACK callbacks

* fix(native): serialize retry operations

* docs(web): document unknown steer delivery and retry controls

* fix(steer): handle retry failures and abort-before-connect

* fix(cursor): drain foreground prompt after soft-steer abort

* fix(steer): reinitialize after transport loss and finish iOS retry errors

* fix(steer): preserve indeterminate rows across reconnect gaps

* test(web): mock indeterminate queued recovery state

* fix(steer): recover consumed ACK tombstones

* fix(steer): expose consumed cancel tombstones

* fix(cursor): drain soft steers before handler replacement

* fix(cursor): preserve buffered output on abort
This commit is contained in:
SSU-WEI HUANG
2026-08-20 08:53:41 +08:00
committed by GitHub
parent 1f5602ad6a
commit 0f7a3da68b
15 changed files with 1138 additions and 57 deletions
+300 -5
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { EnhancedMode } from './loop';
import type { AgentMessage } from '@/agent/types';
import { ACP_INDETERMINATE_SYMBOL } from '@/agent/backends/acp/AcpStdioTransport';
const harness = vi.hoisted(() => ({
initializeError: null as Error | null,
@@ -15,11 +16,14 @@ const harness = vi.hoisted(() => ({
newSessionAttempts: 0,
promptCalls: 0,
prompts: [] as unknown[][],
deferPrompt: null as Promise<void> | null,
deferSoftSteer: null as Promise<void> | null,
softSteerDispatchError: null as Error | null,
deferSoftSteerDispatch: null as Promise<void> | null,
promptErrors: [] as Error[],
promptMessages: [] as AgentMessage[],
promptMessageBatches: [] as AgentMessage[][],
promptStderrErrors: [] as Array<{ type: string; message: string; raw: string }>,
deferPrompt: null as Promise<void> | null,
releasePrompt: null as (() => void) | null,
backendArgs: null as { command: string; args?: string[] } | null,
setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>,
@@ -145,6 +149,16 @@ vi.mock('./utils/cursorAcpBackend', () => ({
if (error) throw error;
}),
cancelPrompt: vi.fn(async () => {}),
getPromptGeneration: vi.fn(() => 1),
beginSoftSteerPrompt: vi.fn(() => ({
dispatched: harness.softSteerDispatchError
? Promise.reject(harness.softSteerDispatchError)
: (harness.deferSoftSteerDispatch ?? Promise.resolve()),
completed: harness.deferSoftSteer ?? Promise.resolve()
})),
softSteerPrompt: vi.fn(async () => {}),
abortSoftSteers: vi.fn(),
waitForResponseComplete: vi.fn(async () => {}),
respondToPermission: vi.fn(async () => {}),
onStderrError: vi.fn((handler) => {
harness.stderrErrorHandler = handler ?? null;
@@ -206,6 +220,7 @@ vi.mock('@/ui/logger', () => ({
import { classifyCursorAcpLoadError, cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher';
import { createCursorAcpBackend } from './utils/cursorAcpBackend';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import { CursorSession } from './session';
import { ApiSessionClient } from '@/api/apiSession';
import {
@@ -213,7 +228,7 @@ import {
writeSharedCursorModelsCache
} from '@/modules/common/cursorModelsSharedCache';
function makeSession(sessionId: string | null): CursorSession {
function makeSession(sessionId: string | null, closeQueue = true): CursorSession {
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
const client = makeClient();
@@ -232,22 +247,31 @@ function makeSession(sessionId: string | null): CursorSession {
});
session.onSessionFoundWithProtocol = vi.fn();
queue.close();
if (closeQueue) {
queue.close();
}
return session;
}
function makeClient() {
const handlers = new Map<string, (payload?: unknown) => Promise<unknown>>();
return {
sessionId: 'test-session-id',
rpcHandlerManager: {
registerHandler: vi.fn(),
handlers,
registerHandler: vi.fn((method: string, handler: (payload?: unknown) => Promise<unknown>) => {
handlers.set(method, handler);
}),
unregisterHandler: vi.fn()
},
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
emitMessagesConsumed: vi.fn(),
emitSteerIndeterminate: vi.fn(),
setSteerDeliveryState: vi.fn(async () => true),
sendClaudeSessionMessage: vi.fn(),
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
@@ -267,11 +291,14 @@ describe('cursorAcpRemoteLauncher', () => {
harness.newSessionAttempts = 0;
harness.promptCalls = 0;
harness.prompts = [];
harness.deferPrompt = null;
harness.deferSoftSteer = null;
harness.softSteerDispatchError = null;
harness.deferSoftSteerDispatch = null;
harness.promptErrors = [];
harness.promptMessages = [];
harness.promptMessageBatches = [];
harness.promptStderrErrors = [];
harness.deferPrompt = null;
harness.releasePrompt = null;
harness.setConfigOptionCalls = [];
harness.deferSetConfigOption = null;
@@ -292,6 +319,274 @@ describe('cursorAcpRemoteLauncher', () => {
_resetSharedCursorModelsCacheForTests();
});
it('ends the launcher when a soft steer outlives an abort', async () => {
let releasePrompt!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
// Soft-steer completion never settles — simulates Cursor keeping the
// concurrent request open after an ordinary Abort.
harness.deferSoftSteer = new Promise(() => {});
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' });
harness.deferPrompt = null;
releasePrompt();
await handlers.get(RPC_METHODS.Abort)!();
// The old soft steer never settled, so the launcher must not install
// another prompt handler over it; the bounded drain ends the session.
expect(harness.promptCalls).toBe(1);
session.queue.close();
await runPromise;
}, 10_000);
it('restores a queued steer when ACP dispatch fails', async () => {
let releasePrompt!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.softSteerDispatchError = new Error('stdin closed');
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }))
.resolves.toEqual({ steered: false, error: 'Failed to soft-steer into active turn' });
expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true });
harness.softSteerDispatchError = null;
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('restores the row when ACP explicitly rejects after dispatch', async () => {
let releasePrompt!: () => void;
let rejectSoftSteer!: (error: Error) => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; });
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }))
.resolves.toEqual({ steered: true });
// An explicit JSON-RPC rejection means ACP never accepted the
// instruction — the row is restored for the next prompt (no data loss).
rejectSoftSteer(new Error('request rejected'));
await vi.waitFor(() => expect(session.queue.cancelByLocalId('steer')).toBe(true));
expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true });
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('holds a transport-ambiguous steer for explicit retry or cancel', async () => {
let releasePrompt!: () => void;
let rejectSoftSteer!: (error: Error) => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; });
const indeterminate = new Error('ACP transport closed');
Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true });
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }))
.resolves.toEqual({ steered: true });
rejectSoftSteer(indeterminate);
await vi.waitFor(() => expect(session.queue.cancelByLocalId('steer')).toBe(true));
expect(session.client.emitSteerIndeterminate).toHaveBeenCalledWith(['steer']);
expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true });
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('holds an indeterminate dispatch failure instead of restoring it', async () => {
let releasePrompt!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
const indeterminate = new Error('ACP write callback failed');
Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true });
harness.softSteerDispatchError = indeterminate;
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }))
.resolves.toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
expect(session.client.emitSteerIndeterminate).toHaveBeenCalledWith(['steer']);
expect(session.queue.cancelByLocalId('steer')).toBe(true);
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('prevents cancellation once ACP steer dispatch starts', async () => {
let releasePrompt!: () => void;
let releaseDispatch!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' });
await Promise.resolve();
expect(session.queue.cancelByLocalId('steer')).toBe('in-flight');
releaseDispatch();
await expect(steerResult).resolves.toEqual({ steered: true });
expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['steer'], { steered: true });
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('blocks the next prompt while soft-steer dispatch is pending', async () => {
let releasePrompt!: () => void;
let releaseDispatch!: () => void;
let releaseSoftSteer!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; });
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' });
await Promise.resolve();
session.queue.push('next', mode, 'next');
harness.deferPrompt = null;
releasePrompt();
await Promise.resolve();
expect(harness.promptCalls).toBe(1);
releaseDispatch();
await expect(steerResult).resolves.toEqual({ steered: true });
expect(harness.promptCalls).toBe(1);
releaseSoftSteer();
await vi.waitFor(() => expect(harness.promptCalls).toBe(2));
session.queue.close();
await runPromise;
});
it('acknowledges a steer dispatched before an overlapping abort', async () => {
let releasePrompt!: () => void;
let releaseDispatch!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' });
await Promise.resolve();
releaseDispatch();
await expect(steerResult).resolves.toEqual({ steered: true });
await handlers.get(RPC_METHODS.Abort)!();
await vi.waitFor(() => expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['steer'], { steered: true }));
expect(vi.mocked(session.client.emitMessagesConsumed).mock.calls
.filter(([ids]) => ids.includes('steer'))).toHaveLength(1);
harness.deferPrompt = null;
releasePrompt();
session.queue.close();
await runPromise;
});
it('does not hang teardown while a soft steer completion is unresolved', async () => {
let releasePrompt!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
// Completion never resolves — simulates Cursor keeping the concurrent
// request open past Exit/Switch.
harness.deferSoftSteer = new Promise(() => {});
const session = makeSession(null, false);
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
const runPromise = cursorAcpRemoteLauncher(session);
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
const handlers = (session.client as unknown as {
rpcHandlerManager: { handlers: Map<string, (payload?: unknown) => Promise<unknown>> };
}).rpcHandlerManager.handlers;
session.queue.push('soft steer', mode, 'steer');
await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }))
.resolves.toEqual({ steered: true });
// Exit/Switch must reach cleanup (which disconnects the transport and
// rejects pending ACP requests) without waiting on the soft steer.
harness.deferPrompt = null;
releasePrompt();
await handlers.get(RPC_METHODS.Switch)!();
await vi.waitFor(() => expect(runPromise).resolves.toBeDefined());
});
it('spawns agent acp backend, not stream-json', async () => {
const session = makeSession(null);
await cursorAcpRemoteLauncher(session);