diff --git a/electron/main/index.ts b/electron/main/index.ts index 380dbec..2746856 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -22,6 +22,7 @@ import { } from './session-database' import { cleanupLogPath, ensureLogDirectory, logCleanup, logDirectory, logException } from './diagnostics' import { removeCodexPlugin } from './plugins' +import { readSessionTranscript } from './session-transcript' import { newerReleaseVersion } from './release-update' import { appendAutomationLog, @@ -249,6 +250,15 @@ handle('sessions:leftovers', () => ({ logPath: cleanupLogPath() })) +/** Read-only preview of a scanned conversation; only paths from the latest scan are ever opened. */ +handle('sessions:transcript', (_event, id: string) => { + if (!latestSnapshot) throw new MessageError(message('error.scanFirst')) + const session = latestSnapshot.sessions.find((item) => item.id === id) + if (!session) throw new MessageError(message('error.invalidSelection')) + const generatedImageDirectories = session.assetURLs.filter((url) => dirname(url) === locations.generatedImages) + return readSessionTranscript([...session.segmentURLs, session.fileURL], generatedImageDirectories) +}) + handle('sessions:repairLeftovers', () => { if (codexIsRunning()) throw new MessageError(message('error.codexRunningForRepair')) const report = deleteOrphanSessionRecords(locations.home, stateBackupDirectory()) diff --git a/electron/main/preview.ts b/electron/main/preview.ts index 7c3e955..372b435 100644 --- a/electron/main/preview.ts +++ b/electron/main/preview.ts @@ -20,13 +20,19 @@ function stripRequestPreamble(value: string): string { return value } -export function cleanPreview(text: unknown): string | null { +/** The user's own words with Codex's scaffolding removed, or null for a pure-scaffolding message. */ +export function cleanUserMessage(text: unknown): string | null { if (typeof text !== 'string' || !text) return null - let value = text.trim() - value = stripRequestPreamble(value) + const value = stripRequestPreamble(text.trim()) if (!value || value.startsWith('<')) return null const head = value.slice(0, 400).toLowerCase() if (PREAMBLE_MARKERS.some((item) => head.includes(item))) return null - value = value.replace(/\s+/g, ' ') + return value +} + +export function cleanPreview(text: unknown): string | null { + const cleaned = cleanUserMessage(text) + if (!cleaned) return null + const value = cleaned.replace(/\s+/g, ' ') return value.length > 90 ? `${value.slice(0, 90)}…` : value } \ No newline at end of file diff --git a/electron/main/session-transcript.ts b/electron/main/session-transcript.ts new file mode 100644 index 0000000..3b4c589 --- /dev/null +++ b/electron/main/session-transcript.ts @@ -0,0 +1,196 @@ +import { createReadStream, lstatSync, readdirSync, readFileSync } from 'node:fs' +import { extname, join } from 'node:path' +import { createInterface } from 'node:readline' +import { Readable } from 'node:stream' +import * as zlib from 'node:zlib' +import { cleanUserMessage } from './preview' +import type { GeneratedImagePreview, SessionTranscript, TranscriptMessage } from '../../shared/types' + +/** + * Reads a conversation back out of its rollout segments so it can be previewed before + * it is deleted. Only user and assistant text is kept; tool calls are counted, and + * everything else a rollout carries (reasoning, tool output, context injection) is left + * out. Nothing here writes to disk. + */ + +const MAX_MESSAGES = 400 +const MAX_MESSAGE_CHARS = 8000 +/** A line with a pasted screenshot can run to many megabytes; beyond this it is tool output. */ +const MAX_LINE_CHARS = 64 * 1024 * 1024 +/** Images cross IPC as data URLs, so their total size per preview is bounded. */ +const MAX_IMAGE_CHARS = 48 * 1024 * 1024 +const IMAGE_TAG_RE = /<\/?image\b[^>]*>/gi +const IMAGE_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' } +const TOOL_CALL_TYPES = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']) + +interface SegmentMessages { + items: TranscriptMessage[] + events: TranscriptMessage[] + toolCalls: number + /** Image characters still allowed into `items` / `events`, so a screenshot-heavy rollout is never held whole. */ + itemImageBudget: number + eventImageBudget: number +} +interface MessageContent { text: string | null; images: string[] } + +function objectValue(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} +function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length ? value : null } + +function imageURL(value: unknown): string | null { + const url = stringValue(value) + return url && /^data:image\/[a-z0-9.+-]+;base64,/i.test(url) ? url : null +} + +/** + * Codex stores a pasted image as an `input_image` part holding the whole picture as a + * data URL, framed by `` / `` text parts. The path is a + * temporary clipboard file that is usually gone by now, so the frame text is dropped + * and the embedded data is what gets shown. + */ +function messageContent(content: unknown): MessageContent { + if (typeof content === 'string') return { text: content, images: [] } + if (!Array.isArray(content)) return { text: null, images: [] } + const texts: string[] = [] + const images: string[] = [] + for (const part of content) { + const value = objectValue(part) + if (!value) continue + const image = imageURL(value['image_url']) + if (image) { images.push(image); continue } + const text = stringValue(value['text'])?.replace(IMAGE_TAG_RE, '').trim() + if (text) texts.push(text) + } + return { text: texts.length ? texts.join('\n\n') : null, images } +} + +function message(role: TranscriptMessage['role'], content: MessageContent, timestamp: number | null): TranscriptMessage | null { + const raw = content.text?.replace(IMAGE_TAG_RE, '') ?? null + const text = (role === 'user' ? cleanUserMessage(raw) : raw?.trim() || null) ?? '' + // A message that is only Codex scaffolding is skipped, unless it carries an image. + if (!text && !content.images.length) return null + return { + role, timestamp, images: content.images, omittedImages: 0, + text: text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text + } +} + +function withinBudget(item: TranscriptMessage, budgets: Record, key: K): TranscriptMessage { + const kept = item.images.filter((image) => { + if (image.length > budgets[key]) return false + budgets[key] -= image.length + return true + }) + return { ...item, images: kept, omittedImages: item.omittedImages + item.images.length - kept.length } +} + +/** + * Codex records each turn twice: as a `response_item` message (what the model saw) and + * as an `event_msg` (what the UI showed). Response items are preferred; events are the + * fallback for rollouts that only carry one of them. Very old rollouts have no wrapper, + * so the line itself is the payload. + */ +function parseTranscriptLine(line: string, into: SegmentMessages): void { + if (!line || line.length > MAX_LINE_CHARS) return + let root: Record + try { root = JSON.parse(line) as Record } catch { return } + const payload = objectValue(root['payload']) ?? root + const parsedTime = Date.parse(stringValue(root['timestamp']) ?? '') + const timestamp = Number.isNaN(parsedTime) ? null : parsedTime + const type = payload['type'] + if (root['type'] === 'event_msg') { + if (type === 'user_message') { + const images = Array.isArray(payload['images']) ? payload['images'].flatMap((value) => imageURL(value) ?? []) : [] + const item = message('user', { text: stringValue(payload['message']), images }, timestamp) + if (item) into.events.push(withinBudget(item, into, 'eventImageBudget')) + } else if (type === 'agent_message') { + const item = message('assistant', { text: stringValue(payload['message']), images: [] }, timestamp) + if (item) into.events.push(item) + } + return + } + if (type === 'message' && (payload['role'] === 'user' || payload['role'] === 'assistant')) { + const item = message(payload['role'], messageContent(payload['content']), timestamp) + if (item) into.items.push(withinBudget(item, into, 'itemImageBudget')) + } else if (typeof type === 'string' && TOOL_CALL_TYPES.has(type)) into.toolCalls += 1 +} + +function openSegment(path: string): Readable { + if (!path.endsWith('.zst')) return createReadStream(path, { encoding: 'utf8' }) + const decompress = (zlib as unknown as { zstdDecompressSync?: (input: Buffer) => Buffer }).zstdDecompressSync + if (!decompress) throw new Error('zstd is not available') + return Readable.from([decompress(readFileSync(path)).toString('utf8')]) +} + +async function readSegment(path: string): Promise { + const result: SegmentMessages = { items: [], events: [], toolCalls: 0, itemImageBudget: MAX_IMAGE_CHARS, eventImageBudget: MAX_IMAGE_CHARS } + const lines = createInterface({ input: openSegment(path), crlfDelay: Infinity }) + for await (const line of lines) parseTranscriptLine(line, result) + return result +} + +/** Image files under a thread's generated-images directory, oldest first; symlinks are never followed. */ +function generatedImageFiles(directory: string): Array<{ path: string; name: string; type: string; size: number; modifiedAt: number }> { + const files: Array<{ path: string; name: string; type: string; size: number; modifiedAt: number }> = [] + const stack = [directory] + while (stack.length) { + const current = stack.pop()! + let entries: string[] + try { entries = readdirSync(current) } catch { continue } + for (const name of entries) { + if (name.startsWith('.')) continue + const path = join(current, name) + let stats + try { stats = lstatSync(path) } catch { continue } + if (stats.isDirectory()) { stack.push(path); continue } + const type = IMAGE_TYPES[extname(name).slice(1).toLowerCase()] + if (stats.isFile() && type) files.push({ path, name, type, size: stats.size, modifiedAt: stats.mtimeMs }) + } + } + return files.sort((a, b) => a.modifiedAt - b.modifiedAt || a.name.localeCompare(b.name)) +} + +function readGeneratedImages(directories: string[], budget: { images: number }): { images: GeneratedImagePreview[]; omitted: number } { + const images: GeneratedImagePreview[] = [] + let omitted = 0 + for (const file of directories.flatMap(generatedImageFiles)) { + // Base64 grows a file by a third; check before reading so an oversized file is never loaded. + const encodedLength = Math.ceil(file.size / 3) * 4 + if (encodedLength > budget.images) { omitted += 1; continue } + let data: Buffer + try { data = readFileSync(file.path) } catch { omitted += 1; continue } + const src = `data:${file.type};base64,${data.toString('base64')}` + budget.images -= src.length + images.push({ name: file.name, src, modifiedAt: file.modifiedAt }) + } + return { images, omitted } +} + +/** + * `paths` are the conversation's rollout segments, oldest first; `generatedImageDirectories` + * are the thread's `generated_images` directories the scan attributed to it. + */ +export async function readSessionTranscript(paths: string[], generatedImageDirectories: string[] = []): Promise { + const messages: TranscriptMessage[] = [] + let toolCalls = 0 + let unreadableSegments = 0 + for (const path of paths) { + let segment: SegmentMessages + try { segment = await readSegment(path) } catch { unreadableSegments += 1; continue } + messages.push(...(segment.items.length ? segment.items : segment.events)) + toolCalls += segment.toolCalls + } + const shown = messages.slice(0, MAX_MESSAGES) + const budget = { images: MAX_IMAGE_CHARS } + for (const [index, item] of shown.entries()) shown[index] = withinBudget(item, budget, 'images') + const generated = readGeneratedImages(generatedImageDirectories, budget) + return { + messages: shown, + toolCalls, + truncated: messages.length > MAX_MESSAGES, + unreadableSegments, + generatedImages: generated.images, + omittedGeneratedImages: generated.omitted + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6741bfb..835146b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { ScanSnapshot, ScanProgress, AppInfo, CleanupReport, CleanupProgress, - WorkspaceSnapshot, AutomationSettings, AutomationState, CleanupSelection, CleanupPreview, CleanupRequest + WorkspaceSnapshot, AutomationSettings, AutomationState, CleanupSelection, CleanupPreview, CleanupRequest, SessionTranscript } from '../../shared/types' import type { Language, Message } from '../../shared/messages' @@ -23,6 +23,8 @@ const api = { /** Session rows Codex still lists although their rollout file is gone. */ sessionLeftovers: (): Promise<{ count: number; logPath: string }> => ipcRenderer.invoke('sessions:leftovers'), repairSessionLeftovers: (): Promise<{ threads: number; removedRows: number }> => ipcRenderer.invoke('sessions:repairLeftovers'), + /** The user and assistant messages of one scanned conversation, for previewing before deletion. */ + sessionTranscript: (id: string): Promise => ipcRenderer.invoke('sessions:transcript', id), revealPath: (path: string): Promise => ipcRenderer.invoke('path:reveal', path), openPath: (path: string): Promise => ipcRenderer.invoke('path:open', path), /** This app's own log folder, created on demand so the settings entry can open it. */ diff --git a/shared/types.ts b/shared/types.ts index a288e4a..3eefb0b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -113,6 +113,39 @@ export const SessionTagLabel: Record = { worktree: 'Worktree' } +/** One user or assistant turn read back from a rollout file, for previewing before deletion. */ +export interface TranscriptMessage { + role: 'user' | 'assistant' + text: string + /** `data:image/…` URLs of images embedded in the message, in order. */ + images: string[] + /** Embedded images left out because the preview's image budget was spent. */ + omittedImages: number + /** epoch ms, when the rollout line carried a timestamp */ + timestamp: number | null +} + +export interface SessionTranscript { + messages: TranscriptMessage[] + /** Tool invocations between the messages; counted rather than shown. */ + toolCalls: number + /** The message cap was reached, so later turns are left out. */ + truncated: boolean + /** Segments that could not be read (missing, or compressed without a decompressor). */ + unreadableSegments: number + /** Images Codex generated for this thread (`generated_images//`), oldest first. */ + generatedImages: GeneratedImagePreview[] + /** Generated images left out because the preview's image budget was spent. */ + omittedGeneratedImages: number +} + +export interface GeneratedImagePreview { + name: string + /** `data:image/…` URL of the file's contents. */ + src: string + modifiedAt: number // epoch ms +} + export interface SessionItem { id: string threadID: string diff --git a/src/App.css b/src/App.css index f788485..63f4c59 100644 --- a/src/App.css +++ b/src/App.css @@ -633,7 +633,7 @@ label.entry-label { cursor: pointer; } /* --- session table --- */ .table-head, .session-row { display: grid; - grid-template-columns: 15px minmax(0, 1fr) 72px 116px 88px 92px 30px; + grid-template-columns: 15px minmax(0, 1fr) 72px 116px 88px 92px 60px; gap: 12px; align-items: center; padding: 0 16px; @@ -1131,6 +1131,28 @@ label.entry-label { cursor: pointer; } box-shadow: inset 3px 0 0 var(--danger); } .worktree-session-option:has(input:disabled) { cursor: default; opacity: 0.65; } +.row-actions { display: flex; justify-content: flex-end; } + +/* --- conversation preview --- */ +.transcript-dialog { width: min(720px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); display: flex; flex-direction: column; overflow: hidden; } +.transcript-header h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transcript-body { flex: 1; min-height: 0; margin-top: 14px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-sunken); } +.transcript-list { list-style: none; margin: 0; padding: 6px 14px; } +.transcript-message { padding: 10px 0; border-top: 1px solid var(--line-soft); } +.transcript-message:first-child { border-top: 0; } +.transcript-meta { display: flex; align-items: baseline; gap: 8px; font-size: 11.5px; font-weight: 600; color: var(--text-secondary); } +.transcript-message.role-user .transcript-meta { color: var(--accent); } +.transcript-meta time { font-weight: 400; color: var(--text-tertiary); } +.transcript-message p { margin: 4px 0 0; font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; } +.transcript-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.transcript-images img { max-width: min(100%, 320px); max-height: 240px; object-fit: contain; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); cursor: zoom-in; } +.transcript-generated { margin: 0 14px; padding: 12px 0 14px; border-top: 1px solid var(--line); } +.transcript-body > .transcript-generated:first-child { border-top: 0; } +.transcript-generated h3 { margin: 0; font-size: 11.5px; font-weight: 600; color: var(--text-secondary); } +.transcript-omitted { color: var(--text-tertiary); font-size: 11.5px; } +.image-zoom { position: fixed; inset: 0; z-index: 110; display: grid; place-items: center; padding: 32px; background: rgba(10, 10, 18, 0.72); cursor: zoom-out; } +.image-zoom img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); } +.transcript-notes { margin: 10px 0 0; color: var(--text-tertiary); font-size: 11.5px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } .confirm-dialog { width: min(430px, calc(100vw - 48px)); } .confirm-dialog .notice { margin: 14px 0 0; } @@ -1355,7 +1377,7 @@ label.entry-label { cursor: pointer; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } @media (max-width: 1120px) { - .table-head, .session-row { grid-template-columns: 15px minmax(0, 1fr) 60px 96px 84px 92px 30px; } + .table-head, .session-row { grid-template-columns: 15px minmax(0, 1fr) 60px 96px 84px 92px 60px; } .asset-head, .asset-row { grid-template-columns: 15px minmax(0, 1fr) 88px 48px 96px 76px 30px; } } diff --git a/src/icons.tsx b/src/icons.tsx index a138189..fc7f51d 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -8,6 +8,12 @@ export function FolderIcon() { } +export function PreviewIcon() { + return +} + export function BackIcon() { return
    - {visible.map((session) => selection.toggle(session)} locale={locale} />)} + {visible.map((session) => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)}
{!visible.length &&

{listable.length ? t('没有符合筛选条件的会话', 'No sessions match these filters') @@ -189,10 +191,11 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea cleaning={cleaning} actionsDisabled={actionsDisabled} progress={cleanProgress} onDelete={() => onCleanup({ kind: 'sessions-delete', ids: selectedSessions.map(sessionID) })} /> + {previewing && setPreviewing(null)} />} } -function SessionRow({ session, checked, locale, onToggle }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void }) { +function SessionRow({ session, checked, locale, onToggle, onPreview }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void; onPreview: () => void }) { const { t, m } = usePreferences() return

  • @@ -208,6 +211,83 @@ function SessionRow({ session, checked, locale, onToggle }: { session: SessionIt {formatShortDate(session.modifiedAt, locale)} {formatBytes(session.fileBytes)} {formatBytes(sessionTotalBytes(session))} - + + + +
  • } + +/** Read-only look at a conversation's messages, so it can be recognised before it is deleted. */ +function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; locale: string; onClose: () => void }) { + const { t, e } = usePreferences() + const [transcript, setTranscript] = useState(null) + const [error, setError] = useState(null) + const [zoomed, setZoomed] = useState(null) + + useEffect(() => { + let cancelled = false + window.cleanmycodex.sessionTranscript(session.id) + .then((result) => { if (!cancelled) setTranscript(result) }) + .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) }) + return () => { cancelled = true } + }, [session.id]) + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + if (zoomed) setZoomed(null) + else onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose, zoomed]) + + const notes: string[] = [] + if (transcript?.toolCalls) notes.push(t(`另有 ${transcript.toolCalls} 次工具调用未显示`, `${transcript.toolCalls} tool calls not shown`)) + if (transcript?.truncated) notes.push(t(`只显示前 ${transcript.messages.length} 条消息`, `Showing the first ${transcript.messages.length} messages`)) + if (transcript?.unreadableSegments) notes.push(t(`${transcript.unreadableSegments} 个会话文件无法读取`, `${transcript.unreadableSegments} session files could not be read`)) + if (session.childThreadCount) notes.push(t(`${session.childThreadCount} 个子代理会话未显示`, `${session.childThreadCount} subagent conversations not shown`)) + + return
    { if (event.target === event.currentTarget) onClose() }}> +
    +
    +

    {sessionDisplayName(session)}

    +

    {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')}

    +
    +
    + {error &&

    {e(error)}

    } + {!error && !transcript &&

    {t('正在读取…', 'Loading…')}

    } + {transcript && !transcript.messages.length && !transcript.generatedImages.length &&

    {t('这个会话里没有可显示的消息', 'No messages to show in this conversation')}

    } + {transcript && transcript.messages.length > 0 &&
      + {transcript.messages.map((item, index) =>
    1. + + {item.role === 'user' ? t('你', 'You') : 'Codex'} + {item.timestamp !== null && } + + {item.text &&

      {item.text}

      } + {item.images.length > 0 &&
      + {item.images.map((src, imageIndex) => {t(`图片 setZoomed(src)} />)} +
      } + {item.omittedImages > 0 &&

      {t(`${item.omittedImages} 张图片过大,未在预览中显示`, `${item.omittedImages} images too large to preview`)}

      } +
    2. )} +
    } + {transcript && (transcript.generatedImages.length > 0 || transcript.omittedGeneratedImages > 0) &&
    +

    {t(`生成的图片(${transcript.generatedImages.length + transcript.omittedGeneratedImages})`, `Generated images (${transcript.generatedImages.length + transcript.omittedGeneratedImages})`)}

    +
    + {transcript.generatedImages.map((image) => {image.name} setZoomed(image.src)} />)} +
    + {transcript.omittedGeneratedImages > 0 &&

    {t(`${transcript.omittedGeneratedImages} 张图片过大,未在预览中显示`, `${transcript.omittedGeneratedImages} images too large to preview`)}

    } +
    } +
    + {notes.length > 0 &&

    {notes.join(' · ')}

    } +
    + + +
    +
    + {zoomed &&
    setZoomed(null)}>
    } +
    +} diff --git a/tests/session-transcript.test.ts b/tests/session-transcript.test.ts new file mode 100644 index 0000000..7de6ee3 --- /dev/null +++ b/tests/session-transcript.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readSessionTranscript } from '../electron/main/session-transcript' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +function rollout(lines: unknown[]): string { + const root = mkdtempSync(join(tmpdir(), 'cleanmycodex-transcript-')); roots.push(root) + const path = join(root, `rollout-${roots.length}.jsonl`) + writeFileSync(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n') + return path +} + +const item = (role: string, text: string, timestamp = '2026-08-01T10:00:00Z') => + ({ timestamp, type: 'response_item', payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] } }) + +describe('session transcript', () => { + it('keeps user and assistant messages, strips scaffolding and counts tool calls', async () => { + const path = rollout([ + { type: 'session_meta', payload: { id: 'x', cwd: '/work' } }, + item('developer', 'system rules'), + item('user', 'cwd'), + item('user', '# Files mentioned by the user:\n- a.ts\n## My request:\n修复登录 bug'), + { type: 'event_msg', payload: { type: 'user_message', message: '修复登录 bug' } }, + { type: 'response_item', payload: { type: 'reasoning', summary: [] } }, + { type: 'response_item', payload: { type: 'function_call', name: 'shell', arguments: '{}' } }, + item('assistant', '已经修好了。'), + { type: 'event_msg', payload: { type: 'agent_message', message: '已经修好了。' } } + ]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages.map(({ role, text }) => [role, text])).toEqual([['user', '修复登录 bug'], ['assistant', '已经修好了。']]) + expect(transcript.messages[0].timestamp).toBe(Date.parse('2026-08-01T10:00:00Z')) + expect(transcript.toolCalls).toBe(1) + expect(transcript.truncated).toBe(false) + }) + + it('falls back to UI events and reads segments in order, counting unreadable ones', async () => { + const first = rollout([{ type: 'event_msg', payload: { type: 'user_message', message: 'first' } }]) + const second = rollout([item('user', 'second')]) + const transcript = await readSessionTranscript([first, join(tmpdir(), 'missing-rollout.jsonl'), second]) + expect(transcript.messages.map((message) => message.text)).toEqual(['first', 'second']) + expect(transcript.unreadableSegments).toBe(1) + }) + + it('shows embedded images in place of the clipboard placeholder text', async () => { + const png = 'data:image/png;base64,iVBORw0KGgo=' + const path = rollout([ + { type: 'response_item', payload: { type: 'message', role: 'user', content: [ + { type: 'input_text', text: '底色是不是不太匹配?' }, + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: png }, + { type: 'input_text', text: '' } + ] } }, + { type: 'response_item', payload: { type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: png }, + { type: 'input_image', image_url: 'https://example.com/remote.png' }, + { type: 'input_text', text: '' } + ] } } + ]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages.map(({ text, images }) => [text, images])).toEqual([ + ['底色是不是不太匹配?', [png]], + ['', [png]] + ]) + }) + + it('keeps images from UI events when a rollout has no response items', async () => { + const png = 'data:image/jpeg;base64,/9j/4AAQ' + const path = rollout([{ type: 'event_msg', payload: { type: 'user_message', message: 'look', images: [png] } }]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages[0].images).toEqual([png]) + }) + + it('reads generated images oldest first and skips non-images and symlinks', async () => { + const path = rollout([item('user', '画一只猫')]) + const directory = join(roots[0], 'generated_images', 'thread') + mkdirSync(join(directory, 'nested'), { recursive: true }) + writeFileSync(join(directory, 'b.png'), Buffer.from([1, 2, 3])) + writeFileSync(join(directory, 'nested', 'a.webp'), Buffer.from([4])) + writeFileSync(join(directory, 'notes.txt'), 'not an image') + symlinkSync(join(directory, 'b.png'), join(directory, 'link.png')) + utimesSync(join(directory, 'nested', 'a.webp'), new Date('2026-01-01'), new Date('2026-01-01')) + const transcript = await readSessionTranscript([path], [directory, join(roots[0], 'missing')]) + expect(transcript.generatedImages.map(({ name, src }) => [name, src])).toEqual([ + ['a.webp', 'data:image/webp;base64,BA=='], + ['b.png', 'data:image/png;base64,AQID'] + ]) + expect(transcript.omittedGeneratedImages).toBe(0) + }) + + it('caps long conversations', async () => { + const path = rollout(Array.from({ length: 450 }, (_, index) => item('user', `message ${index}`))) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages).toHaveLength(400) + expect(transcript.truncated).toBe(true) + }) +})