import { beforeEach, describe, expect, it, vi } from 'vitest' const { initializeTokenMock, maybeAutoStartServerMock, authAndSetupMachineIfNeededMock, listResumableSessionsMock, getLocalResumeTargetMock, handoffSessionToLocalMock, renderMock, runCodexMock, runClaudeMock, assertCodexLocalSupportedMock, existsSyncMock } = vi.hoisted(() => ({ initializeTokenMock: vi.fn(async () => {}), maybeAutoStartServerMock: vi.fn(async () => {}), authAndSetupMachineIfNeededMock: vi.fn(async () => ({ machineId: 'machine-1' })), listResumableSessionsMock: vi.fn(), getLocalResumeTargetMock: vi.fn(), handoffSessionToLocalMock: vi.fn(async () => {}), renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) })) vi.mock('@/ui/tokenInit', () => ({ initializeToken: initializeTokenMock })) vi.mock('@/utils/autoStartServer', () => ({ maybeAutoStartServer: maybeAutoStartServerMock })) vi.mock('@/ui/auth', () => ({ authAndSetupMachineIfNeeded: authAndSetupMachineIfNeededMock })) vi.mock('@/api/api', () => ({ ApiClient: { create: async () => ({ listResumableSessions: listResumableSessionsMock, getLocalResumeTarget: getLocalResumeTargetMock, handoffSessionToLocal: handoffSessionToLocalMock }) } })) vi.mock('ink', () => ({ render: renderMock })) vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ ResumeSessionPicker: 'ResumeSessionPicker' })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) import { resumeCommand } from './resume' function createContext(commandArgs: string[]) { return { args: ['resume'].concat(commandArgs), subcommand: 'resume', commandArgs } } describe('resumeCommand', () => { beforeEach(() => { initializeTokenMock.mockClear() maybeAutoStartServerMock.mockClear() authAndSetupMachineIfNeededMock.mockClear() listResumableSessionsMock.mockReset() getLocalResumeTargetMock.mockReset() handoffSessionToLocalMock.mockClear() renderMock.mockReset() renderMock.mockImplementation((element: { props?: { onSelect?: (sessionId: string) => void } }) => { queueMicrotask(() => element.props?.onSelect?.('picked-session')) return { unmount: vi.fn() } }) runCodexMock.mockClear() runClaudeMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) }) it('resumes a Codex target by HAPI session id', async () => { getLocalResumeTargetMock.mockResolvedValue({ sessionId: 'hapi-session-1', flavor: 'codex', directory: '/tmp/project', machineId: 'machine-1', active: true, thinking: false, controlledByUser: false, agentSessionId: 'codex-thread-1', model: 'gpt-5.4', modelReasoningEffort: 'xhigh', permissionMode: 'default', collaborationMode: 'default' }) await resumeCommand.run(createContext(['hapi-session-1'])) expect(handoffSessionToLocalMock).toHaveBeenCalledWith('hapi-session-1') expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() expect(runCodexMock).toHaveBeenCalledWith({ existingSessionId: 'hapi-session-1', workingDirectory: '/tmp/project', resumeSessionId: 'codex-thread-1', startedBy: 'terminal', permissionMode: 'default', model: 'gpt-5.4', modelReasoningEffort: 'xhigh', collaborationMode: 'default' }) }) it('resumes an inactive Claude target without handoff', async () => { getLocalResumeTargetMock.mockResolvedValue({ sessionId: 'hapi-session-2', flavor: 'claude', directory: '/tmp/project', machineId: 'machine-1', active: false, thinking: false, controlledByUser: false, agentSessionId: '11111111-1111-4111-8111-111111111111', model: 'sonnet', effort: 'high', permissionMode: 'default' }) await resumeCommand.run(createContext(['hapi-session-2'])) expect(handoffSessionToLocalMock).not.toHaveBeenCalled() expect(runClaudeMock).toHaveBeenCalledWith({ existingSessionId: 'hapi-session-2', workingDirectory: '/tmp/project', resumeSessionId: '11111111-1111-4111-8111-111111111111', startedBy: 'terminal', startingMode: 'local', permissionMode: 'default', model: 'sonnet', effort: 'high' }) }) it('fails before launching when the target belongs to another machine', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 'undefined'}`) }) as never) getLocalResumeTargetMock.mockResolvedValue({ sessionId: 'hapi-session-3', flavor: 'codex', directory: '/tmp/project', machineId: 'machine-2', active: false, thinking: false, controlledByUser: false, agentSessionId: 'codex-thread-1' }) try { await expect(resumeCommand.run(createContext(['hapi-session-3']))).rejects.toThrow('process.exit:1') expect(runCodexMock).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('another machine')) } finally { consoleErrorSpy.mockRestore() exitSpy.mockRestore() } }) it('resumes an inactive local target even when controlledByUser is sticky', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 'undefined'}`) }) as never) getLocalResumeTargetMock.mockResolvedValue({ sessionId: 'hapi-session-4', flavor: 'claude', directory: '/tmp/project', machineId: 'machine-1', active: false, thinking: false, controlledByUser: true, agentSessionId: '11111111-1111-4111-8111-111111111111', permissionMode: 'default' }) try { await resumeCommand.run(createContext(['hapi-session-4'])) expect(exitSpy).not.toHaveBeenCalled() expect(consoleErrorSpy).not.toHaveBeenCalled() expect(handoffSessionToLocalMock).not.toHaveBeenCalled() expect(runClaudeMock).toHaveBeenCalledWith(expect.objectContaining({ existingSessionId: 'hapi-session-4', resumeSessionId: '11111111-1111-4111-8111-111111111111' })) } finally { consoleErrorSpy.mockRestore() exitSpy.mockRestore() } }) it('uses the interactive picker when no session id is provided on a TTY', async () => { const originalIsTTY = process.stdin.isTTY Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }) listResumableSessionsMock.mockResolvedValue([ { sessionId: 'picked-session', flavor: 'codex', directory: '/tmp/project', machineId: 'machine-1', active: false, thinking: false, controlledByUser: false, agentSessionId: 'codex-thread-1', updatedAt: 2, name: 'Picked' } ]) getLocalResumeTargetMock.mockResolvedValue({ sessionId: 'picked-session', flavor: 'codex', directory: '/tmp/project', machineId: 'machine-1', active: false, thinking: false, controlledByUser: false, agentSessionId: 'codex-thread-1' }) try { await resumeCommand.run(createContext([])) expect(renderMock).toHaveBeenCalledOnce() expect(getLocalResumeTargetMock).toHaveBeenCalledWith('picked-session') expect(runCodexMock).toHaveBeenCalledWith(expect.objectContaining({ existingSessionId: 'picked-session', resumeSessionId: 'codex-thread-1' })) } finally { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalIsTTY }) } }) it('keeps the non-TTY fallback and asks for an explicit session id', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 'undefined'}`) }) as never) const originalIsTTY = process.stdin.isTTY Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }) listResumableSessionsMock.mockResolvedValue([ { sessionId: 'hapi-session-1', flavor: 'claude', directory: '/tmp/project', machineId: 'machine-1', active: false, thinking: false, controlledByUser: false, agentSessionId: 'claude-session-1', updatedAt: 1 } ]) try { await expect(resumeCommand.run(createContext([]))).rejects.toThrow('process.exit:1') expect(renderMock).not.toHaveBeenCalled() expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('hapi-session-1')) expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Run: hapi resume ') } finally { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalIsTTY }) consoleLogSpy.mockRestore() consoleErrorSpy.mockRestore() exitSpy.mockRestore() } }) })