diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index d22b960daf..4b54a37784 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -28,6 +28,7 @@ import { CodexSessionAdapter } from '../codex-session-adapter.js'; import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; const CURRENT_FIXTURE = fixturePath('codex-rollout-v0.144.jsonl'); +const ITEM_COMPLETED_FIXTURE = fixturePath('codex-rollout-v0.149-item-completed.jsonl'); describe('CodexSessionAdapter', () => { test('lists active and archived root Sessions from the newest Codex state database', async () => { @@ -241,6 +242,62 @@ describe('CodexSessionAdapter', () => { }); }); + test('converts Codex Desktop completed items without importing response mirrors', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-item-completed'; + await seedRawRollout(codexHome, sessionId, await readFile(ITEM_COMPLETED_FIXTURE, 'utf8')); + + const adapter = new CodexSessionAdapter({ codexHome }); + assert.deepEqual( + (await adapter.listSessions()).map(({ id, name }) => ({ id, name })), + [{ id: sessionId, name: 'Analyze the image. Use OpenCV.js.' }], + ); + const session = await adapter.readSession(sessionId); + + assert.deepEqual(session.metadata, { + name: 'Analyze the image. Use OpenCV.js.', + cwd: '/workspace/opencv', + }); + assert.equal(session.messages.length, 4); + assert.deepEqual( + session.messages.map((message) => message.type), + ['user', 'assistant', 'assistant', 'turn_state'], + ); + for (const message of session.messages) { + assert.deepEqual(decodeStoredMessage(message), message); + } + + assert.deepEqual(session.messages[0], { + type: 'user', + id: 'user-client-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:02.100Z'), + text: 'Analyze the image.\nUse OpenCV.js.', + }); + assert.deepEqual(session.messages[1], { + type: 'assistant', + id: 'reasoning-item-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:03.000Z'), + text: '', + thinking: { text: 'Inspect the pixels.\nDraft the solution.' }, + contentOrder: ['thinking'], + modelId: 'gpt-codex-item-test', + }); + assert.deepEqual(session.messages[2], { + type: 'assistant', + id: 'assistant-item-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:04.000Z'), + text: 'Use canvas.\nThen process the pixels.', + modelId: 'gpt-codex-item-test', + contentOrder: ['text'], + }); + assert.equal(session.messages[3]?.type, 'turn_state'); + assert.equal(session.messages[3]?.status, 'completed'); + }); + }); + test('imports terminal errors as failed without failing turns on non-terminal errors', async () => { await withCodexHome(async (codexHome) => { const sessionId = 'codex-error-semantics'; diff --git a/packages/storage/src/__tests__/fixtures/codex-rollout-v0.149-item-completed.jsonl b/packages/storage/src/__tests__/fixtures/codex-rollout-v0.149-item-completed.jsonl new file mode 100644 index 0000000000..b30e247612 --- /dev/null +++ b/packages/storage/src/__tests__/fixtures/codex-rollout-v0.149-item-completed.jsonl @@ -0,0 +1,10 @@ +{"timestamp":"2026-08-22T00:00:00.000Z","ordinal":0,"type":"session_meta","payload":{"session_id":"codex-item-completed","id":"codex-item-completed","timestamp":"2026-08-22T00:00:00.000Z","cwd":"/workspace/opencv","originator":"Codex Desktop","cli_version":"0.149.0-alpha.4.1","source":"vscode","model_provider":"openai"}} +{"timestamp":"2026-08-22T00:00:01.000Z","ordinal":1,"type":"event_msg","payload":{"type":"task_started","turn_id":"codex-turn-item-completed","started_at":"2026-08-22T00:00:01.000Z"}} +{"timestamp":"2026-08-22T00:00:01.100Z","ordinal":2,"type":"turn_context","payload":{"turn_id":"codex-turn-item-completed","cwd":"/workspace/opencv","model":"gpt-codex-item-test"}} +{"timestamp":"2026-08-22T00:00:02.000Z","ordinal":3,"type":"response_item","payload":{"type":"message","id":"provider-user-mirror","role":"user","content":[{"type":"input_text","text":"Analyze the image.\nUse OpenCV.js."}]}} +{"timestamp":"2026-08-22T00:00:02.100Z","ordinal":4,"type":"event_msg","payload":{"type":"item_completed","thread_id":"codex-item-completed","turn_id":"codex-turn-item-completed","item":{"type":"UserMessage","id":"user-item-1","client_id":"user-client-1","content":[{"type":"text","text":"Analyze the image.","text_elements":[]},{"type":"local_image","path":"/tmp/input.png"},{"type":"text","text":"Use OpenCV.js.","text_elements":[]}]},"started_at_ms":1787356802000,"completed_at_ms":1787356802100}} +{"timestamp":"2026-08-22T00:00:03.000Z","ordinal":5,"type":"event_msg","payload":{"type":"item_completed","thread_id":"codex-item-completed","turn_id":"codex-turn-item-completed","item":{"type":"Reasoning","id":"reasoning-item-1","summary_text":["Inspect the pixels.","Draft the solution."],"raw_content":[]},"started_at_ms":1787356803000,"completed_at_ms":1787356803100}} +{"timestamp":"2026-08-22T00:00:03.100Z","ordinal":6,"type":"response_item","payload":{"type":"reasoning","id":"reasoning-item-1","summary":[{"type":"summary_text","text":"Inspect the pixels."},{"type":"summary_text","text":"Draft the solution."}],"encrypted_content":"opaque"}} +{"timestamp":"2026-08-22T00:00:04.000Z","ordinal":7,"type":"event_msg","payload":{"type":"item_completed","thread_id":"codex-item-completed","turn_id":"codex-turn-item-completed","item":{"type":"AgentMessage","id":"assistant-item-1","content":[{"type":"Text","text":"Use canvas."},{"type":"Text","text":"Then process the pixels."}],"phase":"final_answer"},"started_at_ms":1787356804000,"completed_at_ms":1787356804100}} +{"timestamp":"2026-08-22T00:00:04.100Z","ordinal":8,"type":"response_item","payload":{"type":"message","id":"assistant-item-1","role":"assistant","content":[{"type":"output_text","text":"Use canvas.\nThen process the pixels."}],"phase":"final_answer"}} +{"timestamp":"2026-08-22T00:00:05.000Z","ordinal":9,"type":"event_msg","payload":{"type":"task_complete","turn_id":"codex-turn-item-completed","last_agent_message":"Use canvas.\nThen process the pixels.","completed_at":"2026-08-22T00:00:05.000Z"}} diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index fdfa0a9209..c352f92b01 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -73,10 +73,11 @@ type JsonRecord = Record; * Read-only adapter for Codex rollout JSONL. * * 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. + * protocol facts as `response_item` records. Presentation messages use either + * the legacy `user_message` / `agent_*` events or the newer `item_completed` + * event. Reading both shapes from `event_msg` avoids importing their + * response-item mirrors twice. 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; @@ -295,6 +296,71 @@ function convertCodexRollout( continue; } + if (eventType === 'item_completed') { + const item = asRecord(payload.item); + const itemType = stringField(item, 'type')?.toLowerCase(); + 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 = codexCompletedItemText(item) || codexCompletedItemMediaText(item); + if (text.length === 0) continue; + firstUserText ??= text; + messages.push({ + type: 'user', + id: + stringField(item, 'client_id') ?? + stringField(item, 'id') ?? + generatedCodexId(expectedSessionId, 'user', record.line), + turnId: ensureTurnId(record.line), + ts: timestampFor(record), + text, + }); + continue; + } + + if (itemType === 'agentmessage') { + const text = codexCompletedItemText(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 (itemType === 'reasoning') { + const reasoning = codexCompletedReasoningText(item); + if (reasoning.length === 0) continue; + messages.push({ + type: 'assistant', + id: + stringField(item, 'id') ?? + generatedCodexId(expectedSessionId, 'reasoning', record.line), + turnId: ensureTurnId(record.line), + ts: timestampFor(record), + text: '', + thinking: { text: reasoning }, + contentOrder: ['thinking'], + modelId: activeModel, + }); + continue; + } + } + if (eventType === 'user_message') { if (!activeTurnIsExplicit) { activeTurnId = generatedCodexId(expectedSessionId, 'turn', record.line); @@ -514,12 +580,15 @@ function catalogEntryFromRolloutHead( cwd = safeCodexCwd(payload.cwd) || cwd; createdAt = normalizeEpochMs(record.timestamp) ?? normalizeEpochMs(payload.timestamp) ?? createdAt; - } else if ( - record.type === 'event_msg' && - payload.type === 'user_message' && - firstUserText === undefined - ) { - firstUserText = stringField(payload, 'message'); + } else if (record.type === 'event_msg' && firstUserText === undefined) { + if (payload.type === 'user_message') { + firstUserText = stringField(payload, 'message'); + } else if (payload.type === 'item_completed') { + const item = asRecord(payload.item); + if (stringField(item, 'type')?.toLowerCase() === 'usermessage') { + firstUserText = codexCompletedItemText(item) || codexCompletedItemMediaText(item); + } + } } if (id && firstUserText !== undefined) break; } @@ -814,6 +883,50 @@ function codexToolOutputText(value: unknown): string { } } +function codexCompletedItemText(item: JsonRecord | undefined): string { + if (!item) return ''; + const direct = stringField(item, 'content'); + if (direct) return direct; + if (!Array.isArray(item.content)) return ''; + return item.content + .flatMap((part) => { + const record = asRecord(part); + const type = stringField(record, 'type')?.toLowerCase(); + return type === 'text' || type === 'input_text' || type === 'output_text' + ? [stringField(record, 'text') ?? ''] + : []; + }) + .filter((text) => text.length > 0) + .join('\n'); +} + +function codexCompletedReasoningText(item: JsonRecord | undefined): string { + if (!item) return ''; + const summary = codexTextFragments(item.summary_text); + if (summary.length > 0) return summary.join('\n'); + return codexCompletedItemText(item); +} + +function codexTextFragments(value: unknown): string[] { + if (typeof value === 'string') return value.length > 0 ? [value] : []; + if (!Array.isArray(value)) return []; + return value.flatMap((part) => { + if (typeof part === 'string') return part.length > 0 ? [part] : []; + const text = stringField(asRecord(part), 'text'); + return text ? [text] : []; + }); +} + +function codexCompletedItemMediaText(item: JsonRecord | undefined): string { + if (!item || !Array.isArray(item.content)) return ''; + const contentTypes = item.content.flatMap((part) => { + const type = stringField(asRecord(part), 'type')?.toLowerCase(); + return type ? [type] : []; + }); + if (contentTypes.some((type) => type.includes('image'))) return '[Image]'; + return contentTypes.some((type) => type.includes('audio')) ? '[Audio]' : ''; +} + function mediaOnlyUserText(payload: JsonRecord): string { const images = Array.isArray(payload.images) ? payload.images : []; const localImages = Array.isArray(payload.local_images) ? payload.local_images : [];