Files
hapi/hub/src/sync/sessionModel.test.ts
T
64834467e3 feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
2026-07-19 14:14:42 +08:00

3121 lines
117 KiB
TypeScript

import { describe, expect, it, spyOn } from 'bun:test'
import { toSessionSummary } from '@hapi/protocol'
import type { SyncEvent } from '@hapi/protocol/types'
import { Store } from '../store'
import { RpcRegistry } from '../socket/rpcRegistry'
import { registerSessionHandlers } from '../socket/handlers/cli/sessionHandlers'
import type { EventPublisher } from './eventPublisher'
import { SessionCache } from './sessionCache'
import { SyncEngine } from './syncEngine'
function createPublisher(events: SyncEvent[]): EventPublisher {
return {
emit: (event: SyncEvent) => {
events.push(event)
}
} as unknown as EventPublisher
}
describe('session model', () => {
it('includes explicit model in session summaries', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-summary',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4'
)
expect(session.model).toBe('gpt-5.4')
expect(toSessionSummary(session).model).toBe('gpt-5.4')
expect(toSessionSummary(session).effort).toBeNull()
})
it('includes explicit effort in session summaries', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-effort-summary',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
null,
'default',
'sonnet',
'high'
)
expect(session.effort).toBe('high')
expect(toSessionSummary(session).effort).toBe('high')
})
it('persists explicit model reasoning effort on Codex sessions', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-reasoning-effort',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4',
undefined,
'xhigh'
)
expect(session.modelReasoningEffort).toBe('xhigh')
expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBe('xhigh')
})
it('preserves model from old session when merging into resumed session', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const oldSession = cache.getOrCreateSession(
'session-model-old',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4'
)
const newSession = cache.getOrCreateSession(
'session-model-new',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
const merged = cache.getSession(newSession.id)
expect(merged?.model).toBe('gpt-5.4')
})
it('preserves service tier from old session when merging into resumed session', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const oldSession = cache.getOrCreateSession(
'session-tier-old',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
// Fast was selected on the original session before it was resumed.
store.sessions.setSessionServiceTier(oldSession.id, 'fast', 'default')
const newSession = cache.getOrCreateSession(
'session-tier-new',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
expect(store.sessions.getSession(newSession.id)?.serviceTier).toBe('fast')
})
it('persists applied session model updates, including clear-to-auto', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-config',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
null,
'default',
'sonnet'
)
cache.applySessionConfig(session.id, { model: 'opus[1m]' })
expect(cache.getSession(session.id)?.model).toBe('opus[1m]')
expect(store.sessions.getSession(session.id)?.model).toBe('opus[1m]')
cache.applySessionConfig(session.id, { model: null })
expect(cache.getSession(session.id)?.model).toBeNull()
expect(store.sessions.getSession(session.id)?.model).toBeNull()
})
it('ignores stale keepalive model values after an applied config update', () => {
const originalDateNow = Date.now
let now = 1_780_000_000_000
Date.now = () => now
try {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-stale-heartbeat',
{ path: '/tmp/project', host: 'localhost', flavor: 'cursor' },
null,
'default',
'composer-2.5[fast=true]'
)
const staleKeepAliveTime = now
now += 1_000
cache.applySessionConfig(session.id, { model: 'gpt-5.5[reasoning=medium]' })
cache.handleSessionAlive({
sid: session.id,
time: staleKeepAliveTime,
thinking: false,
model: 'composer-2.5[fast=true]'
})
expect(cache.getSession(session.id)?.model).toBe('gpt-5.5[reasoning=medium]')
expect(store.sessions.getSession(session.id)?.model).toBe('gpt-5.5[reasoning=medium]')
now += 1_000
cache.handleSessionAlive({
sid: session.id,
time: now,
thinking: false,
model: 'claude-opus-4-8[effort=high]'
})
expect(cache.getSession(session.id)?.model).toBe('claude-opus-4-8[effort=high]')
} finally {
Date.now = originalDateNow
}
})
it('syncs cursor spawn model to resolved ACP wire id via keepalive', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cursor-spawn-model',
{ path: '/tmp/project', host: 'localhost', flavor: 'cursor' },
null,
'default',
'composer-2.5'
)
expect(session.model).toBe('composer-2.5')
cache.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
model: 'composer-2.5[fast=true]'
})
expect(cache.getSession(session.id)?.model).toBe('composer-2.5[fast=true]')
expect(store.sessions.getSession(session.id)?.model).toBe('composer-2.5[fast=true]')
})
it('passes cursor spawn model to runner when spawning a remote session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
engine.getOrCreateMachine(
'machine-cursor',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-cursor', time: Date.now() })
let capturedModel: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
agent: string,
model?: string
) => {
capturedModel = model
return { type: 'success', sessionId: 'spawned-cursor-session' }
}
const result = await engine.spawnSession(
'machine-cursor',
'/tmp/project',
'cursor',
'composer-2.5[fast=false]'
)
expect(result).toEqual({ type: 'success', sessionId: 'spawned-cursor-session' })
expect(capturedModel).toBe('composer-2.5[fast=false]')
} finally {
engine.stop()
}
})
it('persists keepalive model changes, including clearing the model', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-heartbeat',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
null,
'default',
'sonnet'
)
cache.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
model: null
})
expect(cache.getSession(session.id)?.model).toBeNull()
expect(store.sessions.getSession(session.id)?.model).toBeNull()
})
it('persists applied session effort updates, including clear-to-auto', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-effort-config',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
null,
'default',
'sonnet',
'medium'
)
cache.applySessionConfig(session.id, { effort: 'max' })
expect(cache.getSession(session.id)?.effort).toBe('max')
expect(store.sessions.getSession(session.id)?.effort).toBe('max')
cache.applySessionConfig(session.id, { effort: null })
expect(cache.getSession(session.id)?.effort).toBeNull()
expect(store.sessions.getSession(session.id)?.effort).toBeNull()
})
it('persists applied session model reasoning effort updates, including clear-to-default', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-reasoning-config',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4',
undefined,
'high'
)
cache.applySessionConfig(session.id, { modelReasoningEffort: 'xhigh' })
expect(cache.getSession(session.id)?.modelReasoningEffort).toBe('xhigh')
expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBe('xhigh')
cache.applySessionConfig(session.id, { modelReasoningEffort: null })
expect(cache.getSession(session.id)?.modelReasoningEffort).toBeNull()
expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBeNull()
})
it('persists keepalive effort changes, including clearing the effort', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-effort-heartbeat',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
null,
'default',
'sonnet',
'high'
)
cache.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
effort: null
})
expect(cache.getSession(session.id)?.effort).toBeNull()
expect(store.sessions.getSession(session.id)?.effort).toBeNull()
})
it('persists keepalive model reasoning effort changes, including clearing the value', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-model-reasoning-heartbeat',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4',
undefined,
'high'
)
cache.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
modelReasoningEffort: null
})
expect(cache.getSession(session.id)?.modelReasoningEffort).toBeNull()
expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBeNull()
})
it('tracks collaboration mode updates in memory from config and keepalive', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-collaboration-mode',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default',
'gpt-5.4'
)
cache.applySessionConfig(session.id, { collaborationMode: 'plan' })
expect(cache.getSession(session.id)?.collaborationMode).toBe('plan')
cache.handleSessionAlive({
sid: session.id,
time: Date.now(),
thinking: false,
collaborationMode: 'default'
})
expect(cache.getSession(session.id)?.collaborationMode).toBe('default')
})
it('touches session updatedAt when new message activity is recorded', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-message-activity',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const activityAt = session.updatedAt + 60_000
cache.recordSessionActivity(session.id, activityAt)
expect(store.sessions.getSession(session.id)?.updatedAt).toBe(activityAt)
expect(cache.getSession(session.id)?.updatedAt).toBe(activityAt)
expect(events).toContainEqual({
type: 'session-updated',
sessionId: session.id,
namespace: 'default',
data: { updatedAt: activityAt }
})
})
it('rejects active session config updates when CLI ignores requested keys', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{ of: () => ({ to: () => ({ emit() {} }) }) } as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-config-ignored',
{ path: '/tmp/project', host: 'localhost', flavor: 'opencode' },
null,
'default'
)
engine.handleSessionAlive({ sid: session.id, time: Date.now() })
;(engine as any).rpcGateway.requestSessionConfig = async () => ({ applied: {} })
await expect(
engine.applySessionConfig(session.id, { modelReasoningEffort: 'high' })
).rejects.toThrow('Session did not apply modelReasoningEffort')
expect(engine.getSession(session.id)?.modelReasoningEffort).toBeNull()
} finally {
engine.stop()
}
})
it('touches session updatedAt when web sends a message through sync engine', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{ of: () => ({ to: () => ({ emit() {} }) }) } as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-web-message-activity',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const before = store.sessions.getSession(session.id)?.updatedAt ?? 0
await new Promise((resolve) => setTimeout(resolve, 2))
await engine.sendMessage(session.id, { text: 'hello' })
const after = store.sessions.getSession(session.id)?.updatedAt ?? 0
expect(after).toBeGreaterThan(before)
expect(engine.getSession(session.id)?.updatedAt).toBe(after)
} finally {
engine.stop()
}
})
it('reports session activity when CLI receives a turn-ready event over socket', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cli-message-activity',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const handlers = new Map<string, (payload: unknown) => void>()
const activity: Array<{ sessionId: string; updatedAt: number }> = []
registerSessionHandlers({
on: (event: string, handler: (payload: unknown) => void) => {
handlers.set(event, handler)
},
to: () => ({ emit() {} })
} as never, {
store,
resolveSessionAccess: (sessionId) => {
const stored = store.sessions.getSessionByNamespace(sessionId, 'default')
return stored ? { ok: true, value: stored } : { ok: false, reason: 'not-found' }
},
emitAccessError: () => {},
onSessionActivity: (sessionId, updatedAt) => {
activity.push({ sessionId, updatedAt })
}
})
handlers.get('message')?.({
sid: session.id,
message: JSON.stringify({
role: 'agent',
content: {
type: 'event',
data: { type: 'ready' }
}
})
})
expect(activity).toHaveLength(1)
expect(activity[0].sessionId).toBe(session.id)
expect(activity[0].updatedAt).toBe(store.messages.getMessages(session.id, 1)[0]?.createdAt)
})
it('does not report session activity for CLI tool messages', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cli-tool-activity',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const handlers = new Map<string, (payload: unknown) => void>()
const activity: Array<{ sessionId: string; updatedAt: number }> = []
registerSessionHandlers({
on: (event: string, handler: (payload: unknown) => void) => {
handlers.set(event, handler)
},
to: () => ({ emit() {} })
} as never, {
store,
resolveSessionAccess: (sessionId) => {
const stored = store.sessions.getSessionByNamespace(sessionId, 'default')
return stored ? { ok: true, value: stored } : { ok: false, reason: 'not-found' }
},
emitAccessError: () => {},
onSessionActivity: (sessionId, updatedAt) => {
activity.push({ sessionId, updatedAt })
}
})
handlers.get('message')?.({
sid: session.id,
message: JSON.stringify({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'tool-call',
name: 'CodexBash',
callId: 'call-1',
input: { cmd: 'date' }
}
}
})
})
handlers.get('message')?.({
sid: session.id,
message: JSON.stringify({
role: 'agent',
content: {
type: 'codex',
data: {
type: 'tool-call-result',
callId: 'call-1',
output: { stdout: 'Sat Apr 25' }
}
}
})
})
expect(activity).toHaveLength(0)
})
it('passes the stored model when respawning a resumed session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-model-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
null,
'default',
'gpt-5.4'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedModel: string | undefined
let capturedModelReasoningEffort: string | undefined
let capturedEffort: string | undefined
;(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
) => {
capturedModel = model
capturedModelReasoningEffort = modelReasoningEffort
capturedEffort = effort
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedModel).toBe('gpt-5.4')
expect(capturedModelReasoningEffort).toBeUndefined()
expect(capturedEffort).toBeUndefined()
} finally {
engine.stop()
}
})
it('passes the stored model reasoning effort when respawning a resumed Codex session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-model-reasoning-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
null,
'default',
'gpt-5.4',
undefined,
'xhigh'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedModelReasoningEffort: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
modelReasoningEffort?: string
) => {
capturedModelReasoningEffort = modelReasoningEffort
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedModelReasoningEffort).toBe('xhigh')
} finally {
engine.stop()
}
})
it('marks a resumed session active in hub cache before returning success without persisting runtime active state', async () => {
const store = new Store(':memory:')
const events: unknown[] = []
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast(event: unknown) { events.push(event) } } as never
)
try {
const session = engine.getOrCreateSession(
'session-resume-active-state',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
null,
'default',
'gpt-5.4'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(engine.getSession(session.id)?.active).toBe(true)
// 中文注释:active=true 是运行时状态,不能跨 Hub 重启持久化;否则旧会话会在重启后假在线。
expect(store.sessions.getSession(session.id)?.active).toBe(false)
expect(events.some((event) => {
const record = event as { type?: string; sessionId?: string; data?: { active?: boolean } }
return record.type === 'session-updated'
&& record.sessionId === session.id
&& record.data?.active === true
})).toBe(true)
} finally {
engine.stop()
}
})
it('passes resume session ID to rpc gateway when resuming claude session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude',
claudeSessionId: 'claude-session-1'
},
null,
'default',
'sonnet'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: 'simple' | 'worktree',
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBe('claude-session-1')
} finally {
engine.stop()
}
})
it('recovers claude resume session ID from stored messages when metadata is missing it', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-from-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '7f5cd4ee-3a76-4601-a7b4-f9eb976bf515'
}
}
})
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: 'simple' | 'worktree',
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBe('7f5cd4ee-3a76-4601-a7b4-f9eb976bf515')
expect(store.sessions.getSession(session.id)?.metadata).toMatchObject({
claudeSessionId: '7f5cd4ee-3a76-4601-a7b4-f9eb976bf515'
})
} finally {
engine.stop()
}
})
it('recovers the newest claude session ID when stored messages contain multiple IDs', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-newest-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '11111111-1111-4111-8111-111111111111'
}
}
})
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '22222222-2222-4222-8222-222222222222'
}
}
})
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: 'simple' | 'worktree',
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBe('22222222-2222-4222-8222-222222222222')
expect(store.sessions.getSession(session.id)?.metadata).toMatchObject({
claudeSessionId: '22222222-2222-4222-8222-222222222222'
})
} finally {
engine.stop()
}
})
it('does not recover a non-UUID sessionId from stored messages', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-no-token',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
sessionId: 'hapi-session-id-not-claude-uuid'
}
}
})
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('does not let stale default resume option override persisted Codex yolo', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-codex-yolo-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
preferredPermissionMode: 'yolo'
},
null,
'default',
'gpt-5'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedPermissionMode: string | undefined
;(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
) => {
capturedPermissionMode = permissionMode
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default', { permissionMode: 'default' })
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedPermissionMode).toBe('yolo')
} finally {
engine.stop()
}
})
it('passes the cached permissionMode when respawning a resumed session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-permission-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude',
claudeSessionId: 'claude-session-perm'
},
null,
'default',
'sonnet'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
engine.handleSessionAlive({
sid: session.id,
permissionMode: 'bypassPermissions',
time: Date.now()
})
engine.handleSessionEnd({ sid: session.id, time: Date.now() })
let capturedPermissionMode: string | undefined
;(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
) => {
capturedPermissionMode = permissionMode
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedPermissionMode).toBe('bypassPermissions')
} finally {
engine.stop()
}
})
it('resume succeeds when session-alive races ahead of set-session-config and merges spawned session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const oldSession = engine.getOrCreateSession(
'session-resume-config-race',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-race'
},
null,
'default',
'gpt-5.4'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
engine.handleSessionAlive({
sid: oldSession.id,
permissionMode: 'yolo',
time: Date.now()
})
engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() })
const spawnedSession = engine.getOrCreateSession(
'session-resume-config-race-spawned',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-race'
},
null,
'default',
'gpt-5.4'
)
const spawnedSessionId = spawnedSession.id
let configRpcCalls = 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
) => {
engine.handleSessionAlive({
sid: spawnedSessionId,
time: Date.now(),
permissionMode: permissionMode as never
})
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.requestSessionConfig = async () => {
configRpcCalls += 1
throw new Error('RPC handler not registered')
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(oldSession.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId })
expect(configRpcCalls).toBe(0)
expect(mergeCalls).toBe(1)
expect(engine.getSession(spawnedSessionId)?.permissionMode).toBe('yolo')
expect(store.sessions.getSession(oldSession.id)).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(
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() })
const spawnedSession = engine.getOrCreateSession(
'cursor-reopen-spawned',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'cursor-csid-load-fail',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
const spawnedSessionId = spawnedSession.id
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 () => {
engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' })
;(engine as any).waitForSessionActive = async () => true
;(engine as any).waitForSessionReady = async () => 'ended'
const result = await engine.resumeSession(oldSession.id, 'default')
expect(result).toEqual({
type: 'error',
message: 'Session ended before Cursor ACP load completed',
code: 'resume_failed'
})
expect(mergeCalls).toBe(0)
expect(store.sessions.getSession(oldSession.id)).not.toBeNull()
} finally {
engine.stop()
}
})
it('does not dedup-merge when ACP spawn ends without session-ready', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const oldSession = engine.getOrCreateSession(
'cursor-acp-dedup-old',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'cursor-csid-dedup-fail',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
const spawnedSession = engine.getOrCreateSession(
'cursor-acp-dedup-spawned',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'cursor-csid-dedup-fail',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
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.handleSessionAlive({ sid: spawnedSession.id, time: Date.now() })
engine.handleSessionEnd({ sid: spawnedSession.id, time: Date.now(), reason: 'error' })
expect(mergeCalls).toBe(0)
expect(store.sessions.getSession(oldSession.id)).not.toBeNull()
} finally {
engine.stop()
}
})
it('mergeSessions runs for cursor reopen after session-ready', 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() })
const spawnedSession = engine.getOrCreateSession(
'cursor-reopen-spawned-ready',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'cursor-csid-load-ok',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
const spawnedSessionId = spawnedSession.id
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 () => {
engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() })
engine.handleSessionReady({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(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: spawnedSessionId })
expect(mergeCalls).toBe(1)
expect(store.sessions.getSession(oldSession.id)).toBeNull()
} finally {
engine.stop()
}
})
it('does not wait for session-ready on cursor stream-json reopen', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const oldSession = engine.getOrCreateSession(
'cursor-legacy-reopen-old',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'legacy-csid',
cursorSessionProtocol: 'stream-json'
},
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() })
const spawnedSession = engine.getOrCreateSession(
'cursor-legacy-reopen-spawned',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor',
cursorSessionId: 'legacy-csid',
cursorSessionProtocol: 'stream-json'
},
null,
'default'
)
const spawnedSessionId = spawnedSession.id
let waitForSessionReadyCalls = 0
;(engine as any).waitForSessionReady = async () => {
waitForSessionReadyCalls += 1
return 'timeout'
}
;(engine as any).rpcGateway.spawnSession = async () => {
engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'legacy' })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(oldSession.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId })
expect(waitForSessionReadyCalls).toBe(0)
} finally {
engine.stop()
}
})
it('resolves a local resume target for a Codex session', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-codex',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: false },
'default',
'gpt-5.4',
undefined,
'xhigh'
)
const result = engine.resolveLocalResumeTarget(session.id, 'default')
expect(result).toEqual({
type: 'success',
target: {
sessionId: session.id,
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
host: 'localhost',
active: session.active,
thinking: session.thinking,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
model: 'gpt-5.4',
effort: null,
modelReasoningEffort: 'xhigh',
permissionMode: undefined,
collaborationMode: undefined
}
})
} finally {
engine.stop()
}
})
it('resolves a local resume target for a Grok session', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-grok',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'grok',
grokSessionId: 'grok-session-1'
},
{ controlledByUser: false },
'default',
'grok-4.5',
'low'
)
const result = engine.resolveLocalResumeTarget(session.id, 'default')
expect(result.type).toBe('success')
if (result.type === 'success') {
expect(result.target).toMatchObject({
flavor: 'grok',
agentSessionId: 'grok-session-1',
model: 'grok-4.5',
effort: 'low'
})
}
} finally {
engine.stop()
}
})
it('recovers a Claude local resume target from stored messages', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-claude-from-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
sessionId: '22222222-2222-4222-8222-222222222222'
}
}
})
const result = engine.resolveLocalResumeTarget(session.id, 'default')
expect(result.type).toBe('success')
if (result.type === 'success') {
expect(result.target.flavor).toBe('claude')
expect(result.target.agentSessionId).toBe('22222222-2222-4222-8222-222222222222')
}
} finally {
engine.stop()
}
})
it('returns resume_unavailable when the local resume target lacks an agent session id', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-no-agent-id',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex'
},
null,
'default'
)
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('returns resume_unavailable when a cursor session lacks cursorSessionId', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-cursor-no-id',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor'
},
null,
'default'
)
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('refuses Cursor resume before spawning when the recorded chat store is missing', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-missing-store',
{
path: '/tmp/project',
host: 'cursor-host',
machineId: 'cursor-machine',
homeDir: '/home/cursor-owner',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-missing',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'cursor-machine',
{ host: 'cursor-host', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'cursor-machine', time: Date.now() })
let spawnCalled = false
let probeArgs: unknown[] | null = null
;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => {
probeArgs = args
return { onDisk: false, store: null }
}
;(engine as any).rpcGateway.spawnSession = async () => {
spawnCalled = true
return { type: 'success', sessionId: session.id }
}
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({
type: 'error',
message: 'Cursor chat data is no longer available on the recorded machine',
code: 'resume_unavailable'
})
expect(probeArgs as unknown).toEqual([
'cursor-machine',
'/tmp/project',
'cursor-thread-missing',
'/home/cursor-owner'
])
expect(spawnCalled).toBe(false)
} finally {
engine.stop()
}
})
it('probes Cursor chat data on the session recorded machine', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-machine-scoped-store',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine',
homeDir: '/home/recorded-owner',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-remote',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
for (const machineId of ['other-machine', 'recorded-machine']) {
engine.getOrCreateMachine(
machineId,
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId, time: Date.now() })
}
let captured: unknown[] | null = null
;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => {
captured = args
return { onDisk: true, store: 'acp' }
}
const result = await engine.getCursorChatStoreStatus(session.id, 'default')
expect(result).toEqual({
type: 'success',
status: { onDisk: true, store: 'acp' }
})
expect(captured as unknown).toEqual([
'recorded-machine',
'/remote/project',
'cursor-thread-remote',
'/home/recorded-owner'
])
} finally {
engine.stop()
}
})
it('does not probe a same-host machine when the recorded Cursor machine is offline', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-offline-recorded-machine-status',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine-offline',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-offline',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'recorded-machine-offline',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.getOrCreateMachine(
'wrong-same-host-machine',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() })
let probeCalled = false
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => {
probeCalled = true
return { onDisk: true, store: 'acp' }
}
expect(await engine.getCursorChatStoreStatus(session.id, 'default')).toEqual({
type: 'error',
message: 'No machine online',
code: 'no_machine_online'
})
expect(probeCalled).toBe(false)
} finally {
engine.stop()
}
})
it('does not probe or spawn on a same-host machine when the recorded Cursor machine is offline', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-offline-recorded-machine-resume',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine-offline',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-offline',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'recorded-machine-offline',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.getOrCreateMachine(
'wrong-same-host-machine',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() })
let probeCalled = false
let spawnCalled = false
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => {
probeCalled = true
return { onDisk: true, store: 'acp' }
}
;(engine as any).rpcGateway.spawnSession = async () => {
spawnCalled = true
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
expect(await engine.resumeSession(session.id, 'default')).toEqual({
type: 'error',
message: 'No machine online',
code: 'no_machine_online'
})
expect(probeCalled).toBe(false)
expect(spawnCalled).toBe(false)
} finally {
engine.stop()
}
})
it('resumeSession fresh-spawns when inactive cursor session has no agent id and no user messages', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'never-started-cursor',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor'
},
null,
'default'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined = 'unset'
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: string,
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBeUndefined()
} finally {
engine.stop()
}
})
it('includes first user message in local resumable sessions', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-first-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
name: 'Generated title'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: { type: 'text', text: 'agent warmup' }
})
store.messages.addMessage(session.id, {
role: 'user',
content: { type: 'text', text: ' Build the picker\nwith search ' }
})
const sessions = engine.listLocalResumableSessions('default', { machineId: 'machine-1' })
expect(sessions.find((item) => item.sessionId === session.id)).toMatchObject({
name: 'Generated title',
firstUserMessage: 'Build the picker with search'
})
} finally {
engine.stop()
}
})
it('recovers first user message from stored Claude user output events', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-first-claude-output',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude',
claudeSessionId: '11111111-1111-4111-8111-111111111111',
name: 'Generated title'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: 'First Claude prompt' }]
}
}
}
})
const sessions = engine.listLocalResumableSessions('default', { machineId: 'machine-1' })
expect(sessions.find((item) => item.sessionId === session.id)).toMatchObject({
name: 'Generated title',
firstUserMessage: 'First Claude prompt'
})
} finally {
engine.stop()
}
})
it('local handoff succeeds immediately for inactive sessions', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-handoff-inactive',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: false },
'default'
)
engine.handleSessionEnd({ sid: session.id, time: Date.now() })
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
type: 'success'
})
} finally {
engine.stop()
}
})
it('local handoff rejects sessions already controlled by a local terminal', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-handoff-already-local',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: true },
'default'
)
engine.handleSessionAlive({ sid: session.id, time: Date.now(), mode: 'local' })
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
type: 'error',
message: 'Session is already controlled by a local terminal',
code: 'already_local'
})
} finally {
engine.stop()
}
})
describe('session dedup by agent session ID', () => {
it('merges duplicate when codexSessionId collides', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Add a message to s1
store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1')
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
expect(s1.id).not.toBe(s2.id)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeUndefined()
expect(cache.getSession(s2.id)).toBeDefined()
const messages = store.messages.getMessages(s2.id, 100)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('preserves sessions with different agent session IDs', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-Y' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('does not merge across namespaces', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'ns1'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'ns2'
)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('no-op when session has no agent session ID', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s1.id)
expect(cache.getSession(s1.id)).toBeDefined()
})
it('does not move history while duplicate sessions are both active', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: { 'req-from-active-duplicate': { tool: 'Bash', arguments: {} } },
completedRequests: {}
},
'default'
)
store.messages.addMessage(s1.id, { type: 'text', text: 'history from s1' }, 'local-s1')
cache.handleSessionAlive({ sid: s1.id, time: Date.now(), thinking: false })
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: { 'req-from-target': { tool: 'Read', arguments: {} } },
completedRequests: {}
},
'default'
)
store.messages.addMessage(s2.id, { type: 'text', text: 'history from s2' }, 'local-s2')
cache.handleSessionAlive({ sid: s2.id, time: Date.now() + 1000, thinking: false })
await cache.deduplicateByAgentSessionId(s2.id)
// Both live session records keep their own histories until one of the
// duplicates becomes inactive. The web may still be showing either
// active session id, so the hub must not pick a canonical target yet.
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
expect(store.messages.getMessages(s1.id, 100).map((message) => (message.content as { text?: string }).text)).toEqual([
'history from s1'
])
expect(store.messages.getMessages(s2.id, 100).map((message) => (message.content as { text?: string }).text)).toEqual([
'history from s2'
])
expect(events.some((event) => event.type === 'messages-invalidated')).toBe(false)
const sourceRequests = cache.getSession(s1.id)?.agentState?.requests ?? {}
const targetRequests = cache.getSession(s2.id)?.agentState?.requests ?? {}
expect(sourceRequests['req-from-active-duplicate']).toBeDefined()
expect(targetRequests['req-from-active-duplicate']).toBeUndefined()
expect(targetRequests['req-from-target']).toBeDefined()
})
it('invalidates both sessions for history-only merges', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
{
requests: { 'req-from-source': { tool: 'Bash', arguments: {} } },
completedRequests: {}
},
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
{
requests: { 'req-from-target': { tool: 'Read', arguments: {} } },
completedRequests: {}
},
'default'
)
store.messages.addMessage(s1.id, { type: 'text', text: 'history from s1' }, 'local-s1')
store.messages.addMessage(s2.id, { type: 'text', text: 'history from s2' }, 'local-s2')
await cache.mergeSessionHistory(s1.id, s2.id, 'default', { mergeAgentState: false })
expect(store.messages.getMessages(s1.id, 100)).toHaveLength(0)
expect(store.messages.getMessages(s2.id, 100).map((message) => (message.content as { text?: string }).text)).toEqual([
'history from s1',
'history from s2'
])
expect(events).toContainEqual({ type: 'messages-invalidated', sessionId: s1.id, namespace: 'default' })
expect(events).toContainEqual({ type: 'messages-invalidated', sessionId: s2.id, namespace: 'default' })
const sourceRequests = cache.getSession(s1.id)?.agentState?.requests ?? {}
const targetRequests = cache.getSession(s2.id)?.agentState?.requests ?? {}
expect(sourceRequests['req-from-source']).toBeDefined()
expect(targetRequests['req-from-source']).toBeUndefined()
expect(targetRequests['req-from-target']).toBeDefined()
})
it('merges duplicate after it becomes inactive via session-end', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const s1 = engine.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = engine.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Mark s1 as active
engine.handleSessionAlive({ sid: s1.id, time: Date.now() })
// s1 is active, so dedup keeps its live record around
const events: SyncEvent[] = []
const cache = (engine as any).sessionCache as SessionCache
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
// Now s1 ends — handleSessionEnd should trigger dedup retry
engine.handleSessionEnd({ sid: s1.id, time: Date.now() })
// Give the fire-and-forget dedup a tick to complete
await new Promise((r) => setTimeout(r, 50))
// One of them should be merged away
const s1Exists = cache.getSession(s1.id)
const s2Exists = cache.getSession(s2.id)
expect(!s1Exists || !s2Exists).toBe(true)
} finally {
engine.stop()
}
})
it('merges duplicate after inactivity timeout expires it', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Mark both duplicates active. The older live record should keep
// existing while active, because its socket may still send keepalives.
const now = Date.now()
cache.handleSessionAlive({ sid: s1.id, time: now })
cache.handleSessionAlive({ sid: s2.id, time: now })
// s1 is active — dedup only moves history and keeps the record.
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
// Simulate only s1 passing beyond the 30s timeout.
cache.getSession(s1.id)!.activeAt = now - 31_000
const expired = cache.expireInactive(now)
expect(expired).toContain(s1.id)
expect(expired).not.toContain(s2.id)
// Now s1 is inactive — dedup should merge it
await cache.deduplicateByAgentSessionId(s2.id)
// Exactly one session should survive after dedup; which one is the
// target depends on activeAt/updatedAt ordering, which can vary by
// millisecond timing in CI.
const remaining = [cache.getSession(s1.id), cache.getSession(s2.id)].filter(Boolean)
expect(remaining).toHaveLength(1)
})
it('deep-merges agentState and filters completed requests', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: {
'req-1': { tool: 'Bash', arguments: {} },
'req-2': { tool: 'Bash', arguments: {} }
},
completedRequests: {}
},
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: {
'req-3': { tool: 'Bash', arguments: {} }
},
completedRequests: {
'req-1': { tool: 'Bash', arguments: {}, status: 'approved' }
}
},
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
const session = cache.getSession(s2.id)
expect(session).toBeDefined()
const state = session!.agentState!
// req-1 was completed in s2 — should NOT appear in requests
expect(state.requests?.['req-1']).toBeUndefined()
// req-2 and req-3 are still pending
expect(state.requests?.['req-2']).toBeDefined()
expect(state.requests?.['req-3']).toBeDefined()
// completedRequests has req-1
expect(state.completedRequests?.['req-1']).toBeDefined()
})
it('merges duplicate when piSessionId collides', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' },
null,
'default'
)
store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1')
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' },
null,
'default'
)
expect(s1.id).not.toBe(s2.id)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeUndefined()
expect(cache.getSession(s2.id)).toBeDefined()
const messages = store.messages.getMessages(s2.id, 100)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('preserves sessions with different piSessionId', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-A' },
null,
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-B' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
})
describe('clearSessionArchiveMetadata', () => {
it('clears lifecycleState/archivedBy/archiveReason from an archived session', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-archived',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'thread-X',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated'
},
null,
'default'
)
const result = await cache.clearSessionArchiveMetadata(session.id)
expect(result.cursorSessionProtocol).toBeUndefined()
const updated = cache.getSession(session.id)
const meta = updated?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBeUndefined()
expect(meta?.archivedBy).toBeUndefined()
expect(meta?.archiveReason).toBeUndefined()
expect(typeof meta?.lifecycleStateSince).toBe('number')
})
it('defaults cursorSessionProtocol to stream-json for pre-#799 cursor sessions', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cursor-legacy',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'legacy-cursor-id',
lifecycleState: 'archived'
},
null,
'default'
)
const result = await cache.clearSessionArchiveMetadata(session.id)
expect(result.cursorSessionProtocol).toBe('stream-json')
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.cursorSessionProtocol).toBe('stream-json')
})
it('keeps an existing acp protocol intact when clearing archive metadata', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cursor-acp',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'acp-cursor-id',
cursorSessionProtocol: 'acp',
lifecycleState: 'archived'
},
null,
'default'
)
const result = await cache.clearSessionArchiveMetadata(session.id)
expect(result.cursorSessionProtocol).toBe('acp')
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.cursorSessionProtocol).toBe('acp')
expect(meta?.lifecycleState).toBeUndefined()
})
it('does not stamp cursorSessionProtocol when no cursorSessionId is present', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-cursor-fresh',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
lifecycleState: 'archived'
},
null,
'default'
)
const result = await cache.clearSessionArchiveMetadata(session.id)
expect(result.cursorSessionProtocol).toBeUndefined()
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.cursorSessionProtocol).toBeUndefined()
})
it('throws when the session id is unknown', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
await expect(cache.clearSessionArchiveMetadata('missing-session')).rejects.toThrow('Session not found')
})
})
describe('reopenSession rollback', () => {
it('restores archive metadata when resumeSession fails after the clear', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-reopen-rollback',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'Session crashed',
lifecycleStateSince: 1000
},
null,
'default'
)
// No machine registered -> resumeSession returns no_machine_online.
const result = await engine.reopenSession(session.id, 'default')
expect(result.type).toBe('error')
if (result.type === 'error') {
expect(result.code).toBe('no_machine_online')
}
const restored = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record<string, unknown> | null | undefined
expect(restored?.lifecycleState).toBe('archived')
expect(restored?.archivedBy).toBe('cli')
expect(restored?.archiveReason).toBe('Session crashed')
} finally {
engine.stop()
}
})
it('does not roll back when resumeSession succeeds', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-reopen-success',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-2',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated'
},
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 as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.reopenSession(session.id, 'default')
expect(result.type).toBe('success')
if (result.type === 'success') {
expect(result.resumed).toBe(true)
}
const after = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record<string, unknown> | null | undefined
expect(after?.lifecycleState).toBeUndefined()
expect(after?.archivedBy).toBeUndefined()
expect(after?.archiveReason).toBeUndefined()
} finally {
engine.stop()
}
})
})
describe('restoreSessionArchiveMetadata', () => {
it('puts back lifecycleState/archivedBy/archiveReason from a snapshot', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-restore',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'thread-Y',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated',
lifecycleStateSince: 1234567890
},
null,
'default'
)
await cache.clearSessionArchiveMetadata(session.id)
const cleared = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(cleared?.lifecycleState).toBeUndefined()
await cache.restoreSessionArchiveMetadata(session.id, {
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated',
lifecycleStateSince: 1234567890
})
const restored = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(restored?.lifecycleState).toBe('archived')
expect(restored?.archivedBy).toBe('cli')
expect(restored?.archiveReason).toBe('User terminated')
expect(restored?.lifecycleStateSince).toBe(1234567890)
})
it('deletes archive fields that were absent in the snapshot for an exact restore', async () => {
// Covers the legacy case: an archived session that predates `lifecycleStateSince`.
// `clearSessionArchiveMetadata` stamps a fresh `lifecycleStateSince`; if reopen
// then fails, the restore must drop that stamp so the row's lifecycle age does
// not appear to be "just now" to UI / import code.
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-restore-partial',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'thread-Z',
lifecycleState: 'archived',
archiveReason: 'Session crashed'
// no archivedBy, no lifecycleStateSince
},
null,
'default'
)
await cache.clearSessionArchiveMetadata(session.id)
// lifecycleStateSince was just stamped fresh by the clear; verify it's set so
// the next assertion proves the restore actively deleted it.
const cleared = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(typeof cleared?.lifecycleStateSince).toBe('number')
await cache.restoreSessionArchiveMetadata(session.id, {
lifecycleState: 'archived',
archiveReason: 'Session crashed'
// archivedBy + lifecycleStateSince intentionally absent from snapshot
})
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBe('archived')
expect(meta?.archiveReason).toBe('Session crashed')
expect(meta?.archivedBy).toBeUndefined()
expect(meta?.lifecycleStateSince).toBeUndefined()
})
it('is a no-op when the session is gone', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
await expect(cache.restoreSessionArchiveMetadata('missing', {
lifecycleState: 'archived'
})).resolves.toBeUndefined()
})
})
// tiann/hapi#916: when the CLI is gone, the kill-RPC throws
// RpcTargetMissingError. markSessionArchivedFromHub writes the archive
// metadata directly so the row's lifecycleState still flips to 'archived'.
describe('markSessionArchivedFromHub (tiann/hapi#916)', () => {
it('flips lifecycleState to archived with archivedBy=hub and the supplied reason', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-hub-archive',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-1' },
null,
'default'
)
cache.markSessionArchivedFromHub(session.id, 'Archived from hub (CLI unreachable)')
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBe('archived')
expect(meta?.archivedBy).toBe('hub')
expect(meta?.archiveReason).toBe('Archived from hub (CLI unreachable)')
expect(typeof meta?.lifecycleStateSince).toBe('number')
})
it('is idempotent for already-archived sessions (does not reset lifecycleStateSince)', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const initialSince = 1700000000000
const session = cache.getOrCreateSession(
'session-already-archived',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated',
lifecycleStateSince: initialSince
},
null,
'default'
)
cache.markSessionArchivedFromHub(session.id, 'Should not overwrite')
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBe('archived')
expect(meta?.archivedBy).toBe('cli')
expect(meta?.archiveReason).toBe('User terminated')
expect(meta?.lifecycleStateSince).toBe(initialSince)
})
it('self-heals on version-mismatch via refresh-and-retry', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-hub-archive-stale',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')!
const oobWrite = store.sessions.updateSessionMetadata(
session.id,
{ ...dbSession.metadata!, name: 'oob' },
dbSession.metadataVersion,
'default',
{ touchUpdatedAt: false }
)
expect(oobWrite.result).toBe('success')
cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBe('archived')
expect(meta?.archivedBy).toBe('hub')
expect(meta?.name).toBe('oob')
})
// tiann/hapi#916 review feedback: persistence failures must surface
// so the /archive route returns 5xx per the acceptance criteria
// "Non-RPC errors during archive still propagate as 5xx (DB write
// failure, etc.)" — silent return would let the route claim success
// while the row stays unarchived.
it('throws when the store reports a hard error on the metadata write', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-hub-archive-error',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({
result: 'error',
error: new Error('simulated DB write failure')
} as ReturnType<typeof store.sessions.updateSessionMetadata>)
try {
expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Failed to archive session metadata from hub/)
} finally {
updateSpy.mockRestore()
}
})
it('throws when retries are exhausted by sustained version-mismatch contention', () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-hub-archive-exhausted',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({
result: 'version-mismatch'
} as ReturnType<typeof store.sessions.updateSessionMetadata>)
try {
expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Session was modified concurrently while archiving from hub/)
} finally {
updateSpy.mockRestore()
}
})
})
// tiann/hapi#919: the three metadata writers must self-heal on
// version-mismatch instead of one-shot-throwing. The bug was that a
// stale cache snapshot produced forever-409 on the corresponding HTTP
// endpoints — the cache never refreshed, so the same retry hit the
// same mismatch. Pattern mirrors mergeSessions (line ~780).
describe('version-mismatch self-heal (tiann/hapi#919)', () => {
it('renameSession recovers after a stale cache snapshot is detected', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-rename-stale',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
// Simulate a concurrent writer bumping the DB version under our feet:
// write a metadata patch out-of-band via the store, leaving the cache
// snapshot stale.
const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')!
const oobWrite = store.sessions.updateSessionMetadata(
session.id,
{ ...dbSession.metadata!, name: 'concurrent-rename' },
dbSession.metadataVersion,
'default',
{ touchUpdatedAt: false }
)
expect(oobWrite.result).toBe('success')
// Cache still holds the pre-OOB snapshot. Pre-fix, this call threw
// 'Session was modified concurrently'. Post-fix, it refreshes and
// succeeds.
await expect(cache.renameSession(session.id, 'final-name')).resolves.toBeUndefined()
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.name).toBe('final-name')
})
it('clearSessionArchiveMetadata recovers after a stale cache snapshot', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-clear-stale',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'thread-stale',
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated'
},
null,
'default'
)
// Concurrent rename via the store bumps the DB version.
const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')!
const oobWrite = store.sessions.updateSessionMetadata(
session.id,
{ ...dbSession.metadata!, name: 'oob-name' },
dbSession.metadataVersion,
'default',
{ touchUpdatedAt: false }
)
expect(oobWrite.result).toBe('success')
await expect(cache.clearSessionArchiveMetadata(session.id)).resolves.toBeDefined()
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBeUndefined()
expect(meta?.archivedBy).toBeUndefined()
expect(meta?.name).toBe('oob-name')
})
it('restoreSessionArchiveMetadata recovers after a stale cache snapshot', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const session = cache.getOrCreateSession(
'session-restore-stale',
{
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'thread-restore-stale'
// Started without archive metadata - simulates the post-clear state.
},
null,
'default'
)
// Concurrent unrelated write bumps DB version.
const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')!
const oobWrite = store.sessions.updateSessionMetadata(
session.id,
{ ...dbSession.metadata!, name: 'parallel-rename' },
dbSession.metadataVersion,
'default',
{ touchUpdatedAt: false }
)
expect(oobWrite.result).toBe('success')
await expect(cache.restoreSessionArchiveMetadata(session.id, {
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'User terminated',
lifecycleStateSince: 1234
})).resolves.toBeUndefined()
const meta = cache.getSession(session.id)?.metadata as Record<string, unknown> | null | undefined
expect(meta?.lifecycleState).toBe('archived')
expect(meta?.archiveReason).toBe('User terminated')
expect(meta?.lifecycleStateSince).toBe(1234)
expect(meta?.name).toBe('parallel-rename')
})
})
})