Files
Codeman/test/codex-transcript.test.ts
T

208 lines
8.7 KiB
TypeScript
Raw Normal View History

/**
* 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);
});
});