fix(cli,hub): wire Cursor --existing-session-id for ACP remote resume (#991) (#1128)

Hub already passes access.sessionId on resume (#1088); Cursor CLI still ignored
it (Codex-only). Parse/pass the flag for cursor and lock in reuse-without-ready-wait tests.

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-24 10:52:55 +08:00
committed by GitHub
co-authored by Debian Cursor
parent fee853766a
commit 40314237ae
4 changed files with 176 additions and 2 deletions
+7
View File
@@ -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') {
+13 -1
View File
@@ -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', {
+4 -1
View File
@@ -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);
+152
View File
@@ -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(