diff --git a/src/codex-transcript.ts b/src/codex-transcript.ts new file mode 100644 index 00000000..9f360ee3 --- /dev/null +++ b/src/codex-transcript.ts @@ -0,0 +1,393 @@ +/** + * @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 { join } from 'node:path'; + +import { LRUMap } from './utils/lru-map.js'; + +/** 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. Counted in emitted rows, not files scanned: the + * store is mostly sub-agent threads this never returns, so capping files first + * would spend the budget on rows nobody sees. + */ +const MAX_ROLLOUTS = 400; + +/** + * How many emitted rows also get a tail read for `lastPrompt`. The head read is + * cached (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. Counted in emitted rows for the same reason as above — against + * file index a store of sub-agent threads spends the whole budget before the + * first row that needed it. + */ +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; + /** + * `session_meta.originator`, which codex stamps from + * CODEX_INTERNAL_ORIGINATOR_OVERRIDE — `codeman_` for every pane + * Codeman spawns. The only link between a FRESH codex pane and the rollout it + * is writing, since such a pane knows no thread id of its own. + */ + originator?: 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; + /** `codeman_` for a pane Codeman spawned; codex's own default otherwise. */ + originator?: string; + firstPrompt?: string; +} + +/** + * `session_meta` is written once and never rewritten — the same fact + * `readCodexRolloutMetaCached()` in session-routes.ts relies on — so a path's + * identity is cached, and a rescan costs a `stat` per file plus head reads for + * rollouts this process has not seen before. + * + * ⚠️ The first user message is NOT written up front: codex writes it when the + * user submits. Caching before then pins `firstPrompt: undefined` for the life + * of the process, and every scan of the home screen, the command palette and the + * search-index refresh can land in that window — so the row reads as having no + * prompt until a restart. `shouldCacheIdentity()` is the guard. + * + * Bounded, unlike a plain Map: this process runs for days and every sub-agent + * rollout adds an entry. Same reason and same size as `codexRolloutMetaCache`. + */ +const identityCache = new LRUMap({ maxSize: 4096 }); + +/** + * Is this identity settled enough to keep? + * + * A known `firstPrompt` settles it. So does a head read that FILLED its window, + * which means the prompt is genuinely not in the first `HEAD_BYTES` rather than + * not written yet. A short file with no prompt is the ambiguous case — codex is + * still to write one — so that one is re-read next scan. + */ +function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boolean { + if (!identity.threadId) return false; + return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES; +} + +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; + originator?: 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; + out.originator ??= p.originator; + } 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()); + const out: CodexHistorySession[] = []; + + for (const file of files) { + if (out.length >= MAX_ROLLOUTS) break; + + let identity = identityCache.get(file.path); + if (!identity) { + identity = parseIdentity(await readHead(file.path, HEAD_BYTES)); + if (shouldCacheIdentity(identity, file.size)) identityCache.set(file.path, identity); + } + if (!identity.threadId || identity.threadSource === 'subagent') continue; + // A row with no directory has nowhere to resume INTO, and emitting an empty + // one makes a click post `workingDir: ''`. omp drops such a row; so does this. + if (!identity.cwd) continue; + + const lastPrompt = + out.length < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined; + + out.push({ + sessionId: identity.threadId, + originator: identity.originator, + workingDir: identity.cwd, + sizeBytes: file.size, + lastModified: new Date(file.mtimeMs).toISOString(), + firstPrompt: identity.firstPrompt, + lastPrompt: lastPrompt ?? identity.firstPrompt, + }); + } + + return out; +} + +/** + * Which codex thread each Codeman-spawned pane is writing, keyed by Codeman + * session id. + * + * Codeman spawns every codex pane with + * CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_, and codex stamps that + * into `session_meta.originator`. That is the ONLY link between a fresh codex + * pane and the rollout it is writing: such a pane knows no thread id of its own, + * so it cannot be folded into its own Past-Sessions row from its own side. + * + * Newest wins. `/new` typed inside the codex TUI leaves several rollouts sharing + * one originator, and the pane is on the most recent — so this expects `rows` + * newest-first, as `scanCodexSessionsHistory()` returns them. + */ +export function codexThreadBySessionId(rows: CodexHistorySession[]): Map { + const out = new Map(); + for (const row of rows) { + const owner = /^codeman_(.+)$/.exec(row.originator ?? '')?.[1]; + if (owner && !out.has(owner)) out.set(owner, row.sessionId); + } + 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..9bd447b3 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -2,12 +2,16 @@ * @fileoverview Pure merge/filter logic for the unified session list (COD-121). * * Combines four read-only views of a session — live (in-memory `Session`), - * persisted (`state.json`), transcript history (`~/.claude/projects`), and the - * lifecycle audit log — plus mux process stats, into one de-duplicated list - * keyed by sessionId. Transcript-history rows are keyed by the Claude - * conversation UUID (the `.jsonl` filename stem), which diverges from the - * Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id, - * built from the live/persisted views) folds them into the owning session item. + * persisted (`state.json`), transcript history, and the lifecycle audit log — + * plus mux process stats, into one de-duplicated list keyed by sessionId. + * + * Transcript history is not one source but three, because the CLIs keep their + * conversations in their own stores: Claude's `~/.claude/projects`, omp's + * `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Each row is keyed by + * whatever id that CLI names the conversation with, which diverges from the + * Codeman id for a resumed session and for every non-Claude one — an alias map + * (claudeSessionId → Codeman id, built from the live/persisted views) folds them + * into the owning session item. * Higher-precedence sources overwrite scalar fields when present * (history < lifecycle < persisted < live), while the `sources` array * always accumulates every contributing view. A "meaningfulness floor" drops @@ -39,6 +43,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 +109,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 +204,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/session.ts b/src/session.ts index 1f8a3b07..59caaf55 100644 --- a/src/session.ts +++ b/src/session.ts @@ -724,13 +724,20 @@ export class Session extends EventEmitter { this._wireActivityAt = config.lastActivityAt || Date.now(); this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0; // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - // For omp, `claudeSessionId` doubles as the generic "external transcript id" - // alias key mergeUnifiedSessions() folds a history row into its owning - // session by: omp mints its OWN uuid, unrelated to this Codeman id, so - // without this an omp conversation's Past-Sessions row (keyed by omp's - // id) would never merge with its own live/persisted row (keyed by this - // id) — it would just show up a second time. - this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id; + // For omp and codex, `claudeSessionId` doubles as the generic "external + // transcript id" alias key mergeUnifiedSessions() folds a history row into + // its owning session by: each mints its OWN thread id, unrelated to this + // Codeman id, so without this the conversation's Past-Sessions row (keyed by + // that thread id) would never merge with its own live/persisted row (keyed + // by this id) — it would just show up a second time. For codex a duplicate + // is worse than cosmetic: the stale row still resumes, so clicking it starts + // a SECOND `codex resume` on a thread already open in another pane. + // + // This covers a RESUMED codex session, which knows its thread id up front. A + // fresh one learns its id only once codex writes the rollout, so it is folded + // from the other side — see the originator stamping in `gatherUnifiedInputs()`. + this._claudeSessionId = + config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.resumeSessionId || this.id; // Restored from state.json on boot recovery. start() resets _claudeSessionId // to the launch id even when re-attaching to a mux session whose CLI has // moved on (a `/clear` before the restart), so this anchor is what lets the @@ -2068,16 +2075,23 @@ export class Session extends EventEmitter { // this to omp's own session uuid — that already-resolved id must win // over the generic `this.id` fallback, or this line clobbers it back // to the Codeman id - // on every single respawn. + // on every single respawn. codex needs the same fallback for the same + // reason: its thread id lives in `_codexConfig`, so without it every + // respawn drops a resumed codex session's alias and its Past-Sessions + // row springs back as a duplicate that still resumes. // ⚠️ A RESTORED mux session is the one case where the launch id is a // lie: the CLI never stopped, so a `/clear` before the Codeman restart // already moved it to a conversation `this.id` knows nothing about. The // persisted chain's tail is that conversation, reported first-hand by - // the CLI's own hook, so it outranks the fallback here. A NEW pane has - // an empty chain and falls through to exactly today's expression. + // the CLI's own hook, so it outranks every fallback here. A NEW pane has + // an empty chain and falls through to the resume/alias fallbacks. restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined; this._claudeSessionId = - restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; + restoredConversation || + this._resumeSessionId || + this._ompConfig?.resumeSessionId || + this._codexConfig?.resumeSessionId || + this.id; // For NEW mux sessions: wait for readiness then clean buffer // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch @@ -2178,15 +2192,19 @@ export class Session extends EventEmitter { // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. // Mirrors the mux branch above and must not clobber it: this line runs // unconditionally after both the mux and direct-PTY paths, so it also needs - // the ompConfig fallback or it stomps the mux branch's correctly-resolved - // OMP alias back to this.id on every mux/plain-reattach boot recovery - // (the "third reset point" — see DECISIONS.md). For the same reason it needs - // `restoredConversation`: on a RESTORED mux attach the CLI never stopped and - // may have `/clear`ed before the restart, so the launch id is a lie and the - // chain's tail is the live conversation. Empty on every other path, which - // leaves this expression exactly as it was. + // the ompConfig and codexConfig fallbacks or it stomps the mux branch's + // correctly-resolved OMP/codex alias back to this.id on every mux/plain- + // reattach boot recovery (the "third reset point" — see DECISIONS.md). + // For the same reason it needs `restoredConversation`: on a RESTORED mux + // attach the CLI never stopped and may have `/clear`ed before the restart, + // so the launch id is a lie and the chain's tail is the live conversation. + // It is empty on every other path, so those paths keep the alias chain. this._claudeSessionId = - restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; + restoredConversation || + this._resumeSessionId || + this._ompConfig?.resumeSessionId || + this._codexConfig?.resumeSessionId || + this.id; this._pid = this.ptyProcess.pid; console.log('[Session] Interactive PTY spawned with PID:', this._pid); 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 482e7639..e1f0e8e1 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 8e6f192c..26ed4390 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -804,7 +804,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..09c232cf 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' }, @@ -2982,11 +2990,16 @@ Object.assign(CodemanApp.prototype, { // as a duplicate — click it 3 times, see the same name 3 times. Claude // rows are left alone: `sessionId` there is a claudeSessionId, which // usually has no live/persisted Codeman session of its own to delete. - // Gated on `continuesSomething`: for codex/gemini/antigravity (no - // continuation wired above), this is really a FRESH session with no + // Gated on `continuesSomething`: for gemini/antigravity, and for a codex + // row carrying no `resumeId`, this is really a FRESH session with no // relation to the old row's conversation, so retiring it would discard // the old conversation with no recovery — worse than the duplicate row // this guard exists to prevent for the modes that DO continue. + // + // A codex row that DOES continue passes this gate, but the DELETE is a + // no-op for it: `sessionId` there is codex's thread id and no Codeman + // session carries that id. Its duplicate is cleared from the other side + // instead, by the alias fold in gatherUnifiedInputs()/Session. if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) { fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {}); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 8b30ba5b..9ea80060 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -148,6 +148,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, codexThreadBySessionId } from '../../codex-transcript.js'; import { getLastTranscriptResponse, isExternalCliTranscriptMode, @@ -4164,6 +4165,13 @@ export function registerSessionRoutes( // Persisted sessions (state.json). resumeSessionId is the Claude // conversation UUID a resumed session continues — feed it to the merge's // alias map so its transcript row folds into this session. + // + // codex keeps its thread id in `codexConfig` instead, and state.json stores + // that, so read it here as well. Without it a resumed codex session that has + // been demoted to a persisted-only record loses its alias and duplicates: the + // originator fallback below cannot rescue that one, because a RESUMED rollout + // keeps the original session_meta (see findActiveCodexFile) and so still + // names whichever pane first created the thread. const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({ id: p.id, name: p.name, @@ -4172,7 +4180,7 @@ export function registerSessionRoutes( workingDir: p.workingDir, createdAt: p.createdAt, lastActivityAt: p.lastActivityAt, - claudeSessionId: p.resumeSessionId, + claudeSessionId: p.resumeSessionId || p.codexConfig?.resumeSessionId, pinned: p.pinned, pinnedAt: p.pinnedAt, })); @@ -4240,6 +4248,49 @@ 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 { + const codexRows = await scanCodexSessionsHistory(); + for (const h of codexRows) { + 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, + }); + } + + // Fold a FRESH codex pane into its own rollout row. A resumed one already + // folds, because Session sets `claudeSessionId` from the resume id it was + // given; a fresh one has no thread id until codex writes the rollout, so + // the link has to come from the other side. Codeman spawns every codex pane + // with CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_, and codex + // stamps that into session_meta.originator, so the rollout names the pane. + // + // Newest rollout wins: `/new` typed inside the TUI leaves several rollouts + // carrying the same originator, and the pane is on the most recent one. + // Rows arrive newest-first, so the first match is it. + // + // Never overwrites an id a session already knows — that one came from the + // resume path and is authoritative. + const codexThreads = codexThreadBySessionId(codexRows); + for (const row of [...live, ...persisted]) { + if (row.claudeSessionId && row.claudeSessionId !== row.id) continue; + const threadId = codexThreads.get(row.id); + if (threadId) row.claudeSessionId = threadId; + } + } 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-resume-alias-survives-start.test.ts b/test/codex-resume-alias-survives-start.test.ts new file mode 100644 index 00000000..903e4f06 --- /dev/null +++ b/test/codex-resume-alias-survives-start.test.ts @@ -0,0 +1,82 @@ +/** + * @fileoverview A resumed codex session must keep its thread-id alias across + * `start()`, not just at construction. + * + * `claudeSessionId` doubles as the generic "external transcript id" the unified + * list folds a Past-Sessions row into its owning session by. For codex that id + * is the rollout's thread id, and losing it is not cosmetic: the stale row stays + * in PAST and still resumes, so clicking it starts a SECOND `codex resume` on a + * thread already open in the live pane. + * + * The bug this pins: the alias was wired into the constructor only. `start()` + * recomputes `claudeSessionId` at two further points — the mux branch and the + * unconditional "third reset point" that runs after both the mux and direct-PTY + * paths — and both listed only Claude's `resumeSessionId` and omp's. For codex + * both are undefined, so every mux reattach and every boot recovery reset the + * alias back to the Codeman id and the duplicate came back. The existing comment + * at the third reset point already warned that omitting omp's fallback there + * "stomps the mux branch's correctly-resolved OMP alias"; codex needed the same. + * + * Mirrors `test/omp-fresh-run-no-resume.test.ts`, which drives a real `Session` + * against the in-memory tmux layer that vitest substitutes. + */ +import { mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +describe('codex: a resumed thread id survives start()', () => { + const workingDir = join(homedir(), 'codeman-cases', 'codex-resume-alias'); + const THREAD_ID = '01a060f0-0361-7f91-abde-b283020db0d7'; + const sessions: Session[] = []; + + afterEach(() => { + for (const s of sessions.splice(0)) s.stop(); + rmSync(workingDir, { recursive: true, force: true }); + }); + + function makeSession(useMux: boolean): Session { + mkdirSync(workingDir, { recursive: true }); + const session = new Session({ + workingDir, + mode: 'codex', + codexConfig: { resumeSessionId: THREAD_ID }, + mux: new TmuxManager(), + useMux, + }); + sessions.push(session); + return session; + } + + it('carries the thread id from construction', () => { + expect(makeSession(true).claudeSessionId).toBe(THREAD_ID); + }); + + it('still carries it after starting under mux', async () => { + const session = makeSession(true); + await session.startInteractive(); + expect(session.claudeSessionId).toBe(THREAD_ID); + }); + + it('refuses to start without mux at all, so the mux path is the only one to cover', async () => { + // codex declares `requiresMux`, so there is no direct-PTY codex session for + // the third reset point to run against on its own — the assertion above is + // the whole surface. + await expect(makeSession(false).startInteractive()).rejects.toThrow(/require tmux/i); + }); + + it('a fresh codex session keeps the Codeman id, having no thread of its own', async () => { + mkdirSync(workingDir, { recursive: true }); + const session = new Session({ workingDir, mode: 'codex', mux: new TmuxManager(), useMux: true }); + sessions.push(session); + + await session.startInteractive(); + + // Nothing to alias to yet — codex has not written the rollout. Such a + // session is folded from the other side, by originator (codexThreadBySessionId). + expect(session.claudeSessionId).toBe(session.id); + }); +}); diff --git a/test/codex-transcript.test.ts b/test/codex-transcript.test.ts new file mode 100644 index 00000000..255b8c01 --- /dev/null +++ b/test/codex-transcript.test.ts @@ -0,0 +1,304 @@ +/** + * 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 { appendFile, mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + scanCodexSessionsHistory, + codexThreadBySessionId, + __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; originator?: string }) => + JSON.stringify({ + timestamp: '2026-09-02T07:05:37.421Z', + type: 'session_meta', + payload: { + id: opts.id, + session_id: opts.id, + ...(opts.cwd ? { cwd: opts.cwd } : {}), + originator: opts.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('reports the originator, which is how a fresh pane finds its own rollout', async () => { + await writeRollout('bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb', [ + sessionMeta({ + id: 'bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb', + cwd: '/repo/nine', + originator: 'codeman_2f1c9a44-1111-2222-3333-444455556666', + }), + itemCompletedUser('hello'), + ]); + + const rows = await scanCodexSessionsHistory(); + expect(rows[0].originator).toBe('codeman_2f1c9a44-1111-2222-3333-444455556666'); + }); + + it('drops a rollout that records no working directory', async () => { + // Emitting workingDir: '' would make a click post an empty directory. + await writeRollout('cccccccc-cccc-7ccc-8ccc-cccccccccccc', [ + sessionMeta({ id: 'cccccccc-cccc-7ccc-8ccc-cccccccccccc' }), + itemCompletedUser('nowhere to resume into'), + ]); + + expect(await scanCodexSessionsHistory()).toEqual([]); + }); + + it('picks up a prompt written after an earlier scan saw none', async () => { + // The bug this pins: the identity cache was written as soon as the thread id + // was known, but codex writes the first UserMessage only when the user + // submits. Any scan in that window — the home screen, the command palette, + // the search-index refresh — pinned `firstPrompt: undefined` until restart. + const id = 'dddddddd-dddd-7ddd-8ddd-dddddddddddd'; + const path = await writeRollout(id, [sessionMeta({ id, cwd: '/repo/ten' })]); + + const before = await scanCodexSessionsHistory(); + expect(before).toHaveLength(1); + expect(before[0].firstPrompt).toBeUndefined(); + + await appendFile(path, itemCompletedUser('the prompt, typed a moment later') + '\n', 'utf-8'); + + const after = await scanCodexSessionsHistory(); + expect(after[0].firstPrompt).toBe('the prompt, typed a moment later'); + }); + + it('does not spend the lastPrompt budget on rollouts it never returns', async () => { + // The budget used to count file index, so a store whose newest files are all + // sub-agent threads exhausted it before the first row that needed it. + for (let i = 0; i < 3; i++) { + await writeRollout( + `eeeeeeee-eeee-7eee-8eee-00000000000${i}`, + [ + sessionMeta({ id: `eeeeeeee-eeee-7eee-8eee-00000000000${i}`, cwd: '/repo/sub', threadSource: 'subagent' }), + itemCompletedUser('subagent work'), + ], + new Date('2026-09-05T00:00:00Z') + ); + } + await writeRollout( + 'ffffffff-ffff-7fff-8fff-ffffffffffff', + [ + sessionMeta({ id: 'ffffffff-ffff-7fff-8fff-ffffffffffff', cwd: '/repo/real' }), + itemCompletedUser('opening'), + itemCompletedUser('the latest thing asked'), + ], + new Date('2026-09-04T00:00:00Z') + ); + + const rows = await scanCodexSessionsHistory(); + expect(rows).toHaveLength(1); + expect(rows[0].lastPrompt).toBe('the latest thing asked'); + }); + + 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); + }); +}); + +describe('codexThreadBySessionId', () => { + const row = (sessionId: string, originator?: string) => + ({ sessionId, originator, workingDir: '/w', sizeBytes: 1, lastModified: '2026-09-02T00:00:00.000Z' }) as never; + + it('maps a Codeman-spawned pane to the thread it is writing', () => { + const map = codexThreadBySessionId([row('thread-a', 'codeman_sess-1')]); + expect(map.get('sess-1')).toBe('thread-a'); + }); + + it('ignores a rollout codex started on its own', () => { + expect(codexThreadBySessionId([row('thread-a', 'codex-tui')]).size).toBe(0); + expect(codexThreadBySessionId([row('thread-a', undefined)]).size).toBe(0); + }); + + it('keeps the newest rollout when a pane has several', () => { + // `/new` inside the codex TUI leaves the pane's originator on more than one + // rollout; the pane is on the most recent, and rows arrive newest-first. + const map = codexThreadBySessionId([row('thread-new', 'codeman_sess-1'), row('thread-old', 'codeman_sess-1')]); + expect(map.get('sess-1')).toBe('thread-new'); + }); +}); 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/resume-history-mode-fidelity.test.ts b/test/resume-history-mode-fidelity.test.ts index 85619ae4..3f71d27e 100644 --- a/test/resume-history-mode-fidelity.test.ts +++ b/test/resume-history-mode-fidelity.test.ts @@ -90,7 +90,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation', fetchMock = stubFetch('new-session-id'); }); - it.each(['codex', 'gemini', 'antigravity'])( + it.each(['gemini', 'antigravity'])( 'does NOT retire the old row for %s (no continuation is wired for it)', async (mode) => { const app = makeApp(); @@ -104,6 +104,44 @@ describe('resumeHistorySession: row retirement is gated on actual continuation', } ); + // codex continues only when the row carried its thread id. A row without one + // is a live session's row, whose sessionId is Codeman's own uuid — sending + // THAT to `codex resume` asks for a thread that does not exist, so it must + // stay a fresh session and must not retire the row it came from. + it('does NOT continue or retire a codex row that carries no resumeId', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'codeman-uuid', '/repo', 'w1-repo', 'codex'); + + expect(createBody(fetchMock)).toMatchObject({ mode: 'codex' }); + expect(createBody(fetchMock).codexConfig).toBeUndefined(); + expect(deleteCalls(fetchMock)).toEqual([]); + }); + + it('resumes a codex row by the thread id the row carried', async () => { + const app = makeApp(); + await app.resumeHistorySession.call( + app, + '01a060f0-0361-7f91-abde-b283020db0d7', + '/repo', + 'w1-repo', + 'codex', + '01a060f0-0361-7f91-abde-b283020db0d7' + ); + + expect(createBody(fetchMock)).toMatchObject({ + mode: 'codex', + codexConfig: { resumeSessionId: '01a060f0-0361-7f91-abde-b283020db0d7' }, + }); + }); + + it('ignores a resumeId on a row that is not codex', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'gemini', 'some-thread-id'); + + expect(createBody(fetchMock).codexConfig).toBeUndefined(); + expect(deleteCalls(fetchMock)).toEqual([]); + }); + it.each([ ['opencode', 'openCodeConfig'], ['pi', 'piConfig'], diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 539af732..82e67097 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -14,6 +14,94 @@ import { } from '../../src/services/unified-session-service.js'; describe('mergeUnifiedSessions', () => { + // A codex conversation showing twice is worse than cosmetic: the stale PAST row + // still resumes, so clicking it starts a SECOND `codex resume` on a thread + // already open in another pane. Both folds below are what prevent that. + it('folds a RESUMED codex session into its own rollout row', () => { + // Session sets claudeSessionId from codexConfig.resumeSessionId, so the live + // row already names the thread the rollout is keyed by. + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'codex-thread-id' }], + history: [ + { + sessionId: 'codex-thread-id', + workingDir: '/w', + sizeBytes: 4000, + lastModified: '2026-09-02T00:00:00.000Z', + mode: 'codex', + resumeId: 'codex-thread-id', + }, + ], + }); + expect(merged).toHaveLength(1); + expect(merged[0].sessionId).toBe('codeman-uuid'); + expect([...merged[0].sources].sort()).toEqual(['history', 'live']); + }); + + it('folds a FRESH codex session once its rollout has been matched by originator', () => { + // A fresh pane knows no thread id, so gatherUnifiedInputs() stamps one onto + // the live row from session_meta.originator (see codexThreadBySessionId). + // This is that stamped row. + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-uuid', status: 'busy', mode: 'codex', claudeSessionId: 'fresh-thread-id' }], + persisted: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'fresh-thread-id' }], + history: [ + { + sessionId: 'fresh-thread-id', + workingDir: '/w', + sizeBytes: 900, + lastModified: '2026-09-02T00:00:00.000Z', + mode: 'codex', + resumeId: 'fresh-thread-id', + }, + ], + }); + expect(merged).toHaveLength(1); + expect(merged[0].sessionId).toBe('codeman-uuid'); + expect(merged[0].status).toBe('busy'); + }); + + it('leaves an unrelated codex rollout as its own row', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'thread-one' }], + history: [ + { + sessionId: 'thread-two', + workingDir: '/w', + sizeBytes: 4000, + lastModified: '2026-09-02T00:00:00.000Z', + mode: 'codex', + resumeId: 'thread-two', + }, + ], + }); + expect(merged.map((m) => m.sessionId).sort()).toEqual(['codeman-uuid', 'thread-two']); + }); + + 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 }], diff --git a/test/session-claude-conversation-chain.test.ts b/test/session-claude-conversation-chain.test.ts index 49d00060..785c84b9 100644 --- a/test/session-claude-conversation-chain.test.ts +++ b/test/session-claude-conversation-chain.test.ts @@ -100,9 +100,11 @@ describe('Session claude conversation chain', () => { // before this existed. Every assignment built from the launch-id fallback // must therefore carry `restoredConversation` first. const source = readFileSync(resolve(import.meta.dirname, '../src/session.ts'), 'utf8'); - const fallbackAssignments = source.match( - /_claudeSessionId =\s*\n?\s*[^;]*?_resumeSessionId \|\| this\._ompConfig\?\.resumeSessionId \|\| this\.id;/g - ); + // The tail of the chain grows as each CLI gains a resume alias of its own + // (omp, then codex), so the pattern pins the two ends and lets the middle + // widen. A `[^;]` run cannot cross a statement boundary, so each match is + // still one assignment. + const fallbackAssignments = source.match(/_claudeSessionId =[^;]*?_resumeSessionId[^;]*?this\.id;/g); expect(fallbackAssignments).not.toBeNull(); expect(fallbackAssignments!.length).toBeGreaterThanOrEqual(2); for (const assignment of fallbackAssignments!) {