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 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 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() }) }) })