Files
Codeman/test/local-history.test.ts
T
Aamer Akhter 17b2edce22 fix(history): count compressed rollouts, read each file once, degrade an unreadable parent
Codex's local_thread_store_compression rewrites cold rollouts to
*.jsonl.zst, which the store scan skipped without a trace. Each one is now
counted in compressedFiles (complete: false), skipped only when its plain
.jsonl sibling exists, as Codex's own discovery does, and a lookup that
misses names the compressed file whose name carries the wanted id. A
Zstandard file given to --file, or sitting in the store under a .jsonl
name, is recognised by its magic number.

Each selected rollout is parsed once into every view later needed of it,
so a legacy copy's boundary proof reuses the parent's own read instead of
charging it to --scan-bytes a second time (and counting its malformed or
inherited records twice).

A parent read that fails for any reason other than a budget now leaves
the boundary unproven (cut at the last candidate, unverifiedPrefixes,
complete: false) instead of failing a child whose own file is intact. A
budget error still fails the read.

--max-bytes is validated before the first file is read, next to the
other budgets, and every budget flag accepts plain decimal integers only.
2026-10-25 09:08:54 -04:00

854 lines
39 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as history from '../src/local-history.js';
import { Command } from 'commander';
import { registerHistoryCommands } from '../src/cli-history.js';
import {
assistantTurn,
developer,
injected,
realMeta,
settingsApplied,
taskStarted,
userTurn,
withOrdinals,
writeRollout,
} from './fixtures/codex-rollout.js';
const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111';
const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111';
const GRANDCHILD = 'cccccccc-1111-4111-8111-111111111111';
let dir: string;
const meta = (id: string, parent?: string) => ({
type: 'session_meta',
payload: {
id,
timestamp: '2026-10-24T01:00:00Z',
...(parent ? { source: { subagent: { thread_spawn: { parent_thread_id: parent } } } } : {}),
},
});
const row = (role: string, text: string, timestamp = '2026-10-24T01:00:00Z') => ({
type: 'response_item',
timestamp,
payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] },
});
async function fixture(id: string, records: unknown[], parent?: string) {
const path = join(dir, `rollout-${id}.jsonl`);
await writeFile(path, [meta(id, parent), ...records].map((r) => JSON.stringify(r)).join('\n') + '\n');
return path;
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'local-history-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe('offline native history', () => {
it('reads a native transcript without a server and preserves identity and timestamps', async () => {
const file = await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.records[0]).toMatchObject({
sessionId: ROOT,
rootSessionId: ROOT,
timestamp: '2026-10-24T01:00:00Z',
});
expect(result.complete).toBe(true);
});
it('resolves metadata identities rather than filenames', async () => {
await fixture(ROOT, [row('assistant', 'yes')]);
const result = await history.readLocalHistory({ sessionId: ROOT.slice(0, 8), sessionsRoot: dir });
expect(result.records[0].sessionId).toBe(ROOT);
});
it('rejects ambiguous native prefixes', async () => {
await fixture(ROOT, [row('assistant', 'one')]);
await fixture('aaaaaaaa-2222-4222-8222-222222222222', [row('assistant', 'two')]);
await expect(history.readLocalHistory({ sessionId: 'aaaaaaaa', sessionsRoot: dir })).rejects.toThrow(/ambiguous/);
});
it('gathers transitive children and retains a whole topic/date matching turn', async () => {
await fixture(ROOT, [row('user', 'other'), row('assistant', 'unrelated')]);
await fixture(
CHILD,
[row('user', 'topic question'), row('assistant', 'answer after midnight', '2026-10-25T00:01:00Z')],
ROOT
);
await fixture(GRANDCHILD, [row('user', 'topic child'), row('assistant', 'child answer')], CHILD);
const result = await history.readLocalHistory({
rootSessionId: ROOT,
sessionsRoot: dir,
topic: 'TOPIC',
before: '2026-10-24',
});
expect(result.sessions).toHaveLength(3);
expect(result.records.map((r) => r.message)).toEqual([
'topic question',
'answer after midnight',
'topic child',
'child answer',
]);
expect(result.records.every((r) => r.rootSessionId === ROOT)).toBe(true);
});
it('resolves the real native root when querying a child by id', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await fixture(GRANDCHILD, [row('assistant', 'grandchild')], CHILD);
const result = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir });
expect(result.records[0].rootSessionId).toBe(ROOT);
expect(result.sessions[0]).toMatchObject({ parentSessionId: CHILD, rootSessionId: ROOT });
});
it('labels a file-only child root unknown rather than inventing an ancestor', async () => {
const file = await fixture(CHILD, [row('assistant', 'child')], ROOT);
const result = await history.readLocalHistory({ file });
expect(result.records[0].rootSessionId).toBeNull();
expect(result.sessions[0]).toMatchObject({ parentSessionId: ROOT, rootSessionId: null });
});
it('labels missing ancestors unknown and refuses cyclic ancestry', async () => {
await fixture(CHILD, [row('assistant', 'child')], ROOT);
expect(
(await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).records[0].rootSessionId
).toBeNull();
await fixture(ROOT, [row('assistant', 'root')], CHILD);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/cyclic/);
});
it('supports legacy shared session_id genealogy', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(
join(dir, 'legacy.jsonl'),
[{ type: 'session_meta', payload: { id: CHILD, session_id: ROOT } }, row('assistant', 'legacy child')]
.map((r) => JSON.stringify(r))
.join('\n') + '\n'
);
expect((await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir })).sessions).toHaveLength(2);
});
it('parses modern events and removes adjacent duplicate representations without erasing repeated turns', async () => {
const file = await fixture(ROOT, [
row('user', 'question'),
{
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'UserMessage', content: [{ type: 'Text', text: 'question' }] },
},
},
row('assistant', 'answer'),
{
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'answer' }] },
},
},
{ type: 'event_msg', payload: { type: 'user_message', message: 'question' } },
{ type: 'event_msg', payload: { type: 'agent_message', message: 'answer' } },
]);
expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([
'question',
'answer',
'question',
'answer',
]);
});
it('preserves genuinely repeated identical messages from the same representation', async () => {
const file = await fixture(ROOT, [row('user', 'repeat'), row('user', 'repeat'), row('assistant', 'answer')]);
expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([
'repeat',
'repeat',
'answer',
]);
});
it('labels unreadable metadata as incomplete rather than hiding possible children', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(join(dir, 'unattributed.jsonl'), '{bad metadata}\n');
const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(result.complete).toBe(false);
expect(result.scan.unattributedFiles).toBe(1);
});
it('refuses a later session_meta whose id is not a declared ancestor', async () => {
// ROOT declares no parent, fork source or shared root, so CHILD can only be another conversation.
const file = await fixture(ROOT, [row('user', 'root question'), meta(CHILD), row('assistant', 'child answer')]);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/conflicting.*metadata/);
// A fork child is refused too when the inherited metadata names a thread it does not descend from.
const forked = await writeRollout(dir, 'unrelated-fork.jsonl', [
realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }),
realMeta({ id: GRANDCHILD, mode: 'legacy' }),
row('assistant', 'not ours'),
settingsApplied(),
row('assistant', 'child answer'),
]);
await expect(history.readLocalHistory({ file: forked })).rejects.toThrow(/conflicting.*metadata/);
});
it('accepts repeated metadata only when native identity and genealogy agree', async () => {
const file = await fixture(ROOT, [row('user', 'question'), meta(ROOT), row('assistant', 'answer')]);
expect((await history.readLocalHistory({ file })).records.map((record) => record.message)).toEqual([
'question',
'answer',
]);
await fixture(CHILD, [meta(CHILD, GRANDCHILD), row('assistant', 'child answer')], ROOT);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(
/conflicting.*metadata/
);
});
it('labels skipped malformed and pending partial records', async () => {
const file = await fixture(ROOT, [row('assistant', 'complete')]);
const { appendFile } = await import('node:fs/promises');
await appendFile(file, 'not json\n{"type":');
const result = await history.readLocalHistory({ file });
expect(result.complete).toBe(false);
expect(result.scan.malformedRecords).toBe(1);
expect(result.scan.partialRecords).toBe(1);
expect(result.records[0].message).toBe('complete');
});
it('rejects invalid UTF8, nonregular inputs and symlinks', async () => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
const linked = join(dir, 'linked.jsonl');
await symlink(file, linked);
await expect(history.readLocalHistory({ file: linked })).rejects.toThrow(/regular|symlink/);
await expect(history.readLocalHistory({ file: dir })).rejects.toThrow(/regular/);
await writeFile(file, Buffer.from([0xff, 0x0a]));
await expect(history.readLocalHistory({ file })).rejects.toThrow(/UTF8/);
});
it('fails closed for byte budgets and preserves valid JSON for successful output', async () => {
const file = await fixture(ROOT, [row('assistant', '😀'.repeat(80))]);
await expect(history.readLocalHistory({ file, scanBytes: 32 })).rejects.toThrow(/scan.*budget/);
const result = await history.readLocalHistory({ file });
expect(() => history.formatLocalHistory(result, 'json', 40)).toThrow(/output.*budget/);
const output = history.formatLocalHistory(result, 'json', 8192);
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(8192);
expect(JSON.parse(output).records[0].message).toBe('😀'.repeat(80));
});
it.each([{ after: '2026-02-30' }, { before: 'bad' }, { after: '2026-10-25', before: '2026-10-24' }, { topic: ' ' }])(
'rejects invalid filters %j',
async (filter) => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
await expect(history.readLocalHistory({ file, ...filter })).rejects.toThrow(/date|topic/);
}
);
it('rejects missing selectors, multiple selectors, empty matches and absent native ids', async () => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
await expect(history.readLocalHistory({})).rejects.toThrow(/exactly one/);
await expect(history.readLocalHistory({ file, sessionId: ROOT })).rejects.toThrow(/exactly one/);
await expect(history.readLocalHistory({ file, topic: 'absent' })).rejects.toThrow(/evidence/);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/missing/);
});
it('rejects exhausted directory traversal rather than returning an incomplete family', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await mkdir(join(dir, 'nested'));
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await expect(history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, maxEntries: 1 })).rejects.toThrow(
/scan.*budget/
);
});
it('executes the offline CLI with complete JSON and no internal parser fields', async () => {
const file = await fixture(ROOT, [row('assistant', 'offline')]);
let output = '';
const program = new Command().exitOverride().configureOutput({
writeOut: (text) => {
output += text;
},
writeErr: () => {},
});
registerHistoryCommands(program);
await program.parseAsync(['history', 'read', '--file', file, '--json'], { from: 'user' });
const result = JSON.parse(output);
expect(result.records[0].message).toBe('offline');
expect(result.records[0]).not.toHaveProperty('representation');
await expect(
program.parseAsync(['history', 'read', '--file', file, '--session-id', ROOT], { from: 'user' })
).rejects.toThrow(/exactly one/);
});
it('registers offline commands and reports invalid selectors without process exit', async () => {
const program = new Command().exitOverride();
registerHistoryCommands(program);
expect(program.commands.find((c) => c.name() === 'history')?.commands.map((c) => c.name())).toEqual(['read']);
});
});
const messages = (result: history.LocalHistoryResult) =>
result.records.map((r) => `${r.sessionId.slice(0, 1)}:${r.message}`);
/** A parent R, and a legacy child C whose file copies R's history before its own, as Codex writes it. */
async function legacyFamily(root: string, parentExtra: unknown[] = [], copiedExtra: unknown[] = []) {
await mkdir(root, { recursive: true });
const parentHistory = [
taskStarted(),
developer(),
injected(),
...userTurn('parent question'),
...assistantTurn('parent commentary', 'commentary'),
...assistantTurn('parent answer'),
];
await writeRollout(root, `rollout-${ROOT}.jsonl`, [
realMeta({ id: ROOT }),
...parentHistory,
...parentExtra,
...userTurn('parent later question'),
...assistantTurn('parent later answer'),
]);
return writeRollout(root, `rollout-${CHILD}.jsonl`, [
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...parentHistory,
...copiedExtra,
settingsApplied(),
taskStarted(),
...userTurn('child task'),
...assistantTurn('child commentary', 'commentary'),
...assistantTurn('child answer'),
]);
}
describe('forked and spawned threads', () => {
it('drops the parent history a legacy fork copies, so the child reads as itself', async () => {
const childFile = await legacyFamily(dir);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.complete).toBe(true);
expect(child.scan.inheritedPrefixes).toBe(1);
expect(child.sessions[0]).toMatchObject({ sessionId: CHILD, parentSessionId: ROOT, rootSessionId: ROOT });
// The whole family: every message once, the copy not repeated under the child.
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual([
'a:parent question',
'a:parent commentary',
'a:parent answer',
'a:parent later question',
'a:parent later answer',
'b:child task',
'b:child commentary',
'b:child answer',
]);
expect(family.complete).toBe(true);
// An explicit file needs no store: the copy ends at the one thread_settings_applied.
const file = await history.readLocalHistory({ file: childFile });
expect(messages(file)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(file.complete).toBe(true);
});
it('finds the end of a legacy copy that carries the parent own thread_settings_applied events', async () => {
// The parent compacted (or checkpointed its first turn) before the fork, so the copy holds that event too.
const parentSettings = [
settingsApplied(),
...userTurn('parent second question'),
...assistantTurn('parent second answer'),
];
const childFile = await legacyFamily(dir, parentSettings, parentSettings);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.complete).toBe(true);
// Without the parent's file the boundary cannot be proven: nothing of the parent is attributed to the child.
const alone = await history.readLocalHistory({ file: childFile });
expect(messages(alone)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(alone.scan.unverifiedPrefixes).toBe(1);
expect(alone.complete).toBe(false);
});
it('keeps the child own later thread_settings_applied events on its side of the cut', async () => {
await legacyFamily(dir);
const path = join(dir, `rollout-${CHILD}.jsonl`);
const { appendFile } = await import('node:fs/promises');
await appendFile(
path,
[settingsApplied(), ...userTurn('child second task'), ...assistantTurn('child second answer')]
.map((line) => JSON.stringify(line))
.join('\n') + '\n'
);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual([
'b:child task',
'b:child commentary',
'b:child answer',
'b:child second task',
'b:child second answer',
]);
expect(child.complete).toBe(true);
});
it('accepts the copied metadata of an ancestor that was itself a fork', async () => {
const file = await writeRollout(dir, 'grandchild.jsonl', [
realMeta({ id: GRANDCHILD, parent: CHILD, root: ROOT }),
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...userTurn('root question'),
settingsApplied(),
...userTurn('child question'),
settingsApplied(),
...userTurn('grandchild task'),
...assistantTurn('grandchild answer'),
]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['c:grandchild task', 'c:grandchild answer']);
expect(result.records[0].rootSessionId).toBe(ROOT);
});
it('marks a legacy copy that never reached its end marker incomplete and keeps none of it', async () => {
const file = await writeRollout(dir, 'cut.jsonl', [
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...userTurn('parent question'),
]);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/);
await writeRollout(dir, 'cut.jsonl', [
realMeta({ id: CHILD, parent: ROOT }),
...userTurn('child before copy'),
realMeta({ id: ROOT }),
...userTurn('parent question'),
]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['b:child before copy']);
expect(result.scan.incompletePrefixes).toBe(1);
expect(result.complete).toBe(false);
});
it('drops the inherited records of a paginated sub-agent by ordinal, as Codex projects them', async () => {
const inherited = [
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 7 }),
realMeta({ id: ROOT, mode: 'paginated' }),
taskStarted(),
injected(),
...userTurn('parent question'),
...assistantTurn('parent answer').slice(1),
];
expect(inherited).toHaveLength(7);
const lines = withOrdinals([
...inherited,
settingsApplied(),
taskStarted(),
...assistantTurn('child commentary', 'commentary'),
...assistantTurn('child answer'),
]);
await writeRollout(
dir,
`rollout-${ROOT}.jsonl`,
withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')])
);
await writeRollout(dir, `rollout-${CHILD}.jsonl`, lines);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child commentary', 'b:child answer']);
expect(child.records.map((r) => r.phase)).toEqual(['commentary', 'final_answer']);
expect(child.complete).toBe(true);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual(['a:parent question', 'b:child commentary', 'b:child answer']);
});
it('reports a paginated copy that ends before its declared start ordinal', async () => {
const file = await writeRollout(
dir,
'paginated-cut.jsonl',
withOrdinals([
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 9 }),
realMeta({ id: ROOT, mode: 'paginated' }),
...userTurn('parent question'),
...assistantTurn('parent answer'),
])
);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/);
// In a family read the parent still answers, and the cut child makes the result incomplete.
await writeRollout(
dir,
`rollout-${ROOT}.jsonl`,
withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')])
);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual(['a:parent question']);
expect(family.scan.incompletePrefixes).toBe(1);
expect(family.complete).toBe(false);
await rm(join(dir, `rollout-${ROOT}.jsonl`));
// A paginated record without an ordinal is skipped, visibly, as Codex's projection skips it.
const lines = withOrdinals([
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 2 }),
realMeta({ id: ROOT, mode: 'paginated' }),
settingsApplied(),
...assistantTurn('child answer'),
]);
await writeRollout(dir, 'paginated-cut.jsonl', [...lines, assistantTurn('stray')[0]]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['b:child answer']);
expect(result.scan.malformedRecords).toBe(1);
expect(result.complete).toBe(false);
});
});
describe('one read per file, and a parent that cannot be read', () => {
const parentSettings = [
settingsApplied(),
...userTurn('parent second question'),
...assistantTurn('parent second answer'),
];
const sizes = async () => {
const { stat } = await import('node:fs/promises');
const parent = (await stat(join(dir, `rollout-${ROOT}.jsonl`))).size;
const child = (await stat(join(dir, `rollout-${CHILD}.jsonl`))).size;
return { parent, child };
};
it('charges the parent of a legacy copy to --scan-bytes once, even when it proves the boundary', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { parent, child } = await sizes();
// A budget of exactly the two files: the family read selects the parent and proves the child's cut with it.
const family = await history.readLocalHistory({
rootSessionId: ROOT,
sessionsRoot: dir,
scanBytes: parent + child,
});
expect(family.scan.bytesRead).toBe(parent + child);
expect(family.complete).toBe(true);
expect(messages(family).filter((m) => m.startsWith('b:'))).toEqual([
'b:child task',
'b:child commentary',
'b:child answer',
]);
// One byte less fails closed rather than returning less.
await expect(
history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, scanBytes: parent + child - 1 })
).rejects.toThrow(/scan byte budget/);
// A child read pays for the parent it proves against, once.
const alone = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: parent + child });
expect(alone.scan.bytesRead).toBe(parent + child);
expect(alone.complete).toBe(true);
});
it('counts a parent record once when the parent is both selected and the proof of a child', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { appendFile } = await import('node:fs/promises');
await appendFile(join(dir, `rollout-${ROOT}.jsonl`), '{not json\n');
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(family.scan.malformedRecords).toBe(1);
expect(family.scan.inheritedPrefixes).toBe(1);
});
it('falls back to the unproven cut when the parent file cannot be read, instead of failing the child', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { appendFile } = await import('node:fs/promises');
await appendFile(join(dir, `rollout-${ROOT}.jsonl`), Buffer.from([0xff, 0x0a]));
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.scan.unverifiedPrefixes).toBe(1);
expect(child.complete).toBe(false);
// The parent's own read still fails: its evidence is what is broken.
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(/invalid UTF8/);
});
it('still fails when the parent read runs out of budget', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { child } = await sizes();
await expect(
history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: child + 1 })
).rejects.toThrow(/scan byte budget/);
});
});
describe('compressed rollouts', () => {
const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x00, 0x00, 0x00]);
const compressedName = (id: string) => `rollout-2026-10-24T01-00-00-${id}.jsonl.zst`;
it('counts a .jsonl.zst rollout, so a family with a compressed member is incomplete', async () => {
await legacyFamily(dir);
await mkdir(join(dir, '2026', '10', '24'), { recursive: true });
await writeFile(join(dir, '2026', '10', '24', compressedName(GRANDCHILD)), ZSTD);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(family.scan.compressedFiles).toBe(1);
expect(family.scan.unattributedFiles).toBe(0);
expect(family.complete).toBe(false);
});
it('names the compressed rollout when the wanted thread is not otherwise found', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
const path = join(dir, compressedName(CHILD));
await writeFile(path, ZSTD);
const error = await history.readLocalHistory({ sessionId: CHILD.slice(0, 8), sessionsRoot: dir }).catch((e) => e);
expect(error.message).toContain(path);
expect(error.message).toMatch(/compressed rollout/);
// A compressed file whose name does not carry the id is still reported, by count.
const other = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }).catch((e) => e);
expect(other.message).toMatch(/1 compressed \.jsonl\.zst rollout\(s\) could not be read/);
});
it('skips a compressed rollout whose plain sibling exists, as Codex does', async () => {
const plain = await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(`${plain}.zst`, ZSTD);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.scan.compressedFiles).toBe(0);
expect(result.complete).toBe(true);
});
it('refuses a Zstandard file given to --file, and counts one in the store whatever its name', async () => {
const file = join(dir, 'renamed.jsonl');
await writeFile(file, ZSTD);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/Zstandard-compressed/);
await fixture(ROOT, [row('assistant', 'root')]);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
});
});
describe('large and untidy stores', () => {
it('finds one id among thousands of rollouts with real-sized session_meta at the default budgets', async () => {
const count = 3500;
const base = realMeta({ id: ROOT });
const pad = JSON.stringify(base).length;
expect(pad).toBeGreaterThan(19 * 1024);
const writes: Array<Promise<void>> = [];
for (let i = 0; i < count; i++) {
const day = join(dir, '2026', String(1 + (i % 12)).padStart(2, '0'), String(1 + (i % 28)).padStart(2, '0'));
const id = `${i.toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`;
writes.push(
mkdir(day, { recursive: true }).then(() =>
writeFile(
join(day, `rollout-${id}.jsonl`),
[realMeta({ id }), ...userTurn(`question ${i}`), ...assistantTurn(`answer ${i}`)]
.map((line) => JSON.stringify(line))
.join('\n') + '\n'
)
)
);
}
await Promise.all(writes);
const target = `${(count - 1).toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`;
const result = await history.readLocalHistory({ sessionId: target, sessionsRoot: dir });
expect(messages(result)).toEqual([`0:question ${count - 1}`, `0:answer ${count - 1}`]);
expect(result.complete).toBe(true);
// Metadata is charged to its own budget; the transcript budget only paid for the one rollout.
expect(result.scan.metadataBytesRead).toBeGreaterThan(history.HISTORY_MAX_SCAN_BYTES / 4);
expect(result.scan.bytesRead).toBeLessThan(64 * 1024);
// The store-wide caps fail closed and are raisable.
await expect(
history.readLocalHistory({ sessionId: target, sessionsRoot: dir, metadataBytes: 1024 * 1024 })
).rejects.toThrow(/metadata.*--metadata-bytes/);
await expect(history.readLocalHistory({ sessionId: target, sessionsRoot: dir, maxEntries: 100 })).rejects.toThrow(
/entry budget.*--max-entries/
);
}, 60_000);
it('reads only the head of each rollout to attribute it, not its whole window', async () => {
await writeRollout(dir, 'rollout-big.jsonl', [
realMeta({ id: ROOT }),
...Array.from({ length: 400 }, (_, i) => assistantTurn(`${i} ${'x'.repeat(2048)}`)).flat(),
]);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir, scanBytes: 8 * 1024 * 1024 });
expect(result.records).toHaveLength(400);
// One 32 KiB step holds the ~20 KiB session_meta line; the 128 KiB window is only the ceiling.
expect(result.scan.metadataBytesRead).toBe(32 * 1024);
});
it.each([
['invalid UTF8 in its head', Buffer.from([0x7b, 0xff, 0xfe, 0x7d, 0x0a])],
[
'no session_meta within 128 KiB',
Buffer.from(`${JSON.stringify({ type: 'event_msg', payload: {} })}\n`.repeat(6000)),
],
])('counts one unrelated file with %s as unattributed instead of failing the lookup', async (_label, bytes) => {
await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]);
await writeFile(join(dir, 'rollout-broken.jsonl'), bytes);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
// The same file named explicitly is still an error.
await expect(history.readLocalHistory({ file: join(dir, 'rollout-broken.jsonl') })).rejects.toThrow(/UTF8|128 KiB/);
});
it('counts an unreadable directory instead of failing the lookup', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
const locked = join(dir, 'locked');
await mkdir(locked);
await chmod(locked, 0);
try {
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records[0].message).toBe('answer');
expect(result.scan.unreadableDirectories).toBe(process.getuid?.() === 0 ? 0 : 1);
} finally {
await chmod(locked, 0o700);
}
});
it('follows a sessions root that is itself a symlink, and still skips links below it', async () => {
const real = join(dir, 'real-sessions');
await mkdir(real);
await writeRollout(real, 'rollout-root.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('answer'),
]);
const linkedRoot = join(dir, 'sessions-link');
await symlink(real, linkedRoot);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.complete).toBe(true);
await symlink(join(real, 'rollout-root.jsonl'), join(real, 'rollout-link.jsonl'));
const withLink = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot });
expect(withLink.scan.skippedSymlinks).toBe(1);
await expect(
history.readLocalHistory({ sessionId: ROOT, sessionsRoot: join(real, 'rollout-root.jsonl') })
).rejects.toThrow(/must be a directory/);
});
});
describe('records, filters and output', () => {
it('gives user records no phase and keeps the phase Codex recorded on assistant messages', async () => {
const file = await writeRollout(dir, 'phases.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('working', 'commentary'),
...assistantTurn('done'),
row('assistant', 'legacy answer without a phase'),
]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => [r.role, r.phase])).toEqual([
['user', null],
['assistant', 'commentary'],
['assistant', 'final_answer'],
['assistant', null],
]);
});
it('drops every injected-context wrapper but never a typed prompt that starts with #', async () => {
const file = await writeRollout(dir, 'injected.jsonl', [
realMeta({ id: ROOT }),
injected('environment_context'),
injected('recommended_plugins'),
injected('subagent_notification'),
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '# AGENTS.md instructions for /x' }],
},
},
...userTurn('# a heading the user typed'),
...assistantTurn('answer'),
]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => r.message)).toEqual(['# a heading the user typed', 'answer']);
});
it('compares date bounds as UTC calendar dates', async () => {
const file = await writeRollout(dir, 'dates.jsonl', [
realMeta({ id: ROOT }),
// 23:30 at UTC-2 is 01:30 UTC the next day.
...userTurn('late evening west of UTC', '2026-10-24T23:30:00.000-02:00'),
...userTurn('plain UTC', '2026-10-24T12:00:00.000Z'),
]);
const after = await history.readLocalHistory({ file, after: '2026-10-25' });
expect(after.records.map((r) => r.message)).toEqual(['late evening west of UTC']);
const on = await history.readLocalHistory({ file, before: '2026-10-24' });
expect(on.records.map((r) => r.message)).toEqual(['plain UTC']);
});
it('reads the default store at $CODEX_HOME/sessions, else ~/.codex/sessions', async () => {
const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME };
try {
process.env.CODEX_HOME = join(dir, 'codex-home');
await mkdir(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), { recursive: true });
await writeRollout(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), 'rollout-a.jsonl', [
realMeta({ id: ROOT }),
...userTurn('from CODEX_HOME'),
]);
expect((await history.readLocalHistory({ sessionId: ROOT })).records[0].message).toBe('from CODEX_HOME');
delete process.env.CODEX_HOME;
process.env.HOME = join(dir, 'home');
await mkdir(join(dir, 'home', '.codex', 'sessions'), { recursive: true });
await writeRollout(join(dir, 'home', '.codex', 'sessions'), 'rollout-b.jsonl', [
realMeta({ id: ROOT }),
...userTurn('from ~/.codex'),
]);
const result = await history.readLocalHistory({ sessionId: ROOT });
expect(result.records[0].message).toBe('from ~/.codex');
expect(result.sessions[0].path).toBe(join(dir, 'home', '.codex', 'sessions', 'rollout-b.jsonl'));
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
describe('history CLI flags', () => {
const run = async (args: string[]) => {
let output = '';
const program = new Command().exitOverride().configureOutput({
writeOut: (text) => {
output += text;
},
writeErr: () => {},
});
registerHistoryCommands(program);
await program.parseAsync(['history', 'read', ...args], { from: 'user' });
return output;
};
it('prints text with --format text, a header line and one block per record', async () => {
const file = await writeRollout(dir, 'text.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('answer'),
]);
const output = await run(['--file', file, '--format', 'text']);
const blocks = output.trimEnd().split('\n\n');
expect(blocks[0]).toMatch(/^source=codex-jsonl complete=true scannedBytes=\d+ metadataBytes=\d+$/);
expect(blocks[1]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:00.000Z user]\nquestion`);
expect(blocks[2]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:01.000Z assistant final_answer]\nanswer`);
expect(() => JSON.parse(output)).toThrow();
});
it('refuses --json together with --format text, and accepts either alone', async () => {
const file = await writeRollout(dir, 'flags.jsonl', [realMeta({ id: ROOT }), ...assistantTurn('answer')]);
await expect(run(['--file', file, '--json', '--format', 'text'])).rejects.toThrow(
/--json conflicts with --format text/
);
expect(JSON.parse(await run(['--file', file, '--json'])).records[0].message).toBe('answer');
expect(JSON.parse(await run(['--file', file, '--json', '--format', 'json'])).records[0].message).toBe('answer');
expect(await run(['--file', file, '--format', 'text'])).toMatch(/^source=codex-jsonl/);
});
it('validates --max-bytes before any file is read', async () => {
// A missing store would be the first thing a read touches; an invalid --max-bytes must be refused first.
const missing = join(dir, 'no-such-store');
for (const value of ['0', String(history.HISTORY_MAX_OUTPUT_BYTES + 1), 'lots', '0x10', '1e3', ' 5'])
await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', value])).rejects.toThrow(
/Invalid --max-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', '65536'])).rejects.toThrow(
/Sessions root not found/
);
});
it('reads budget flags as plain decimal integers only', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '0x100000'])).rejects.toThrow(
/Invalid --scan-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1e3'])).rejects.toThrow(
/Invalid --max-entries/
);
expect(
JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '1048576'])).complete
).toBe(true);
});
it('passes the store caps through and validates them', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1'])).rejects.toThrow(
/--max-entries/
);
expect(JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '2'])).complete).toBe(
true
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', '16'])).rejects.toThrow(
/--metadata-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', 'lots'])).rejects.toThrow(
/Invalid --metadata-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '0'])).rejects.toThrow(
/Invalid --max-entries/
);
});
});