Merge PR #152 from pirronewantlux529-coder/codex-response-viewer: response-viewer (eye) support for Codex sessions

Includes review fixes: full route-test coverage for the rollout locator/parser (originator/uuid/pin resolution, dedup, injected-context filtering), LRU caches, multi-block text joins.

# Conflicts:
#	src/web/routes/session-routes.ts
This commit is contained in:
Codeman maintainer
2026-07-12 20:09:53 +02:00
5 changed files with 755 additions and 6 deletions
+19
View File
@@ -2332,11 +2332,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
@@ -2390,6 +2408,7 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise<boolean> {
this._trackCodexSubmit(data);
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
}
+6
View File
@@ -1075,6 +1075,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}`,
+17 -5
View File
@@ -1794,8 +1794,13 @@ 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 + 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' || sessionMode === 'shell')) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = (await termRes.json())?.data ?? {};
if (termData.terminalBuffer) {
@@ -1804,8 +1809,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');
@@ -1839,6 +1848,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');
@@ -1847,7 +1859,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');
+330 -1
View File
@@ -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';
@@ -74,6 +74,7 @@ import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.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');
@@ -909,6 +910,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');
@@ -1022,6 +1031,326 @@ export function registerSessionRoutes(
};
});
function isCodexInjectedContext(text: string): boolean {
return (
/^# AGENTS\.md instructions\b/i.test(text) ||
/^<environment_context\b/i.test(text) ||
/^<turn_aborted\b/i.test(text) ||
/^<codex_internal_context\b/i.test(text) ||
/^<recommended_plugins\b/i.test(text) ||
/^<user_instructions\b/i.test(text) ||
/^# Options\b/i.test(text)
);
}
// ── Codex response-viewer support ───────────────────────────────────────────────────────
// Read the rollout's session_meta identity fields (plus turn_context cwd as
// a fallback when the huge session_meta line got truncated by the head read).
function readCodexRolloutMeta(head: string): { cwd?: string; originator?: string } {
let cwd: string | undefined;
let originator: string | undefined;
for (const line of head.split('\n')) {
if (!line) continue;
try {
const entry = JSON.parse(line) as {
type?: string;
payload?: { cwd?: string; originator?: string };
};
if (entry.type === 'session_meta') {
cwd ??= entry.payload?.cwd;
originator ??= entry.payload?.originator;
} else if (entry.type === 'turn_context') {
cwd ??= entry.payload?.cwd;
}
} catch {
// Malformed or truncated head line — keep scanning.
}
if (cwd && originator) break;
}
return { cwd, originator };
}
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
// ~/.codex/history.jsonl, which logs every submitted user message as
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
// is the only signal that survives /resume, /new and /fork typed inside the
// 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 LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
async function resolveCodexThreadFromHistory(
session: { id: string; codexLastSubmitAt?: number },
codexHome: string
): Promise<string | null> {
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;
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_<sessionId>, 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<string | null> {
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<void> => {
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 LRUMap<string, { cwd?: string; originator?: string }>({ maxSize: 4096 });
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;
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('\n\n');
}
// 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<string, number>();
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:
@@ -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<LocalHarness> {
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/<date>/ 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('<environment_context>\n<cwd>/somewhere</cwd>'),
eventUserMsg('<user_instructions>be nice</user_instructions>'),
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 });
}
});
});