Skip to content
Merged
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. Use 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. Then 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. Use 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 ima","text_elements":[]},{"type":"local_image","path":"/tmp/input.png"},{"type":"text","text":"ge. 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 can"},{"type":"Text","text":"vas. 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. Then 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. Then 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] codexCompletedItemText(item) || codexCompletedItemMediaText(item):同一条 UserMessage 里只要有非空 text part,后面的 local_image / audio 就不会变成 [Image] / [Audio]。新 fixture 在两段文本中间夹了 local_image,只为了钉 join(''),导入结果是纯文本,图被丢掉。旧 user_message 路径也是「有 message 就不用 media placeholder」,所以不是新回归,只是 0.149 的混合 content 仍然没有图。不挡这个 PR。

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('');
}

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