diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index c82c5d2..3624bc9 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -39,6 +39,36 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole return typeof payload.message === 'string' && payload.message.trim().length > 0; } +function isSyntheticCodexUserContext(content: string): boolean { + const trimmed = content.trim(); + return ( + (trimmed.startsWith('# AGENTS.md instructions for ') + && trimmed.includes('') + && trimmed.includes('')) + || (trimmed.startsWith('') + && trimmed.endsWith('')) + ); +} + +function extractCodexResponseUserImages( + content: unknown, +): Array<{ path?: string; data?: string }> | undefined { + if (!Array.isArray(content)) return undefined; + + const attachments: Array<{ path?: string; data?: string }> = []; + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const record = item as AnyRecord; + if (record.type !== 'input_image' || typeof record.image_url !== 'string') continue; + if (record.image_url.startsWith('data:')) { + attachments.push({ data: record.image_url }); + } else if (record.image_url.trim()) { + attachments.push(...toImageAttachments([record.image_url])); + } + } + return attachments.length > 0 ? attachments : undefined; +} + /** * Reads the image attachments Codex records on `user_message` events. * Turns sent with `local_image` input items land in `local_images` as file @@ -120,6 +150,7 @@ type CodexHistoryCacheEntry = { normalizedBytes: number; toolResults: Map; toolUses: Map; + userSources: WeakMap; sortTimestamps: WeakMap; }; @@ -165,6 +196,7 @@ function parseCodexHistoryLine(line: string, accumulator: CodexHistoryAccumulato if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) { accumulator.messages.push({ type: 'user', + codexUserSource: 'event_msg', timestamp: entry.timestamp, message: { role: 'user', @@ -174,6 +206,28 @@ function parseCodexHistoryLine(line: string, accumulator: CodexHistoryAccumulato }); } + if ( + entry.type === 'response_item' + && entry.payload?.type === 'message' + && entry.payload.role === 'user' + ) { + const textContent = extractCodexTextContent(entry.payload.content); + const images = extractCodexResponseUserImages(entry.payload.content); + if ((textContent.trim() || images) && !isSyntheticCodexUserContext(textContent)) { + accumulator.messages.push({ + type: 'user', + codexUserSource: 'response_item', + uuid: entry.payload.id, + timestamp: entry.timestamp, + message: { + role: 'user', + content: textContent, + }, + images, + }); + } + } + if ( entry.type === 'response_item' && entry.payload?.type === 'message' @@ -386,9 +440,48 @@ function appendNormalizedCodexHistory( const rawTimestamp = new Date(raw.timestamp || 0).getTime(); const sortTimestamp = Number.isFinite(rawTimestamp) ? rawTimestamp : 0; for (const message of normalize(raw, sessionId)) { - entry.normalizedBytes += estimateCodexMessageBytes(message); entry.sortTimestamps.set(message, sortTimestamp); + if (message.kind === 'text' && message.role === 'user') { + let duplicateIndex = -1; + for (let index = entry.messages.length - 1; index >= 0; index -= 1) { + const previous = entry.messages[index]; + const previousTimestamp = codexMessageTimestamp(previous, entry.sortTimestamps); + if (sortTimestamp - previousTimestamp > 100) break; + if ( + previous.kind === 'text' + && previous.role === 'user' + && previous.content === message.content + && Math.abs(sortTimestamp - previousTimestamp) <= 10 + ) { + duplicateIndex = index; + break; + } + } + + if (duplicateIndex >= 0) { + const previous = entry.messages[duplicateIndex]; + const previousSource = entry.userSources.get(previous); + const nextSource = raw.codexUserSource; + // Older Codex versions write the same prompt twice: first as a + // response_item and then as event_msg. Prefer event_msg because it + // carries compact local image paths instead of inline base64 data. + if (previousSource === 'response_item' && nextSource === 'event_msg') { + entry.normalizedBytes -= estimateCodexMessageBytes(previous); + entry.messages[duplicateIndex] = message; + entry.userSources.set(message, 'event_msg'); + entry.normalizedBytes += estimateCodexMessageBytes(message); + } + continue; + } + + if (raw.codexUserSource === 'event_msg' || raw.codexUserSource === 'response_item') { + entry.userSources.set(message, raw.codexUserSource); + } + } + + entry.normalizedBytes += estimateCodexMessageBytes(message); + if (message.kind === 'tool_result' && message.toolId) { entry.toolResults.set(message.toolId, message); const matchingToolUses = entry.toolUses.get(message.toolId) ?? []; @@ -470,6 +563,7 @@ async function refreshCodexHistoryCache( malformed: false, toolResults: new Map(), toolUses: new Map(), + userSources: new WeakMap(), sortTimestamps: new WeakMap(), }; } diff --git a/server/modules/providers/tests/codex-sessions.test.ts b/server/modules/providers/tests/codex-sessions.test.ts index a000479..d5edf91 100644 --- a/server/modules/providers/tests/codex-sessions.test.ts +++ b/server/modules/providers/tests/codex-sessions.test.ts @@ -336,6 +336,102 @@ test('Codex history incrementally appends complete JSONL records', { concurrency } }); +test('Codex history reads response-item-only user prompts without exposing injected context', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-history-response-user-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + + try { + const sessionId = 'codex-response-user-history'; + const transcriptPath = await writeCodexTranscript(tempRoot, sessionId, workspacePath); + const pairedImagePath = path.join(workspacePath, 'paired.png'); + await appendFile(transcriptPath, [ + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-14T00:00:00.000Z', + payload: { + type: 'message', + id: 'synthetic-context', + role: 'user', + content: [ + { type: 'input_text', text: '# AGENTS.md instructions for /workspace\n\n\ninternal\n' }, + { type: 'input_text', text: '\ninternal\n' }, + ], + }, + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-14T00:00:01.000Z', + payload: { + type: 'message', + id: 'response-user-only', + role: 'user', + content: [ + { type: 'input_text', text: 'Visible new-format prompt' }, + { type: 'input_image', image_url: 'data:image/png;base64,QUJD' }, + ], + }, + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-14T00:00:02.000Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Visible answer' }], + }, + }), + // Older Codex versions persist both forms for one prompt. The later + // event record must replace, not duplicate, the response item. + JSON.stringify({ + type: 'response_item', + timestamp: '2026-08-14T00:00:03.000Z', + payload: { + type: 'message', + id: 'paired-response-user', + role: 'user', + content: [ + { type: 'input_text', text: 'One paired prompt' }, + { type: 'input_image', image_url: 'data:image/png;base64,REVG' }, + ], + }, + }), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-14T00:00:03.000Z', + payload: { + type: 'user_message', + message: 'One paired prompt', + local_images: [pairedImagePath], + }, + }), + '', + ].join('\n'), 'utf8'); + + await withIsolatedDatabase(async () => { + sessionsDb.createSession( + sessionId, + 'codex', + workspacePath, + undefined, + undefined, + undefined, + transcriptPath, + ); + + const history = await new CodexSessionsProvider().fetchHistory(sessionId); + assert.deepEqual( + history.messages.map((message) => message.content), + ['Visible new-format prompt', 'Visible answer', 'One paired prompt'], + ); + assert.deepEqual(history.messages[0]?.images, [{ data: 'data:image/png;base64,QUJD' }]); + assert.deepEqual(history.messages[2]?.images, [{ path: pairedImagePath }]); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('Codex history detects same-size rewrites beyond the former raw cache bound', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-history-cache-bound-')); const workspacePath = path.join(tempRoot, 'workspace');