diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index d22b960daf..7fa849c451 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -241,6 +241,51 @@ describe('CodexSessionAdapter', () => { }); }); + test('imports current item-completed presentation messages without provider mirrors', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-item-completed'; + await seedRawRollout(codexHome, sessionId, itemCompletedRollout(sessionId)); + const adapter = new CodexSessionAdapter({ codexHome }); + + assert.equal((await adapter.listSessions())[0]?.name, 'Review the current PR'); + const session = await adapter.readSession(sessionId); + assert.deepEqual( + session.messages.map((message) => + message.type === 'user' || message.type === 'assistant' + ? { type: message.type, id: message.id, turnId: message.turnId, text: message.text } + : { type: message.type }, + ), + [ + { + type: 'user', + id: 'current-user-item', + turnId: 'current-turn', + text: 'Review the current PR', + }, + { + type: 'assistant', + id: 'current-commentary-item', + turnId: 'current-turn', + text: 'I am checking the changed paths.', + }, + { + type: 'assistant', + id: 'current-final-item', + turnId: 'current-turn', + text: 'The PR is ready.', + }, + { type: 'turn_state' }, + ], + ); + assert.equal( + session.messages.some( + (message) => message.type === 'user' && message.text.includes('environment_context'), + ), + false, + ); + }); + }); + test('imports terminal errors as failed without failing turns on non-terminal errors', async () => { await withCodexHome(async (codexHome) => { const sessionId = 'codex-error-semantics'; @@ -427,6 +472,81 @@ function minimalRollout( ].join('\n'); } +function itemCompletedRollout(sessionId: string): string { + const event = (second: number, payload: Record): string => + JSON.stringify({ + timestamp: `2026-08-08T00:00:${String(second).padStart(2, '0')}.000Z`, + type: 'event_msg', + payload, + }); + const response = (second: number, payload: Record): string => + JSON.stringify({ + timestamp: `2026-08-08T00:00:${String(second).padStart(2, '0')}.100Z`, + type: 'response_item', + payload, + }); + return [ + JSON.stringify({ + timestamp: '2026-08-08T00:00:00.000Z', + type: 'session_meta', + payload: { + session_id: sessionId, + id: sessionId, + cwd: '/workspace/project', + source: 'cli', + }, + }), + event(1, { type: 'task_started', turn_id: 'current-turn' }), + response(1, { + type: 'message', + id: 'injected-context', + role: 'user', + content: [{ type: 'input_text', text: 'private' }], + }), + event(2, { + type: 'item_completed', + thread_id: sessionId, + turn_id: 'current-turn', + item: { + type: 'UserMessage', + id: 'current-user-item', + content: [{ type: 'text', text: 'Review the current PR', text_elements: [] }], + }, + }), + event(3, { + type: 'item_completed', + thread_id: sessionId, + turn_id: 'current-turn', + item: { + type: 'AgentMessage', + id: 'current-commentary-item', + content: [{ type: 'Text', text: 'I am checking the changed paths.' }], + phase: 'commentary', + }, + }), + response(3, { + type: 'message', + id: 'provider-commentary-mirror', + role: 'assistant', + content: [{ type: 'output_text', text: 'I am checking the changed paths.' }], + phase: 'commentary', + }), + event(4, { + type: 'item_completed', + thread_id: sessionId, + turn_id: 'current-turn', + item: { + type: 'AgentMessage', + id: 'current-final-item', + content: [{ type: 'Text', text: 'The PR is ready.' }], + phase: 'final_answer', + }, + }), + event(5, { type: 'task_complete', turn_id: 'current-turn' }), + '', + ].join('\n'); +} + function errorSemanticsRollout(sessionId: string): string { const event = (second: number, payload: Record): string => JSON.stringify({ diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index fdfa0a9209..6a9cb22be8 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -74,9 +74,11 @@ type JsonRecord = Record; * * Codex persists presentation history as `event_msg` records and provider * protocol facts as `response_item` records. User, assistant, and reasoning - * messages come from `event_msg` to avoid importing their response-item - * mirrors twice. Tool calls/results come from response items because they own - * the stable call identity and raw arguments/output. + * messages come from the presentation events to avoid importing their + * response-item mirrors twice. Codex <=0.144 wrote one event per message; + * newer builds wrap the presentation item in `item_completed`. Tool + * calls/results come from response items because they own the stable call + * identity and raw arguments/output. */ export class CodexSessionAdapter implements ExternalSessionAdapter { readonly id = CODEX_SESSION_ADAPTER_ID; @@ -261,6 +263,18 @@ function convertCodexRollout( let lastTimestamp = normalizeEpochMs(sessionMeta?.timestamp) ?? 0; let firstUserText: string | undefined; const failedTurnIds = new Set(); + // The two presentation schemas are mirrors, not additive streams. Prefer + // the legacy events if a transitional rollout contains both so one visible + // message cannot be imported twice. + const usesCompletedPresentationItems = !records.some((record) => { + if (record.value.type !== 'event_msg') return false; + const payload = asRecord(record.value.payload); + return ( + payload?.type === 'user_message' || + payload?.type === 'agent_message' || + payload?.type === 'agent_reasoning' + ); + }); const timestampFor = (record: ParsedRolloutRecord): number => { const parsed = normalizeEpochMs(record.value.timestamp); @@ -295,6 +309,51 @@ function convertCodexRollout( continue; } + if (eventType === 'item_completed' && usesCompletedPresentationItems) { + const item = asRecord(payload.item); + if (!item) continue; + const itemType = stringField(item, 'type'); + const eventTurnId = stringField(payload, 'turn_id'); + if (eventTurnId) { + activeTurnId = eventTurnId; + activeTurnIsExplicit = true; + } + + if (itemType === 'UserMessage') { + if (!activeTurnIsExplicit) { + activeTurnId = generatedCodexId(expectedSessionId, 'turn', record.line); + } + const text = completedUserMessageText(item); + if (text.length === 0) continue; + firstUserText ??= text; + messages.push({ + type: 'user', + id: stringField(item, 'id') ?? generatedCodexId(expectedSessionId, 'user', record.line), + turnId: ensureTurnId(record.line), + ts: timestampFor(record), + text, + }); + continue; + } + + if (itemType === 'AgentMessage') { + const text = completedAgentMessageText(item); + if (text.length === 0) continue; + messages.push({ + type: 'assistant', + id: + stringField(item, 'id') ?? + generatedCodexId(expectedSessionId, 'assistant', record.line), + turnId: ensureTurnId(record.line), + ts: timestampFor(record), + text, + modelId: activeModel, + contentOrder: ['text'], + }); + continue; + } + } + if (eventType === 'user_message') { if (!activeTurnIsExplicit) { activeTurnId = generatedCodexId(expectedSessionId, 'turn', record.line); @@ -520,6 +579,16 @@ function catalogEntryFromRolloutHead( firstUserText === undefined ) { firstUserText = stringField(payload, 'message'); + } else if ( + record.type === 'event_msg' && + payload.type === 'item_completed' && + firstUserText === undefined + ) { + const item = asRecord(payload.item); + if (item?.type === 'UserMessage') { + const text = completedUserMessageText(item); + if (text.length > 0) firstUserText = text; + } } if (id && firstUserText !== undefined) break; } @@ -823,6 +892,40 @@ function mediaOnlyUserText(payload: JsonRecord): string { return audio.length > 0 || localAudio.length > 0 ? '[Audio]' : ''; } +function completedUserMessageText(item: JsonRecord): string { + const content = Array.isArray(item.content) ? item.content : []; + const text = presentationItemText(content, 'text'); + if (text.length > 0) return text; + if ( + content.some((part) => { + const type = stringField(asRecord(part), 'type'); + return type === 'image' || type === 'local_image'; + }) + ) { + return '[Image]'; + } + return content.some((part) => { + const type = stringField(asRecord(part), 'type'); + return type === 'audio' || type === 'local_audio'; + }) + ? '[Audio]' + : ''; +} + +function completedAgentMessageText(item: JsonRecord): string { + const content = Array.isArray(item.content) ? item.content : []; + return presentationItemText(content, 'Text'); +} + +function presentationItemText(content: unknown[], expectedType: string): string { + return content + .flatMap((part) => { + const record = asRecord(part); + return record?.type === expectedType && typeof record.text === 'string' ? [record.text] : []; + }) + .join('\n'); +} + async function isDirectory(path: string): Promise { try { return (await stat(path)).isDirectory();