diff --git a/src/codex-transcript.ts b/src/codex-transcript.ts new file mode 100644 index 00000000..fc9251c3 --- /dev/null +++ b/src/codex-transcript.ts @@ -0,0 +1,338 @@ +/** + * @fileoverview Scan `~/.codex/sessions///
/rollout-*.jsonl` for Past + * Sessions rows, the codex analog of what `scanOmpSessionsHistory()` + * (omp-transcript.ts) does for omp and `scanProjectDir()` (session-routes.ts) + * does for Claude's own `~/.claude/projects` transcripts. + * + * Without this a codex conversation is invisible to Codeman the moment its + * session record goes away, even though codex itself never forgot it: the + * unified list is built from `~/.claude/projects` plus omp's own store, and + * codex writes to neither. A user who wanted to pick a codex thread back up had + * to find its id by hand and pass `codexConfig.resumeSessionId` to the API. + * + * ## Why this reads windows rather than whole files + * + * An omp session file is the conversation only, so its scanner reads each file + * whole. A codex rollout is not comparable: it carries every reasoning block and + * every tool call, and its `session_meta` line alone embeds the full base + * instructions. Measured on a real store of 519 rollouts, the median file is + * 407 KiB, the 90th percentile 1.3 MiB and the largest 25 MiB, for 381 MiB in + * total. So this reads a head window for the identity and the opening prompt, + * and a tail window for the most recent one. + * + * The head budget is 128 KiB because `session_meta` runs to roughly 19 KiB and + * the first real user message lands near 69 KiB behind it, both measured on + * codex 0.152.1. + * + * ## Where the prompt text comes from + * + * Codex has emitted user input under three shapes, and this reads all of them, + * preferring the ones that carry real input only: + * + * - `event_msg` / `item_completed` with an `item.type` of `UserMessage`, which + * is what codex 0.152.1 writes. + * - `event_msg` / `user_message`, which older versions wrote. + * - `response_item` rows with `role: 'user'`, the last resort. These mix real + * input with injected context (AGENTS.md, environment context, compaction + * summaries), so they are read only when neither shape above appears, and + * the obvious injections are dropped. + * + * @module codex-transcript + */ + +import { open, readdir, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; + +/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */ +const HEAD_BYTES = 131072; + +/** Enough to hold the last few turns' worth of lines without re-reading the file. */ +const TAIL_BYTES = 65536; + +/** + * Newest rollouts to report. A store this size is already far more than any list + * shows, and the cap keeps one enormous `~/.codex` from stalling a request. + */ +const MAX_ROLLOUTS = 400; + +/** + * How many of those also get a tail read for `lastPrompt`. The head read is + * cached forever (see below) but the tail cannot be, because appending to a + * rollout is exactly what changes it, so this is the one genuinely per-request + * cost and it stays bounded. + */ +const MAX_TAIL_READS = 100; + +/** Directory nesting under `sessions/` is year/month/day; stop well past that. */ +const MAX_WALK_DEPTH = 5; + +/** A rollout shorter than this cannot hold a complete `session_meta` line. */ +const MIN_ROLLOUT_BYTES = 100; + +export interface CodexHistorySession { + /** The rollout's own thread id — the token `codex resume ` expects. */ + sessionId: string; + workingDir: string; + sizeBytes: number; + /** ISO timestamp, from the file's own mtime. */ + lastModified: string; + firstPrompt?: string; + lastPrompt?: string; +} + +/** The half of a rollout that never changes once codex has written it. */ +interface RolloutIdentity { + threadId?: string; + cwd?: string; + /** `'subagent'` marks a thread codex spawned for itself. */ + threadSource?: string; + firstPrompt?: string; +} + +/** + * `session_meta` is written once and never rewritten — the same fact + * `readCodexRolloutMetaCached()` in session-routes.ts relies on — and the first + * user message cannot change either. So a path's identity is cached for the life + * of the process, and a rescan costs a `stat` per file plus head reads for + * rollouts this process has not seen before. + */ +const identityCache = new Map(); + +function codexSessionsRoot(): string { + const home = process.env.CODEX_HOME || join(homedir(), '.codex'); + return join(home, 'sessions'); +} + +/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */ +async function readHead(path: string, bytes: number): Promise { + const fh = await open(path, 'r').catch(() => null); + if (!fh) return ''; + try { + const buf = Buffer.alloc(bytes); + const { bytesRead } = await fh.read(buf, 0, bytes, 0); + return buf.subarray(0, bytesRead).toString('utf-8'); + } catch { + return ''; + } finally { + await fh.close().catch(() => {}); + } +} + +/** + * Read at most `bytes` from the end of a file, dropping the leading partial + * line so every line handed back parses. + */ +async function readTail(path: string, size: number, bytes: number): Promise { + const fh = await open(path, 'r').catch(() => null); + if (!fh) return ''; + try { + const want = Math.min(bytes, size); + const buf = Buffer.alloc(want); + const { bytesRead } = await fh.read(buf, 0, want, size - want); + const text = buf.subarray(0, bytesRead).toString('utf-8'); + if (want >= size) return text; // whole file, nothing was cut + const nl = text.indexOf('\n'); + return nl === -1 ? '' : text.slice(nl + 1); + } catch { + return ''; + } finally { + await fh.close().catch(() => {}); + } +} + +/** Flatten codex's message content, which is a string or an array of text blocks. */ +function contentText(content: unknown): string { + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return ''; + return content + .filter( + (b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' + ) + .map((b) => b.text) + .join('\n') + .trim(); +} + +/** One line's user-prompt text, whichever of the three shapes it is. */ +function userPromptFromLine(entry: { + type?: string; + payload?: { + type?: string; + role?: string; + content?: unknown; + message?: unknown; + item?: { type?: string; content?: unknown }; + }; +}): { text: string; injectionProne: boolean } | null { + const p = entry.payload; + if (!p) return null; + + if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') { + const text = contentText(p.item.content); + return text ? { text, injectionProne: false } : null; + } + if (entry.type === 'event_msg' && p.type === 'user_message') { + const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message); + return text ? { text, injectionProne: false } : null; + } + if (entry.type === 'response_item' && p.role === 'user') { + const text = contentText(p.content); + return text ? { text, injectionProne: true } : null; + } + return null; +} + +/** + * Injected context rather than something the user typed. Codex prepends the + * repository's AGENTS.md and wraps environment context in a tag, and both arrive + * as `response_item` user rows. + */ +function isInjectedContext(text: string): boolean { + return text.startsWith('#') || text.startsWith('<'); +} + +/** Collapse to one line and cap, so a row carries a title rather than an essay. */ +function asPreview(text: string): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat; +} + +/** Parse a head window into the facts about a rollout that never change. */ +function parseIdentity(head: string): RolloutIdentity { + const out: RolloutIdentity = {}; + let fallback: string | undefined; + for (const line of head.split('\n')) { + if (!line) continue; + let entry: { + type?: string; + payload?: { + id?: string; + session_id?: string; + cwd?: string; + thread_source?: string; + type?: string; + role?: string; + content?: unknown; + message?: unknown; + item?: { type?: string; content?: unknown }; + }; + }; + try { + entry = JSON.parse(line); + } catch { + continue; // truncated tail of the window, or a malformed line + } + const p = entry.payload; + if (entry.type === 'session_meta' && p) { + out.threadId ??= p.id || p.session_id; + out.cwd ??= p.cwd; + out.threadSource ??= p.thread_source; + } else if (entry.type === 'turn_context' && p) { + out.cwd ??= p.cwd; + } + if (out.firstPrompt) continue; + const prompt = userPromptFromLine(entry); + if (!prompt) continue; + if (!prompt.injectionProne) { + out.firstPrompt = asPreview(prompt.text); + } else if (!fallback && !isInjectedContext(prompt.text)) { + fallback = asPreview(prompt.text); + } + } + out.firstPrompt ??= fallback; + return out; +} + +/** The most recent user prompt in a tail window, or undefined. */ +function parseLastPrompt(tail: string): string | undefined { + let best: string | undefined; + let fallback: string | undefined; + for (const line of tail.split('\n')) { + if (!line) continue; + try { + const prompt = userPromptFromLine(JSON.parse(line)); + if (!prompt) continue; + if (!prompt.injectionProne) best = asPreview(prompt.text); + else if (!isInjectedContext(prompt.text)) fallback = asPreview(prompt.text); + } catch { + // Malformed line — keep scanning. + } + } + return best ?? fallback; +} + +/** Every rollout file under `sessions/`, newest first. */ +async function listRollouts(root: string): Promise> { + const files: Array<{ path: string; mtimeMs: number; size: number }> = []; + const walk = async (dir: string, depth: number): Promise => { + if (depth > MAX_WALK_DEPTH) return; + const entries = await readdir(dir, { withFileTypes: true }).catch(() => null); + if (!entries) return; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full, depth + 1); + continue; + } + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + const st = await stat(full).catch(() => null); + if (!st || st.size < MIN_ROLLOUT_BYTES) continue; + files.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } + }; + await walk(root, 0); + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + return files; +} + +/** + * Codex conversations on this host, newest first, for the unified session list. + * + * Sub-agent threads are left out: codex spawns them for itself, they are not + * something a person picks back up, and on a real store they outnumber the + * threads that are. + */ +export async function scanCodexSessionsHistory(): Promise { + const files = (await listRollouts(codexSessionsRoot())).slice(0, MAX_ROLLOUTS); + const out: CodexHistorySession[] = []; + + for (const [index, file] of files.entries()) { + let identity = identityCache.get(file.path); + if (!identity) { + identity = parseIdentity(await readHead(file.path, HEAD_BYTES)); + // A rollout still being created may not have flushed session_meta yet; + // caching that would pin an empty identity for the life of the process. + if (identity.threadId) identityCache.set(file.path, identity); + } + if (!identity.threadId || identity.threadSource === 'subagent') continue; + + // The filename ends in the thread id, so a rollout whose head window was too + // small to reach session_meta still yields an id worth resuming. + const fromName = basename(file.path) + .replace(/\.jsonl$/, '') + .split('-') + .slice(-5) + .join('-'); + const sessionId = identity.threadId || fromName; + + const lastPrompt = + index < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined; + + out.push({ + sessionId, + workingDir: identity.cwd || '', + sizeBytes: file.size, + lastModified: new Date(file.mtimeMs).toISOString(), + firstPrompt: identity.firstPrompt, + lastPrompt: lastPrompt ?? identity.firstPrompt, + }); + } + + return out; +} + +/** Test seam: drop the per-path identity cache. */ +export function __clearCodexIdentityCache(): void { + identityCache.clear(); +} diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 209d4a59..6af01801 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -39,6 +39,11 @@ export type UnifiedSessionItem = { /** Main repo root a worktree belongs to (#266). */ worktreeRepo?: string; remote?: boolean; + /** + * Token this row's CLI resumes by, when that is not `sessionId`. Set only from + * a transcript scanner — see the field of the same name on `HistoryInput`. + */ + resumeId?: string; /** Pinned to the top of the session manager list (COD-139). */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group desc. */ @@ -100,12 +105,21 @@ export type HistoryInput = { worktreeName?: string; worktreeRepo?: string; /** - * Set only by a non-claude transcript source (currently omp); the Claude - * scanner never stamps this; the meaningfulness floor below still counts a - * row with a `mode` as real, since that also signals "not claude" — see - * where it's read below for the isReal check this touches. + * Set only by a non-claude transcript source (currently omp and codex); the + * Claude scanner never stamps this; the meaningfulness floor below still + * counts a row with a `mode` as real, since that also signals "not claude" — + * see where it's read below for the isReal check this touches. */ mode?: string; + /** + * The token this CLI's own resume command expects, when it is NOT the row's + * `sessionId`. Codex names a thread by an id of its own that lives in the + * rollout, and a live codex session's `sessionId` is Codeman's uuid instead — + * so a resume that reused `sessionId` would ask codex for a thread that does + * not exist. Only a transcript scanner sets this, which is what keeps the two + * kinds of row apart. + */ + resumeId?: string; }; /** Mux process-stat view. */ @@ -186,6 +200,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte // transcript source (currently only omp) does, so a history-only row // still gets a mode badge instead of reading as claude by default. overwrite(item, 'mode', h.mode); + overwrite(item, 'resumeId', h.resumeId); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index df647ae9..923fb73c 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -389,7 +389,13 @@ Object.assign(CodemanApp.prototype, { async resumeMobileOverviewSession(sessionId) { const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId); if (!row || !row.workingDir) return; - await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode); + await this.resumeHistorySession( + row.claudeSessionId || row.id, + row.workingDir, + row.name || undefined, + row.mode, + row.resumeId + ); }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 66197c27..c035272a 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -670,7 +670,13 @@ Object.assign(CodemanApp.prototype, { } else if (record.workingDir) { // History rows are keyed by the Claude conversation UUID; resumed // sessions carry theirs separately as claudeSessionId. - void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode); + void this.resumeHistorySession( + s.claudeSessionId || s.sessionId, + record.workingDir, + undefined, + s.mode, + s.resumeId + ); } }, }); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index b4d18479..aa9535a4 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -692,7 +692,7 @@ Object.assign(CodemanApp.prototype, { btn.append(...parts); btn.addEventListener('click', (e) => { e.stopPropagation(); - this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode); + this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId); }); container.appendChild(btn); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index bb83cc5a..aa2cbad0 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, { if (isLive && this.sessions.has(s.sessionId)) { this.selectSession(s.sessionId); } else { - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId); } }) ); @@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, { } else { // Resume by the Claude conversation UUID when present (resumed sessions // carry theirs separately from their Codeman id). - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId); } this.closeSessionManager?.(); closeMenu(); @@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, { return `w${startNumber}-${dirName}`; }, - async resumeHistorySession(sessionId, workingDir, existingName, mode) { + async resumeHistorySession(sessionId, workingDir, existingName, mode, resumeId) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); // Close folder history modal if open @@ -2942,19 +2942,27 @@ Object.assign(CodemanApp.prototype, { grok: 'grokConfig', omp: 'ompConfig', }[effectiveMode]; - // codex/gemini/antigravity have no wired continuation here yet (their - // configs use an exact conversation id, not a "continue most recent" - // flag, and the row's own `sessionId` is not verified to carry that - // id for these three modes) — `continuesSomething` below is what keeps - // their row from being retired for a resume that didn't actually - // continue anything. + // codex names a thread by an id of its own, not by Codeman's session id, + // so it continues only when the row carried that id: `resumeId` is set by + // the rollout scanner (codex-transcript.ts) and by nothing else, which is + // what stops a LIVE codex row — whose sessionId is Codeman's uuid — from + // asking codex for a thread that does not exist. + // + // gemini/antigravity still have no wired continuation here (same reason + // codex used to have none: an exact conversation id nothing supplies) — + // `continuesSomething` below is what keeps their row from being retired + // for a resume that didn't actually continue anything. + const codexResumeId = effectiveMode === 'codex' ? resumeId : undefined; const modeConfig = modeConfigKey ? { [modeConfigKey]: { continueSession: true } } : effectiveMode === 'deepseek' ? { deepSeekConfig: { resumeSession: true } } - : {}; - const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek'; + : codexResumeId + ? { codexConfig: { resumeSessionId: codexResumeId } } + : {}; + const continuesSomething = + Boolean(modeConfigKey) || effectiveMode === 'deepseek' || Boolean(codexResumeId); const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 8ef5ccaa..d3e37eec 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -145,6 +145,7 @@ import { import { LRUMap } from '../../utils/lru-map.js'; import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; import { scanOmpSessionsHistory } from '../../omp-transcript.js'; +import { scanCodexSessionsHistory } from '../../codex-transcript.js'; import { getLastTranscriptResponse, isExternalCliTranscriptMode, @@ -4137,6 +4138,28 @@ export function registerSessionRoutes( // Best-effort, same as the claude scan above. } + // Codex's own rollout store (~/.codex/sessions) — the same treatment omp + // gets above, and for the same reason: codex writes no Claude transcript, so + // without this a codex conversation disappears from the list as soon as its + // session record does. `resumeId` is the rollout's own thread id, which is + // what `codex resume` takes; see codex-transcript.ts. + try { + for (const h of await scanCodexSessionsHistory()) { + history.push({ + sessionId: h.sessionId, + workingDir: h.workingDir, + sizeBytes: h.sizeBytes, + lastModified: h.lastModified, + firstPrompt: h.firstPrompt, + lastPrompt: h.lastPrompt, + mode: 'codex', + resumeId: h.sessionId, + }); + } + } catch { + // Best-effort, same as the two scans above. + } + // Mux process stats (best-effort; guard against mocks lacking the method). let mux: MuxStatInput[] = []; try { diff --git a/test/codex-transcript.test.ts b/test/codex-transcript.test.ts new file mode 100644 index 00000000..ca4584ad --- /dev/null +++ b/test/codex-transcript.test.ts @@ -0,0 +1,208 @@ +/** + * Reading codex's own rollout store for Past Sessions rows. + * + * Three of these assertions exist because the obvious implementation was + * measured to be wrong against real files (codex CLI 0.152.1): + * + * - codex 0.152.1 emits NO `event_msg`/`user_message` rows at all. It writes + * `event_msg`/`item_completed` carrying an `item.type` of `UserMessage` + * instead, so a scanner that knew only the older shape found a prompt for + * July rollouts and nothing for September ones. + * - the `response_item` fallback sees codex's injected context, and on a real + * store the FIRST such row is the repository's AGENTS.md every time. Taking + * it literally titled every row with the same instructions block. + * - codex spawns sub-agent threads into the same store, stamped + * `thread_source: 'subagent'`. On the store this was built against they + * outnumbered the threads a person can actually resume. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { scanCodexSessionsHistory, __clearCodexIdentityCache } from '../src/codex-transcript.js'; + +let home: string; +let prevCodexHome: string | undefined; + +/** A rollout's opening line, as codex writes it. */ +const sessionMeta = (opts: { id: string; cwd: string; threadSource?: string }) => + JSON.stringify({ + timestamp: '2026-09-02T07:05:37.421Z', + type: 'session_meta', + payload: { + id: opts.id, + session_id: opts.id, + cwd: opts.cwd, + originator: 'codex-tui', + ...(opts.threadSource ? { thread_source: opts.threadSource } : {}), + // The real thing embeds full base instructions here; padded so the file + // clears the size floor and exercises the head window. + base_instructions: { text: 'x'.repeat(500) }, + }, + }); + +/** codex 0.152.1's user-input row. */ +const itemCompletedUser = (text: string) => + JSON.stringify({ + type: 'event_msg', + payload: { type: 'item_completed', item: { type: 'UserMessage', id: 'i1', content: [{ type: 'text', text }] } }, + }); + +/** The shape older codex versions wrote. */ +const legacyUserMessage = (text: string) => + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } }); + +/** The last-resort shape, which also carries codex's injected context. */ +const responseItemUser = (text: string) => + JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'text', text }] } }); + +async function writeRollout(id: string, lines: string[], mtime?: Date): Promise { + const dir = join(home, 'sessions', '2026', '09', '02'); + await mkdir(dir, { recursive: true }); + const path = join(dir, `rollout-2026-09-02T09-05-37-${id}.jsonl`); + await writeFile(path, lines.join('\n') + '\n', 'utf-8'); + if (mtime) await utimes(path, mtime, mtime); + return path; +} + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codex-transcript-')); + prevCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + __clearCodexIdentityCache(); +}); + +afterEach(async () => { + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + await rm(home, { recursive: true, force: true }); +}); + +describe('scanCodexSessionsHistory', () => { + it('returns nothing when the store does not exist', async () => { + process.env.CODEX_HOME = join(home, 'nope'); + expect(await scanCodexSessionsHistory()).toEqual([]); + }); + + it('reads the thread id, working directory and opening prompt', async () => { + await writeRollout('01a060f0-0361-7f91-abde-b283020db0d7', [ + sessionMeta({ id: '01a060f0-0361-7f91-abde-b283020db0d7', cwd: '/repo/one' }), + itemCompletedUser('Continue the audit log architecture'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows).toHaveLength(1); + expect(rows[0].sessionId).toBe('01a060f0-0361-7f91-abde-b283020db0d7'); + expect(rows[0].workingDir).toBe('/repo/one'); + expect(rows[0].firstPrompt).toBe('Continue the audit log architecture'); + expect(rows[0].sizeBytes).toBeGreaterThan(0); + }); + + it('still reads the prompt shape older codex versions wrote', async () => { + await writeRollout('11111111-1111-7111-8111-111111111111', [ + sessionMeta({ id: '11111111-1111-7111-8111-111111111111', cwd: '/repo/two' }), + legacyUserMessage('$pr-review-comment-fixer 349'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).toBe('$pr-review-comment-fixer 349'); + }); + + it('skips injected context when only the fallback shape is present', async () => { + await writeRollout('22222222-2222-7222-8222-222222222222', [ + sessionMeta({ id: '22222222-2222-7222-8222-222222222222', cwd: '/repo/three' }), + responseItemUser('# AGENTS.md instructions for /repo/three\n ...'), + responseItemUser('cwd=/repo/three'), + responseItemUser('Replace PanicOnError with Require().NoError'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).toBe('Replace PanicOnError with Require().NoError'); + }); + + it('prefers a real user row over the injection-prone fallback', async () => { + await writeRollout('33333333-3333-7333-8333-333333333333', [ + sessionMeta({ id: '33333333-3333-7333-8333-333333333333', cwd: '/repo/four' }), + responseItemUser('Some earlier response_item row'), + itemCompletedUser('The prompt the user actually typed'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).toBe('The prompt the user actually typed'); + }); + + it('leaves out sub-agent threads, which nobody resumes', async () => { + await writeRollout('44444444-4444-7444-8444-444444444444', [ + sessionMeta({ id: '44444444-4444-7444-8444-444444444444', cwd: '/repo/five' }), + itemCompletedUser('a real conversation'), + ]); + await writeRollout('55555555-5555-7555-8555-555555555555', [ + sessionMeta({ id: '55555555-5555-7555-8555-555555555555', cwd: '/repo/five', threadSource: 'subagent' }), + itemCompletedUser('work codex gave itself'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows.map((r) => r.sessionId)).toEqual(['44444444-4444-7444-8444-444444444444']); + }); + + it('reports the most recent prompt as well as the first', async () => { + await writeRollout('66666666-6666-7666-8666-666666666666', [ + sessionMeta({ id: '66666666-6666-7666-8666-666666666666', cwd: '/repo/six' }), + itemCompletedUser('the opening question'), + itemCompletedUser('a follow-up'), + itemCompletedUser('the latest thing asked'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).toBe('the opening question'); + expect(rows[0].lastPrompt).toBe('the latest thing asked'); + }); + + it('orders rows newest first', async () => { + await writeRollout( + '77777777-7777-7777-8777-777777777777', + [sessionMeta({ id: '77777777-7777-7777-8777-777777777777', cwd: '/repo/old' }), itemCompletedUser('older')], + new Date('2026-08-01T00:00:00Z') + ); + await writeRollout( + '88888888-8888-7888-8888-888888888888', + [sessionMeta({ id: '88888888-8888-7888-8888-888888888888', cwd: '/repo/new' }), itemCompletedUser('newer')], + new Date('2026-09-05T00:00:00Z') + ); + + const rows = await scanCodexSessionsHistory(); + expect(rows.map((r) => r.workingDir)).toEqual(['/repo/new', '/repo/old']); + }); + + it('ignores a file too short to hold a session_meta line', async () => { + const dir = join(home, 'sessions', '2026', '09', '02'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'rollout-2026-09-02T09-05-37-short.jsonl'), '{}\n', 'utf-8'); + + expect(await scanCodexSessionsHistory()).toEqual([]); + }); + + it('survives a rollout whose lines are malformed', async () => { + await writeRollout('99999999-9999-7999-8999-999999999999', [ + sessionMeta({ id: '99999999-9999-7999-8999-999999999999', cwd: '/repo/seven' }), + '{not json at all', + itemCompletedUser('still found me'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).toBe('still found me'); + }); + + it('collapses a long prompt to a single capped line', async () => { + await writeRollout('aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', [ + sessionMeta({ id: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', cwd: '/repo/eight' }), + itemCompletedUser('line one\nline two\n' + 'y'.repeat(500)), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].firstPrompt).not.toContain('\n'); + expect(rows[0].firstPrompt!.length).toBeLessThanOrEqual(201); + expect(rows[0].firstPrompt!.endsWith('…')).toBe(true); + }); +}); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index 263d9b6a..1eabef11 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -397,11 +397,13 @@ describe('Session Manager unified list', () => { expect(app.selectSession).toHaveBeenCalledWith('sess-alpha'); expect(app.resumeHistorySession).not.toHaveBeenCalled(); - // History row → resume by conversation UUID. + // History row → resume by conversation UUID. The trailing `resumeId` is the + // CLI's own thread token, which only a non-claude transcript scanner sets; + // a Claude row carries none, so it arrives undefined here. const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1]; expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' }); historyOptions.onActivate(); - expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined); + expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined); }); it('surfaces an error message instead of an empty list when the endpoint fails', async () => { diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 539af732..a9f97573 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -14,6 +14,30 @@ import { } from '../../src/services/unified-session-service.js'; describe('mergeUnifiedSessions', () => { + it("carries a transcript row's own resume token, and stamps none on a live row", () => { + // codex names a thread by an id in its rollout, not by Codeman's session id. + // The scanner sets `resumeId`; a live session never does, which is what stops + // a resume from asking codex for a thread whose id is really Codeman's uuid. + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex' }], + history: [ + { + sessionId: 'codex-thread-id', + workingDir: '/w', + sizeBytes: 4000, + lastModified: '2026-09-02T00:00:00.000Z', + mode: 'codex', + resumeId: 'codex-thread-id', + }, + ], + }); + const fromTranscript = merged.find((m) => m.sessionId === 'codex-thread-id'); + const fromLive = merged.find((m) => m.sessionId === 'codeman-uuid'); + expect(fromTranscript?.resumeId).toBe('codex-thread-id'); + expect(fromTranscript?.mode).toBe('codex'); + expect(fromLive?.resumeId).toBeUndefined(); + }); + it('dedupes the same sessionId across live + persisted into one item', () => { const merged = mergeUnifiedSessions({ live: [{ id: 's1', status: 'working', isWorking: true }],