import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; const harness = { launches: [] as Array>, sessionHookHandlers: [] as Array<(sessionId: string, data: Record) => void>, runBarrier: null as Promise | null }; vi.mock('./codexLocal', () => ({ codexLocal: async (opts: Record) => { harness.launches.push(opts); } })); vi.mock('./utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => ({ server: { url: 'http://localhost:0', stop: () => {} }, mcpServers: {} }) })); vi.mock('@/claude/utils/startHookServer', () => ({ startHookServer: async (opts: { onSessionHook: (sessionId: string, data: Record) => void }) => { harness.sessionHookHandlers.push(opts.onSessionHook); return { port: 4242, token: 'hook-token', stop: () => {} }; } })); vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ BaseLocalLauncher: class { readonly control = { requestExit: () => {} }; constructor(private readonly opts: { launch: (signal: AbortSignal) => Promise }) {} async run(): Promise<'exit'> { await this.opts.launch(new AbortController().signal); if (harness.runBarrier) { await harness.runBarrier; } return 'exit'; } } })); import { codexLocalLauncher } from './codexLocalLauncher'; function createQueueStub() { return { size: () => 0, reset: () => {}, setOnMessage: () => {} }; } function createSessionStub( permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree', initialTranscriptPath: string | null = null, replayTranscriptHistoryOnStart = false, pendingClient = false ) { const sessionEvents: Array<{ type: string; message?: string }> = []; const userMessages: string[] = []; const agentMessages: unknown[] = []; let userActivityCount = 0; let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; let sessionId: string | null = null; let transcriptPath: string | null = initialTranscriptPath; let transcriptHistoryReplayPending = replayTranscriptHistoryOnStart; let modelReasoningEffort: string | null = null; const modelReasoningEffortUpdates: Array = []; const transcriptPathCallbacks: Array<(path: string) => void> = []; return { session: { get sessionId() { return sessionId; }, get transcriptPath() { return transcriptPath; }, path, startedBy: 'terminal' as const, startingMode: 'local' as const, codexArgs, shouldReplayTranscriptHistory: () => transcriptHistoryReplayPending, markTranscriptHistoryReplayConsumed: () => { transcriptHistoryReplayPending = false; }, client: { isPending: () => pendingClient, rpcHandlerManager: { registerHandler: () => {} } }, getPermissionMode: () => permissionMode, getModelReasoningEffort: () => modelReasoningEffort, setModelReasoningEffort: (effort: string | null) => { modelReasoningEffort = effort; modelReasoningEffortUpdates.push(effort); }, onSessionFound: (value: string) => { sessionId = value; }, onTranscriptPathFound: (pathValue: string) => { transcriptPath = pathValue; for (const callback of transcriptPathCallbacks) { callback(pathValue); } }, addTranscriptPathCallback: (callback: (path: string) => void) => { transcriptPathCallbacks.push(callback); }, removeTranscriptPathCallback: (callback: (path: string) => void) => { const index = transcriptPathCallbacks.indexOf(callback); if (index !== -1) { transcriptPathCallbacks.splice(index, 1); } }, resetTranscriptPath: () => { transcriptPath = null; }, sendSessionEvent: (event: { type: string; message?: string }) => { sessionEvents.push(event); }, recordLocalLaunchFailure: (message: string, exitReason: 'switch' | 'exit') => { localLaunchFailure = { message, exitReason }; }, sendUserMessage: (message: string) => { userMessages.push(message); }, notifyUserActivity: () => { userActivityCount += 1; }, sendAgentMessage: (message: unknown) => { agentMessages.push(message); }, queue: createQueueStub() }, sessionEvents, userMessages, agentMessages, getUserActivityCount: () => userActivityCount, getLocalLaunchFailure: () => localLaunchFailure, getModelReasoningEffort: () => modelReasoningEffort, getModelReasoningEffortUpdates: () => modelReasoningEffortUpdates }; } describe('codexLocalLauncher', () => { let tempDir = ''; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const writeTranscriptMeta = async (fileName: string, sessionId: string): Promise => { const transcriptPath = join(tempDir, fileName); await writeFile( transcriptPath, JSON.stringify({ type: 'session_meta', payload: { id: sessionId } }) + '\n' ); return transcriptPath; }; beforeEach(async () => { tempDir = join(tmpdir(), `codex-local-launcher-${Date.now()}`); await mkdir(tempDir, { recursive: true }); }); afterEach(() => { vi.useRealTimers(); harness.launches = []; harness.sessionHookHandlers = []; harness.runBarrier = null; }); afterEach(async () => { if (existsSync(tempDir)) { await rm(tempDir, { recursive: true, force: true }); } }); it('rebuilds approval and sandbox args from yolo mode', async () => { const { session } = createSessionStub('yolo', [ '--sandbox', 'read-only', '--ask-for-approval', 'untrusted', '--model', 'o3', '--full-auto' ]); await codexLocalLauncher(session as never); expect(harness.launches).toHaveLength(1); expect(harness.launches[0]?.codexArgs).toEqual([ '--ask-for-approval', 'never', '--sandbox', 'danger-full-access', '--model', 'o3' ]); }); it('preserves raw Codex approval flags in default mode', async () => { const { session } = createSessionStub('default', [ '--ask-for-approval', 'on-request', '--sandbox', 'workspace-write', '--model', 'o3' ]); await codexLocalLauncher(session as never); expect(harness.launches).toHaveLength(1); expect(harness.launches[0]?.codexArgs).toEqual([ '--ask-for-approval', 'on-request', '--sandbox', 'workspace-write', '--model', 'o3' ]); }); it('keeps sandbox escalation available in safe-yolo mode', async () => { const { session } = createSessionStub('safe-yolo', [ '--ask-for-approval', 'never', '--sandbox', 'danger-full-access', '--model', 'o3' ]); await codexLocalLauncher(session as never); expect(harness.launches).toHaveLength(1); expect(harness.launches[0]?.codexArgs).toEqual([ '--ask-for-approval', 'on-request', '--sandbox', 'workspace-write', '--model', 'o3' ]); }); it('does not emit a session warning while waiting for the first transcript path', async () => { const { session, sessionEvents, getLocalLaunchFailure } = createSessionStub('default', undefined, 'c:\\workspace\\project'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); vi.useFakeTimers(); const launcherPromise = codexLocalLauncher(session as never); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); vi.advanceTimersByTime(10_000); await Promise.resolve(); expect(sessionEvents).toEqual([]); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(harness.launches.length).toBeGreaterThan(0); expect(getLocalLaunchFailure()).toBeNull(); expect(sessionEvents).toEqual([]); }); it('does not reuse a stale transcript path from a previous launch', async () => { const staleTranscriptPath = join(tempDir, 'stale-transcript.jsonl'); const { session, sessionEvents } = createSessionStub('default', undefined, '/tmp/worktree', staleTranscriptPath); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); vi.useFakeTimers(); const launcherPromise = codexLocalLauncher(session as never); await Promise.resolve(); await Promise.resolve(); expect(session.transcriptPath).toBeNull(); vi.advanceTimersByTime(10_000); await Promise.resolve(); expect(sessionEvents).toEqual([]); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(sessionEvents).toEqual([]); }); it('passes SessionStart hook config into local Codex launch', async () => { const { session } = createSessionStub('default'); await codexLocalLauncher(session as never); expect(harness.launches).toHaveLength(1); expect(harness.launches[0]?.sessionHook).toEqual({ port: 4242, token: 'hook-token' }); }); it('creates scanner only after transcript path arrives from SessionStart hook', async () => { const transcriptPath = join(tempDir, 'codex-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-1' } }) + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); expect(session.transcriptPath).toBeNull(); expect(agentMessages).toHaveLength(0); harness.sessionHookHandlers[0]?.('codex-thread-1', { transcript_path: transcriptPath }); await wait(100); await appendFile( transcriptPath, JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello from transcript' } }) + '\n' ); await wait(700); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(session.transcriptPath).toBe(transcriptPath); expect(agentMessages).toContainEqual({ type: 'message', message: 'hello from transcript', id: expect.any(String) }); }); it('tracks local turn context and stamps its model on usage', async () => { const transcriptPath = await writeTranscriptMeta('codex-turn-context.jsonl', 'codex-thread-effort'); const { session, agentMessages, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-effort', { transcript_path: transcriptPath }); await wait(100); await appendFile(transcriptPath, [ JSON.stringify({ type: 'turn_context', payload: { effort: 'max' } }), JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.4' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'token_count', info: {} } }) ].join('\n') + '\n'); await wait(700); releaseRunBarrier?.(); await launcherPromise; expect(getModelReasoningEffortUpdates()).toEqual(['max', null]); expect(getModelReasoningEffort()).toBeNull(); expect(agentMessages).toContainEqual(expect.objectContaining({ type: 'token_count', model: 'gpt-5.4', usageSchema: 'hapi.usage.v1', inputTokenSemantics: 'includes-cache' })); }); it('renders nested Code Mode plans and commands without their covered exec wrapper', async () => { const transcriptPath = join(tempDir, 'codex-hook-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-hook' } }) + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-hook', { hook_event_name: 'SessionStart', transcript_path: transcriptPath }); await wait(100); await appendFile(transcriptPath, JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call', name: 'exec', call_id: 'call-wrapper', input: [ 'await tools.update_plan({ plan: [{ step: "Inspect", status: "completed" }] });', 'const r = await tools.exec_command({ cmd: "pwd" });', 'text(r.output);' ].join('\n'), internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' } } }) + '\n'); await wait(700); harness.sessionHookHandlers[0]?.('codex-thread-hook', { hook_event_name: 'PreToolUse', turn_id: 'turn-hook', cwd: '/tmp/worktree', tool_name: 'update_plan', tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] }, tool_use_id: 'exec-plan-1' }); harness.sessionHookHandlers[0]?.('codex-thread-hook', { hook_event_name: 'PostToolUse', turn_id: 'turn-hook', cwd: '/tmp/worktree', tool_name: 'update_plan', tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] }, tool_response: 'Plan updated', tool_use_id: 'exec-plan-1' }); harness.sessionHookHandlers[0]?.('codex-thread-hook', { hook_event_name: 'PreToolUse', turn_id: 'turn-hook', cwd: '/tmp/worktree', tool_name: 'Bash', tool_input: { command: 'pwd' }, tool_use_id: 'exec-command-1' }); harness.sessionHookHandlers[0]?.('codex-thread-hook', { hook_event_name: 'PostToolUse', turn_id: 'turn-hook', cwd: '/tmp/worktree', tool_name: 'Bash', tool_input: { command: 'pwd' }, tool_response: '/tmp/worktree\n', tool_use_id: 'exec-command-1' }); await appendFile(transcriptPath, JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'call-wrapper', output: [{ type: 'input_text', text: '/tmp/worktree\n' }], internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' } } }) + '\n'); await wait(700); releaseRunBarrier?.(); await launcherPromise; expect(agentMessages).toEqual([{ type: 'tool-call', name: 'update_plan', callId: 'exec-plan-1', input: { plan: [{ step: 'Inspect', status: 'completed' }] }, id: expect.any(String) }, { type: 'tool-call-result', callId: 'exec-plan-1', output: 'Plan updated', id: expect.any(String) }, { type: 'tool-call', name: 'CodexBash', callId: 'exec-command-1', input: { command: 'pwd', cwd: '/tmp/worktree', source: 'codex-hook' }, id: expect.any(String) }, { type: 'tool-call-result', callId: 'exec-command-1', output: { stdout: '/tmp/worktree\n', stderr: '', status: 'completed' }, id: expect.any(String) }]); }); it('falls back to the top-level review transcript when a review subagent is active', async () => { const originalCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = tempDir; const now = new Date(); const sessionDirectory = join( tempDir, 'sessions', String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), String(now.getUTCDate()).padStart(2, '0') ); await mkdir(sessionDirectory, { recursive: true }); const transcriptPath = join(sessionDirectory, 'rollout-review-primary.jsonl'); const reviewSubagentPath = join(sessionDirectory, 'rollout-review-subagent.jsonl'); const { session, userMessages, agentMessages } = createSessionStub( 'default', ['--cd', '/tmp/effective-codex-cwd'], '/tmp/worktree', null, true, true ); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); try { const launcherPromise = codexLocalLauncher(session as never); await vi.waitFor(() => expect(harness.launches).toHaveLength(1)); expect(session.sessionId).toBeNull(); await Promise.all([ writeFile(transcriptPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'review-primary', cwd: '/tmp/effective-codex-cwd', source: 'cli' } }), JSON.stringify({ timestamp: new Date().toISOString(), type: 'event_msg', payload: { type: 'user_message', message: '/review' } }), JSON.stringify({ timestamp: new Date().toISOString(), type: 'event_msg', payload: { type: 'agent_message', message: 'final review result' } }) ].join('\n') + '\n'), writeFile(reviewSubagentPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'review-subagent', cwd: '/tmp/effective-codex-cwd', source: { subagent: 'review' } } }), JSON.stringify({ timestamp: new Date().toISOString(), type: 'event_msg', payload: { type: 'user_message', message: 'review instructions' } }), JSON.stringify({ timestamp: new Date().toISOString(), type: 'event_msg', payload: { type: 'agent_message', message: 'internal review work' } }) ].join('\n') + '\n') ]); await vi.waitFor( () => expect(session.sessionId).toBe('review-primary'), { timeout: 3_000, interval: 50 } ); if (releaseRunBarrier) releaseRunBarrier(); await launcherPromise; expect(session.transcriptPath).toBe(transcriptPath); expect(userMessages).toContain('/review'); expect(agentMessages).toContainEqual(expect.objectContaining({ type: 'message', message: 'final review result' })); expect(agentMessages).not.toContainEqual(expect.objectContaining({ type: 'message', message: 'internal review work' })); } finally { if (releaseRunBarrier) releaseRunBarrier(); if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; } else { process.env.CODEX_HOME = originalCodexHome; } } }); it('replays imported transcript history only on the first local attachment', async () => { const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl'); const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old imported prompt' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 100, output_tokens: 10 } } } }) ].join('\n') + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-import', { transcript_path: transcriptPath }); await wait(300); await appendFile( transcriptPath, JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'tail before switch' } }) + '\n' ); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(userMessages).toEqual(['old imported prompt', 'tail before switch']); expect(agentMessages.filter((message) => ( message as { message?: string } ).message === 'old imported message')).toHaveLength(1); let releaseSecondRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseSecondRunBarrier = resolve; }); const secondLauncherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[1]?.('codex-thread-import', { transcript_path: transcriptPath }); await wait(300); expect(userMessages).toEqual(['old imported prompt', 'tail before switch']); expect(agentMessages.filter((message) => ( message as { message?: string } ).message === 'old imported message')).toHaveLength(1); await appendFile( transcriptPath, [ JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'new local prompt' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'new local response' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 120, output_tokens: 12 } } } }) ].join('\n') + '\n' ); await wait(700); if (releaseSecondRunBarrier) { releaseSecondRunBarrier(); } await secondLauncherPromise; expect(userMessages).toEqual(['old imported prompt', 'tail before switch', 'new local prompt']); expect(agentMessages.filter((message) => ( message as { message?: string } ).message === 'old imported message')).toHaveLength(1); expect(agentMessages).toContainEqual({ type: 'message', message: 'new local response', id: expect.any(String) }); const tokenMessages = agentMessages.filter((message) => ( message as { type?: string } ).type === 'token_count') as Array>; expect(tokenMessages).toHaveLength(2); expect(tokenMessages[0]).toMatchObject({ hapiUsageScope: 'imported-history', usageSchema: 'hapi.usage.v1', inputTokenSemantics: 'includes-cache' }); expect(tokenMessages[0]).not.toHaveProperty('thread_id'); expect(tokenMessages[1]).toMatchObject({ threadId: 'codex-thread-import', thread_id: 'codex-thread-import', hapiUsageScope: 'managed', usageSchema: 'hapi.usage.v1', inputTokenSemantics: 'includes-cache' }); }); it('replays semantic chat and tool events once and keeps a same-turn preface before its plan', async () => { const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl'); const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import-response-item' } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'visible user message' }] } }), JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'visible user message' } }), JSON.stringify({ type: 'response_item', payload: { type: 'function_call', name: 'LegacyTool', call_id: 'call-function', arguments: '{"path":"README.md"}' } }), JSON.stringify({ type: 'response_item', payload: { type: 'function_call_output', call_id: 'call-function', output: { ok: true } } }), JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call', call_id: 'call-exec', name: 'exec', input: 'pwd' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'exec_command_end', call_id: 'call-exec', output: '/tmp/worktree' } }), JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'call-exec', output: [{ type: 'input_text', text: '/tmp/worktree' }] } }), JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call', call_id: 'call-patch', name: 'apply_patch', input: '*** Begin Patch\n*** End Patch' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'patch_apply_end', call_id: 'call-patch', output: 'Done!' } }), JSON.stringify({ type: 'response_item', payload: { type: 'custom_tool_call_output', call_id: 'call-patch', output: 'Done!' } }), JSON.stringify({ type: 'response_item', payload: { type: 'tool_search_call', call_id: 'call-tool-search', arguments: { query: 'hapi change title', limit: 5 } } }), JSON.stringify({ type: 'response_item', payload: { type: 'tool_search_output', call_id: 'call-tool-search', execution: 'client', status: 'completed', tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }] } }), JSON.stringify({ type: 'response_item', payload: { type: 'web_search_call', status: 'failed', action: { type: 'search', query: 'Codex transcript format', queries: ['Codex transcript format'] } } }), JSON.stringify({ type: 'event_msg', payload: { type: 'web_search_end', query: 'Codex transcript format' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'mcp_tool_call_end', call_id: 'call-mcp-summary' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'item_completed', turn_id: 'turn-with-preface', item: { type: 'Plan', id: 'plan-1', text: '## Proposed plan\n\n1. Inspect\n2. Implement' } } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'visible assistant preface\n\n## Proposed plan\n\n1. Inspect\n2. Implement' }], internal_chat_message_metadata_passthrough: { turn_id: 'turn-with-preface' } } }), JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'visible assistant preface', phase: 'final_answer' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-with-preface' } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hidden context' }] } }) ].join('\n') + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-import-response-item', { transcript_path: transcriptPath }); await wait(300); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(userMessages).toEqual(['visible user message']); expect(getUserActivityCount()).toBe(0); expect(agentMessages).toEqual([{ type: 'tool-call', name: 'LegacyTool', callId: 'call-function', input: { path: 'README.md' }, id: expect.any(String) }, { type: 'tool-call-result', callId: 'call-function', output: { ok: true }, id: expect.any(String) }, { type: 'tool-call', name: 'exec', callId: 'call-exec', input: 'pwd', id: expect.any(String) }, { type: 'tool-call-result', callId: 'call-exec', output: [{ type: 'input_text', text: '/tmp/worktree' }], id: expect.any(String) }, { type: 'tool-call', name: 'apply_patch', callId: 'call-patch', input: '*** Begin Patch\n*** End Patch', id: expect.any(String) }, { type: 'tool-call-result', callId: 'call-patch', output: 'Done!', id: expect.any(String) }, { type: 'tool-call', name: 'ToolSearch', callId: 'call-tool-search', input: { query: 'hapi change title', limit: 5 }, id: expect.any(String) }, { type: 'tool-call-result', callId: 'call-tool-search', output: { execution: 'client', tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }] }, id: expect.any(String) }, { type: 'tool-call', name: 'WebSearch', callId: expect.any(String), input: { type: 'search', query: 'Codex transcript format', queries: ['Codex transcript format'] }, id: expect.any(String) }, { type: 'tool-call-result', callId: expect.any(String), output: null, id: expect.any(String), is_error: true }, { type: 'message', message: 'visible assistant preface', id: expect.any(String) }, { type: 'tool-call', name: 'ExitPlanMode', callId: 'codex-proposed-plan:plan-1', input: { plan: '## Proposed plan\n\n1. Inspect\n2. Implement' }, id: 'plan-1' }, { type: 'tool-call-result', callId: 'codex-proposed-plan:plan-1', output: null, id: 'plan-1:result' }]); expect(agentMessages[9]).toMatchObject({ callId: (agentMessages[8] as { callId: string }).callId }); }); it('replays Codex 0.147 completed messages and response-only final answers once', async () => { const transcriptPath = join(tempDir, 'codex-import-0.147-transcript.jsonl'); const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub( 'default', undefined, '/tmp/worktree', null, true ); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-147' } }), JSON.stringify({ type: 'turn_context', payload: { turn_id: 'turn-147', model: 'gpt-5.6-sol' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'item_completed', turn_id: 'turn-147', item: { type: 'UserMessage', id: 'user-147', content: [{ type: 'Text', text: 'visible 0.147 prompt' }] } } }), JSON.stringify({ type: 'event_msg', payload: { type: 'item_completed', turn_id: 'turn-147', item: { type: 'AgentMessage', id: 'commentary-147', phase: 'commentary', content: [{ type: 'Text', text: 'visible 0.147 commentary' }] } } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', id: 'commentary-147', role: 'assistant', phase: 'commentary', content: [{ type: 'output_text', text: 'visible 0.147 commentary' }], internal_chat_message_metadata_passthrough: { turn_id: 'turn-147' } } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', id: 'final-147', role: 'assistant', phase: 'final_answer', content: [{ type: 'output_text', text: 'visible 0.147 final answer' }], internal_chat_message_metadata_passthrough: { turn_id: 'turn-147' } } }) ].join('\n') + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-147', { transcript_path: transcriptPath }); await wait(300); releaseRunBarrier?.(); await launcherPromise; expect(userMessages).toEqual(['visible 0.147 prompt']); expect(getUserActivityCount()).toBe(0); expect(agentMessages).toEqual([{ type: 'message', message: 'visible 0.147 commentary', id: 'commentary-147' }, { type: 'message', message: 'visible 0.147 final answer', id: 'final-147' }]); }); it('replays a plan-only turn when the turn completes', async () => { const transcriptPath = join(tempDir, 'codex-import-plan-only-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); await writeFile( transcriptPath, [ JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-plan-only' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'item_completed', turn_id: 'turn-plan-only', item: { type: 'Plan', id: 'plan-only', text: '## Plan only' } } }), JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '## Plan only' }], internal_chat_message_metadata_passthrough: { turn_id: 'turn-plan-only' } } }), JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-plan-only' } }) ].join('\n') + '\n' ); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-plan-only', { transcript_path: transcriptPath }); await wait(300); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(agentMessages).toEqual([{ type: 'tool-call', name: 'ExitPlanMode', callId: 'codex-proposed-plan:plan-only', input: { plan: '## Plan only' }, id: 'plan-only' }, { type: 'tool-call-result', callId: 'codex-proposed-plan:plan-only', output: null, id: 'plan-only:result' }]); }); it('does not let a later non-clear hook replace the primary session', async () => { const primaryTranscriptPath = await writeTranscriptMeta('primary-later-hook.jsonl', 'primary-thread'); const otherTranscriptPath = await writeTranscriptMeta('later-other-transcript.jsonl', 'other-thread'); const { session } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('primary-thread', { transcript_path: primaryTranscriptPath, source: 'startup' }); await wait(100); harness.sessionHookHandlers[0]?.('other-thread', { transcript_path: otherTranscriptPath, source: 'startup' }); await wait(100); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(session.sessionId).toBe('primary-thread'); expect(session.transcriptPath).toBe(primaryTranscriptPath); }); it('does not let a later hook without source replace the primary session', async () => { const primaryTranscriptPath = await writeTranscriptMeta('primary-no-source.jsonl', 'primary-thread'); const otherTranscriptPath = await writeTranscriptMeta('other-no-source.jsonl', 'other-thread'); const { session } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('primary-thread', { transcript_path: primaryTranscriptPath }); await wait(100); harness.sessionHookHandlers[0]?.('other-thread', { transcript_path: otherTranscriptPath }); await wait(100); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(session.sessionId).toBe('primary-thread'); expect(session.transcriptPath).toBe(primaryTranscriptPath); }); it('allows a clear hook to replace the primary session', async () => { const primaryTranscriptPath = await writeTranscriptMeta('primary-before-clear.jsonl', 'primary-thread'); const clearTranscriptPath = await writeTranscriptMeta('clear-transcript.jsonl', 'clear-thread'); const { session, agentMessages } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('primary-thread', { transcript_path: primaryTranscriptPath, source: 'startup' }); await wait(100); await appendFile( primaryTranscriptPath, JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'same text in both threads' }] } }) + '\n' ); await wait(300); harness.sessionHookHandlers[0]?.('clear-thread', { transcript_path: clearTranscriptPath, source: 'clear' }); await wait(100); await appendFile( clearTranscriptPath, JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'same text in both threads' } }) + '\n' ); await wait(300); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(session.sessionId).toBe('clear-thread'); expect(session.transcriptPath).toBe(clearTranscriptPath); expect(agentMessages.filter((message) => ( message as { message?: string } ).message === 'same text in both threads')).toHaveLength(2); }); it('ignores mismatched session metadata from the active transcript scanner', async () => { const transcriptPath = await writeTranscriptMeta('mismatched-scanner.jsonl', 'primary-thread'); const { session } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('primary-thread', { transcript_path: transcriptPath }); await wait(100); await appendFile( transcriptPath, JSON.stringify({ type: 'session_meta', payload: { id: 'unexpected-thread' } }) + '\n' ); await wait(2300); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; expect(session.sessionId).toBe('primary-thread'); expect(session.transcriptPath).toBe(transcriptPath); }); it('does not leave transcript scanning alive after launcher teardown', async () => { const transcriptPath = join(tempDir, 'teardown-race-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const oldLines = Array.from({ length: 20_000 }, (_, index) => JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: `old-${index}` } }) ).join('\n'); await writeFile(transcriptPath, oldLines + '\n'); const launcherPromise = codexLocalLauncher(session as never); await wait(50); harness.sessionHookHandlers[0]?.('codex-thread-race', { transcript_path: transcriptPath }); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; await appendFile( transcriptPath, JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'post-teardown' } }) + '\n' ); await wait(2300); expect(agentMessages).toHaveLength(0); }); it('ignores late SessionStart hooks after shutdown begins', async () => { const staleTranscriptPath = join(tempDir, 'late-hook-transcript.jsonl'); const { session } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; }); const launcherPromise = codexLocalLauncher(session as never); await wait(50); if (releaseRunBarrier) { releaseRunBarrier(); } await launcherPromise; harness.sessionHookHandlers[0]?.('late-local-thread', { transcript_path: staleTranscriptPath }); expect(session.sessionId).toBeNull(); expect(session.transcriptPath).toBeNull(); }); });