Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/storage/src/__tests__/codex-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}}
133 changes: 123 additions & 10 deletions packages/storage/src/codex-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ type JsonRecord = Record<string, unknown>;
* 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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 : [];
Expand Down