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