Files
hapi/cli/src/cursor/cursorAcpRemoteLauncher.test.ts
T
26d3c2eb34 fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds (closes #939) (#948)
* fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds

Emit session-ready from the CLI after ACP load/newSession completes; hub
resumeSession and cursor dedup wait for that signal before merging rows so a
failed session/load no longer deletes the archived session the operator can retry.

Refs #917. Closes #939.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): gate session-ready wait on cursor ACP protocol only

Legacy stream-json Cursor resumes use cursorLegacyRemoteLauncher, which does
not emit session-ready; limiting the defer-merge and dedup gates to ACP avoids
60s resume_failed timeouts on those sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): block ACP dedup until session-ready, including on session-end

Inactive ACP spawns that never emitted session-ready could still trigger
deduplicateByAgentSessionId on session-end and delete the original row.
Require session-ready for all ACP dedup paths and skip end-of-session dedup
when load never succeeded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): restore session-end dedup for non-ACP cursor duplicates

Only skip the session-end dedup retry for Cursor ACP rows that never emitted
session-ready. Codex/Claude/legacy Cursor duplicates still merge when the live
row ends.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:10:10 +08:00

699 lines
25 KiB
TypeScript

import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
initializeError: null as Error | null,
loadSessionError: null as Error | null,
supportsLoadSession: true,
loadSessionCalled: false,
newSessionCalled: false,
promptCalls: 0,
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
}));
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 () => {
if (harness.initializeError) 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.newSessionCalled = true;
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;
}
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]' }
],
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]' }
]
};
}
return undefined;
}),
prompt: vi.fn(async () => {
harness.promptCalls++;
}),
cancelPrompt: vi.fn(async () => {}),
respondToPermission: vi.fn(async () => {}),
onStderrError: vi.fn(),
setUsageUpdateListener: vi.fn(),
onPermissionRequest: vi.fn(),
registerExtensionRequestHandler: vi.fn(),
disconnect: vi.fn(async () => {})
};
})
}));
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: {}
})
}));
vi.mock('@/ui/ink/OpencodeDisplay', () => ({
OpencodeDisplay: () => null
}));
vi.mock('@/ui/logger', () => ({
logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() }
}));
import { cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher';
import { createCursorAcpBackend } from './utils/cursorAcpBackend';
import { CursorSession } from './session';
import { ApiSessionClient } from '@/api/apiSession';
function makeSession(sessionId: string | null): 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();
queue.close();
return session;
}
function makeClient() {
return {
rpcHandlerManager: {
registerHandler: vi.fn()
},
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
}
describe('cursorAcpRemoteLauncher', () => {
beforeEach(() => {
harness.initializeError = null;
harness.loadSessionError = null;
harness.supportsLoadSession = true;
harness.loadSessionCalled = false;
harness.newSessionCalled = false;
harness.promptCalls = 0;
harness.setConfigOptionCalls = [];
harness.deferSetConfigOption = null;
harness.releaseSetConfigOption = null;
harness.deferLoadSession = null;
harness.releaseLoadSession = null;
legacyLauncher.mockClear();
process.stdin.isTTY = false;
process.stdout.isTTY = false;
});
afterEach(() => {
vi.clearAllMocks();
});
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('throws on initialize failure without invoking legacy launcher', async () => {
harness.initializeError = new Error('agent acp not found');
const session = makeSession(null);
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Cursor ACP mode is required for new Cursor remote sessions/
);
expect(legacyLauncher).not.toHaveBeenCalled();
expect(harness.newSessionCalled).toBe(false);
});
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');
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Legacy stream-json sessions cannot be loaded via ACP/
);
expect(harness.loadSessionCalled).toBe(true);
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(
/Legacy stream-json sessions cannot be loaded via ACP/
);
expect(session.client.emitSessionReady).not.toHaveBeenCalled();
});
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(),
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(),
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(),
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(),
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(),
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(),
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(),
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(),
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(),
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);
});
});