import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Session } from '@/api/types' const { getSessionMock, getOrCreateSessionMock, getOrCreateMachineMock, sessionSyncClientMock, notifyRunnerSessionStartedMock, readSettingsMock } = vi.hoisted(() => ({ getSessionMock: vi.fn(), getOrCreateSessionMock: vi.fn(), getOrCreateMachineMock: vi.fn(), sessionSyncClientMock: vi.fn(), notifyRunnerSessionStartedMock: vi.fn(async () => ({})), readSettingsMock: vi.fn() })) vi.mock('@/api/api', () => ({ ApiClient: { create: async () => ({ getSession: getSessionMock, getOrCreateSession: getOrCreateSessionMock, getOrCreateMachine: getOrCreateMachineMock, sessionSyncClient: sessionSyncClientMock }) } })) vi.mock('@/runner/controlClient', () => ({ notifyRunnerSessionStarted: notifyRunnerSessionStartedMock, getInstalledCliMtimeMs: () => 1_700_000_000_000, })) vi.mock('@/persistence', () => ({ readSettings: readSettingsMock })) vi.mock('@/configuration', () => ({ configuration: { happyHomeDir: '/tmp/.hapi', logsDir: '/tmp/.hapi/logs', isRunnerProcess: false } })) vi.mock('@/ui/logger', () => ({ logger: { debug: vi.fn() } })) import { HAPI_SESSION_ID_ENV, bootstrapExistingSession, bootstrapLazySession, bootstrapSession, buildMachineMetadata, buildSessionMetadata } from './sessionFactory' function createSession(): Session { return { id: 'hapi-session-1', namespace: 'default', seq: 1, createdAt: 1, updatedAt: 1, active: false, activeAt: 1, metadata: { path: '/tmp/project', host: 'localhost', machineId: 'machine-1', flavor: 'codex', codexSessionId: 'codex-thread-1' }, metadataVersion: 1, agentState: { controlledByUser: false }, agentStateVersion: 1, thinking: false, thinkingAt: 1, todos: [], model: null, modelReasoningEffort: null, effort: null, serviceTier: null, permissionMode: undefined, collaborationMode: undefined } } describe('bootstrapExistingSession', () => { beforeEach(() => { getSessionMock.mockReset() getOrCreateSessionMock.mockReset() getOrCreateMachineMock.mockReset() sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() readSettingsMock.mockReset() delete process.env[HAPI_SESSION_ID_ENV] }) it('loads an existing HAPI session and reports it to the runner', async () => { const session = createSession() const sessionClient = { updateMetadata: vi.fn() } getSessionMock.mockResolvedValue(session) getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' }) sessionSyncClientMock.mockReturnValue(sessionClient) readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) const result = await bootstrapExistingSession({ sessionId: 'hapi-session-1', flavor: 'codex', workingDirectory: '/tmp/project' }) expect(result.sessionInfo.id).toBe('hapi-session-1') expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1') expect(result.workingDirectory).toBe('/tmp/project') expect(sessionSyncClientMock).toHaveBeenCalledWith(session) expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( 'hapi-session-1', expect.objectContaining({ path: '/tmp/project', flavor: 'codex', startedBy: 'terminal', startedFromRunner: false, machineId: 'machine-1' }) ) }) it('preserves existing native resume metadata when reactivating a session', async () => { const session = createSession() const existingMetadata = session.metadata if (!existingMetadata) throw new Error('expected test session metadata') session.metadata = { ...existingMetadata, claudeSessionId: 'claude-thread-1', codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', piSessionId: 'pi-thread-1', piResumeAttempt: { state: 'resuming', machineId: 'machine-1', startedAt: 123, }, ptyResumeAttempt: { state: 'quarantined', machineId: 'machine-1', startedAt: 456, }, summary: { text: 'resume me', updatedAt: 100 }, tools: ['read_file'], slashCommands: ['/compact'], conversationHistoryPoints: { 'local-user-1': true }, conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }, capabilities: { terminal: true, conversationHistory: { forkCurrent: true } } } const sessionClient = { updateMetadata: vi.fn() } getSessionMock.mockResolvedValue(session) getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' }) sessionSyncClientMock.mockReturnValue(sessionClient) readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) const result = await bootstrapExistingSession({ sessionId: 'hapi-session-1', flavor: 'codex', workingDirectory: '/tmp/project' }) expect(result.metadata).toEqual(expect.objectContaining({ claudeSessionId: 'claude-thread-1', codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', piSessionId: 'pi-thread-1', piResumeAttempt: { state: 'resuming', machineId: 'machine-1', startedAt: 123, }, ptyResumeAttempt: { state: 'quarantined', machineId: 'machine-1', startedAt: 456, }, summary: { text: 'resume me', updatedAt: 100 }, tools: ['read_file'], slashCommands: ['/compact'], conversationHistoryPoints: { 'local-user-1': true }, conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }, capabilities: { terminal: true, conversationHistory: { forkCurrent: true } } })) expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() const updateHandler = sessionClient.updateMetadata.mock.calls[0][0] expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({ codexSessionId: 'codex-thread-1', grokSessionId: 'grok-thread-1', conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' } })) expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( 'hapi-session-1', expect.objectContaining({ codexSessionId: 'codex-thread-1', grokSessionId: 'grok-thread-1', conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' } }) ) }) it('advertises remote terminal capability in session metadata', () => { const metadata = buildSessionMetadata({ flavor: 'codex', startedBy: 'terminal', workingDirectory: '/tmp/project', machineId: 'machine-1', now: 123 }) expect(metadata.capabilities?.terminal).toBe(true) }) }) describe('bootstrapLazySession', () => { beforeEach(() => { getOrCreateSessionMock.mockReset() getOrCreateMachineMock.mockReset() sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() readSettingsMock.mockReset() delete process.env[HAPI_SESSION_ID_ENV] }) it('does not export HAPI_SESSION_ID until the hub row is materialized', async () => { const pendingClient = { isPending: () => true } sessionSyncClientMock.mockReturnValue(pendingClient) readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) const result = await bootstrapLazySession({ flavor: 'codex', startedBy: 'terminal', workingDirectory: '/tmp/project', agentState: { controlledByUser: false } }) expect(process.env[HAPI_SESSION_ID_ENV]).toBeUndefined() expect(result.sessionInfo.id).toMatch(/^[0-9a-f-]{36}$/) const [, options] = sessionSyncClientMock.mock.calls[0] const materialized = createSession() materialized.id = result.sessionInfo.id options.onMaterialized(materialized, { metadata: result.metadata, agentState: { controlledByUser: false } }) expect(process.env[HAPI_SESSION_ID_ENV]).toBe(result.sessionInfo.id) }) it('does not persist a machine or session until materialization', async () => { const pendingClient = { isPending: () => true } sessionSyncClientMock.mockReturnValue(pendingClient) readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) const result = await bootstrapLazySession({ flavor: 'codex', startedBy: 'terminal', workingDirectory: '/tmp/project', agentState: { controlledByUser: false } }) expect(result.session).toBe(pendingClient) expect(getOrCreateMachineMock).not.toHaveBeenCalled() expect(getOrCreateSessionMock).not.toHaveBeenCalled() expect(notifyRunnerSessionStartedMock).not.toHaveBeenCalled() const [provisional, options] = sessionSyncClientMock.mock.calls[0] expect(provisional.id).toMatch(/^[0-9a-f-]{36}$/) expect(provisional.metadata).toEqual(expect.objectContaining({ machineId: 'machine-1', path: '/tmp/project', flavor: 'codex' })) const materialized = createSession() materialized.id = provisional.id getOrCreateSessionMock.mockResolvedValue(materialized) const snapshot = { metadata: { ...provisional.metadata, codexSessionId: 'codex-thread-1' }, agentState: { controlledByUser: true } } await options.materialize(snapshot, new AbortController().signal) expect(getOrCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({ id: provisional.id, metadata: snapshot.metadata, state: snapshot.agentState, timeoutMs: 10_000, machine: expect.objectContaining({ id: 'machine-1' }) })) options.onMaterialized(materialized, snapshot) expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( provisional.id, expect.objectContaining({ codexSessionId: 'codex-thread-1' }) ) }) }) describe('bootstrapSession HAPI_SESSION_ID export', () => { beforeEach(() => { getOrCreateSessionMock.mockReset() getOrCreateMachineMock.mockReset() sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() readSettingsMock.mockReset() delete process.env[HAPI_SESSION_ID_ENV] }) it('exports the hub session id so spawned agents inherit it', async () => { const session = createSession() session.id = 'hub-session-42' getOrCreateSessionMock.mockResolvedValue(session) getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' }) sessionSyncClientMock.mockReturnValue({ isPending: () => false }) readSettingsMock.mockResolvedValue({ machineId: 'machine-1' }) const result = await bootstrapSession({ flavor: 'claude', workingDirectory: '/tmp/project' }) expect(result.sessionInfo.id).toBe('hub-session-42') expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42') }) }) describe('buildMachineMetadata runner-only capabilities', () => { const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED afterEach(() => { if (originalSupervised === undefined) { delete process.env.HAPI_RUNNER_SUPERVISED } else { process.env.HAPI_RUNNER_SUPERVISED = originalSupervised } }) it('omits machine RPC capabilities for terminal bootstrap metadata', () => { delete process.env.HAPI_RUNNER_SUPERVISED const metadata = buildMachineMetadata() expect(metadata.capabilities).toBeUndefined() expect(metadata.startedCliMtimeMs).toBeUndefined() expect(metadata.installedCliMtimeMs).toBeUndefined() expect(metadata.supervisedRestart).toBeUndefined() }) it('advertises capabilities and supervisedRestart only for asRunner', () => { process.env.HAPI_RUNNER_SUPERVISED = '1' const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 }) expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner'])) expect(metadata.startedCliMtimeMs).toBe(42) expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) expect(metadata.supervisedRestart).toBe(true) }) it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => { delete process.env.HAPI_RUNNER_SUPERVISED const metadata = buildMachineMetadata({ asRunner: true }) expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner'])) expect(metadata.supervisedRestart).toBe(false) }) })