diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts index 66e5533d..900a2a0e 100644 --- a/cli/src/commands/cursor.ts +++ b/cli/src/commands/cursor.ts @@ -13,6 +13,7 @@ export type ParsedCursorCommandOptions = { cursorAddDirs?: string[] permissionMode?: CursorPermissionMode resumeSessionId?: string + existingSessionId?: string model?: string } @@ -84,6 +85,12 @@ export function parseCursorCommandArgs(commandArgs: string[]): ParsedCursorComma } else { unknownArgs.push(arg) } + } else if (arg === '--existing-session-id') { + const hapiSessionId = commandArgs[++i] + if (!hapiSessionId || hapiSessionId.startsWith('-')) { + throw new Error('Missing --existing-session-id value') + } + options.existingSessionId = hapiSessionId } else if (arg === '--continue') { unknownArgs.push(arg) } else if (arg === '--hapi-starting-mode') { diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index ca4f095d..aaf4775c 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -119,7 +119,7 @@ describe('buildCliArgs', () => { - it('does not pass Codex-only existing session id flag to non-Codex agents', () => { + it('does not pass existing session id flag to agents that do not reuse HAPI rows', () => { const args = buildCliArgs('claude', { directory: '/tmp', resumeSessionId: 'claude-session-1', @@ -131,6 +131,18 @@ describe('buildCliArgs', () => { expect(args).not.toContain('hapi-session-1') }) + it('passes --existing-session-id for cursor resume when sessionId is set (#991)', () => { + const args = buildCliArgs('cursor', { + directory: '/tmp', + resumeSessionId: 'cursor-csid-1', + sessionId: 'hapi-session-991', + }) + expect(args).toContain('--existing-session-id') + expect(args).toContain('hapi-session-991') + expect(args).toContain('--resume') + expect(args).toContain('cursor-csid-1') + }) + it('validates all known permission modes', () => { for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 2c0868a4..2f1c0b84 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1115,7 +1115,10 @@ export function buildCliArgs( } } args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); - if (agent === 'codex') { + // Codex import/resume (#1088) and Cursor ACP remote resume (#991) both reuse + // the original HAPI row via --existing-session-id so the hub does not depend + // on session-ready over a remote socket before merge. + if (agent === 'codex' || agent === 'cursor') { const existingSessionId = options.existingSessionId ?? options.sessionId; if (existingSessionId) { args.push('--existing-session-id', existingSessionId); diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index fdc750de..2381801e 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1240,6 +1240,158 @@ describe('session model', () => { } }) + it('cursor ACP resume passes existingSessionId and reuses row without session-ready wait (#991)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + let capturedExistingSessionId: string | undefined + let waitForSessionReadyCalls = 0 + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + _permissionMode?: string, + _serviceTier?: string, + existingSessionId?: string + ) => { + capturedExistingSessionId = existingSessionId + engine.handleSessionAlive({ sid: oldSession.id, time: Date.now() }) + return { type: 'success', sessionId: oldSession.id } + } + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' }) + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => { + waitForSessionReadyCalls += 1 + return 'timeout' + } + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: oldSession.id }) + expect(capturedExistingSessionId).toBe(oldSession.id) + expect(waitForSessionReadyCalls).toBe(0) + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + + it('cursor ACP resume succeeds on same row without merge (#991)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old-ready', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-ok', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + _permissionMode?: string, + _serviceTier?: string, + existingSessionId?: string + ) => { + expect(existingSessionId).toBe(oldSession.id) + engine.handleSessionAlive({ sid: oldSession.id, time: Date.now() }) + return { type: 'success', sessionId: oldSession.id } + } + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' }) + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: oldSession.id }) + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + it('defers mergeSessions for cursor reopen until session-ready (load failure leaves old row)', async () => { const store = new Store(':memory:') const engine = new SyncEngine(