mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
* 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
1767 lines
72 KiB
TypeScript
1767 lines
72 KiB
TypeScript
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,
|
|
initializeAttempts: 0,
|
|
loadSessionError: null as Error | null,
|
|
newSessionError: null as Error | null,
|
|
failSetConfigOption: false,
|
|
supportsLoadSession: true,
|
|
loadSessionCalled: false,
|
|
newSessionCalled: false,
|
|
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 }>,
|
|
releasePrompt: null as (() => void) | null,
|
|
backendArgs: null as { command: string; args?: string[] } | null,
|
|
setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>,
|
|
deferSetConfigOption: null as Promise<void> | null,
|
|
releaseSetConfigOption: null as (() => void) | null,
|
|
deferLoadSession: null as Promise<void> | null,
|
|
releaseLoadSession: null as (() => void) | null,
|
|
stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null,
|
|
disconnectError: null as Error | null,
|
|
overlayCleanup: null as ReturnType<typeof vi.fn> | null,
|
|
agentActivityListener: null as ((thinking: boolean) => void) | null
|
|
}));
|
|
|
|
const legacyLauncher = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('./cursorLegacyRemoteLauncher', () => ({
|
|
cursorLegacyRemoteLauncher: legacyLauncher
|
|
}));
|
|
|
|
vi.mock('./utils/cursorAcpBackend', () => ({
|
|
CURSOR_ACP_REQUIRED_MESSAGE: 'Cursor ACP mode is required for new Cursor remote sessions.',
|
|
createCursorAcpBackend: vi.fn((opts?: { model?: string | null }) => {
|
|
const args = ['acp'];
|
|
const model = opts?.model?.trim();
|
|
if (model && model !== 'auto' && model !== 'default' && model !== 'default[]') {
|
|
args.unshift('--model', model);
|
|
}
|
|
harness.backendArgs = { command: 'agent', args };
|
|
return {
|
|
initialize: vi.fn(async () => {
|
|
harness.initializeAttempts += 1;
|
|
if (harness.initializeError && harness.initializeAttempts === 1) {
|
|
harness.stderrErrorHandler?.({
|
|
type: 'model_not_found',
|
|
message: harness.initializeError.message,
|
|
raw: harness.initializeError.message
|
|
});
|
|
throw harness.initializeError;
|
|
}
|
|
}),
|
|
authenticateIfAvailable: vi.fn(async () => {}),
|
|
supportsLoadSession: vi.fn(() => harness.supportsLoadSession),
|
|
loadSession: vi.fn(async () => {
|
|
harness.loadSessionCalled = true;
|
|
if (harness.deferLoadSession) {
|
|
await harness.deferLoadSession;
|
|
}
|
|
if (harness.loadSessionError) throw harness.loadSessionError;
|
|
return 'loaded-acp-session';
|
|
}),
|
|
newSession: vi.fn(async () => {
|
|
harness.newSessionAttempts += 1;
|
|
harness.newSessionCalled = true;
|
|
if (harness.newSessionError && harness.newSessionAttempts === 1) {
|
|
harness.stderrErrorHandler?.({
|
|
type: 'model_not_found',
|
|
message: harness.newSessionError.message,
|
|
raw: harness.newSessionError.message
|
|
});
|
|
throw harness.newSessionError;
|
|
}
|
|
return 'new-acp-session';
|
|
}),
|
|
setMode: vi.fn(async () => {}),
|
|
setModel: vi.fn(async () => {}),
|
|
setConfigOption: vi.fn(async (sessionId: string, configId: string, value: string) => {
|
|
if (configId === 'model-opt' && harness.deferSetConfigOption) {
|
|
await harness.deferSetConfigOption;
|
|
}
|
|
if (harness.failSetConfigOption && configId === 'model-opt') {
|
|
throw new Error('set_config_option rejected');
|
|
}
|
|
harness.setConfigOptionCalls.push({ sessionId, configId, value });
|
|
}),
|
|
pinSessionModelWireId: vi.fn(),
|
|
getSessionModelsMetadata: vi.fn(() => ({
|
|
availableModels: [
|
|
{ modelId: 'composer-2.5[fast=true]' },
|
|
{ modelId: 'composer-2.5[fast=false]' },
|
|
{ modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]' },
|
|
{ modelId: 'gpt-5.3-codex[reasoning=medium,fast=true]' },
|
|
{ modelId: 'cursor-grok-4.5-medium' },
|
|
{ modelId: 'cursor-grok-4.5-medium-fast' },
|
|
],
|
|
currentModelId: 'composer-2.5[fast=true]'
|
|
})),
|
|
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
|
|
if (category === 'mode') {
|
|
return {
|
|
id: 'mode-opt',
|
|
options: [
|
|
{ value: 'agent' },
|
|
{ value: 'plan' },
|
|
{ value: 'debug' }
|
|
]
|
|
};
|
|
}
|
|
if (category === 'model') {
|
|
return {
|
|
id: 'model-opt',
|
|
options: [
|
|
{ value: 'default[]' },
|
|
{ value: 'composer-2.5[fast=true]' },
|
|
{ value: 'composer-2.5[fast=false]' },
|
|
{ value: 'gpt-5.3-codex[reasoning=medium,fast=false]' },
|
|
{ value: 'gpt-5.3-codex[reasoning=medium,fast=true]' },
|
|
{ value: 'cursor-grok-4.5-medium' },
|
|
{ value: 'cursor-grok-4.5-medium-fast' },
|
|
]
|
|
};
|
|
}
|
|
return undefined;
|
|
}),
|
|
prompt: vi.fn(async (_sessionId: string, content: unknown[], onMessage?: (message: AgentMessage) => void) => {
|
|
harness.promptCalls++;
|
|
harness.prompts.push(content);
|
|
const messages = harness.promptMessageBatches.shift() ?? harness.promptMessages.splice(0, 1);
|
|
for (const message of messages) onMessage?.(message);
|
|
const stderrError = harness.promptStderrErrors.shift();
|
|
if (stderrError) harness.stderrErrorHandler?.(stderrError);
|
|
if (harness.deferPrompt) await harness.deferPrompt;
|
|
const error = harness.promptErrors.shift();
|
|
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;
|
|
}),
|
|
setUsageUpdateListener: vi.fn(),
|
|
setAgentActivityListener: vi.fn((listener: ((thinking: boolean) => void) | null) => {
|
|
harness.agentActivityListener = listener;
|
|
}),
|
|
setSessionInfoUpdateListener: vi.fn(),
|
|
refreshSessionInfo: vi.fn(async () => {}),
|
|
onPermissionRequest: vi.fn(),
|
|
registerExtensionRequestHandler: vi.fn(),
|
|
disconnect: vi.fn(async () => {
|
|
if (harness.disconnectError) {
|
|
throw harness.disconnectError;
|
|
}
|
|
})
|
|
};
|
|
})
|
|
}));
|
|
|
|
vi.mock('./utils/cursorExtensionAdapter', () => ({
|
|
CursorExtensionAdapter: class {
|
|
handlePermissionResponse = vi.fn(async () => false);
|
|
cancelAll = vi.fn(async () => {});
|
|
}
|
|
}));
|
|
|
|
vi.mock('@/agent/permissionAdapter', () => ({
|
|
PermissionAdapter: class {
|
|
cancelAll = vi.fn(async () => {});
|
|
}
|
|
}));
|
|
|
|
vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
|
|
buildHapiMcpBridge: async () => ({
|
|
server: { stop: () => {} },
|
|
mcpServers: {
|
|
hapi: { command: 'hapi', args: ['mcp', '--url', 'http://127.0.0.1:1/'] },
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('./utils/cursorMcpOverlay', () => ({
|
|
cursorHapiMcpServerId: (sessionId: string) => `hapi-${sessionId}`,
|
|
installCursorMcpOverlay: () => {
|
|
harness.overlayCleanup = vi.fn();
|
|
return { cleanup: harness.overlayCleanup };
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/ui/ink/OpencodeDisplay', () => ({
|
|
OpencodeDisplay: () => null
|
|
}));
|
|
|
|
vi.mock('@/ui/logger', () => ({
|
|
logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() }
|
|
}));
|
|
|
|
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 {
|
|
_resetSharedCursorModelsCacheForTests,
|
|
writeSharedCursorModelsCache
|
|
} from '@/modules/common/cursorModelsSharedCache';
|
|
|
|
function makeSession(sessionId: string | null, closeQueue = true): CursorSession {
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient();
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
if (closeQueue) {
|
|
queue.close();
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
function makeClient() {
|
|
const handlers = new Map<string, (payload?: unknown) => Promise<unknown>>();
|
|
return {
|
|
sessionId: 'test-session-id',
|
|
rpcHandlerManager: {
|
|
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()
|
|
} as unknown as ApiSessionClient;
|
|
}
|
|
|
|
describe('cursorAcpRemoteLauncher', () => {
|
|
beforeEach(() => {
|
|
harness.initializeError = null;
|
|
harness.initializeAttempts = 0;
|
|
harness.loadSessionError = null;
|
|
harness.newSessionError = null;
|
|
harness.failSetConfigOption = false;
|
|
harness.supportsLoadSession = true;
|
|
harness.loadSessionCalled = false;
|
|
harness.newSessionCalled = false;
|
|
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.releasePrompt = null;
|
|
harness.setConfigOptionCalls = [];
|
|
harness.deferSetConfigOption = null;
|
|
harness.releaseSetConfigOption = null;
|
|
harness.deferLoadSession = null;
|
|
harness.releaseLoadSession = null;
|
|
harness.stderrErrorHandler = null;
|
|
harness.disconnectError = null;
|
|
harness.overlayCleanup = null;
|
|
harness.agentActivityListener = null;
|
|
legacyLauncher.mockClear();
|
|
process.stdin.isTTY = false;
|
|
process.stdout.isTTY = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
_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);
|
|
|
|
expect(createCursorAcpBackend).toHaveBeenCalled();
|
|
expect(harness.backendArgs).toEqual({ command: 'agent', args: ['acp'] });
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies harness thinking transitions once per edge (#1470)', async () => {
|
|
const keepAlive = vi.fn();
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = {
|
|
...makeClient(),
|
|
keepAlive
|
|
} as unknown as ApiSessionClient;
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
// Keep the launcher in the main loop long enough to wire the listener.
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.agentActivityListener).not.toBeNull());
|
|
|
|
expect(session.thinking).toBe(false);
|
|
keepAlive.mockClear();
|
|
|
|
harness.agentActivityListener!(true);
|
|
harness.agentActivityListener!(true);
|
|
harness.agentActivityListener!(false);
|
|
|
|
expect(session.thinking).toBe(false);
|
|
expect(keepAlive.mock.calls.map((call) => call[0])).toEqual([true, false]);
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('retries a transient Cursor connection failure three times using api_error events', async () => {
|
|
harness.promptErrors = [
|
|
new Error('Error: RetriableError: [canceled] http/2 stream closed with error code CANCEL'),
|
|
new Error('Error: RetriableError: [unavailable] connection reset'),
|
|
new Error("ACP request 'session/prompt' timed out after 120000ms")
|
|
];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient() as unknown as ApiSessionClient & {
|
|
sendClaudeSessionMessage: ReturnType<typeof vi.fn>;
|
|
sendAgentMessage: ReturnType<typeof vi.fn>;
|
|
};
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(4);
|
|
expect(harness.prompts).toEqual(Array(4).fill([{ type: 'text', text: 'finish the task' }]));
|
|
expect(client.sendClaudeSessionMessage.mock.calls.map(([message]) => ({
|
|
subtype: message.subtype,
|
|
retryAttempt: message.retryAttempt,
|
|
maxRetries: message.maxRetries
|
|
}))).toEqual([
|
|
{ subtype: 'api_error', retryAttempt: 1, maxRetries: 4 },
|
|
{ subtype: 'api_error', retryAttempt: 2, maxRetries: 4 },
|
|
{ subtype: 'api_error', retryAttempt: 3, maxRetries: 4 }
|
|
]);
|
|
expect(client.sendAgentMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
|
|
});
|
|
|
|
it('suppresses an inline Cursor connection error and retries instead of rendering it as plaintext', async () => {
|
|
harness.promptMessages = [
|
|
{ type: 'text', text: 'Error: RetriableError: [canceled] http/2 stream closed with error code CANCEL' }
|
|
];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient() as unknown as ApiSessionClient & {
|
|
sendClaudeSessionMessage: ReturnType<typeof vi.fn>;
|
|
sendAgentMessage: ReturnType<typeof vi.fn>;
|
|
};
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(2);
|
|
expect(client.sendClaudeSessionMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
subtype: 'api_error',
|
|
retryAttempt: 1,
|
|
maxRetries: 4
|
|
}));
|
|
expect(client.sendAgentMessage).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
message: expect.stringContaining('http/2 stream closed')
|
|
}));
|
|
});
|
|
|
|
it('suppresses retryable Cursor stderr while a prompt is retried', async () => {
|
|
harness.promptStderrErrors = [{
|
|
type: 'unknown',
|
|
message: 'http/2 stream closed with error code CANCEL',
|
|
raw: 'http/2 stream closed with error code CANCEL'
|
|
}];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient() as unknown as ApiSessionClient & {
|
|
sendClaudeSessionMessage: ReturnType<typeof vi.fn>;
|
|
sendAgentMessage: ReturnType<typeof vi.fn>;
|
|
};
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(2);
|
|
expect(client.sendClaudeSessionMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
subtype: 'api_error',
|
|
retryAttempt: 1
|
|
}));
|
|
expect(client.sendAgentMessage).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
message: expect.stringContaining('http/2 stream closed')
|
|
}));
|
|
});
|
|
|
|
it('does not retry when Cursor completes the turn after transient stderr', async () => {
|
|
harness.promptStderrErrors = [{
|
|
type: 'unknown',
|
|
message: 'http/2 stream closed with error code CANCEL',
|
|
raw: 'http/2 stream closed with error code CANCEL'
|
|
}];
|
|
harness.promptMessages = [{ type: 'turn_complete', stopReason: 'end_turn' }];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient() as unknown as ApiSessionClient & {
|
|
sendClaudeSessionMessage: ReturnType<typeof vi.fn>;
|
|
};
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(1);
|
|
expect(client.sendClaudeSessionMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries when inline failure accompanies recovered stderr and turn_complete', async () => {
|
|
harness.promptStderrErrors = [{
|
|
type: 'unknown',
|
|
message: 'http/2 stream closed with error code CANCEL',
|
|
raw: 'http/2 stream closed with error code CANCEL'
|
|
}];
|
|
harness.promptMessageBatches = [[
|
|
{ type: 'text', text: 'Error: RetriableError: [canceled] http/2 stream closed' },
|
|
{ type: 'turn_complete', stopReason: 'end_turn' }
|
|
]];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client: makeClient(),
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(2);
|
|
});
|
|
|
|
it('does not retry when Stop resolves a prompt after a retryable stderr signal', async () => {
|
|
harness.promptStderrErrors = [{
|
|
type: 'unknown',
|
|
message: 'http/2 stream closed with error code CANCEL',
|
|
raw: 'http/2 stream closed with error code CANCEL'
|
|
}];
|
|
harness.deferPrompt = new Promise<void>((resolve) => {
|
|
harness.releasePrompt = resolve;
|
|
});
|
|
const handlers = new Map<string, () => Promise<void>>();
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = {
|
|
...makeClient(),
|
|
rpcHandlerManager: {
|
|
registerHandler: vi.fn((method: string, handler: () => Promise<void>) => handlers.set(method, handler)),
|
|
unregisterHandler: vi.fn()
|
|
}
|
|
} as unknown as ApiSessionClient;
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
|
|
const launchPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
|
|
await handlers.get('abort')!();
|
|
harness.releasePrompt!();
|
|
queue.close();
|
|
await launchPromise;
|
|
|
|
expect(harness.promptCalls).toBe(1);
|
|
});
|
|
|
|
it('does not replay a prompt when a transient failure follows tool activity', async () => {
|
|
harness.promptMessages = [{
|
|
type: 'tool_call',
|
|
id: 'tool-1',
|
|
name: 'shell',
|
|
input: { command: 'touch output.txt' },
|
|
status: 'completed'
|
|
}];
|
|
harness.promptErrors = [
|
|
new Error('Error: RetriableError: [canceled] http/2 stream closed with error code CANCEL')
|
|
];
|
|
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
|
|
const client = makeClient() as unknown as ApiSessionClient & {
|
|
sendAgentMessage: ReturnType<typeof vi.fn>;
|
|
};
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('finish the task', { permissionMode: 'default' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(1);
|
|
expect(client.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'error',
|
|
message: expect.stringContaining('not retried')
|
|
}));
|
|
});
|
|
|
|
it('removes the Cursor MCP overlay even when backend.disconnect rejects', async () => {
|
|
harness.disconnectError = new Error('disconnect failed');
|
|
const session = makeSession(null);
|
|
|
|
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow('disconnect failed');
|
|
expect(harness.overlayCleanup).toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
it('throws on initialize failure without invoking legacy launcher', async () => {
|
|
harness.initializeError = new Error('agent acp not found');
|
|
const session = makeSession(null);
|
|
const client = session.client as unknown as { sendAgentMessage: ReturnType<typeof vi.fn> };
|
|
|
|
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
|
|
/Cursor ACP mode is required for new Cursor remote sessions/
|
|
);
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
expect(client.sendAgentMessage).toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces Cursor model rejection during initialize instead of the generic ACP-required message', async () => {
|
|
harness.initializeError = new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto'
|
|
);
|
|
const session = makeSession(null);
|
|
|
|
const error = await cursorAcpRemoteLauncher(session).then(
|
|
() => null,
|
|
(err: unknown) => err
|
|
);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toMatch(
|
|
/^Failed to start Cursor ACP session: Cannot use this model: grok-4\.5\[fast=true\]/
|
|
);
|
|
expect((error as Error).message).toMatch(/Available models: auto/);
|
|
expect((error as Error).message).not.toMatch(/Cursor ACP mode is required/);
|
|
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('registers cursorSessionId before session/load completes', async () => {
|
|
let releaseLoadSession!: () => void;
|
|
harness.deferLoadSession = new Promise<void>((resolve) => {
|
|
harness.releaseLoadSession = resolve;
|
|
releaseLoadSession = resolve;
|
|
});
|
|
|
|
const session = makeSession('resume-thread-1');
|
|
const launchPromise = cursorAcpRemoteLauncher(session);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('resume-thread-1', 'acp');
|
|
});
|
|
expect(harness.loadSessionCalled).toBe(true);
|
|
|
|
releaseLoadSession();
|
|
await launchPromise;
|
|
|
|
expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('loaded-acp-session', 'acp');
|
|
});
|
|
|
|
it('throws when session/load fails instead of falling back to stream-json', async () => {
|
|
harness.loadSessionError = new Error('session not found');
|
|
const session = makeSession('old-stream-json-id');
|
|
|
|
const error = await cursorAcpRemoteLauncher(session).then(
|
|
() => null,
|
|
(err: unknown) => err
|
|
);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toMatch(/Failed to resume Cursor ACP session: session not found/);
|
|
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
|
|
|
|
expect(harness.loadSessionCalled).toBe(true);
|
|
expect(harness.newSessionCalled).toBe(false);
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries session/new once after remapping a rejected bracket wire (#1430)', async () => {
|
|
_resetSharedCursorModelsCacheForTests();
|
|
harness.newSessionError = new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: gpt-5.3-codex[fast=false]. Available models: auto, gpt-5.3-codex, gpt-5.3-codex-fast'
|
|
);
|
|
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default',
|
|
model: 'gpt-5.3-codex[fast=false]'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionAttempts).toBe(2));
|
|
await vi.waitFor(() => {
|
|
expect(harness.backendArgs?.args).toEqual(['--model', 'gpt-5.3-codex', 'acp']);
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) =>
|
|
call.configId === 'model-opt'
|
|
&& call.value === 'gpt-5.3-codex[reasoning=medium,fast=false]'
|
|
)
|
|
).toBe(true);
|
|
expect(session.model).toBe('gpt-5.3-codex[fast=false]');
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('fails launch when remapped spawn cannot restore the desired variant (#1430)', async () => {
|
|
_resetSharedCursorModelsCacheForTests();
|
|
harness.failSetConfigOption = true;
|
|
harness.newSessionError = new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: composer-2.5[fast=true]. Available models: auto, composer-2.5'
|
|
);
|
|
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = makeClient();
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default',
|
|
model: 'composer-2.5[fast=true]'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.close();
|
|
|
|
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
|
|
/Cursor model is not available via ACP: composer-2\.5\[fast=true\]/
|
|
);
|
|
expect(harness.newSessionAttempts).toBe(2);
|
|
expect(harness.backendArgs?.args).toEqual(['--model', 'composer-2.5', 'acp']);
|
|
});
|
|
|
|
it('spawns bare remap but reapplies original fast=true variant via ACP (#1430)', async () => {
|
|
writeSharedCursorModelsCache({
|
|
success: true,
|
|
availableModels: [{ modelId: 'composer-2.5' }],
|
|
currentModelId: 'composer-2.5',
|
|
cliModelSkus: [{ modelId: 'composer-2.5' }],
|
|
});
|
|
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const keepAlive = vi.fn();
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive,
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default',
|
|
model: 'composer-2.5[fast=true]'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => {
|
|
expect(harness.backendArgs?.args).toEqual(['--model', 'composer-2.5', 'acp']);
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'model-opt' && call.value === 'composer-2.5[fast=true]'
|
|
)
|
|
).toBe(true);
|
|
expect(session.model).toBe('composer-2.5[fast=true]');
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
_resetSharedCursorModelsCacheForTests();
|
|
});
|
|
|
|
it('remaps stale spawn model and retries initialize once on model rejection', async () => {
|
|
harness.initializeError = new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=false]. Available models: auto, cursor-grok-4.5-medium, cursor-grok-4.5-medium-fast'
|
|
);
|
|
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const keepAlive = vi.fn();
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive,
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default',
|
|
model: 'grok-4.5[fast=false]'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.initializeAttempts).toBe(2));
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
|
|
expect(harness.backendArgs?.args).toContain('cursor-grok-4.5-medium');
|
|
expect(keepAlive).toHaveBeenCalled();
|
|
expect(
|
|
(client.sendAgentMessage as ReturnType<typeof vi.fn>).mock.calls.some((call) =>
|
|
JSON.stringify(call[0]).includes('Cannot use this model')
|
|
)
|
|
).toBe(false);
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('surfaces Cursor model rejection from session/load instead of claiming legacy protocol', async () => {
|
|
harness.loadSessionError = new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, cursor-grok-4.5-high-fast'
|
|
);
|
|
const session = makeSession('acp-thread-1');
|
|
|
|
const error = await cursorAcpRemoteLauncher(session).then(
|
|
() => null,
|
|
(err: unknown) => err
|
|
);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toMatch(/Cannot use this model: grok-4\.5\[fast=true\]/);
|
|
expect((error as Error).message).toMatch(/Available models: auto, cursor-grok-4\.5-high-fast/);
|
|
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
|
|
|
|
expect(harness.newSessionCalled).toBe(false);
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws when resume id is set but session/load is unsupported', async () => {
|
|
harness.supportsLoadSession = false;
|
|
const session = makeSession('some-session-id');
|
|
|
|
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
|
|
/session\/load is not supported/
|
|
);
|
|
|
|
expect(harness.loadSessionCalled).toBe(false);
|
|
expect(harness.newSessionCalled).toBe(false);
|
|
expect(legacyLauncher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a new ACP session when no resume id is provided', async () => {
|
|
const session = makeSession(null);
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.newSessionCalled).toBe(true);
|
|
expect(harness.loadSessionCalled).toBe(false);
|
|
expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('new-acp-session', 'acp');
|
|
expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('emits session-ready after session/load succeeds', async () => {
|
|
const session = makeSession('resume-thread-ready');
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.loadSessionCalled).toBe(true);
|
|
expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not emit session-ready when session/load fails', async () => {
|
|
harness.loadSessionError = new Error('session not found');
|
|
const session = makeSession('old-stream-json-id');
|
|
|
|
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
|
|
/Failed to resume Cursor ACP session: session not found/
|
|
);
|
|
|
|
expect(session.client.emitSessionReady).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('classifyCursorAcpLoadError', () => {
|
|
it('prefers Cannot use this model text from the underlying error', () => {
|
|
const message = classifyCursorAcpLoadError(
|
|
new Error('ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, composer-2.5')
|
|
);
|
|
expect(message).toContain('Cannot use this model: grok-4.5[fast=true]');
|
|
expect(message).toContain('Available models: auto, composer-2.5');
|
|
expect(message).not.toMatch(/Legacy stream-json/);
|
|
});
|
|
|
|
it('uses recentStderr hint when exit error omits the model line', () => {
|
|
const message = classifyCursorAcpLoadError(
|
|
new Error('ACP process exited (code=1, signal=null)'),
|
|
{ recentStderr: 'Cannot use this model: stale-id. Available models: auto' }
|
|
);
|
|
expect(message).toContain('Cannot use this model: stale-id');
|
|
expect(message).toContain('Available models: auto');
|
|
expect(message).not.toMatch(/Legacy stream-json/);
|
|
});
|
|
|
|
it('prefers accumulated close stderr over a partial recentStderr hint', () => {
|
|
const message = classifyCursorAcpLoadError(
|
|
new Error(
|
|
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: full-id. Available models: auto, composer-2.5'
|
|
),
|
|
{ recentStderr: 'Cannot use this mo' }
|
|
);
|
|
expect(message).toContain('Cannot use this model: full-id');
|
|
expect(message).toContain('Available models: auto, composer-2.5');
|
|
expect(message).not.toContain('Cannot use this mo:');
|
|
});
|
|
|
|
it('propagates generic load failures without inventing a legacy diagnosis', () => {
|
|
const message = classifyCursorAcpLoadError(new Error('Session "abc" not found'));
|
|
expect(message).toBe('Failed to resume Cursor ACP session: Session "abc" not found');
|
|
expect(message).not.toMatch(/Legacy stream-json/);
|
|
});
|
|
|
|
it('uses start action prefix for spawn-time model rejection', () => {
|
|
const message = classifyCursorAcpLoadError(
|
|
new Error('ACP process exited (code=1, signal=null)'),
|
|
{
|
|
recentStderr: 'Cannot use this model: stale-id. Available models: auto',
|
|
action: 'start'
|
|
}
|
|
);
|
|
expect(message).toMatch(/^Failed to start Cursor ACP session: Cannot use this model: stale-id/);
|
|
expect(message).not.toMatch(/Failed to resume/);
|
|
});
|
|
});
|
|
|
|
// tiann/hapi#913: fresh ACP sessions previously persisted `cursorSessionId`
|
|
// via fire-and-forget `updateMetadata`. A SIGTERM within ~1s of the first
|
|
// turn (hub-restart cascade) could strand the session because the ACK
|
|
// never arrived. The fix awaits `client.flushMetadata()` between
|
|
// `onSessionFoundWithProtocol` and the main loop, gating turn processing
|
|
// on a durable persist.
|
|
it('awaits flushMetadata after registering a fresh cursorSessionId so SIGTERM cannot strand the session', async () => {
|
|
const session = makeSession(null);
|
|
const flushSpy = vi.fn(async () => true);
|
|
// Replace the mock fixture's flushMetadata so we can observe ordering.
|
|
(session.client as unknown as { flushMetadata: typeof flushSpy }).flushMetadata = flushSpy;
|
|
|
|
let flushCalled = false;
|
|
flushSpy.mockImplementation(async () => {
|
|
flushCalled = true;
|
|
return true;
|
|
});
|
|
|
|
const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType<typeof vi.fn>;
|
|
let onSessionFoundCalledBeforeFlush = false;
|
|
onSessionFoundSpy.mockImplementation(() => {
|
|
if (!flushCalled) {
|
|
onSessionFoundCalledBeforeFlush = true;
|
|
}
|
|
});
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(onSessionFoundCalledBeforeFlush).toBe(true);
|
|
expect(flushSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves the #834 resume-path pre-registration shape (registration before backend.loadSession)', async () => {
|
|
// PR #834 pre-registers `cursorSessionId` BEFORE `backend.loadSession`
|
|
// so a load-session failure on a legacy store does not strand the
|
|
// session. The #913 fix must not relocate or remove that
|
|
// pre-registration. We verify by observing call ordering on the spy.
|
|
const session = makeSession('resume-acp-session');
|
|
const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType<typeof vi.fn>;
|
|
|
|
let preRegisterCalledBeforeLoadSession = false;
|
|
let preRegisterArgs: unknown[] | null = null;
|
|
onSessionFoundSpy.mockImplementation((id: string, protocol: string) => {
|
|
if (!harness.loadSessionCalled) {
|
|
preRegisterCalledBeforeLoadSession = true;
|
|
preRegisterArgs = [id, protocol];
|
|
}
|
|
});
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(preRegisterCalledBeforeLoadSession).toBe(true);
|
|
expect(preRegisterArgs).toEqual(['resume-acp-session', 'acp']);
|
|
expect(harness.loadSessionCalled).toBe(true);
|
|
});
|
|
|
|
it('applies debug mode immediately when setPermissionMode is called', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
|
|
session.setPermissionMode('debug');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'mode-opt' && call.value === 'debug'
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('syncs spawn model to hub via keepAlive after initial ACP apply', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const keepAlive = vi.fn();
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive,
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default',
|
|
model: 'composer-2.5[fast=false]'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(session.model).toBe('composer-2.5[fast=false]');
|
|
expect(keepAlive).toHaveBeenCalled();
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('pushes keepalive with requested model before ACP apply finishes', async () => {
|
|
harness.deferSetConfigOption = new Promise<void>((resolve) => {
|
|
harness.releaseSetConfigOption = resolve;
|
|
});
|
|
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const keepAlive = vi.fn();
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive,
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
session.setModel('composer-2.5[fast=false]');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(session.model).toBe('composer-2.5[fast=false]');
|
|
expect(harness.setConfigOptionCalls.some((call) => call.configId === 'model-opt')).toBe(false);
|
|
});
|
|
|
|
harness.releaseSetConfigOption?.();
|
|
await vi.waitFor(() => {
|
|
expect(harness.setConfigOptionCalls.length).toBeGreaterThan(0);
|
|
});
|
|
harness.deferSetConfigOption = null;
|
|
harness.releaseSetConfigOption = null;
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('applies model wire id immediately when setModel is called', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
session.setModel('composer-2.5[fast=false]');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'model-opt' && call.value === 'composer-2.5[fast=false]'
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('applies ACP default model when setModel is cleared', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
session.setModel('composer-2.5[fast=false]');
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'model-opt' && call.value === 'composer-2.5[fast=false]'
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
harness.setConfigOptionCalls.length = 0;
|
|
session.setModel(null);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'model-opt' && call.value === 'default[]'
|
|
)
|
|
).toBe(true);
|
|
expect(session.model).toBeUndefined();
|
|
});
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('rolls back optimistic setModel when ACP does not expose the requested model', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const keepAlive = vi.fn();
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive,
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
session.setModel('missing-model');
|
|
|
|
await vi.waitFor(() => {
|
|
expect(session.model).toBe('composer-2.5[fast=true]');
|
|
});
|
|
expect(keepAlive).toHaveBeenCalled();
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('applyModelConfig(null) resets ACP to the default model option', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
await session.applyModelConfig('composer-2.5[fast=false]');
|
|
harness.setConfigOptionCalls.length = 0;
|
|
|
|
await session.applyModelConfig(null);
|
|
|
|
expect(
|
|
harness.setConfigOptionCalls.some(
|
|
(call) => call.configId === 'model-opt' && call.value === 'default[]'
|
|
)
|
|
).toBe(true);
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('rejects applyModelConfig when ACP does not expose the requested model', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('hold-open', { permissionMode: 'default' });
|
|
|
|
const runPromise = cursorAcpRemoteLauncher(session);
|
|
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
|
|
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
|
|
|
|
await expect(session.applyModelConfig('missing-model')).rejects.toThrow(
|
|
/not available via ACP/
|
|
);
|
|
|
|
queue.close();
|
|
await runPromise;
|
|
});
|
|
|
|
it('processes multiple queued messages with separate prompts', async () => {
|
|
const queue = new MessageQueue2<EnhancedMode>((mode) =>
|
|
`${mode.permissionMode}:${mode.model ?? ''}`
|
|
);
|
|
const client = {
|
|
rpcHandlerManager: { registerHandler: vi.fn() },
|
|
updateMetadata: vi.fn(),
|
|
flushMetadata: vi.fn(async () => true),
|
|
sendSessionEvent: vi.fn(),
|
|
sendAgentMessage: vi.fn(),
|
|
keepAlive: vi.fn(),
|
|
emitSessionReady: vi.fn(),
|
|
emitMessagesConsumed: vi.fn()
|
|
} as unknown as ApiSessionClient;
|
|
|
|
const session = new CursorSession({
|
|
api: {} as never,
|
|
client,
|
|
path: '/tmp/project',
|
|
logPath: '/tmp/log',
|
|
sessionId: null,
|
|
messageQueue: queue,
|
|
onModeChange: vi.fn(),
|
|
mode: 'remote',
|
|
startedBy: 'runner',
|
|
startingMode: 'remote',
|
|
permissionMode: 'default'
|
|
});
|
|
session.onSessionFoundWithProtocol = vi.fn();
|
|
queue.push('first', { permissionMode: 'default' });
|
|
queue.push('second', { permissionMode: 'plan' });
|
|
queue.close();
|
|
|
|
await cursorAcpRemoteLauncher(session);
|
|
|
|
expect(harness.promptCalls).toBe(2);
|
|
expect(JSON.stringify(harness.prompts[0])).toContain('first');
|
|
expect(JSON.stringify(harness.prompts[0])).not.toContain('skill_lookup');
|
|
expect(JSON.stringify(harness.prompts[0])).not.toContain('$name');
|
|
expect(JSON.stringify(harness.prompts[1])).toContain('second');
|
|
expect(JSON.stringify(harness.prompts[1])).not.toContain('skill_lookup');
|
|
});
|
|
});
|