mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
5a3f0c8923
* fix(daemon): archive owned Codex threads after terminal completion * fix(daemon): archive owned Codex threads after interrupted close --------- Co-authored-by: lefarcen <935902669@qq.com>
237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
import { chmodSync, mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
import { describe, expect, it } from 'vitest';
|
||
import Database from 'better-sqlite3';
|
||
|
||
import {
|
||
codexSessionIdFromRunEvents,
|
||
extractCodexLastTurnFirstCallUsage,
|
||
readCodexRolloutFirstCall,
|
||
} from '../src/codex-rollout-usage.js';
|
||
|
||
// Shapes mirror real codex 0.133.0 rollout JSONL (`~/.codex/sessions/**/rollout-*.jsonl`):
|
||
// each turn opens with a `task_started` event_msg, and every model call emits a
|
||
// `token_count` event_msg whose `info.last_token_usage` is that single call's
|
||
// usage (OpenAI-style: input_tokens INCLUDES the cached_input_tokens subset).
|
||
function taskStarted(): string {
|
||
return JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } });
|
||
}
|
||
function tokenCount(input: number | null, cached: number | null): string {
|
||
return JSON.stringify({
|
||
type: 'event_msg',
|
||
payload: {
|
||
type: 'token_count',
|
||
info:
|
||
input === null
|
||
? { last_token_usage: null }
|
||
: { last_token_usage: { input_tokens: input, cached_input_tokens: cached } },
|
||
},
|
||
});
|
||
}
|
||
|
||
describe('extractCodexLastTurnFirstCallUsage', () => {
|
||
it('reports the first call of the LAST turn, not the whole-session aggregate', () => {
|
||
// Real numbers from a 3-turn session: turn-1 is a cold open (18.5%), turn-2
|
||
// a warm resume (96.2%). The daemon just finished turn-3 (95%).
|
||
const rollout = [
|
||
taskStarted(),
|
||
tokenCount(13137, 2432), // turn-1 first call
|
||
tokenCount(13160, 2560), // within-turn-1 follow-up — must be ignored
|
||
taskStarted(),
|
||
tokenCount(13171, 12672), // turn-2 first call (the 96% spec number)
|
||
taskStarted(),
|
||
tokenCount(13342, 12672), // turn-3 first call — this is what we want
|
||
tokenCount(20000, 18000), // within-turn-3 follow-up — must be ignored
|
||
].join('\n');
|
||
|
||
const result = extractCodexLastTurnFirstCallUsage(rollout);
|
||
expect(result).toEqual({
|
||
first_call_input_tokens: 13342,
|
||
first_call_cache_read_input_tokens: 12672,
|
||
first_call_cache_hit_ratio: 12672 / 13342,
|
||
});
|
||
});
|
||
|
||
it('skips zero-input placeholder calls and uses the turn’s real first call', () => {
|
||
const rollout = [
|
||
taskStarted(),
|
||
tokenCount(0, 0), // interrupted/placeholder call — no usable ratio
|
||
tokenCount(40318, 15744), // the turn’s real opening call
|
||
].join('\n');
|
||
|
||
const result = extractCodexLastTurnFirstCallUsage(rollout);
|
||
expect(result?.first_call_input_tokens).toBe(40318);
|
||
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(15744 / 40318);
|
||
});
|
||
|
||
it('treats a missing cached subset as a zero cache hit on a cold open', () => {
|
||
const rollout = [taskStarted(), tokenCount(15761, null)].join('\n');
|
||
const result = extractCodexLastTurnFirstCallUsage(rollout);
|
||
expect(result).toEqual({
|
||
first_call_input_tokens: 15761,
|
||
first_call_cache_read_input_tokens: 0,
|
||
first_call_cache_hit_ratio: 0,
|
||
});
|
||
});
|
||
|
||
it('returns null when the last turn produced no usable token_count yet', () => {
|
||
const rollout = [
|
||
taskStarted(),
|
||
tokenCount(13171, 12672),
|
||
taskStarted(), // a fresh turn opened but no call has landed
|
||
tokenCount(null, null),
|
||
].join('\n');
|
||
expect(extractCodexLastTurnFirstCallUsage(rollout)).toBeNull();
|
||
});
|
||
|
||
it('tolerates blank lines and non-JSON noise without throwing', () => {
|
||
const rollout = ['', 'not json', taskStarted(), ' ', tokenCount(13342, 12672)].join('\n');
|
||
const result = extractCodexLastTurnFirstCallUsage(rollout);
|
||
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(12672 / 13342);
|
||
});
|
||
});
|
||
|
||
describe('codexSessionIdFromRunEvents', () => {
|
||
const statusEvent = (extra: Record<string, unknown>) => ({
|
||
event: 'agent',
|
||
data: { type: 'status', label: 'initializing', ...extra },
|
||
});
|
||
|
||
it('returns the thread id from the initializing status event', () => {
|
||
const events = [
|
||
statusEvent({ sessionId: '019eef4f-7409-7c82-bebe-30504eed3959' }),
|
||
{ event: 'agent', data: { type: 'status', label: 'thinking' } },
|
||
];
|
||
expect(codexSessionIdFromRunEvents(events)).toBe(
|
||
'019eef4f-7409-7c82-bebe-30504eed3959',
|
||
);
|
||
});
|
||
|
||
it('ignores status events without a session id and non-status events', () => {
|
||
const events = [
|
||
{ event: 'agent', data: { type: 'usage', usage: { input_tokens: 1 } } },
|
||
statusEvent({ label: 'thinking' }),
|
||
];
|
||
expect(codexSessionIdFromRunEvents(events)).toBeNull();
|
||
});
|
||
|
||
it('returns null for malformed input', () => {
|
||
expect(codexSessionIdFromRunEvents(null)).toBeNull();
|
||
expect(codexSessionIdFromRunEvents('nope')).toBeNull();
|
||
expect(codexSessionIdFromRunEvents([{ event: 'agent' }, 42])).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('readCodexRolloutFirstCall', () => {
|
||
// Plants a rollout under <home>/sessions/<y>/<m>/<d>/ exactly as codex names
|
||
// them, so the locate-and-read layer is covered (not just the pure extractor).
|
||
function plantRollout(sessionId: string, lines: string[]): string {
|
||
const home = mkdtempSync(path.join(tmpdir(), 'codex-home-'));
|
||
const dayDir = path.join(home, 'sessions', '2026', '06', '24');
|
||
mkdirSync(dayDir, { recursive: true });
|
||
writeFileSync(
|
||
path.join(dayDir, `rollout-2026-06-24T12-00-00-${sessionId}.jsonl`),
|
||
lines.join('\n'),
|
||
);
|
||
return home;
|
||
}
|
||
const rollout = (sid: string) => [
|
||
JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } }),
|
||
JSON.stringify({
|
||
type: 'event_msg',
|
||
payload: {
|
||
type: 'token_count',
|
||
info: { last_token_usage: { input_tokens: 13342, cached_input_tokens: 12672 } },
|
||
},
|
||
}),
|
||
];
|
||
|
||
it('locates the rollout by session id under CODEX_HOME and extracts first-call', async () => {
|
||
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
|
||
const home = plantRollout(sid, rollout(sid));
|
||
const result = await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid });
|
||
expect(result?.first_call_input_tokens).toBe(13342);
|
||
expect(result?.first_call_cache_hit_ratio).toBeCloseTo(12672 / 13342);
|
||
});
|
||
|
||
it('preserves first-call usage after an owned thread has been archived', async () => {
|
||
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
|
||
const home = plantRollout(sid, rollout(sid));
|
||
try {
|
||
const before = await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid });
|
||
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
|
||
mkdirSync(path.join(home, 'archived_sessions'));
|
||
renameSync(path.join(home, 'sessions/2026/06/24', name), path.join(home, 'archived_sessions', name));
|
||
expect(await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid })).toEqual(before);
|
||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||
});
|
||
|
||
it('retains indexed archived usage after the flat archive exceeds the scan limit', async () => {
|
||
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
|
||
const home = plantRollout(sid, rollout(sid));
|
||
try {
|
||
const archive = path.join(home, 'archived_sessions');
|
||
mkdirSync(archive);
|
||
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
|
||
const archivedPath = path.join(archive, name);
|
||
renameSync(path.join(home, 'sessions/2026/06/24', name), archivedPath);
|
||
// Codex archives into one flat directory, while its native primary-key
|
||
// index resolves a known thread without scanning other conversations.
|
||
for (let i = 0; i < 2_049; i += 1) writeFileSync(path.join(archive, `unrelated-${i}`), '');
|
||
const db = new Database(path.join(home, 'state_5.sqlite'));
|
||
try {
|
||
db.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER NOT NULL)');
|
||
db.prepare('INSERT INTO threads VALUES (?, ?, 1)').run(sid, archivedPath);
|
||
} finally { db.close(); }
|
||
expect((await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid }))?.first_call_input_tokens).toBe(13342);
|
||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||
});
|
||
|
||
it.each(['outside archive', 'other thread', 'symlink', 'writable file'])(
|
||
'does not trust an indexed rollout pointing to %s',
|
||
async (scenario) => {
|
||
const sid = '019eef4f-7409-7c82-bebe-30504eed3959';
|
||
const home = plantRollout(sid, rollout(sid));
|
||
try {
|
||
const archive = path.join(home, 'archived_sessions');
|
||
mkdirSync(archive);
|
||
const name = `rollout-2026-06-24T12-00-00-${sid}.jsonl`;
|
||
const activePath = path.join(home, 'sessions/2026/06/24', name);
|
||
const target = path.join(home, name);
|
||
renameSync(activePath, target);
|
||
let indexedPath = path.join(archive, name);
|
||
if (scenario === 'outside archive') indexedPath = target;
|
||
else if (scenario === 'other thread') {
|
||
indexedPath = path.join(archive, 'rollout-2026-06-24T12-00-00-20000000-0000-4000-8000-000000000002.jsonl');
|
||
renameSync(target, indexedPath);
|
||
} else if (scenario === 'symlink') symlinkSync(target, indexedPath);
|
||
else {
|
||
renameSync(target, indexedPath);
|
||
chmodSync(indexedPath, 0o666);
|
||
}
|
||
for (let i = 0; i < 2_049; i += 1) writeFileSync(path.join(archive, `unrelated-${i}`), '');
|
||
const db = new Database(path.join(home, 'state_5.sqlite'));
|
||
try {
|
||
db.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER NOT NULL)');
|
||
db.prepare('INSERT INTO threads VALUES (?, ?, 1)').run(sid, indexedPath);
|
||
} finally { db.close(); }
|
||
expect(await readCodexRolloutFirstCall({ codexHome: home, sessionId: sid })).toBeNull();
|
||
} finally { rmSync(home, { recursive: true, force: true }); }
|
||
},
|
||
);
|
||
|
||
it('returns null (never throws) when the session id has no matching rollout', async () => {
|
||
const home = plantRollout('some-other-session', rollout('some-other-session'));
|
||
await expect(
|
||
readCodexRolloutFirstCall({ codexHome: home, sessionId: 'missing-session' }),
|
||
).resolves.toBeNull();
|
||
});
|
||
|
||
it('returns null when no session id is given', async () => {
|
||
await expect(
|
||
readCodexRolloutFirstCall({ codexHome: '/nonexistent', sessionId: null }),
|
||
).resolves.toBeNull();
|
||
});
|
||
});
|