fix(history): read forked threads, large stores and live rollouts

A forked or spawned thread's rollout carries its own session_meta followed
by its parent's and a copy of the parent's history. A later session_meta
for a declared ancestor now opens an inherited prefix that is dropped:
by ordinal below subagent_history_start_ordinal in paginated history, and
up to the thread_settings_applied event Codex appends after the copy in
legacy history, checked against the parent's own messages when the copy
holds more than one candidate. An unrelated id still refuses the file.

Store attribution now has its own budget (--metadata-bytes) separate from
the transcript budget (--scan-bytes), reads each head in 32 KiB steps, and
--max-entries is a flag, so id lookups work on stores of thousands of
rollouts. An unattributable store file or unreadable directory is counted
instead of aborting the lookup, and a rollout that a running Codex appends
to is read as a snapshot up to its last complete line.

User records carry no phase, date bounds are UTC calendar dates, the
injected-context rule is the shared one, a symlinked sessions root is
followed as the Past Sessions scanner follows it, and --json with
--format text is refused.
This commit is contained in:
Aamer Akhter committed 2026-10-24 23:00:49 -04:00
1 parent 803e7c7f85
commit 002d876556
5 files changed
+1234 -200

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Codex rollout lines shaped like real ones, for the offline
* history tests. The structure (keys, their order, the event types around a
* fork boundary) was taken from real rollouts written by codex 0.120 to 0.163
* and from codex-rs `SessionMeta` (`protocol/src/protocol.rs`); no
* conversation content was copied.
*
* A forked or spawned thread's file, as `record_initial_history` writes it for
* `ForkPersistence::Copied`:
*
* own session_meta, parent session_meta, copied parent records,
* thread_settings_applied, the child's own records
*
* In paginated history every line also carries an `ordinal`, and the child's
* own `session_meta` declares `subagent_history_start_ordinal`.
*/
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
/** Codex's base instructions make every real session_meta line about 20 KiB. */
export const BASE_INSTRUCTIONS_BYTES = 19 * 1024;
export interface MetaOptions {
id: string;
/** Spawn parent: sets parent_thread_id, forked_from_id and source.subagent.thread_spawn. */
parent?: string;
/** Shared root (`session_id`); defaults to `parent ?? id`, as Codex writes it. */
root?: string;
mode?: 'legacy' | 'paginated';
historyStart?: number;
timestamp?: string;
baseInstructionsBytes?: number;
}
export function realMeta(options: MetaOptions): Record<string, unknown> {
const { id, parent, mode = 'legacy', timestamp = '2026-10-24T01:00:00.000Z' } = options;
const payload: Record<string, unknown> = {
session_id: options.root ?? parent ?? id,
id,
...(parent ? { forked_from_id: parent, parent_thread_id: parent } : {}),
timestamp,
cwd: '/work/repo',
originator: 'codex_exec',
cli_version: '0.163.0',
source: parent
? {
subagent: {
thread_spawn: {
parent_thread_id: parent,
depth: 1,
agent_path: null,
agent_nickname: 'Kepler',
agent_role: null,
},
},
}
: 'exec',
thread_source: parent ? 'subagent' : 'user',
...(parent ? { agent_nickname: 'Kepler' } : {}),
model_provider: 'openai',
base_instructions: { text: 'b'.repeat(options.baseInstructionsBytes ?? BASE_INSTRUCTIONS_BYTES) },
history_mode: mode,
...(options.historyStart !== undefined ? { subagent_history_start_ordinal: options.historyStart } : {}),
...(parent ? { multi_agent_version: 'v1' } : {}),
context_window: { window_id: `${id.slice(0, 8)}-window` },
};
return { timestamp, type: 'session_meta', payload };
}
export const taskStarted = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'event_msg',
payload: { type: 'task_started' },
});
export const settingsApplied = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'event_msg',
payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.5', cwd: '/work/repo' } },
});
export const developer = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'response_item',
payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'policy' }] },
});
export const injected = (tag = 'environment_context') => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `<${tag}>\n ctx\n</${tag}>` }] },
});
/** A real user turn: the response_item Codex sent plus its user_message event twin. */
export const userTurn = (text: string, timestamp = '2026-10-24T01:00:00.000Z') => [
{
timestamp,
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
},
{ timestamp, type: 'event_msg', payload: { type: 'user_message', message: text, images: [] } },
];
/** An assistant message: the event and the response_item, both carrying Codex's phase. */
export const assistantTurn = (text: string, phase: 'commentary' | 'final_answer' = 'final_answer') => [
{
timestamp: '2026-10-24T01:00:01.000Z',
type: 'event_msg',
payload: { type: 'agent_message', message: text, phase },
},
{
timestamp: '2026-10-24T01:00:01.000Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], phase },
},
];
/** Number every line, as paginated history does. */
export function withOrdinals(lines: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
return lines.map((line, ordinal) => {
const { timestamp, type, payload, ...rest } = line;
return { timestamp, ordinal, type, payload, ...rest };
});
}
export async function writeRollout(dir: string, name: string, lines: unknown[]): Promise<string> {
const path = join(dir, name);
await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
return path;
}
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview The offline history reader against rollouts that change while
* it reads them: a running Codex appending to its own rollout (or a child's),
* an unrelated rollout rewritten mid-scan, and a selected rollout replaced
* outright. `node:fs/promises` is wrapped so a test can act at an exact point
* of the read (after the open, after a read call) without timing races.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appendFile, mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type Hook = (path: string, openCount: number, handle: import('node:fs/promises').FileHandle) => Promise<void> | void;
const hooks: { onOpen?: Hook } = {};
const opens = new Map<string, number>();
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
const path = String(args[0]);
const count = (opens.get(path) ?? 0) + 1;
opens.set(path, count);
await hooks.onOpen?.(path, count, handle);
return handle;
},
};
});
const history = await import('../src/local-history.js');
const { assistantTurn, realMeta, userTurn, writeRollout } = await import('./fixtures/codex-rollout.js');
const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111';
const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111';
let dir: string;
const line = (value: unknown) => `${JSON.stringify(value)}\n`;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'local-history-live-'));
opens.clear();
hooks.onOpen = undefined;
});
afterEach(async () => {
hooks.onOpen = undefined;
await rm(dir, { recursive: true, force: true });
});
describe('rollouts that change while they are read', () => {
it('reads a snapshot of a rollout a running Codex appends to after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
// The second open is the transcript read; Codex writes a whole record and starts the next.
if (opened === path && count === 2) {
await appendFile(path, assistantTurn('answer').map(line).join(''));
await appendFile(path, '{"timestamp":"2026-10-24T01:00:02.000Z","type":"event_');
}
};
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
// The record cut mid-write is reported, not parsed and not fatal.
expect(result.scan.partialRecords).toBe(1);
expect(result.scan.malformedRecords).toBe(0);
expect(result.complete).toBe(false);
});
it('reads a family whose child is still running', async () => {
await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('root question')]);
const child = await writeRollout(dir, 'rollout-child.jsonl', [
realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }),
...userTurn('child task'),
]);
hooks.onOpen = async (opened, count) => {
// Grows during the metadata scan and again before its transcript read.
if (opened === child)
await appendFile(child, assistantTurn(`child progress ${count}`, 'commentary').map(line).join(''));
};
const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual([
'root question',
'child task',
'child progress 1',
'child progress 2',
]);
expect(result.complete).toBe(true);
});
it('counts an unrelated rollout that shrinks during its head read as unattributed', async () => {
await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
const other = await writeRollout(dir, 'rollout-other.jsonl', [realMeta({ id: CHILD }), ...userTurn('other')]);
hooks.onOpen = async (opened, count, handle) => {
if (opened !== other || count !== 1) return;
const read = handle.read.bind(handle) as (...args: unknown[]) => Promise<unknown>;
(handle as unknown as { read: (...args: unknown[]) => Promise<unknown> }).read = async (...args) => {
const result = await read(...args);
await truncate(other, 10);
return result;
};
};
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question']);
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
});
it('still refuses a selected rollout replaced by another file after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
if (opened !== path || count !== 1) return;
const replacement = join(dir, 'replacement.tmp');
await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join(''));
await rename(replacement, path);
};
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(
/changed after attribution/
);
});
it('still refuses a selected rollout truncated after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
if (opened === path && count === 2) await truncate(path, 100);
};
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(
/changed after attribution/
);
});
});
+470 -2
View File
@@ -1,10 +1,21 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
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';
@@ -155,9 +166,19 @@ describe('offline native history', () => {
expect(result.complete).toBe(false);
expect(result.scan.unattributedFiles).toBe(1);
});
it('refuses conflicting session metadata instead of attributing another conversation answer', async () => {
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')]);
@@ -244,3 +265,450 @@ describe('offline native history', () => {
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('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('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/
);
});
});