diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 7ce2e636..595f5d86 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -210,6 +210,74 @@ describe('codexLocalLauncher', () => { } }); + it('notifies once for a live completion after forwarding the final answer, without replay alerts', async () => { + const transcriptPath = await writeTranscriptMeta('ready.jsonl', 'ready-session'); + const event = (payload: Record) => JSON.stringify({ type: 'event_msg', payload }) + '\n'; + await appendFile(transcriptPath, event({ type: 'task_complete', turn_id: 'old' })); + const { session, sessionEvents, agentMessages } = createSessionStub('default', undefined, tempDir, null, true); + const sendReady = vi.spyOn(session, 'sendSessionEvent'); + let release!: () => void; + harness.runBarrier = new Promise((resolve) => { release = resolve; }); + const running = codexLocalLauncher(session as never); + try { + await vi.waitFor(() => expect(harness.sessionHookHandlers).toHaveLength(1)); + harness.sessionHookHandlers[0]('ready-session', { transcript_path: transcriptPath }); + await wait(300); + expect(sessionEvents).not.toContainEqual({ type: 'ready' }); + sendReady.mockImplementation((message) => { + if (message.type === 'ready') { + expect(agentMessages).toContainEqual(expect.objectContaining({ message: 'finished answer' })); + } + sessionEvents.push(message); + }); + await appendFile(transcriptPath, + event({ type: 'task_started', turn_id: 'live' }) + + event({ type: 'agent_message', message: 'finished answer', phase: 'final_answer' }) + + event({ type: 'task_complete', turn_id: 'live' })); + await vi.waitFor(() => expect(sessionEvents.filter(e => e.type === 'ready')).toHaveLength(1), { timeout: 3000 }); + await appendFile(transcriptPath, event({ type: 'task_complete', turn_id: 'live' }) + event({ type: 'task_complete', turn_id: 'old' })); + await wait(300); + expect(sessionEvents.filter(e => e.type === 'ready')).toHaveLength(1); + } finally { + release(); + await running; + } + }); + + it.each(['next-turn', 'queued', 'aborted', 'failed', 'shutdown'] as const)( + 'does not notify while %s prevents an idle completion', async (scenario) => { + const transcriptPath = await writeTranscriptMeta('suppressed.jsonl', 'ready-session'); + const event = (payload: Record) => JSON.stringify({ type: 'event_msg', payload }) + '\n'; + const { session, sessionEvents } = createSessionStub('default', undefined, tempDir); + if (scenario === 'queued') vi.spyOn(session.queue, 'size').mockReturnValue(1); + let release!: () => void; + harness.runBarrier = new Promise((resolve) => { release = resolve; }); + const running = codexLocalLauncher(session as never); + try { + await vi.waitFor(() => expect(harness.sessionHookHandlers).toHaveLength(1)); + harness.sessionHookHandlers[0]('ready-session', { transcript_path: transcriptPath }); + await wait(300); + const completion = scenario === 'aborted' ? 'turn_aborted' : scenario === 'failed' ? 'task_failed' : 'task_complete'; + await appendFile(transcriptPath, + event({ type: completion, turn_id: 'first' }) + + (scenario === 'next-turn' ? event({ type: 'task_started', turn_id: 'second' }) : '')); + if (scenario === 'shutdown') { + release(); + await running; + } + await wait(300); + expect(sessionEvents).not.toContainEqual({ type: 'ready' }); + if (scenario === 'next-turn') { + await appendFile(transcriptPath, event({ type: 'task_complete', turn_id: 'second' })); + await vi.waitFor(() => expect(sessionEvents).toContainEqual({ type: 'ready' }), { timeout: 3000 }); + } + } finally { + release(); + await running; + } + } + ); + it('rebuilds approval and sandbox args from yolo mode', async () => { const { session } = createSessionStub('yolo', [ '--sandbox', diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index b352505c..a5368ee8 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -58,6 +58,14 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch let scanner: CodexSessionScanner | null = null; let hookReady = false; let shuttingDown = false; + let readyTimer: ReturnType | null = null; + const completedTurns = new Set(); + const cancelReady = (): void => { + if (readyTimer !== null) { + clearTimeout(readyTimer); + readyTimer = null; + } + }; let pendingScannerSetup: Promise | null = null; let transcriptLocator: CodexTranscriptLocator | null = null; let scannerTranscriptPath: string | null = null; @@ -315,6 +323,26 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch session.setModelReasoningEffort(observedReasoningEffort); } dispatchTranscriptActions(convertTranscriptEvent(event), context); + if (event.type === 'event_msg' && event.payload && typeof event.payload === 'object') { + const payload = event.payload as Record; + if (payload.type === 'task_started' || payload.type === 'user_message' + || payload.type === 'turn_aborted' || payload.type === 'task_failed') { + cancelReady(); + } else if (payload.type === 'task_complete' && typeof payload.turn_id === 'string') { + if (completedTurns.has(payload.turn_id)) return; + completedTurns.add(payload.turn_id); + if (context.replayedHistory || shuttingDown) return; + cancelReady(); + // Finish forwarding this scan before notifying. A following turn in + // the same batch cancels the alert; replay and shutdown never alert. + readyTimer = setTimeout(() => { + readyTimer = null; + if (!shuttingDown && session.queue.size() === 0) { + session.sendSessionEvent({ type: 'ready' }); + } + }, 0); + } + } } }); if (shuttingDown) { @@ -448,6 +476,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch return await launcher.run(); } finally { shuttingDown = true; + cancelReady(); session.removeTranscriptPathCallback(handleTranscriptPathCallback); hookServer.stop(); const activeLocator = transcriptLocator;