diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 785f0e64..c11f0423 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -1027,6 +1027,102 @@ describe('codexLocalLauncher', () => { }); }); + 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); @@ -1157,7 +1253,7 @@ describe('codexLocalLauncher', () => { 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 } = createSessionStub('default'); + const { session, agentMessages } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; @@ -1172,12 +1268,34 @@ describe('codexLocalLauncher', () => { }); 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(); } @@ -1185,6 +1303,9 @@ describe('codexLocalLauncher', () => { 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 () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 43b00921..06ca931b 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -5,7 +5,7 @@ import { codexLocal } from './codexLocal'; import type { ReasoningEffort } from './appServerTypes'; import { CodexSession } from './session'; import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner'; -import { convertCodexEvent, type CodexMessage, type CodexSessionEvent } from './utils/codexEventConverter'; +import { createCodexEventConverter, type CodexMessage, type CodexSessionEvent } from './utils/codexEventConverter'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCliOverrides'; import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig'; @@ -58,6 +58,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch let scannerTranscriptPath: string | null = null; let scannerReplayedExistingHistory = false; let transcriptModel: string | null = null; + let convertTranscriptEvent = createCodexEventConverter(); const pendingPlansByTurnId = new Map(); const pendingExecWrappers = new Map(); const toolHookBridge = new CodexToolHookBridge(); @@ -196,6 +197,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch if (scanner) { if (scannerTranscriptPath !== transcriptPath) { flushAllPendingPlans(); + convertTranscriptEvent = createCodexEventConverter(); } await scanner.setTranscriptPath(transcriptPath); scannerTranscriptPath = transcriptPath; @@ -222,7 +224,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch if (observedReasoningEffort !== undefined) { session.setModelReasoningEffort(observedReasoningEffort); } - const converted = convertCodexEvent(event); + const converted = convertTranscriptEvent(event); if (converted?.sessionId) { if (!isPrimarySessionId(converted.sessionId)) { logger.debug(`[codex-local]: Ignoring converted session id ${converted.sessionId}; primary is ${primarySessionId}`); diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index fa4f66e4..9efd3533 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { convertCodexEvent } from './codexEventConverter'; +import { convertCodexEvent, createCodexEventConverter } from './codexEventConverter'; describe('convertCodexEvent', () => { it('extracts session_meta id', () => { @@ -32,6 +32,72 @@ describe('convertCodexEvent', () => { expect(result?.userMessage).toBe('hello user'); }); + it('converts Codex 0.147 completed user-message items', () => { + const result = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-147', + item: { + type: 'UserMessage', + id: 'user-147', + content: [{ type: 'Text', text: 'hello from 0.147' }] + } + } + }); + + expect(result).toMatchObject({ + turnId: 'turn-147', + userActivity: true, + userMessage: 'hello from 0.147' + }); + }); + + it('marks image-only completed user-message items as activity', () => { + const result = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-image', + item: { + type: 'UserMessage', + id: 'user-image', + content: [{ type: 'Image', image_url: 'data:image/png;base64,abc' }] + } + } + }); + + expect(result).toEqual({ + turnId: 'turn-image', + userActivity: true + }); + }); + + it('converts Codex 0.147 completed agent-message items', () => { + const result = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-147', + item: { + type: 'AgentMessage', + id: 'agent-147', + phase: 'commentary', + content: [{ type: 'Text', text: 'visible commentary' }] + } + } + }); + + expect(result).toEqual({ + turnId: 'turn-147', + messages: [{ + type: 'message', + message: 'visible commentary', + id: 'agent-147' + }] + }); + }); + it('marks native token counts as inclusive of cached input', () => { const info = { total_token_usage: { @@ -122,14 +188,6 @@ describe('convertCodexEvent', () => { content: [{ type: 'input_image', image_url: 'data:image/png;base64,abc' }] } }], - ['assistant text', { - type: 'response_item', - payload: { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'hello from response_item assistant' }] - } - }], ['injected user context', { type: 'response_item', payload: { @@ -142,6 +200,111 @@ describe('convertCodexEvent', () => { expect(convertCodexEvent(event)).toBeNull(); }); + it('converts assistant response items that have no semantic mirror', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'message', + id: 'final-1', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'complete final answer' }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' } + } + }); + + expect(result).toEqual({ + turnId: 'turn-1', + messages: [{ + type: 'message', + message: 'complete final answer', + id: 'final-1' + }] + }); + }); + + it('removes the plan envelope from assistant response items', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'message', + id: 'plan-preface', + role: 'assistant', + phase: 'final_answer', + content: [{ + type: 'output_text', + text: 'visible preface\n\n## Hidden duplicate plan' + }] + } + }); + + expect(result?.messages?.[0]).toMatchObject({ + type: 'message', + message: 'visible preface' + }); + }); + + it('deduplicates legacy and 0.147 semantic messages against response items', () => { + const convert = createCodexEventConverter(); + + convert({ + type: 'turn_context', + payload: { turn_id: 'turn-1' } + }); + expect(convert({ + type: 'event_msg', + payload: { type: 'agent_message', phase: 'commentary', message: 'legacy commentary' } + })?.messages?.[0]).toMatchObject({ message: 'legacy commentary' }); + expect(convert({ + type: 'response_item', + payload: { + type: 'message', + id: 'legacy-commentary', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'legacy commentary' }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' } + } + })).toBeNull(); + + expect(convert({ + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-1', + item: { + type: 'AgentMessage', + id: 'new-commentary', + phase: 'commentary', + content: [{ type: 'Text', text: 'new commentary' }] + } + } + })?.messages?.[0]).toMatchObject({ message: 'new commentary' }); + expect(convert({ + type: 'response_item', + payload: { + type: 'message', + id: 'new-commentary', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'new commentary' }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' } + } + })).toBeNull(); + + expect(convert({ + type: 'response_item', + payload: { + type: 'message', + id: 'response-only-final', + role: 'assistant', + phase: 'final_answer', + content: [{ type: 'output_text', text: 'response-only final' }], + internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' } + } + })?.messages?.[0]).toMatchObject({ message: 'response-only final' }); + }); + it('converts reasoning events', () => { const result = convertCodexEvent({ type: 'event_msg', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index c26dec50..3c18dfa0 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -67,6 +67,44 @@ function asString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } +function normalizeItemType(value: unknown): string | null { + const raw = asString(value); + return raw ? raw.toLowerCase().replace(/[\s_-]/g, '') : null; +} + +function extractTextContent(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + if (!Array.isArray(value)) { + return ''; + } + + return value + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + const record = asRecord(entry); + const contentType = normalizeItemType(record?.type); + if ( + !record + || (contentType !== null && contentType !== 'text' && contentType !== 'inputtext' && contentType !== 'outputtext') + ) { + return ''; + } + return typeof record.text === 'string' ? record.text : ''; + }) + .join('') + .trim(); +} + +function extractVisibleAssistantText(value: unknown): string { + return extractTextContent(value) + .replace(/(?:^|\n)[\s\S]*?<\/proposed_plan>(?=\n|$)/gi, '\n') + .trim(); +} + function parseArguments(value: unknown): unknown { if (typeof value !== 'string') { return value; @@ -108,6 +146,117 @@ function extractResponseItemTurnId(payload: Record): string | n return metadata ? asString(metadata.turn_id) ?? asString(metadata.turnId) : null; } +type AssistantMessageProjection = { + source: 'semantic' | 'response'; + text: string; + turnId: string | null; +}; + +function extractEventTurnId(event: CodexSessionEvent): string | null { + const payload = asRecord(event.payload); + if (!payload) return null; + + return asString(payload.turn_id ?? payload.turnId) + ?? extractResponseItemTurnId(payload); +} + +function extractAssistantMessageProjection( + event: CodexSessionEvent, + currentTurnId: string | null +): AssistantMessageProjection | null { + const payload = asRecord(event.payload); + if (!payload) return null; + + if (event.type === 'event_msg' && payload.type === 'agent_message') { + const text = extractVisibleAssistantText(payload.message ?? payload.text ?? payload.content); + if (!text) return null; + return { + source: 'semantic', + text, + turnId: extractEventTurnId(event) ?? currentTurnId + }; + } + + if (event.type === 'event_msg' && payload.type === 'item_completed') { + const item = asRecord(payload.item); + if (normalizeItemType(item?.type) !== 'agentmessage') return null; + const text = extractVisibleAssistantText(item?.content ?? item?.message ?? item?.text); + if (!text) return null; + return { + source: 'semantic', + text, + turnId: extractEventTurnId(event) ?? currentTurnId + }; + } + + if (event.type === 'response_item' && payload.type === 'message' && payload.role === 'assistant') { + const text = extractVisibleAssistantText(payload.content); + if (!text) return null; + return { + source: 'response', + text, + turnId: extractEventTurnId(event) ?? currentTurnId + }; + } + + return null; +} + +function consumeProjection(counts: Map, key: string): boolean { + const count = counts.get(key) ?? 0; + if (count === 0) return false; + if (count === 1) counts.delete(key); + else counts.set(key, count - 1); + return true; +} + +function rememberProjection(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +/** + * Transcript chat text is duplicated across Codex's semantic events and raw + * response items. Keep both as compatible sources, but project each visible + * assistant message once. Some Codex versions only persist final answers as + * response_item messages, while 0.147+ replaced legacy agent_message events + * with item_completed AgentMessage records. + */ +export function createCodexEventConverter(): (rawEvent: unknown) => CodexConversionResult | null { + let currentTurnId: string | null = null; + const unmatchedSemanticMessages = new Map(); + const unmatchedResponseMessages = new Map(); + + return (rawEvent: unknown): CodexConversionResult | null => { + const parsed = CodexSessionEventSchema.safeParse(rawEvent); + if (!parsed.success) return null; + + if (parsed.data.type === 'session_meta') { + currentTurnId = null; + unmatchedSemanticMessages.clear(); + unmatchedResponseMessages.clear(); + } + + currentTurnId = extractEventTurnId(parsed.data) ?? currentTurnId; + const projection = extractAssistantMessageProjection(parsed.data, currentTurnId); + const converted = convertCodexEvent(parsed.data); + if (!projection || !converted) return converted; + + const key = `${projection.turnId ?? ''}\u0000${projection.text}`; + const opposite = projection.source === 'semantic' + ? unmatchedResponseMessages + : unmatchedSemanticMessages; + if (consumeProjection(opposite, key)) { + return null; + } + + rememberProjection( + projection.source === 'semantic' ? unmatchedSemanticMessages : unmatchedResponseMessages, + key + ); + return converted; + }; +} + export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | null { const parsed = CodexSessionEventSchema.safeParse(rawEvent); if (!parsed.success) { @@ -146,7 +295,9 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu } if (eventType === 'agent_message') { - const message = asString(payloadRecord.message); + const message = extractVisibleAssistantText( + payloadRecord.message ?? payloadRecord.text ?? payloadRecord.content + ); if (!message) { return null; } @@ -161,9 +312,32 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu if (eventType === 'item_completed') { const item = asRecord(payloadRecord.item); - const itemType = asString(item?.type)?.toLowerCase(); + const itemType = normalizeItemType(item?.type); + const turnId = asString(payloadRecord.turn_id ?? payloadRecord.turnId); + + if (itemType === 'usermessage') { + const message = extractTextContent(item?.content ?? item?.message ?? item?.text); + return { + ...(turnId ? { turnId } : {}), + userActivity: true, + ...(message ? { userMessage: message } : {}) + }; + } + + if (itemType === 'agentmessage') { + const message = extractVisibleAssistantText(item?.content ?? item?.message ?? item?.text); + if (!message) return null; + return { + ...(turnId ? { turnId } : {}), + messages: [{ + type: 'message', + message, + id: asString(item?.id) ?? randomUUID() + }] + }; + } + const message = itemType === 'plan' ? asString(item?.text) : null; - const turnId = asString(payloadRecord.turn_id); if (!message || message.trim().length === 0 || !turnId) { return null; } @@ -234,8 +408,24 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu } if (itemType === 'message') { - // Response messages are model conversation state; event_msg carries visible chat. - return null; + if (payloadRecord.role !== 'assistant') { + // User/developer response items include injected context. Only + // semantic user events represent visible chat input. + return null; + } + const message = extractVisibleAssistantText(payloadRecord.content); + if (!message) { + return null; + } + const turnId = extractResponseItemTurnId(payloadRecord); + return { + ...(turnId ? { turnId } : {}), + messages: [{ + type: 'message', + message, + id: asString(payloadRecord.id) ?? randomUUID() + }] + }; } if (itemType === 'function_call') { diff --git a/cli/src/codex/utils/codexTranscriptLocator.test.ts b/cli/src/codex/utils/codexTranscriptLocator.test.ts index 1cf58274..33dcae67 100644 --- a/cli/src/codex/utils/codexTranscriptLocator.test.ts +++ b/cli/src/codex/utils/codexTranscriptLocator.test.ts @@ -75,6 +75,36 @@ describe('codexTranscriptLocator', () => { expect(located).toEqual([transcriptPath]); }); + it('attaches after Codex 0.147 completed user-message activity', async () => { + const located: string[] = []; + locator = createCodexTranscriptLocator({ + cwd: '/tmp/project', + startupTimestampMs: Date.now(), + intervalMs: 25, + settlementMs: 25, + onLocated: (result) => located.push(result.transcriptPath) + }); + await locator.ready; + const transcriptPath = await createTranscript('thread-user-147', '/tmp/project'); + + await appendFile(transcriptPath, `${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { + type: 'item_completed', + turn_id: 'turn-147', + item: { + type: 'UserMessage', + id: 'user-147', + content: [{ type: 'Text', text: 'hello from 0.147' }] + } + } + })}\n`); + await wait(150); + + expect(located).toEqual([transcriptPath]); + }); + it('attaches after image-only user activity', async () => { const located: string[] = []; locator = createCodexTranscriptLocator({