Files
hapi/hub/src/sync/sessionModel.test.ts
T

1436 lines
52 KiB
TypeScript

import { describe, expect, it } 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('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('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('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('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',
code: 'resume_unavailable'
})
} 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('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('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',
code: 'resume_unavailable'
})
} 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('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()
})
})
})