Skip to content

Commit 7b959f8

Browse files
committed
feat(scanner): support codex external session import and tail sync
1 parent 3f3dbba commit 7b959f8

2 files changed

Lines changed: 634 additions & 37 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { AgentStore } from '../src/store/AgentStore.js';
6+
import { ExternalAgentScanner } from '../src/services/ExternalAgentScanner.js';
7+
8+
describe('ExternalAgentScanner codex support', () => {
9+
let tmpHome: string;
10+
let tmpData: string;
11+
let originalHome: string | undefined;
12+
let store: AgentStore;
13+
let scanner: ExternalAgentScanner;
14+
15+
beforeEach(() => {
16+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-home-'));
17+
tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-data-'));
18+
originalHome = process.env.HOME;
19+
process.env.HOME = tmpHome;
20+
store = new AgentStore(tmpData);
21+
scanner = new ExternalAgentScanner(store, () => new Set(), { autoImport: true, maxMessages: 50 });
22+
});
23+
24+
afterEach(() => {
25+
process.env.HOME = originalHome;
26+
fs.rmSync(tmpHome, { recursive: true, force: true });
27+
fs.rmSync(tmpData, { recursive: true, force: true });
28+
});
29+
30+
it('imports codex session history, tool calls, and token usage from ~/.codex/sessions', () => {
31+
const cwd = path.join(tmpHome, 'project-codex');
32+
const sessionId = '019d5000-aaaa-7bbb-8ccc-1234567890ab';
33+
const sessionDir = path.join(tmpHome, '.codex', 'sessions', '2026', '04', '01');
34+
const sessionPath = path.join(sessionDir, `rollout-2026-04-01T10-00-00-${sessionId}.jsonl`);
35+
fs.mkdirSync(sessionDir, { recursive: true });
36+
fs.mkdirSync(cwd, { recursive: true });
37+
38+
fs.writeFileSync(sessionPath, [
39+
JSON.stringify({
40+
timestamp: '2026-04-01T15:00:00.000Z',
41+
type: 'session_meta',
42+
payload: { id: sessionId, cwd, cli_version: '0.117.0' },
43+
}),
44+
JSON.stringify({
45+
timestamp: '2026-04-01T15:00:01.000Z',
46+
type: 'event_msg',
47+
payload: { type: 'user_message', message: 'hello from codex' },
48+
}),
49+
JSON.stringify({
50+
timestamp: '2026-04-01T15:00:02.000Z',
51+
type: 'response_item',
52+
payload: {
53+
type: 'function_call',
54+
name: 'shell',
55+
call_id: 'call_1',
56+
arguments: JSON.stringify({ command: ['bash', '-lc', 'pwd'], workdir: cwd }),
57+
},
58+
}),
59+
JSON.stringify({
60+
timestamp: '2026-04-01T15:00:03.000Z',
61+
type: 'response_item',
62+
payload: {
63+
type: 'function_call_output',
64+
call_id: 'call_1',
65+
output: JSON.stringify({ output: `${cwd}\n`, metadata: { exit_code: 0, duration_seconds: 0.2 } }),
66+
},
67+
}),
68+
JSON.stringify({
69+
timestamp: '2026-04-01T15:00:04.000Z',
70+
type: 'event_msg',
71+
payload: { type: 'agent_message', message: 'codex says hi' },
72+
}),
73+
JSON.stringify({
74+
timestamp: '2026-04-01T15:00:05.000Z',
75+
type: 'event_msg',
76+
payload: {
77+
type: 'token_count',
78+
info: {
79+
total_token_usage: { input_tokens: 12, output_tokens: 5 },
80+
model_context_window: 272000,
81+
},
82+
},
83+
}),
84+
].join('\n'));
85+
86+
const agent = (scanner as unknown as {
87+
importProcess: (proc: {
88+
pid: number;
89+
provider: 'codex';
90+
args: string;
91+
cwd: string;
92+
flags: Record<string, boolean | string>;
93+
prompt?: string;
94+
model?: string;
95+
sessionId?: string;
96+
}) => ReturnType<ExternalAgentScanner['importByPid']>;
97+
}).importProcess({
98+
pid: 99999,
99+
provider: 'codex',
100+
args: `codex exec --cd '${cwd}'`,
101+
cwd,
102+
flags: {},
103+
});
104+
105+
expect(agent).not.toBeNull();
106+
expect(agent?.sessionId).toBe(sessionId);
107+
expect(agent?.config.directory).toBe(cwd);
108+
expect(agent?.messages.map((message) => message.role)).toEqual(['user', 'tool', 'assistant']);
109+
expect(agent?.messages[0].content).toBe('hello from codex');
110+
expect(agent?.messages[1].toolResult).toContain(cwd);
111+
expect(agent?.messages[2].content).toBe('codex says hi');
112+
expect(agent?.tokenUsage).toEqual({ input: 12, output: 5 });
113+
expect(agent?.contextWindow).toEqual({ used: 17, total: 272000 });
114+
});
115+
116+
it('finds codex sessions by session id from ~/.codex/sessions', () => {
117+
const sessionId = '019d5000-aaaa-7bbb-8ccc-abcdefabcdef';
118+
const sessionDir = path.join(tmpHome, '.codex', 'sessions', '2026', '04', '01');
119+
const sessionPath = path.join(sessionDir, `rollout-2026-04-01T11-00-00-${sessionId}.jsonl`);
120+
fs.mkdirSync(sessionDir, { recursive: true });
121+
fs.writeFileSync(sessionPath, JSON.stringify({
122+
timestamp: '2026-04-01T16:00:00.000Z',
123+
type: 'session_meta',
124+
payload: { id: sessionId, cwd: '/tmp/codex-project' },
125+
}));
126+
127+
const located = (scanner as unknown as {
128+
findSessionFileById: (provider: 'codex', sessionId: string, cwd: string) => string | null;
129+
}).findSessionFileById('codex', sessionId, '/tmp/codex-project');
130+
131+
expect(located).toBe(sessionPath);
132+
});
133+
134+
it('tails codex sessions and appends new messages/results from manual sessions', () => {
135+
const cwd = path.join(tmpHome, 'project-codex-tail');
136+
const sessionId = '019d5000-aaaa-7bbb-8ccc-feedfeedfeed';
137+
const sessionDir = path.join(tmpHome, '.codex', 'sessions', '2026', '04', '01');
138+
const sessionPath = path.join(sessionDir, `rollout-2026-04-01T12-00-00-${sessionId}.jsonl`);
139+
fs.mkdirSync(sessionDir, { recursive: true });
140+
fs.mkdirSync(cwd, { recursive: true });
141+
142+
fs.writeFileSync(sessionPath, [
143+
JSON.stringify({
144+
timestamp: '2026-04-01T17:00:00.000Z',
145+
type: 'session_meta',
146+
payload: { id: sessionId, cwd, cli_version: '0.117.0' },
147+
}),
148+
JSON.stringify({
149+
timestamp: '2026-04-01T17:00:01.000Z',
150+
type: 'event_msg',
151+
payload: { type: 'user_message', message: 'initial question' },
152+
}),
153+
].join('\n'));
154+
155+
const imported = (scanner as unknown as {
156+
importProcess: (proc: {
157+
pid: number;
158+
provider: 'codex';
159+
args: string;
160+
cwd: string;
161+
flags: Record<string, boolean | string>;
162+
}) => ReturnType<ExternalAgentScanner['importByPid']>;
163+
}).importProcess({
164+
pid: 99998,
165+
provider: 'codex',
166+
args: `codex exec --cd '${cwd}'`,
167+
cwd,
168+
flags: {},
169+
});
170+
171+
expect(imported).not.toBeNull();
172+
expect(imported?.messages).toHaveLength(1);
173+
174+
fs.appendFileSync(sessionPath, `\n${JSON.stringify({
175+
timestamp: '2026-04-01T17:00:02.000Z',
176+
type: 'response_item',
177+
payload: {
178+
type: 'function_call',
179+
name: 'shell',
180+
call_id: 'tail_call_1',
181+
arguments: JSON.stringify({ command: ['bash', '-lc', 'echo tail'], workdir: cwd }),
182+
},
183+
})}`);
184+
fs.appendFileSync(sessionPath, `\n${JSON.stringify({
185+
timestamp: '2026-04-01T17:00:03.000Z',
186+
type: 'response_item',
187+
payload: {
188+
type: 'function_call_output',
189+
call_id: 'tail_call_1',
190+
output: JSON.stringify({ output: 'tail\n', metadata: { exit_code: 0, duration_seconds: 0.1 } }),
191+
},
192+
})}`);
193+
fs.appendFileSync(sessionPath, `\n${JSON.stringify({
194+
timestamp: '2026-04-01T17:00:04.000Z',
195+
type: 'event_msg',
196+
payload: { type: 'agent_message', message: 'tail answer' },
197+
})}`);
198+
199+
const deltaCount = (scanner as unknown as {
200+
tailMessages: (agent: NonNullable<typeof imported>) => number;
201+
}).tailMessages(imported as NonNullable<typeof imported>);
202+
203+
expect(deltaCount).toBeGreaterThan(0);
204+
const reloaded = store.getAgent(imported!.id);
205+
expect(reloaded).not.toBeNull();
206+
expect(reloaded?.messages.map((message) => message.role)).toEqual(['user', 'tool', 'assistant']);
207+
expect(reloaded?.messages[1].toolResult).toContain('tail');
208+
expect(reloaded?.messages[2].content).toBe('tail answer');
209+
});
210+
});

0 commit comments

Comments
 (0)