mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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.
209 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|