From 1d3decdbc47c395c645e8b80f9e5436d55a2ebc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:19:54 -0700 Subject: [PATCH] Fix native chat history across transcript formats (#13393) * fix(native-chat): restore transcript history across formats * fix(native-chat): preserve safe Codex history metadata --- .../transcript-line-decoders-codex.ts | 96 ++++++- ...anscript-reader-codex-history-mode.test.ts | 250 ++++++++++++++++++ 2 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 src/main/native-chat/transcript-reader-codex-history-mode.test.ts diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 98eeb1cc0..c4b0ddbf5 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -24,7 +24,8 @@ export function decodeCodexTranscriptLine( } const payload = asRecord(record.payload) if (!payload) { - return null + const id = extractString(record.id) ?? fallbackId + return codexUnwrappedResponseItem(record, id, parseTimestamp(record.timestamp)) } const timestamp = parseTimestamp(record.timestamp) const baseId = extractString(payload.id) ?? fallbackId @@ -38,18 +39,34 @@ export function decodeCodexTranscriptLine( return null } +function codexUnwrappedResponseItem( + record: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (record.type !== 'message') { + return codexResponseItem(record, id, timestamp) + } + const role = record.role === 'assistant' ? 'assistant' : record.role === 'user' ? 'user' : null + const blocks = codexTurnItemBlocks(record.content) + return role && blocks.length > 0 ? { id, role, blocks, timestamp, source: 'transcript' } : null +} + function codexResponseItem( payload: Record, id: string, timestamp: number | null ): NativeChatMessage | null { if (payload.type === 'message') { + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : null + if (!role) { + return null + } const blocks = claudeContentBlocks(payload.content) if (blocks.length === 0) { return null } - const role = - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' return { id, role, blocks, timestamp, source: 'transcript' } } if (payload.type === 'reasoning') { @@ -65,7 +82,11 @@ function codexResponseItem( source: 'transcript' } } - if (payload.type === 'function_call' || payload.type === 'local_shell_call') { + if ( + payload.type === 'function_call' || + payload.type === 'local_shell_call' || + payload.type === 'custom_tool_call' + ) { const name = extractString(payload.name) ?? 'tool' return { id, @@ -75,7 +96,7 @@ function codexResponseItem( source: 'transcript' } } - if (payload.type === 'function_call_output') { + if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') { return { id, role: 'tool', @@ -101,6 +122,9 @@ function codexEventMessage( source: 'transcript' } } + if (payload.type === 'item_completed') { + return codexCompletedTurnItem(payload, id, timestamp) + } if (payload.type === 'user_message') { const text = extractString(payload.message) return text @@ -116,6 +140,68 @@ function codexEventMessage( return null } +function codexCompletedTurnItem( + payload: Record, + fallbackId: string, + timestamp: number | null +): NativeChatMessage | null { + const item = asRecord(payload.item) + if (!item) { + return null + } + const id = extractString(item.id) ?? fallbackId + const blocks = codexTurnItemBlocks(item.content) + if (blocks.length === 0) { + return null + } + if (item.type === 'UserMessage' || item.type === 'user_message') { + return { id, role: 'user', blocks, timestamp, source: 'transcript' } + } + if (item.type === 'AgentMessage' || item.type === 'agent_message') { + return { id, role: 'assistant', blocks, timestamp, source: 'transcript' } + } + return null +} + +function codexTurnItemBlocks(content: unknown): NativeChatBlock[] { + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const value of content) { + const item = asRecord(value) + if (!item) { + continue + } + if ( + item.type === 'text' || + item.type === 'Text' || + item.type === 'input_text' || + item.type === 'output_text' + ) { + const text = extractString(item.text) + if (text) { + blocks.push({ type: 'text', text }) + } + continue + } + if (item.type === 'image' || item.type === 'Image' || item.type === 'input_image') { + const url = extractString(item.image_url) ?? extractString(item.url) + if (url) { + blocks.push({ type: 'image-ref', url }) + } + continue + } + if (item.type === 'local_image' || item.type === 'LocalImage') { + const path = extractString(item.path) + if (path) { + blocks.push({ type: 'image-ref', path }) + } + } + } + return blocks +} + function codexCallInput(payload: Record): unknown { if (payload.arguments !== undefined) { return payload.arguments diff --git a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts new file mode 100644 index 000000000..5b0f3dc01 --- /dev/null +++ b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts @@ -0,0 +1,250 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' +import { readNativeChatTranscript } from './transcript-reader' +import { readNativeChatTranscriptTail } from './transcript-tail-reader' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function writeCodexFixture(records: unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-codex-history-')) + tempRoots.push(root) + const filePath = join(root, 'rollout.jsonl') + await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n')) + return filePath +} + +function completedItem(item: unknown, timestamp: string): unknown { + return { + timestamp, + type: 'event_msg', + payload: { + type: 'item_completed', + thread_id: 'thread-1', + turn_id: 'turn-1', + item, + completed_at_ms: 1 + } + } +} + +describe('Codex transcript history modes', () => { + it('reads unwrapped response records from early rollouts', async () => { + const filePath = await writeCodexFixture([ + { + id: 'session-1', + timestamp: '2025-06-28T10:00:00.000Z', + instructions: null + }, + { + type: 'message', + id: null, + timestamp: '2025-06-28T10:00:01.000Z', + role: 'user', + content: [ + { type: 'input_text', text: 'Early prompt' }, + { type: 'input_image', image_url: 'data:image/png;base64,abc' } + ] + }, + { + type: 'message', + id: 'assistant-1', + timestamp: '2025-06-28T10:00:02.000Z', + role: 'assistant', + content: [{ type: 'output_text', text: 'Early response' }] + }, + { + type: 'message', + id: 'system-1', + role: 'system', + content: [{ type: 'input_text', text: 'Internal instructions' }] + } + ]) + + const result = await readNativeChatTranscript('codex', 'session-1', { filePath }) + const tail = await readNativeChatTranscriptTail({ + agent: 'codex', + sessionId: 'session-1', + filePath, + limit: 50 + }) + + expect(result).toMatchObject({ + messages: [ + { + role: 'user', + timestamp: Date.parse('2025-06-28T10:00:01.000Z'), + blocks: [ + { type: 'text', text: 'Early prompt' }, + { type: 'image-ref', url: 'data:image/png;base64,abc' } + ] + }, + { + id: 'assistant-1', + role: 'assistant', + timestamp: Date.parse('2025-06-28T10:00:02.000Z'), + blocks: [{ type: 'text', text: 'Early response' }] + } + ] + }) + expect(tail).toMatchObject({ messages: 'messages' in result ? result.messages : [] }) + }) + + it('reads canonical paginated messages without exposing model-only response copies', async () => { + const filePath = await writeCodexFixture([ + { + timestamp: '2026-08-09T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'session-1', history_mode: 'paginated' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'developer', + content: [{ type: 'text', text: 'internal instructions' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'model copy' }] + } + }, + completedItem( + { + type: 'UserMessage', + id: 'user-1', + content: [ + { type: 'text', text: 'Visible prompt', text_elements: [] }, + { type: 'image', image_url: 'data:image/png;base64,abc' }, + { type: 'local_image', path: '/tmp/reference.png' }, + { type: 'skill', name: 'example', path: '/tmp/SKILL.md' } + ] + }, + '2026-08-09T10:00:01.000Z' + ), + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'model copy' }] + } + }, + completedItem( + { + type: 'AgentMessage', + id: 'assistant-1', + content: [{ type: 'Text', text: 'Visible response' }], + phase: 'final_answer' + }, + '2026-08-09T10:00:02.000Z' + ) + ]) + + const result = await readNativeChatTranscript('codex', 'session-1', { filePath }) + const tail = await readNativeChatTranscriptTail({ + agent: 'codex', + sessionId: 'session-1', + filePath, + limit: 50 + }) + + expect(result).toMatchObject({ + messages: [ + { + id: 'user-1', + role: 'user', + blocks: [ + { type: 'text', text: 'Visible prompt' }, + { type: 'image-ref', url: 'data:image/png;base64,abc' }, + { type: 'image-ref', path: '/tmp/reference.png' } + ] + }, + { + id: 'assistant-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Visible response' }] + } + ] + }) + expect('messages' in result && result.messages).toHaveLength(2) + expect(tail).toMatchObject({ messages: 'messages' in result ? result.messages : [] }) + }) + + it('keeps legacy event messages without duplicating response copies', async () => { + const filePath = await writeCodexFixture([ + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Prompt' }] + } + }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Prompt' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'Response' } }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response' }] + } + } + ]) + + const result = await readNativeChatTranscript('codex', 'session-1', { filePath }) + + expect(result).toMatchObject({ + messages: [ + { role: 'user', blocks: [{ type: 'text', text: 'Prompt' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Response' }] } + ] + }) + }) + + it('decodes freeform tool calls and outputs', () => { + const call = decodeCodexTranscriptLine( + JSON.stringify({ + type: 'response_item', + payload: { + type: 'custom_tool_call', + id: 'call-1', + call_id: 'durable-call-1', + name: 'exec', + input: 'pwd' + } + }), + 'fallback-call' + ) + const output = decodeCodexTranscriptLine( + JSON.stringify({ + type: 'response_item', + payload: { type: 'custom_tool_call_output', call_id: 'durable-call-1', output: 'ok' } + }), + 'fallback-output' + ) + + expect(call).toMatchObject({ + id: 'call-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd' }] + }) + expect(output).toMatchObject({ + id: 'fallback-output', + role: 'tool', + blocks: [{ type: 'tool-result', output: 'ok' }] + }) + }) +})