From 116db81002c26d9d3a5a843c0d3f59845591bcbd Mon Sep 17 00:00:00 2001 From: codeman-local Date: Sun, 12 Jul 2026 10:39:42 +0800 Subject: [PATCH 1/2] feat: response-viewer support for Codex sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response-viewer (eye) currently reads only ~/.claude/projects — for Codex panes it falls back to a raw terminal-buffer dump. This adds a Codex-aware reader with exact per-pane rollout attribution. Locating THIS pane's rollout (~/.codex/sessions/**), in confidence order: 1. history match — Session tracks the pane's last Enter (codexLastSubmitAt); correlating it against ~/.codex/history.jsonl {session_id, ts} entries identifies the thread the pane is ACTUALLY on, surviving /resume, /new and /fork typed inside the codex TUI. An entry is credited to the pane whose Enter is closest, so menu keystrokes in other panes can't steal attribution. 2. originator match — codex panes are spawned with CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_, which codex (verified on 0.144.1) writes into session_meta.originator of every rollout it creates. 3. resume-id match — resumed rollouts keep their original session_meta (codex appends without rewriting), but the uuid is in the filename. 4. cwd+mtime heuristic — case-blind compare (codex records launch-time path case) and rollouts claimed by other panes are excluded. Reader details: user turns come from event_msg/user_message (real input only — AGENTS.md / environment_context injections never appear there), deduped against legacy response_item rows per-text so mixed-version rollouts keep full history; image inputs render an [image xN] placeholder; session_meta identity is cached per path (write-once). Frontend: thread role label follows session mode (Codex/Gemini/ OpenCode); the terminal-buffer fallback is Claude-only — TUI modes show a clear placeholder instead of a repaint dump. Co-Authored-By: Claude Fable 5 --- src/session.ts | 19 ++ src/tmux-manager.ts | 6 + src/web/public/app.js | 21 +- src/web/routes/session-routes.ts | 332 ++++++++++++++++++++++++++++++- 4 files changed, 372 insertions(+), 6 deletions(-) diff --git a/src/session.ts b/src/session.ts index daeb1667..e9cafe2e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2252,11 +2252,29 @@ export class Session extends EventEmitter { * ``` */ write(data: string): void { + this._trackCodexSubmit(data); if (this.ptyProcess) { this.ptyProcess.write(data); } } + // ── Codex thread tracking ───────────────────────────────────────────── + // When a codex pane last submitted a message (Enter). The response-viewer + // correlates this against ~/.codex/history.jsonl entry timestamps to find + // the thread the pane is ACTUALLY on — the only signal that survives + // /resume, /new and /fork typed inside the codex TUI itself. + private _codexLastSubmitAt = 0; + + get codexLastSubmitAt(): number { + return this._codexLastSubmitAt; + } + + private _trackCodexSubmit(data: string): void { + if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) { + this._codexLastSubmitAt = Date.now(); + } + } + /** * Per-client highest-applied input sequence, for exactly-once input delivery. * Keyed by the web client's stable `clientId`. Bounded so many devices over a @@ -2310,6 +2328,7 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string): Promise { + this._trackCodexSubmit(data); if (this._mux && this._muxSession) { return this._mux.sendInput(this.id, data); } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0af5caa8..8790b498 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -932,6 +932,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { 'export LC_ALL=en_US.UTF-8', mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM', ...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []), + // Stamp each Codex pane with a unique originator so the response-viewer + // can locate THIS pane's rollout exactly — codex writes the value into + // session_meta.originator of every rollout it creates. Without it, + // rollouts are matched by cwd+mtime and two panes in the same directory + // bleed into each other. + ...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []), 'export CODEMAN_MUX=1', `export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_MUX_NAME=${muxName}`, diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..b78acd62 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1612,8 +1612,12 @@ class CodemanApp { const data = (await res.json())?.data ?? {}; let lastResponse = data.text || ''; - // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome - if (!lastResponse) { + // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome. + // Claude-only: _cleanTerminalBuffer knows Claude CLI's output; for TUI + // modes (codex/opencode/gemini) it yields repaint garbage, so a clear + // placeholder beats a messy screen dump there. + const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude'; + if (!lastResponse && sessionMode === 'claude') { const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`); const termData = (await termRes.json())?.data ?? {}; if (termData.terminalBuffer) { @@ -1622,8 +1626,12 @@ class CodemanApp { } const body = document.getElementById('responseViewerBody'); - body.innerHTML = this._renderMarkdown(lastResponse); - this._bindResponseViewerInteractions(body); + if (lastResponse) { + body.innerHTML = this._renderMarkdown(lastResponse); + this._bindResponseViewerInteractions(body); + } else { + body.textContent = 'No response yet — send a message in this session first.'; + } // Reset state for fresh open const title = document.getElementById('responseViewerTitle'); @@ -1657,6 +1665,9 @@ class CodemanApp { } // Render conversation thread + const mode = this.sessions.get(this.activeSessionId)?.mode; + const agentLabel = + mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude'; body.innerHTML = ''; for (const msg of messages) { const div = document.createElement('div'); @@ -1665,7 +1676,7 @@ class CodemanApp { const role = document.createElement('div'); role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant'); - role.textContent = isUser ? 'You' : 'Claude'; + role.textContent = isUser ? 'You' : agentLabel; div.appendChild(role); const text = document.createElement('div'); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..03b9f7cb 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, dirname, extname } from 'node:path'; +import { join, dirname, extname, basename } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -863,6 +863,14 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); + // Codex sessions don't write to ~/.claude/projects — their transcripts + // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the + // response-viewer works for Codex panes too. + if (session.mode === 'codex') { + const codexQuery = req.query as { context?: string }; + return await readCodexLastResponse(session, codexQuery.context === 'full'); + } + // Scan ~/.claude/projects/*/ for the transcript file const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); @@ -976,6 +984,328 @@ export function registerSessionRoutes( }; }); + function isCodexInjectedContext(text: string): boolean { + return ( + /^# AGENTS\.md instructions\b/i.test(text) || + /^(); + async function resolveCodexThreadFromHistory( + session: { id: string; codexLastSubmitAt?: number }, + codexHome: string + ): Promise { + const submitAt = session.codexLastSubmitAt || 0; + if (!submitAt) return null; + const cached = codexHistoryPinCache.get(session.id); + if (cached && cached.submitAt === submitAt) return cached.threadId; + + const histPath = join(codexHome, 'history.jsonl'); + const st = await fs.stat(histPath).catch(() => null); + if (!st || st.size === 0) return null; + const tail = await readFileTail(histPath, Buffer.alloc(65536), st.size); + if (!tail) return null; + + const WINDOW_MS = 15_000; + const otherSubmits: number[] = []; + for (const s of ctx.sessions.values()) { + if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) { + otherSubmits.push(s.codexLastSubmitAt); + } + } + + let best: { threadId: string; dist: number } | undefined; + for (const line of tail.split('\n')) { + if (!line) continue; + let e: { session_id?: string; ts?: number }; + try { + e = JSON.parse(line); + } catch { + continue; // first tail line may be cut mid-JSON + } + if (!e.session_id || typeof e.ts !== 'number') continue; + const tsMs = e.ts * 1000; // history timestamps are unix seconds + const dist = Math.abs(tsMs - submitAt); + if (dist > WINDOW_MS) continue; + if (otherSubmits.some((o) => Math.abs(tsMs - o) < dist)) continue; // another pane is closer + if (!best || dist < best.dist) best = { threadId: e.session_id, dist }; + } + if (!best) return null; + if (codexHistoryPinCache.size >= 1024) codexHistoryPinCache.clear(); + codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId }); + return best.threadId; + } + + // Locate THIS pane's rollout, in order of confidence: + // 0. history match — the thread the pane last submitted a message to + // (see resolveCodexThreadFromHistory); tracks the pane through + // /resume //new //fork typed inside the TUI. + // 1. originator match — Codeman spawns codex panes with + // CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_, which codex + // writes into session_meta.originator of every rollout it creates + // (including new files after /new in the same pane; newest match wins). + // 2. resume-id match — resumed rollouts keep their ORIGINAL session_meta + // (codex appends without rewriting it), so originator matching can't + // see them; but the rollout uuid is in the filename and we know the id. + // 3. legacy cwd+mtime heuristic — panes started before this feature, or + // TUI-resumed threads before their first tracked submit. Case-blind + // cwd compare (codex records the launch-time case, /mnt paths vary) + // and rollouts claimed by OTHER codeman panes are excluded. + async function findActiveCodexFile(session: { + id: string; + workingDir: string; + codexLastSubmitAt?: number; + codexConfig?: { resumeSessionId?: string }; + }): Promise { + const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex'); + const sessionsDir = join(codexHome, 'sessions'); + + const files: Array<{ path: string; mtimeMs: number }> = []; + const walk = async (dir: string): Promise => { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + const st = await fs.stat(fullPath).catch(() => null); + if (!st || st.size < 100) continue; + files.push({ path: fullPath, mtimeMs: st.mtimeMs }); + } + }; + await walk(sessionsDir); + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const historyThreadId = await resolveCodexThreadFromHistory(session, codexHome); + if (historyThreadId) { + const hit = files.find((f) => basename(f.path).endsWith(`-${historyThreadId}.jsonl`)); + if (hit) return hit.path; + } + + const rawResumeId = session.codexConfig?.resumeSessionId; + const resumeId = + rawResumeId && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(rawResumeId) + ? rawResumeId + : undefined; + const idMatch = resumeId ? files.find((f) => basename(f.path).endsWith(`-${resumeId}.jsonl`)) : undefined; + + // Scan newest-first for our originator; anything strictly older than the + // id match can never beat it, so the head reads stop there (mtime ties are + // still scanned — a /new rollout may land in the same clock tick). The + // 128 KiB head budget covers the session_meta line, which embeds full + // base_instructions (observed max ~22 KiB on codex 0.144). + const originator = `codeman_${session.id}`; + const wantCwd = session.workingDir.toLowerCase(); + const headBuf = Buffer.alloc(131072); + let cwdFallback: { path: string; mtimeMs: number } | undefined; + for (const f of files) { + if (idMatch && f.mtimeMs < idMatch.mtimeMs) break; + const meta = await readCodexRolloutMetaCached(f.path, headBuf); + if (!meta) continue; + if (meta.originator === originator) return f.path; // newest-first → first hit wins + if ( + !cwdFallback && + !idMatch && + meta.cwd?.toLowerCase() === wantCwd && + // A rollout stamped by another codeman pane belongs to that pane. + !(meta.originator?.startsWith('codeman_') && meta.originator !== originator) + ) { + cwdFallback = f; + } + } + + return idMatch?.path ?? cwdFallback?.path ?? null; + } + + // session_meta is written once when codex creates the rollout and never + // rewritten (verified: resume appends without touching it), so the parsed + // identity of a given path can be cached forever. This turns the per-request + // scan into stat calls plus head reads for new files only. + const codexRolloutMetaCache = new Map(); + async function readCodexRolloutMetaCached( + filePath: string, + headBuf: Buffer + ): Promise<{ cwd?: string; originator?: string } | null> { + const cached = codexRolloutMetaCache.get(filePath); + if (cached) return cached; + const head = await readFileHead(filePath, headBuf); + if (!head) return null; + const meta = readCodexRolloutMeta(head); + // Don't cache a still-incomplete head: a rollout being created may not + // have flushed session_meta/turn_context yet. + if (!meta.cwd && !meta.originator) return meta; + if (codexRolloutMetaCache.size >= 4096) codexRolloutMetaCache.clear(); + codexRolloutMetaCache.set(filePath, meta); + return meta; + } + + function extractCodexBlockText(content: unknown, kinds: string[]): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter( + (b): b is { type: string; text: string } => + !!b && + typeof b === 'object' && + kinds.includes((b as { type?: string }).type || '') && + typeof (b as { text?: string }).text === 'string' + ) + .map((b) => b.text) + .join(''); + } + + // Single pass over a Codex rollout: track the last assistant message (for the + // default eye view) and, when `full`, the whole user/assistant thread. + // + // User turns come from event_msg/user_message when available: codex emits one + // per REAL user input, and injected context (AGENTS.md, environment_context, + // compaction summaries, …) never appears there — so no filtering heuristics. + // response_item user rows duplicate those inputs mixed with the injections; + // they are kept only as a fallback for old rollouts without event_msg rows. + async function readCodexLastResponse( + session: { id: string; workingDir: string; codexConfig?: { resumeSessionId?: string } }, + full: boolean + ): Promise<{ + text: string; + timestamp: string; + messages?: Array<{ role: string; text: string; timestamp?: string }>; + }> { + const empty = full ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' }; + const filePath = await findActiveCodexFile(session); + if (!filePath) return empty; + + let content: string; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch { + return empty; + } + + let lastText = ''; + let lastTimestamp = ''; + const messages: Array<{ role: string; text: string; timestamp?: string; legacyUser?: boolean }> = []; + // Multiset of event-sourced user texts: a real input appears BOTH as an + // event_msg and as a response_item row, so each event text cancels exactly + // one legacy twin. Legacy rows without an event twin (turns written by an + // older codex appending to the same rollout) survive — a file-wide boolean + // would wrongly drop them. + const eventUserTexts = new Map(); + + for (const line of content.split('\n')) { + if (!line) continue; + let entry: { + timestamp?: string; + type?: string; + payload?: { + type?: string; + role?: string; + content?: unknown; + message?: unknown; + images?: unknown; + local_images?: unknown; + }; + }; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (full && entry.type === 'event_msg' && entry.payload?.type === 'user_message') { + let text = typeof entry.payload.message === 'string' ? entry.payload.message.trim() : ''; + if (text && isCodexInjectedContext(text)) continue; + if (text) eventUserTexts.set(text, (eventUserTexts.get(text) || 0) + 1); + // Image-only (or image+text) inputs: the text field alone would make + // the turn vanish, so surface a placeholder. + const imageCount = + (Array.isArray(entry.payload.images) ? entry.payload.images.length : 0) + + (Array.isArray(entry.payload.local_images) ? entry.payload.local_images.length : 0); + if (imageCount > 0) text = text ? `${text}\n\n*[image ×${imageCount}]*` : `*[image ×${imageCount}]*`; + if (text) messages.push({ role: 'user', text, timestamp: entry.timestamp }); + continue; + } + if (entry.type !== 'response_item' || entry.payload?.type !== 'message') continue; + const role = entry.payload?.role; + if (role === 'assistant') { + const text = extractCodexBlockText(entry.payload?.content, ['output_text', 'text']); + if (text) { + lastText = text; + lastTimestamp = entry.timestamp || ''; + if (full) messages.push({ role: 'assistant', text, timestamp: entry.timestamp }); + } + } else if (role === 'user' && full) { + const text = extractCodexBlockText(entry.payload?.content, ['input_text', 'text']).trim(); + // Drop Codex's injected context turns (AGENTS.md, environment_context, …) + // so the thread shows real user prompts only. + if (text && !isCodexInjectedContext(text)) { + messages.push({ role: 'user', text, timestamp: entry.timestamp, legacyUser: true }); + } + } + } + + const thread = messages + .filter((m) => { + if (!m.legacyUser) return true; + const n = eventUserTexts.get(m.text) || 0; + if (n > 0) { + eventUserTexts.set(m.text, n - 1); + return false; // duplicate of an event_msg row already in the thread + } + return true; + }) + .map(({ role, text, timestamp }) => ({ role, text, timestamp })); + + return full + ? { text: lastText, timestamp: lastTimestamp, messages: thread } + : { text: lastText, timestamp: lastTimestamp }; + } + // ========== Get Terminal Buffer ========== // Query params: From 895edfedb04619450ee4511c65784f09b1dc57d3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 17:42:39 +0200 Subject: [PATCH 2/2] fix(review): add Codex last-response test coverage + minor hardening (PR #152) - Add test/routes/session-routes-codex-last-response.test.ts (app.inject + temp CODEX_HOME fixture rollouts): originator match beats cwd fallback when two panes share a dir, cwd fallback excludes sibling-claimed/foreign-cwd rollouts, resume-uuid filename match, history.jsonl pin outranks originator, event_msg/legacy user-turn dedup keeps old-codex turns, injected-context filtering, image placeholder, envelope shape ({success:true,data:{text, timestamp[,messages]}}), and a Claude-mode regression guard (codex reader never consulted for claude sessions) - Replace clear-at-cap Map caches (codexHistoryPinCache, codexRolloutMetaCache) with the repo-standard LRUMap so a full cache wipe can't thrash hot entries on large rollout collections - Join multi-block assistant/user text with a blank line instead of no separator (extractCodexBlockText) - Re-enable the terminal-buffer eye fallback for shell sessions (they have no transcript source at all); TUI modes keep the clear placeholder Co-Authored-By: Claude Fable 5 --- src/web/public/app.js | 7 +- src/web/routes/session-routes.ts | 9 +- ...session-routes-codex-last-response.test.ts | 383 ++++++++++++++++++ 3 files changed, 391 insertions(+), 8 deletions(-) create mode 100644 test/routes/session-routes-codex-last-response.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index b78acd62..730b16a6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1613,11 +1613,12 @@ class CodemanApp { let lastResponse = data.text || ''; // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome. - // Claude-only: _cleanTerminalBuffer knows Claude CLI's output; for TUI - // modes (codex/opencode/gemini) it yields repaint garbage, so a clear + // Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and + // shell sessions have no transcript source at all; for TUI modes + // (codex/opencode/gemini) it yields repaint garbage, so a clear // placeholder beats a messy screen dump there. const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude'; - if (!lastResponse && sessionMode === 'claude') { + if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) { const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`); const termData = (await termRes.json())?.data ?? {}; if (termData.terminalBuffer) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 03b9f7cb..04204235 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -62,6 +62,7 @@ import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js'; import { dataPath } from '../../config/instance.js'; +import { LRUMap } from '../../utils/lru-map.js'; // Path to linked-cases registry (same file used by case-routes resolveCasePath) const LINKED_CASES_FILE = dataPath('linked-cases.json'); @@ -1030,7 +1031,7 @@ export function registerSessionRoutes( // codex TUI itself. An entry is credited to this pane only when its Enter is // the closest among all codex panes, so a menu keystroke in another pane // can't steal the attribution. - const codexHistoryPinCache = new Map(); + const codexHistoryPinCache = new LRUMap({ maxSize: 1024 }); async function resolveCodexThreadFromHistory( session: { id: string; codexLastSubmitAt?: number }, codexHome: string @@ -1071,7 +1072,6 @@ export function registerSessionRoutes( if (!best || dist < best.dist) best = { threadId: e.session_id, dist }; } if (!best) return null; - if (codexHistoryPinCache.size >= 1024) codexHistoryPinCache.clear(); codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId }); return best.threadId; } @@ -1168,7 +1168,7 @@ export function registerSessionRoutes( // rewritten (verified: resume appends without touching it), so the parsed // identity of a given path can be cached forever. This turns the per-request // scan into stat calls plus head reads for new files only. - const codexRolloutMetaCache = new Map(); + const codexRolloutMetaCache = new LRUMap({ maxSize: 4096 }); async function readCodexRolloutMetaCached( filePath: string, headBuf: Buffer @@ -1181,7 +1181,6 @@ export function registerSessionRoutes( // Don't cache a still-incomplete head: a rollout being created may not // have flushed session_meta/turn_context yet. if (!meta.cwd && !meta.originator) return meta; - if (codexRolloutMetaCache.size >= 4096) codexRolloutMetaCache.clear(); codexRolloutMetaCache.set(filePath, meta); return meta; } @@ -1198,7 +1197,7 @@ export function registerSessionRoutes( typeof (b as { text?: string }).text === 'string' ) .map((b) => b.text) - .join(''); + .join('\n\n'); } // Single pass over a Codex rollout: track the last assistant message (for the diff --git a/test/routes/session-routes-codex-last-response.test.ts b/test/routes/session-routes-codex-last-response.test.ts new file mode 100644 index 00000000..12bc2ac7 --- /dev/null +++ b/test/routes/session-routes-codex-last-response.test.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview Tests for the Codex branch of GET /api/sessions/:id/last-response (PR #152). + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + * + * Fixture rollouts live in a per-test temp CODEX_HOME (the route resolves + * `process.env.CODEX_HOME || ~/.codex` at request time), exercising the real + * locator/parser code paths against real files: + * - originator match beats the cwd+mtime fallback when two panes share a dir + * - resume-uuid filename match (resumed rollouts keep foreign session_meta) + * - history.jsonl pin outranks the originator match + * - event_msg vs legacy response_item user-turn dedup keeps old-codex turns + * - injected-context rows (AGENTS.md, environment_context, …) are filtered + * - response envelope shape; Claude-mode behavior unchanged (regression guard) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * Mirror of the production uniform-envelope hook (server.ts) — same local + * harness idiom as session-routes.test.ts, so assertions match the wire format. + */ +async function createEnvelopeHarness( + registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + + const ctx = createMockRouteContext(); + registerFn(app, ctx); + + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + + return { app, ctx }; +} + +// ── Rollout fixture helpers (shapes observed on codex-cli 0.144) ────────────── + +const sessionMeta = (cwd: string, originator?: string) => ({ + type: 'session_meta', + payload: { cwd, originator }, +}); + +const assistantMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({ + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }, +}); + +const legacyUserMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({ + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }, +}); + +const eventUserMsg = (message: string, timestamp = '2026-07-01T00:00:00Z') => ({ + timestamp, + type: 'event_msg', + payload: { type: 'user_message', message }, +}); + +const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const UUID_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +// Fixed epoch (seconds) for deterministic mtime ordering. +const BASE_MTIME = 1_750_000_000; + +describe('GET /api/sessions/:id/last-response (codex)', () => { + let harness: LocalHarness; + let codexHome: string; + let prevCodexHome: string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt) + let workdir: string; + + /** Write a rollout under CODEX_HOME/sessions// with a controlled mtime. */ + function writeRollout(name: string, entries: unknown[], mtimeSec: number): string { + const dir = join(codexHome, 'sessions', '2026', '07', '01'); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, name); + let content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'; + // The locator skips files under 100 bytes (blank padding lines are ignored by the parser). + while (content.length < 100) content += '\n'; + writeFileSync(filePath, content); + utimesSync(filePath, mtimeSec, mtimeSec); + return filePath; + } + + function writeHistory(entries: Array<{ session_id: string; ts: number }>): void { + writeFileSync(join(codexHome, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + } + + async function getLastResponse(id: string, full = false) { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${id}/last-response${full ? '?context=full' : ''}`, + }); + return { res, body: JSON.parse(res.body) }; + } + + beforeEach(async () => { + codexHome = mkdtempSync(join(tmpdir(), 'codeman-codex-rv-')); + prevCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + + harness = await createEnvelopeHarness(registerSessionRoutes); + session = harness.ctx._session; + session.mode = 'codex'; + workdir = join(codexHome, 'workdir'); + session.workingDir = workdir; + }); + + afterEach(async () => { + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + rmSync(codexHome, { recursive: true, force: true }); + await harness.app.close(); + }); + + // ── Locator: originator vs cwd fallback ───────────────────────────────── + + it('originator match beats the cwd+mtime fallback when two panes share a dir', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const paneB: any = createMockSession('codex-b'); + paneB.mode = 'codex'; + paneB.workingDir = workdir; + harness.ctx.sessions.set('codex-b', paneB); + + // Pane B's rollout is NEWER — the naive cwd+mtime heuristic would show it for pane A. + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('answer A')], + BASE_MTIME + ); + writeRollout( + `rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`, + [sessionMeta(workdir, 'codeman_codex-b'), assistantMsg('answer B')], + BASE_MTIME + 100 + ); + + const a = await getLastResponse(session.id); + expect(a.res.statusCode).toBe(200); + expect(a.body.data.text).toBe('answer A'); + + const b = await getLastResponse('codex-b'); + expect(b.body.data.text).toBe('answer B'); + }); + + it('cwd fallback excludes rollouts claimed by other panes and skips foreign cwds', async () => { + // Pane has no originator-stamped rollout (pre-existing pane). Newest same-cwd + // rollout belongs to another codeman pane → must fall through to the unclaimed one. + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [sessionMeta(workdir), assistantMsg('unclaimed answer')], + BASE_MTIME + ); + writeRollout( + `rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`, + [sessionMeta(workdir, 'codeman_some-other-pane'), assistantMsg('sibling answer')], + BASE_MTIME + 100 + ); + writeRollout( + `rollout-2026-07-01T00-02-00-${UUID_C}.jsonl`, + [sessionMeta('/elsewhere/entirely'), assistantMsg('foreign-cwd answer')], + BASE_MTIME + 200 + ); + + const { body } = await getLastResponse(session.id); + expect(body.data.text).toBe('unclaimed answer'); + }); + + // ── Locator: resume-uuid match ─────────────────────────────────────────── + + it('resolves a resumed pane via the rollout filename uuid despite foreign session_meta', async () => { + session.codexConfig = { resumeSessionId: UUID_A }; + + // Resumed rollouts keep the ORIGINAL session_meta (foreign originator + launch cwd), + // so neither originator nor cwd matching can find them — only the filename uuid. + writeRollout( + `rollout-2026-06-30T12-00-00-${UUID_A}.jsonl`, + [sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('resumed answer')], + BASE_MTIME + ); + // A newer same-cwd decoy must NOT win over the uuid match. + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_B}.jsonl`, + [sessionMeta(workdir), assistantMsg('decoy answer')], + BASE_MTIME + 100 + ); + + const { body } = await getLastResponse(session.id); + expect(body.data.text).toBe('resumed answer'); + }); + + // ── Locator: history.jsonl pin ─────────────────────────────────────────── + + it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => { + const submitAtSec = BASE_MTIME + 500; + session.codexLastSubmitAt = submitAtSec * 1000; + writeHistory([{ session_id: UUID_B, ts: submitAtSec }]); + + // Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto + // UUID_B inside the TUI — the history pin must follow it there. + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('originator answer')], + BASE_MTIME + 600 + ); + writeRollout( + `rollout-2026-06-30T12-00-00-${UUID_B}.jsonl`, + [sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('history answer')], + BASE_MTIME + ); + + const { body } = await getLastResponse(session.id); + expect(body.data.text).toBe('history answer'); + }); + + // ── Reader: dedup + filtering ──────────────────────────────────────────── + + it('event_msg/legacy dedup keeps old-codex turns and drops event twins (mixed-version rollout)', async () => { + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [ + sessionMeta(workdir, `codeman_${session.id}`), + // Old-codex turn: response_item only, no event_msg twin — must survive. + legacyUserMsg('old prompt'), + assistantMsg('old answer'), + // Modern turn: event_msg + duplicate response_item row — one user row only. + eventUserMsg('new prompt'), + legacyUserMsg('new prompt'), + assistantMsg('new answer'), + ], + BASE_MTIME + ); + + const { body } = await getLastResponse(session.id, true); + expect(body.data.text).toBe('new answer'); + expect(body.data.messages.map((m: { role: string; text: string }) => [m.role, m.text])).toEqual([ + ['user', 'old prompt'], + ['assistant', 'old answer'], + ['user', 'new prompt'], + ['assistant', 'new answer'], + ]); + }); + + it('filters injected-context rows from the full thread', async () => { + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [ + sessionMeta(workdir, `codeman_${session.id}`), + legacyUserMsg('# AGENTS.md instructions for the workspace'), + legacyUserMsg('\n/somewhere'), + eventUserMsg('be nice'), + legacyUserMsg('real question'), + assistantMsg('real answer'), + ], + BASE_MTIME + ); + + const { body } = await getLastResponse(session.id, true); + expect(body.data.messages).toEqual([ + { role: 'user', text: 'real question', timestamp: '2026-07-01T00:00:00Z' }, + { role: 'assistant', text: 'real answer', timestamp: '2026-07-01T00:00:00Z' }, + ]); + }); + + it('renders an image placeholder for image-only event_msg inputs', async () => { + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [ + sessionMeta(workdir, `codeman_${session.id}`), + { timestamp: '2026-07-01T00:00:00Z', type: 'event_msg', payload: { type: 'user_message', images: ['a', 'b'] } }, + assistantMsg('looked at the images'), + ], + BASE_MTIME + ); + + const { body } = await getLastResponse(session.id, true); + expect(body.data.messages[0]).toEqual({ + role: 'user', + text: '*[image ×2]*', + timestamp: '2026-07-01T00:00:00Z', + }); + }); + + // ── Envelope shape + Claude-mode regression guard ──────────────────────── + + it('returns the {success:true,data:{text,timestamp}} envelope; messages only with ?context=full', async () => { + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('the answer', '2026-07-01T01:02:03Z')], + BASE_MTIME + ); + + const brief = await getLastResponse(session.id); + expect(brief.res.statusCode).toBe(200); + expect(brief.body).toEqual({ + success: true, + data: { text: 'the answer', timestamp: '2026-07-01T01:02:03Z' }, + }); + + const full = await getLastResponse(session.id, true); + expect(full.body.success).toBe(true); + expect(Array.isArray(full.body.data.messages)).toBe(true); + }); + + it('returns an empty envelope (not an error) when no rollout matches', async () => { + const brief = await getLastResponse(session.id); + expect(brief.res.statusCode).toBe(200); + expect(brief.body).toEqual({ success: true, data: { text: '', timestamp: '' } }); + + const full = await getLastResponse(session.id, true); + expect(full.body.data.messages).toEqual([]); + }); + + it('leaves Claude-mode sessions on the ~/.claude/projects reader (regression guard)', async () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'codeman-claude-home-')); + const prevHome = process.env.HOME; + process.env.HOME = fakeHome; + try { + session.mode = 'claude'; + const projDir = join(fakeHome, '.claude', 'projects', 'proj1'); + mkdirSync(projDir, { recursive: true }); + writeFileSync( + join(projDir, `${session.id}.jsonl`), + JSON.stringify({ + type: 'assistant', + timestamp: '2026-07-01T00:00:00Z', + message: { content: [{ type: 'text', text: 'claude answer' }] }, + }) + '\n' + ); + // A codex rollout for the same session id must NOT be consulted in claude mode. + writeRollout( + `rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`, + [sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('codex answer')], + BASE_MTIME + ); + + const { res, body } = await getLastResponse(session.id); + expect(res.statusCode).toBe(200); + expect(body.data.text).toBe('claude answer'); + expect(body.data.timestamp).toBe('2026-07-01T00:00:00Z'); + } finally { + process.env.HOME = prevHome; + rmSync(fakeHome, { recursive: true, force: true }); + } + }); +});