diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index 9b4324ea..33bbfcc7 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -46,6 +46,276 @@ function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\ writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') } +function createMirroredTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'mirrored user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'mirrored user message\n' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createMirroredAgentMessageTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'agent_message', message: 'duplicated assistant message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'duplicated assistant message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createInjectedResponseUserTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `# AGENTS.md instructions for /repo +ignore` }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: ` +2026-07-07 +workspace +` }] + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'real event user message' } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createEmbeddedEnvironmentPromptTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `Please inspect this transcript block: + +2026-07-07 + +This is part of my prompt.` }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createSameSourceDuplicateTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'same source user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'same source user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'same source assistant message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'same source assistant message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createRepeatedSameTextDistinctTurnTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'repeat user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'repeat user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'assistant answer' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'repeat user message' }] + } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function createMirroredAndDistinctUserTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, 'rollout-' + sessionId + '.jsonl') + const transcriptLines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:/work/project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'first user message' } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'first user message' }] + } + }, + { + type: 'event_msg', + payload: { type: 'user_message', message: 'second user message' } + } + ] + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n', 'utf-8') +} + +function writeSessionIndex(codexHome: string, records: Array<{ id: string; thread_name: string; updated_at: string }>): void { + writeFileSync( + join(codexHome, 'session_index.jsonl'), + records.map((record) => JSON.stringify(record)).join('\n') + '\n', + 'utf-8' + ) +} + function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine { return { id, @@ -163,6 +433,403 @@ describe('Codex Desktop import routes', () => { } }) + it('deduplicates mirrored event_msg and response_item user messages', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-')) + const store = new Store(':memory:') + const codexSessionId = '66666666-6666-4666-8666-666666666666' + process.env.CODEX_HOME = codexHome + + try { + createMirroredTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'user', + content: { + type: 'text', + text: 'mirrored user message' + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('deduplicates adjacent mirrored agent messages with different ids', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-agent-mirror-test-')) + const store = new Store(':memory:') + const codexSessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.env.CODEX_HOME = codexHome + + try { + createMirroredAgentMessageTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { + type: 'message', + message: 'duplicated assistant message', + id: expect.any(String) + } + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('skips injected response_item user context messages', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-injected-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '88888888-8888-4888-8888-888888888888' + process.env.CODEX_HOME = codexHome + + try { + createInjectedResponseUserTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toEqual({ + role: 'user', + content: { + type: 'text', + text: 'real event user message' + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('preserves a real response_item user prompt containing embedded environment_context text', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-embedded-env-test-')) + const store = new Store(':memory:') + const codexSessionId = 'abababab-abab-4bab-8bab-abababababab' + process.env.CODEX_HOME = codexHome + + try { + createEmbeddedEnvironmentPromptTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(1) + expect(messages[0].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: expect.stringContaining('') + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('preserves adjacent duplicate messages from the same transcript source', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-same-source-duplicate-test-')) + const store = new Store(':memory:') + const codexSessionId = 'babababa-baba-4aba-8aba-babababababa' + process.env.CODEX_HOME = codexHome + + try { + createSameSourceDuplicateTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(4) + expect(messages.map((message) => (message.content as { role?: unknown }).role)).toEqual([ + 'user', + 'user', + 'agent', + 'agent' + ]) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps a later response_item-only user turn with the same text', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-repeat-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '99999999-9999-4999-8999-999999999999' + process.env.CODEX_HOME = codexHome + + try { + createRepeatedSameTextDistinctTurnTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + const roles = messages.map((message) => (message.content as { role?: unknown }).role) + expect(roles).toEqual(['user', 'agent', 'user']) + expect(messages[0].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: 'repeat user message' + } + }) + expect(messages[2].content).toMatchObject({ + role: 'user', + content: { + type: 'text', + text: 'repeat user message' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps distinct user turns while deduplicating mirrored user events', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-distinct-user-test-')) + const store = new Store(':memory:') + const codexSessionId = '77777777-7777-4777-8777-777777777777' + process.env.CODEX_HOME = codexHome + + try { + createMirroredAndDistinctUserTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + expect(messages.map((message) => message.content)).toEqual([ + { + role: 'user', + content: { + type: 'text', + text: 'first user message' + }, + meta: { + sentFrom: 'cli' + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'second user message' + }, + meta: { + sentFrom: 'cli' + } + } + ]) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('uses the latest session_index thread_name for list and imported session title', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + writeSessionIndex(codexHome, [ + { + id: codexSessionId, + thread_name: 'old thread title', + updated_at: '2026-07-07T01:00:00.000000000Z' + }, + { + id: codexSessionId, + thread_name: 'new thread title', + updated_at: '2026-07-07T02:00:00.000000000Z' + } + ]) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'new thread title', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to transcript title when session_index is missing', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-no-index-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'normal user message', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to transcript title when session_index has no matching id', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-index-miss-title-test-')) + const store = new Store(':memory:') + const codexSessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + writeSessionIndex(codexHome, [ + { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + thread_name: 'unrelated title', + updated_at: '2026-07-07T03:00:00.000000000Z' + } + ]) + + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { sessions: Array<{ id: string; title: string }> } + expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session.metadata).toMatchObject({ + name: 'normal user message', + flavor: 'codex', + codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + it('binds imported transcripts to the unique online machine that owns the cwd', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-')) const store = new Store(':memory:') diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index d5723f40..82e5d71f 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -88,10 +88,21 @@ type CodexImportedMessageContent = { } } +type CodexImportedMessageSource = 'event_msg' | 'response_item' +type CodexImportedMessageEntry = { + source: CodexImportedMessageSource + message: CodexImportedMessageContent +} + type CodexTranscriptImportData = CodexLocalSessionSummary & { messages: CodexImportedMessageContent[] } +type CodexSessionIndexTitle = { + threadName: string + updatedAt: string +} + type ImportCandidate = { sessionId: string active: boolean @@ -207,6 +218,10 @@ function getCodexSessionRoots(): string[] { return [join(codexHome, 'sessions')] } +function getCodexSessionIndexPath(): string { + return join(getCodexHome(), 'session_index.jsonl') +} + function collectJsonlFiles(root: string, files: string[]): void { if (!existsSync(root)) return let entries @@ -272,10 +287,13 @@ function truncateText(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value } -function shouldIgnoreSyntheticUserMessage(text: string): boolean { +function shouldIgnoreInjectedResponseUserMessage(text: string): boolean { const normalized = text.trim() - return normalized.startsWith('# AGENTS.md instructions') - || normalized.startsWith('') + const lower = normalized.toLowerCase() + const isAgentInstructions = lower.startsWith('# agents.md instructions') + const isEnvironmentContext = lower.startsWith('') + && lower.endsWith('') + return isAgentInstructions || isEnvironmentContext } function inferSessionIdFromFileName(filePath: string): string | null { @@ -371,7 +389,7 @@ function getLatestCodexUserMessage(lines: string[]): string | null { const payload = asRecord(record.payload) if (payload?.type !== 'message' || payload.role !== 'user') continue const text = extractCodexText(payload.content) - if (text && !shouldIgnoreSyntheticUserMessage(text)) { + if (text && !shouldIgnoreInjectedResponseUserMessage(text)) { return truncateText(text, 140) } } catch { @@ -384,9 +402,14 @@ function getLatestCodexUserMessage(lines: string[]): string | null { function getCodexSessionTitle( cwd: string | null | undefined, sessionId: string, + sessionIndexTitle: string | null, changedTitle: string | null, firstUserMessage: string | null ): string { + if (sessionIndexTitle) { + return truncateText(sessionIndexTitle, 80) + } + if (changedTitle) { return truncateText(changedTitle, 80) } @@ -410,7 +433,48 @@ function isSubagentSource(value: unknown): boolean { return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false } -function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { +function readCodexSessionIndexTitles(): Map { + let content: string + try { + content = readFileSync(getCodexSessionIndexPath(), 'utf-8') + } catch { + return new Map() + } + + const titles = new Map() + for (const line of content.split(/\r?\n/).filter(Boolean)) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const record = asRecord(parsed) + const id = typeof record?.id === 'string' ? record.id : null + const threadName = typeof record?.thread_name === 'string' && record.thread_name.trim() + ? record.thread_name.trim() + : null + const updatedAt = typeof record?.updated_at === 'string' && record.updated_at.trim() + ? record.updated_at.trim() + : null + if (!id || !threadName || !updatedAt) { + continue + } + + const previous = titles.get(id) + if (!previous || previous.updatedAt < updatedAt) { + titles.set(id, { threadName, updatedAt }) + } + } + + return titles +} + +function parseCodexLocalSession( + filePath: string, + sessionIndexTitles = new Map() +): CodexLocalSessionSummary | null { let content: string try { content = readFileSync(filePath, 'utf-8') @@ -461,7 +525,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu const payload = asRecord(record?.payload) if (payload?.type === 'message' && payload.role === 'user') { const text = extractCodexText(payload.content) - if (text && !shouldIgnoreSyntheticUserMessage(text)) { + if (text && !shouldIgnoreInjectedResponseUserMessage(text)) { firstUserMessage = text } } @@ -473,6 +537,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu sessionId = sessionId ?? inferSessionIdFromFileName(filePath) if (!sessionId) return null + const sessionIndexTitle = sessionIndexTitles.get(sessionId)?.threadName ?? null let modifiedAt = Date.now() try { @@ -483,7 +548,7 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu return { id: sessionId, - title: getCodexSessionTitle(cwd, sessionId, changedTitle, firstUserMessage), + title: getCodexSessionTitle(cwd, sessionId, sessionIndexTitle, changedTitle, firstUserMessage), lastUserMessage, cwd, file: filePath, @@ -499,9 +564,10 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex collectJsonlFiles(root, files) } + const sessionIndexTitles = readCodexSessionIndexTitles() const deduped = new Map() for (const filePath of files) { - const session = parseCodexLocalSession(filePath) + const session = parseCodexLocalSession(filePath, sessionIndexTitles) if (!session) continue const previous = deduped.get(session.id) if (!previous || previous.modifiedAt < session.modifiedAt) { @@ -557,7 +623,7 @@ function convertCodexRecordToImportedMessage(record: Record): C const text = asString(payload.message) ?? asString(payload.text) ?? asString(payload.content) - if (!text || shouldIgnoreSyntheticUserMessage(text)) { + if (!text) { return null } return buildImportedUserMessage(text) @@ -595,11 +661,11 @@ function convertCodexRecordToImportedMessage(record: Record): C if (itemType === 'message') { const role = asString(payload.role) const text = extractCodexText(payload.content) - if (!text || shouldIgnoreSyntheticUserMessage(text)) { + if (!text) { return null } if (role === 'user') { - return buildImportedUserMessage(text) + return shouldIgnoreInjectedResponseUserMessage(text) ? null : buildImportedUserMessage(text) } if (role === 'assistant') { return buildImportedAgentMessage({ type: 'message', message: text, id: randomUUID() }) @@ -639,6 +705,86 @@ function convertCodexRecordToImportedMessage(record: Record): C return null } +function getCodexImportedMessageSource(record: Record): CodexImportedMessageSource | null { + const type = asString(record.type) + return type === 'event_msg' || type === 'response_item' ? type : null +} + +function normalizeComparableUserMessage(content: unknown): string | null { + const record = asRecord(content) + if (!record || record.role !== 'user') { + return null + } + + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + + return stableSerialize({ + role: 'user', + text: body.text.trimEnd() + }) +} + +function normalizeComparableAgentMessage(content: unknown): string | null { + const record = asRecord(content) + if (!record || record.role !== 'agent') { + return null + } + + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + + const data = asRecord(body.data) + if (data?.type !== 'message' || typeof data.message !== 'string') { + return null + } + + return stableSerialize({ + role: 'agent', + type: 'message', + message: data.message + }) +} + +function normalizeAdjacentDuplicateMessage(content: unknown): string | null { + return normalizeComparableUserMessage(content) ?? normalizeComparableAgentMessage(content) +} + +function isAdjacentDuplicateImportedMessage( + previous: CodexImportedMessageContent, + next: CodexImportedMessageContent +): boolean { + const previousKey = normalizeAdjacentDuplicateMessage(previous) + const nextKey = normalizeAdjacentDuplicateMessage(next) + return previousKey !== null && previousKey === nextKey +} + +function isMirroredAdjacentDuplicate( + previous: CodexImportedMessageEntry | undefined, + next: CodexImportedMessageEntry +): boolean { + return Boolean( + previous + && previous.source !== next.source + && isAdjacentDuplicateImportedMessage(previous.message, next.message) + ) +} + +function isResponseItemDuplicateOfEventUserMessage( + entry: CodexImportedMessageEntry, + recentEventUserMessageKey: string | null +): boolean { + if (entry.source !== 'response_item' || recentEventUserMessageKey === null) { + return false + } + + return normalizeComparableUserMessage(entry.message) === recentEventUserMessageKey +} + function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { let content: string try { @@ -648,7 +794,8 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } const lines = content.split(/\r?\n/).filter(Boolean) - const messages: CodexImportedMessageContent[] = [] + const entries: CodexImportedMessageEntry[] = [] + let recentEventUserMessageKey: string | null = null for (const line of lines) { let parsed: unknown @@ -660,15 +807,38 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code const record = asRecord(parsed) if (!record) continue + const source = getCodexImportedMessageSource(record) + if (!source) continue const message = convertCodexRecordToImportedMessage(record) if (message) { - messages.push(message) + const entry = { source, message } + const userMessageKey = normalizeComparableUserMessage(message) + if (source === 'event_msg' && userMessageKey !== null) { + const previous = entries[entries.length - 1] + if (previous?.source === 'response_item' && isMirroredAdjacentDuplicate(previous, entry)) { + entries[entries.length - 1] = entry + } else { + entries.push(entry) + } + recentEventUserMessageKey = userMessageKey + continue + } + if (isResponseItemDuplicateOfEventUserMessage(entry, recentEventUserMessageKey)) { + continue + } + const previous = entries[entries.length - 1] + if (isMirroredAdjacentDuplicate(previous, entry)) { + recentEventUserMessageKey = null + continue + } + entries.push(entry) + recentEventUserMessageKey = null } } return { ...summary, - messages + messages: entries.map((entry) => entry.message) } }