Files
Codeman/test/codex-transcript.test.ts
T
Michael Grundberg 8285fff91c feat(codex): list codex conversations and resume them
Codex conversations never appeared in the session list, and the resume path
skipped codex, so picking one back up meant finding its thread id by hand and
POSTing codexConfig.resumeSessionId to /api/sessions.

Two gaps caused it:

- The unified list is built from ~/.claude/projects plus omp's own store.
  Codex writes to neither: its rollouts live in ~/.codex/sessions/<y>/<m>/<d>.
- terminal-ui.js sends a continuation only for the CLIs with a
  "continue most recent" flag. Codex has no such flag — it names a thread by an
  exact id — and nothing supplied one.

Add codex-transcript.ts, the codex analog of omp-transcript.ts, and wire it into
gatherUnifiedInputs() beside the omp scan. A rollout row carries `resumeId`, the
thread id `codex resume` takes, and the resume path sends it as
codexConfig.resumeSessionId.

`resumeId` is what keeps the two kinds of row apart: only a transcript scanner
sets it, so a LIVE codex row — whose sessionId is Codeman's own uuid — can never
ask codex for a thread that does not exist.

Three things measured against a real store of 519 rollouts rather than assumed:

- Rollouts are far too large to read whole (median 407 KiB, p90 1.3 MiB, max
  25 MiB, 381 MiB total), so this reads a 128 KiB head for the identity and the
  opening prompt and a bounded tail for the most recent one. session_meta is
  written once and never rewritten, so per-path identity is cached; a warm
  rescan of that store costs ~75ms against ~470ms cold.
- 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. Both shapes are
  read, plus response_item as a last resort.
- That last resort sees injected context, and the first such row is the repo's
  AGENTS.md every time, so injections are dropped rather than used as titles.

Sub-agent threads (thread_source: 'subagent') are left out; codex spawns them
for itself and on a real store they outnumber the resumable threads.
2026-09-06 19:45:36 +02:00

209 lines
8.7 KiB
TypeScript

/**
* Reading codex's own rollout store for Past Sessions rows.
*
* Three of these assertions exist because the obvious implementation was
* measured to be wrong against real files (codex CLI 0.152.1):
*
* - codex 0.152.1 emits NO `event_msg`/`user_message` rows at all. It writes
* `event_msg`/`item_completed` carrying an `item.type` of `UserMessage`
* instead, so a scanner that knew only the older shape found a prompt for
* July rollouts and nothing for September ones.
* - the `response_item` fallback sees codex's injected context, and on a real
* store the FIRST such row is the repository's AGENTS.md every time. Taking
* it literally titled every row with the same instructions block.
* - codex spawns sub-agent threads into the same store, stamped
* `thread_source: 'subagent'`. On the store this was built against they
* outnumbered the threads a person can actually resume.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { scanCodexSessionsHistory, __clearCodexIdentityCache } from '../src/codex-transcript.js';
let home: string;
let prevCodexHome: string | undefined;
/** A rollout's opening line, as codex writes it. */
const sessionMeta = (opts: { id: string; cwd: string; threadSource?: string }) =>
JSON.stringify({
timestamp: '2026-09-02T07:05:37.421Z',
type: 'session_meta',
payload: {
id: opts.id,
session_id: opts.id,
cwd: opts.cwd,
originator: 'codex-tui',
...(opts.threadSource ? { thread_source: opts.threadSource } : {}),
// The real thing embeds full base instructions here; padded so the file
// clears the size floor and exercises the head window.
base_instructions: { text: 'x'.repeat(500) },
},
});
/** codex 0.152.1's user-input row. */
const itemCompletedUser = (text: string) =>
JSON.stringify({
type: 'event_msg',
payload: { type: 'item_completed', item: { type: 'UserMessage', id: 'i1', content: [{ type: 'text', text }] } },
});
/** The shape older codex versions wrote. */
const legacyUserMessage = (text: string) =>
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } });
/** The last-resort shape, which also carries codex's injected context. */
const responseItemUser = (text: string) =>
JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'text', text }] } });
async function writeRollout(id: string, lines: string[], mtime?: Date): Promise<string> {
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<INSTRUCTIONS> ...'),
responseItemUser('<environment_context>cwd=/repo/three</environment_context>'),
responseItemUser('Replace PanicOnError with Require().NoError'),
]);
const rows = await scanCodexSessionsHistory();
expect(rows[0].firstPrompt).toBe('Replace PanicOnError with Require().NoError');
});
it('prefers a real user row over the injection-prone fallback', async () => {
await writeRollout('33333333-3333-7333-8333-333333333333', [
sessionMeta({ id: '33333333-3333-7333-8333-333333333333', cwd: '/repo/four' }),
responseItemUser('Some earlier response_item row'),
itemCompletedUser('The prompt the user actually typed'),
]);
const rows = await scanCodexSessionsHistory();
expect(rows[0].firstPrompt).toBe('The prompt the user actually typed');
});
it('leaves out sub-agent threads, which nobody resumes', async () => {
await writeRollout('44444444-4444-7444-8444-444444444444', [
sessionMeta({ id: '44444444-4444-7444-8444-444444444444', cwd: '/repo/five' }),
itemCompletedUser('a real conversation'),
]);
await writeRollout('55555555-5555-7555-8555-555555555555', [
sessionMeta({ id: '55555555-5555-7555-8555-555555555555', cwd: '/repo/five', threadSource: 'subagent' }),
itemCompletedUser('work codex gave itself'),
]);
const rows = await scanCodexSessionsHistory();
expect(rows.map((r) => r.sessionId)).toEqual(['44444444-4444-7444-8444-444444444444']);
});
it('reports the most recent prompt as well as the first', async () => {
await writeRollout('66666666-6666-7666-8666-666666666666', [
sessionMeta({ id: '66666666-6666-7666-8666-666666666666', cwd: '/repo/six' }),
itemCompletedUser('the opening question'),
itemCompletedUser('a follow-up'),
itemCompletedUser('the latest thing asked'),
]);
const rows = await scanCodexSessionsHistory();
expect(rows[0].firstPrompt).toBe('the opening question');
expect(rows[0].lastPrompt).toBe('the latest thing asked');
});
it('orders rows newest first', async () => {
await writeRollout(
'77777777-7777-7777-8777-777777777777',
[sessionMeta({ id: '77777777-7777-7777-8777-777777777777', cwd: '/repo/old' }), itemCompletedUser('older')],
new Date('2026-08-01T00:00:00Z')
);
await writeRollout(
'88888888-8888-7888-8888-888888888888',
[sessionMeta({ id: '88888888-8888-7888-8888-888888888888', cwd: '/repo/new' }), itemCompletedUser('newer')],
new Date('2026-09-05T00:00:00Z')
);
const rows = await scanCodexSessionsHistory();
expect(rows.map((r) => r.workingDir)).toEqual(['/repo/new', '/repo/old']);
});
it('ignores a file too short to hold a session_meta line', async () => {
const dir = join(home, 'sessions', '2026', '09', '02');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'rollout-2026-09-02T09-05-37-short.jsonl'), '{}\n', 'utf-8');
expect(await scanCodexSessionsHistory()).toEqual([]);
});
it('survives a rollout whose lines are malformed', async () => {
await writeRollout('99999999-9999-7999-8999-999999999999', [
sessionMeta({ id: '99999999-9999-7999-8999-999999999999', cwd: '/repo/seven' }),
'{not json at all',
itemCompletedUser('still found me'),
]);
const rows = await scanCodexSessionsHistory();
expect(rows[0].firstPrompt).toBe('still found me');
});
it('collapses a long prompt to a single capped line', async () => {
await writeRollout('aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', [
sessionMeta({ id: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', cwd: '/repo/eight' }),
itemCompletedUser('line one\nline two\n' + 'y'.repeat(500)),
]);
const rows = await scanCodexSessionsHistory();
expect(rows[0].firstPrompt).not.toContain('\n');
expect(rows[0].firstPrompt!.length).toBeLessThanOrEqual(201);
expect(rows[0].firstPrompt!.endsWith('…')).toBe(true);
});
});