@ai-devkit/agent-manager 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.d.ts +4 -0
  3. package/dist/AgentManager.d.ts.map +1 -1
  4. package/dist/AgentManager.js +97 -64
  5. package/dist/AgentManager.js.map +1 -1
  6. package/dist/__tests__/AgentManager.test.js +564 -0
  7. package/dist/__tests__/AgentManager.test.js.map +1 -0
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  9. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
  11. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  13. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  15. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  16. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  17. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
  18. package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
  19. package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
  20. package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
  21. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
  22. package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
  23. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
  24. package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
  25. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
  26. package/dist/__tests__/utils/matching.test.js +324 -0
  27. package/dist/__tests__/utils/matching.test.js.map +1 -0
  28. package/dist/__tests__/utils/process.test.js +177 -0
  29. package/dist/__tests__/utils/process.test.js.map +1 -0
  30. package/dist/__tests__/utils/session.test.js +158 -0
  31. package/dist/__tests__/utils/session.test.js.map +1 -0
  32. package/dist/adapters/AgentAdapter.d.ts +1 -1
  33. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  34. package/dist/adapters/AgentAdapter.js +8 -7
  35. package/dist/adapters/AgentAdapter.js.map +1 -1
  36. package/dist/adapters/ClaudeCodeAdapter.js +66 -77
  37. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  38. package/dist/adapters/CodexAdapter.d.ts +13 -1
  39. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  40. package/dist/adapters/CodexAdapter.js +208 -132
  41. package/dist/adapters/CodexAdapter.js.map +1 -1
  42. package/dist/adapters/CopilotAdapter.d.ts +44 -0
  43. package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
  44. package/dist/adapters/CopilotAdapter.js +314 -0
  45. package/dist/adapters/CopilotAdapter.js.map +1 -0
  46. package/dist/adapters/GeminiCliAdapter.d.ts +6 -1
  47. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  48. package/dist/adapters/GeminiCliAdapter.js +160 -149
  49. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +60 -69
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/index.d.ts +1 -0
  53. package/dist/adapters/index.d.ts.map +1 -1
  54. package/dist/adapters/index.js +2 -0
  55. package/dist/adapters/index.js.map +1 -1
  56. package/dist/index.d.ts +6 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +5 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/terminal/TerminalFocusManager.js +62 -49
  61. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  62. package/dist/terminal/TmuxManager.d.ts +28 -0
  63. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  64. package/dist/terminal/TmuxManager.js +137 -0
  65. package/dist/terminal/TmuxManager.js.map +1 -0
  66. package/dist/terminal/TtyWriter.js +37 -16
  67. package/dist/terminal/TtyWriter.js.map +1 -1
  68. package/dist/terminal/index.js +1 -0
  69. package/dist/terminal/index.js.map +1 -1
  70. package/dist/utils/AgentRegistry.d.ts +35 -0
  71. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  72. package/dist/utils/AgentRegistry.js +123 -0
  73. package/dist/utils/AgentRegistry.js.map +1 -0
  74. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  75. package/dist/utils/ClaudeSessionParser.js +62 -93
  76. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  77. package/dist/utils/agents.d.ts +15 -0
  78. package/dist/utils/agents.d.ts.map +1 -0
  79. package/dist/utils/agents.js +41 -0
  80. package/dist/utils/agents.js.map +1 -0
  81. package/dist/utils/applescript.js +3 -6
  82. package/dist/utils/applescript.js.map +1 -1
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/index.js.map +1 -1
  85. package/dist/utils/matching.js +21 -29
  86. package/dist/utils/matching.js.map +1 -1
  87. package/dist/utils/process.js +77 -56
  88. package/dist/utils/process.js.map +1 -1
  89. package/dist/utils/session.js +20 -34
  90. package/dist/utils/session.js.map +1 -1
  91. package/dist/utils/sortAgents.js +6 -8
  92. package/dist/utils/sortAgents.js.map +1 -1
  93. package/package.json +11 -7
  94. package/src/AgentManager.ts +40 -0
  95. package/src/__tests__/AgentManager.test.ts +135 -0
  96. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +3 -2
  97. package/src/__tests__/adapters/CodexAdapter.test.ts +199 -0
  98. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  99. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +185 -1
  100. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  101. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  102. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  103. package/src/adapters/AgentAdapter.ts +1 -1
  104. package/src/adapters/CodexAdapter.ts +155 -8
  105. package/src/adapters/CopilotAdapter.ts +453 -0
  106. package/src/adapters/GeminiCliAdapter.ts +84 -6
  107. package/src/adapters/index.ts +1 -0
  108. package/src/index.ts +7 -0
  109. package/src/terminal/TmuxManager.ts +118 -0
  110. package/src/utils/AgentRegistry.ts +139 -0
  111. package/src/utils/ClaudeSessionParser.ts +21 -10
  112. package/src/utils/agents.ts +41 -0
@@ -0,0 +1,1354 @@
1
+ /**
2
+ * Tests for CodexAdapter
3
+ */ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { CodexAdapter } from '../../adapters/CodexAdapter.js';
6
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
7
+ import { AgentRegistry } from '../../utils/AgentRegistry.js';
8
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
9
+ import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
10
+ import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
11
+ import * as os from 'os';
12
+ vi.mock('../../utils/process.js', ()=>({
13
+ listAgentProcesses: vi.fn(),
14
+ enrichProcesses: vi.fn()
15
+ }));
16
+ vi.mock('../../utils/session.js', async ()=>{
17
+ const actual = await vi.importActual('../../utils/session');
18
+ return {
19
+ ...actual,
20
+ batchGetSessionFileBirthtimes: vi.fn()
21
+ };
22
+ });
23
+ vi.mock('../../utils/matching.js', ()=>({
24
+ matchProcessesToSessions: vi.fn(),
25
+ generateAgentName: vi.fn()
26
+ }));
27
+ const mockedListAgentProcesses = listAgentProcesses;
28
+ const mockedEnrichProcesses = enrichProcesses;
29
+ const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes;
30
+ const mockedMatchProcessesToSessions = matchProcessesToSessions;
31
+ const mockedGenerateAgentName = generateAgentName;
32
+ describe('CodexAdapter', ()=>{
33
+ let adapter;
34
+ beforeEach(()=>{
35
+ adapter = new CodexAdapter();
36
+ mockedListAgentProcesses.mockReset();
37
+ mockedEnrichProcesses.mockReset();
38
+ mockedBatchGetSessionFileBirthtimes.mockReset();
39
+ mockedMatchProcessesToSessions.mockReset();
40
+ mockedGenerateAgentName.mockReset();
41
+ // Default: enrichProcesses returns what it receives
42
+ mockedEnrichProcesses.mockImplementation((procs)=>procs);
43
+ // Default: generateAgentName returns "folder (pid)"
44
+ mockedGenerateAgentName.mockImplementation((cwd, pid)=>{
45
+ const folder = path.basename(cwd) || 'unknown';
46
+ return `${folder} (${pid})`;
47
+ });
48
+ });
49
+ describe('initialization', ()=>{
50
+ it('should expose codex type', ()=>{
51
+ expect(adapter.type).toBe('codex');
52
+ });
53
+ });
54
+ describe('canHandle', ()=>{
55
+ it('should return true for codex commands', ()=>{
56
+ expect(adapter.canHandle({
57
+ pid: 1,
58
+ command: 'codex',
59
+ cwd: '/repo',
60
+ tty: 'ttys001'
61
+ })).toBe(true);
62
+ });
63
+ it('should return true for codex with full path (case-insensitive)', ()=>{
64
+ expect(adapter.canHandle({
65
+ pid: 2,
66
+ command: '/usr/local/bin/CODEX --sandbox workspace-write',
67
+ cwd: '/repo',
68
+ tty: 'ttys002'
69
+ })).toBe(true);
70
+ });
71
+ it('should return false for non-codex processes', ()=>{
72
+ expect(adapter.canHandle({
73
+ pid: 3,
74
+ command: 'node app.js',
75
+ cwd: '/repo',
76
+ tty: 'ttys003'
77
+ })).toBe(false);
78
+ });
79
+ it('should return false for processes with "codex" only in path arguments', ()=>{
80
+ expect(adapter.canHandle({
81
+ pid: 4,
82
+ command: 'node /worktrees/feature-codex-adapter-agent-manager-package/node_modules/nx/src/daemon/server/start.js',
83
+ cwd: '/repo',
84
+ tty: 'ttys004'
85
+ })).toBe(false);
86
+ });
87
+ });
88
+ describe('detectAgents', ()=>{
89
+ it('should return empty list when no codex process is running', async ()=>{
90
+ mockedListAgentProcesses.mockReturnValue([]);
91
+ const agents = await adapter.detectAgents();
92
+ expect(agents).toEqual([]);
93
+ expect(mockedListAgentProcesses).toHaveBeenCalledWith('codex');
94
+ });
95
+ it('should return process-only agents when no sessions discovered', async ()=>{
96
+ const processes = [
97
+ {
98
+ pid: 100,
99
+ command: 'codex',
100
+ cwd: '/repo-a',
101
+ tty: 'ttys001'
102
+ }
103
+ ];
104
+ mockedListAgentProcesses.mockReturnValue(processes);
105
+ mockedEnrichProcesses.mockReturnValue(processes);
106
+ // No sessions dir → discoverSessions returns []
107
+ adapter.codexSessionsDir = '/nonexistent/sessions';
108
+ const agents = await adapter.detectAgents();
109
+ expect(agents).toHaveLength(1);
110
+ expect(agents[0]).toMatchObject({
111
+ type: 'codex',
112
+ status: AgentStatus.RUNNING,
113
+ pid: 100,
114
+ projectPath: '/repo-a',
115
+ sessionId: 'pid-100',
116
+ summary: 'Codex process running'
117
+ });
118
+ });
119
+ it('should detect agents with matched sessions', async ()=>{
120
+ const processes = [
121
+ {
122
+ pid: 100,
123
+ command: 'codex',
124
+ cwd: '/repo-a',
125
+ tty: 'ttys001',
126
+ startTime: new Date('2026-03-18T15:00:00.000Z')
127
+ }
128
+ ];
129
+ mockedListAgentProcesses.mockReturnValue(processes);
130
+ mockedEnrichProcesses.mockReturnValue(processes);
131
+ // Set up sessions dir with date directory
132
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
133
+ const sessionsDir = path.join(tmpDir, 'sessions');
134
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
135
+ fs.mkdirSync(dateDir, {
136
+ recursive: true
137
+ });
138
+ // Create session file with recent timestamps so status isn't idle
139
+ const now = new Date();
140
+ const recentTs = now.toISOString();
141
+ const sessionFile = path.join(dateDir, 'sess-abc.jsonl');
142
+ fs.writeFileSync(sessionFile, [
143
+ JSON.stringify({
144
+ type: 'session_meta',
145
+ payload: {
146
+ id: 'sess-abc',
147
+ timestamp: recentTs,
148
+ cwd: '/repo-a'
149
+ }
150
+ }),
151
+ JSON.stringify({
152
+ type: 'event',
153
+ timestamp: recentTs,
154
+ payload: {
155
+ type: 'token_count',
156
+ message: 'Implement adapter flow'
157
+ }
158
+ })
159
+ ].join('\n'));
160
+ adapter.codexSessionsDir = sessionsDir;
161
+ const sessionFiles = [
162
+ {
163
+ sessionId: 'sess-abc',
164
+ filePath: sessionFile,
165
+ projectDir: dateDir,
166
+ birthtimeMs: new Date('2026-03-18T15:00:05Z').getTime(),
167
+ resolvedCwd: ''
168
+ }
169
+ ];
170
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
171
+ const matches = [
172
+ {
173
+ process: processes[0],
174
+ session: {
175
+ ...sessionFiles[0],
176
+ resolvedCwd: '/repo-a'
177
+ },
178
+ deltaMs: 5000
179
+ }
180
+ ];
181
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
182
+ const agents = await adapter.detectAgents();
183
+ expect(agents).toHaveLength(1);
184
+ expect(agents[0]).toMatchObject({
185
+ type: 'codex',
186
+ status: AgentStatus.RUNNING,
187
+ pid: 100,
188
+ projectPath: '/repo-a',
189
+ sessionId: 'sess-abc',
190
+ summary: 'Implement adapter flow'
191
+ });
192
+ fs.rmSync(tmpDir, {
193
+ recursive: true,
194
+ force: true
195
+ });
196
+ });
197
+ it('should fall back to process-only for unmatched processes', async ()=>{
198
+ const processes = [
199
+ {
200
+ pid: 100,
201
+ command: 'codex',
202
+ cwd: '/repo-a',
203
+ tty: 'ttys001',
204
+ startTime: new Date()
205
+ },
206
+ {
207
+ pid: 200,
208
+ command: 'codex',
209
+ cwd: '/repo-b',
210
+ tty: 'ttys002',
211
+ startTime: new Date()
212
+ }
213
+ ];
214
+ mockedListAgentProcesses.mockReturnValue(processes);
215
+ mockedEnrichProcesses.mockReturnValue(processes);
216
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
217
+ const sessionsDir = path.join(tmpDir, 'sessions');
218
+ const now = new Date();
219
+ const dateDir = path.join(sessionsDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'));
220
+ fs.mkdirSync(dateDir, {
221
+ recursive: true
222
+ });
223
+ const sessionFile = path.join(dateDir, 'only-session.jsonl');
224
+ fs.writeFileSync(sessionFile, JSON.stringify({
225
+ type: 'session_meta',
226
+ payload: {
227
+ id: 'only-session',
228
+ timestamp: now.toISOString(),
229
+ cwd: '/repo-a'
230
+ }
231
+ }));
232
+ adapter.codexSessionsDir = sessionsDir;
233
+ const sessionFiles = [
234
+ {
235
+ sessionId: 'only-session',
236
+ filePath: sessionFile,
237
+ projectDir: dateDir,
238
+ birthtimeMs: Date.now(),
239
+ resolvedCwd: ''
240
+ }
241
+ ];
242
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
243
+ // Only process 100 matches
244
+ const matches = [
245
+ {
246
+ process: processes[0],
247
+ session: {
248
+ ...sessionFiles[0],
249
+ resolvedCwd: '/repo-a'
250
+ },
251
+ deltaMs: 5000
252
+ }
253
+ ];
254
+ mockedMatchProcessesToSessions.mockReturnValue(matches);
255
+ const agents = await adapter.detectAgents();
256
+ expect(agents).toHaveLength(2);
257
+ const matched = agents.find((a)=>a.pid === 100);
258
+ const unmatched = agents.find((a)=>a.pid === 200);
259
+ expect(matched?.sessionId).toBe('only-session');
260
+ expect(unmatched?.sessionId).toBe('pid-200');
261
+ expect(unmatched?.status).toBe(AgentStatus.RUNNING);
262
+ fs.rmSync(tmpDir, {
263
+ recursive: true,
264
+ force: true
265
+ });
266
+ });
267
+ it('should match resumed sessions by id even when the JSONL predates the process', async ()=>{
268
+ const sessionId = '019eabed-4079-7071-9531-b853ddd9914e';
269
+ const processes = [
270
+ {
271
+ pid: 88018,
272
+ command: `codex resume ${sessionId}`,
273
+ cwd: '/repo-a',
274
+ tty: 'ttys001',
275
+ startTime: new Date('2026-06-10T12:00:00.000Z')
276
+ }
277
+ ];
278
+ mockedListAgentProcesses.mockReturnValue(processes);
279
+ mockedEnrichProcesses.mockReturnValue(processes);
280
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-'));
281
+ const sessionsDir = path.join(tmpDir, 'sessions');
282
+ const dateDir = path.join(sessionsDir, '2026', '06', '09');
283
+ fs.mkdirSync(dateDir, {
284
+ recursive: true
285
+ });
286
+ const recentTs = new Date().toISOString();
287
+ const sessionFile = path.join(dateDir, `rollout-2026-06-09T12-28-33-${sessionId}.jsonl`);
288
+ fs.writeFileSync(sessionFile, [
289
+ JSON.stringify({
290
+ type: 'session_meta',
291
+ payload: {
292
+ id: sessionId,
293
+ timestamp: recentTs,
294
+ cwd: '/repo-a'
295
+ }
296
+ }),
297
+ JSON.stringify({
298
+ type: 'event',
299
+ timestamp: recentTs,
300
+ payload: {
301
+ type: 'agent_message',
302
+ message: 'resumed codex conversation'
303
+ }
304
+ })
305
+ ].join('\n'));
306
+ adapter.codexSessionsDir = sessionsDir;
307
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
308
+ const agents = await adapter.detectAgents();
309
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
310
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
311
+ expect(agents).toHaveLength(1);
312
+ expect(agents[0]).toMatchObject({
313
+ type: 'codex',
314
+ pid: 88018,
315
+ sessionId,
316
+ projectPath: '/repo-a',
317
+ sessionFilePath: sessionFile
318
+ });
319
+ expect(agents[0].summary).toBe('resumed codex conversation');
320
+ fs.rmSync(tmpDir, {
321
+ recursive: true,
322
+ force: true
323
+ });
324
+ });
325
+ it('should fall back to process-only when a resumed session becomes unreadable after direct matching', async ()=>{
326
+ const sessionId = '019eabed-4079-7071-9531-b853ddd9914e';
327
+ const processes = [
328
+ {
329
+ pid: 88019,
330
+ command: `codex resume ${sessionId}`,
331
+ cwd: '/repo-a',
332
+ tty: 'ttys001',
333
+ startTime: new Date('2026-06-10T12:00:00.000Z')
334
+ }
335
+ ];
336
+ mockedListAgentProcesses.mockReturnValue(processes);
337
+ mockedEnrichProcesses.mockReturnValue(processes);
338
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-gone-'));
339
+ const sessionsDir = path.join(tmpDir, 'sessions');
340
+ const dateDir = path.join(sessionsDir, '2026', '06', '09');
341
+ fs.mkdirSync(dateDir, {
342
+ recursive: true
343
+ });
344
+ const sessionFile = path.join(dateDir, `rollout-2026-06-09T12-28-33-${sessionId}.jsonl`);
345
+ adapter.codexSessionsDir = sessionsDir;
346
+ const findSpy = vi.spyOn(adapter, 'findSessionFileById').mockReturnValue({
347
+ sessionId,
348
+ filePath: sessionFile,
349
+ projectDir: dateDir,
350
+ birthtimeMs: Date.now(),
351
+ resolvedCwd: '/repo-a'
352
+ });
353
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
354
+ try {
355
+ const agents = await adapter.detectAgents();
356
+ expect(agents).toHaveLength(1);
357
+ expect(agents[0]).toMatchObject({
358
+ type: 'codex',
359
+ pid: 88019,
360
+ sessionId: 'pid-88019',
361
+ projectPath: '/repo-a'
362
+ });
363
+ expect(agents[0].sessionFilePath).toBeUndefined();
364
+ } finally{
365
+ findSpy.mockRestore();
366
+ fs.rmSync(tmpDir, {
367
+ recursive: true,
368
+ force: true
369
+ });
370
+ }
371
+ });
372
+ });
373
+ describe('detectAgents — registry cache short-circuit', ()=>{
374
+ let tmpDir;
375
+ let regPath;
376
+ let registry;
377
+ let cachedAdapter;
378
+ let sessionFilePath;
379
+ function registerEntry(over = {}) {
380
+ registry.register({
381
+ name: 'codex-100',
382
+ type: 'codex',
383
+ pid: 100,
384
+ tmuxSession: '',
385
+ cwd: '/repo-a',
386
+ startedAt: '2026-05-30T00:00:00.000Z',
387
+ sessionId: 'sess-cached',
388
+ sessionFilePath,
389
+ ...over
390
+ });
391
+ }
392
+ beforeEach(()=>{
393
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-cache-'));
394
+ regPath = path.join(tmpDir, 'agents.json');
395
+ registry = new AgentRegistry(regPath);
396
+ cachedAdapter = new CodexAdapter(registry);
397
+ const recentTs = new Date().toISOString();
398
+ sessionFilePath = path.join(tmpDir, 'sess-cached.jsonl');
399
+ fs.writeFileSync(sessionFilePath, [
400
+ JSON.stringify({
401
+ type: 'session_meta',
402
+ payload: {
403
+ id: 'sess-cached',
404
+ timestamp: recentTs,
405
+ cwd: '/repo-a'
406
+ }
407
+ }),
408
+ JSON.stringify({
409
+ type: 'event',
410
+ timestamp: recentTs,
411
+ payload: {
412
+ type: 'token_count',
413
+ message: 'Hello from cache'
414
+ }
415
+ })
416
+ ].join('\n'));
417
+ });
418
+ afterEach(()=>{
419
+ fs.rmSync(tmpDir, {
420
+ recursive: true,
421
+ force: true
422
+ });
423
+ });
424
+ it('short-circuits matching when registry has a valid entry', async ()=>{
425
+ registerEntry();
426
+ const processes = [
427
+ {
428
+ pid: 100,
429
+ command: 'codex',
430
+ cwd: '/repo-a',
431
+ tty: 'ttys001',
432
+ startTime: new Date()
433
+ }
434
+ ];
435
+ mockedListAgentProcesses.mockReturnValue(processes);
436
+ mockedEnrichProcesses.mockReturnValue(processes);
437
+ const agents = await cachedAdapter.detectAgents();
438
+ expect(agents).toHaveLength(1);
439
+ expect(agents[0]).toMatchObject({
440
+ type: 'codex',
441
+ pid: 100,
442
+ sessionId: 'sess-cached',
443
+ sessionFilePath,
444
+ summary: 'Hello from cache'
445
+ });
446
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
447
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
448
+ });
449
+ it('falls through when no registry entry exists for the pid', async ()=>{
450
+ const processes = [
451
+ {
452
+ pid: 100,
453
+ command: 'codex',
454
+ cwd: '/repo-a',
455
+ tty: 'ttys001',
456
+ startTime: new Date()
457
+ }
458
+ ];
459
+ mockedListAgentProcesses.mockReturnValue(processes);
460
+ mockedEnrichProcesses.mockReturnValue(processes);
461
+ cachedAdapter.codexSessionsDir = '/nonexistent';
462
+ const agents = await cachedAdapter.detectAgents();
463
+ expect(agents[0].sessionId).toBe('pid-100');
464
+ });
465
+ it('falls through when registry entry type does not match', async ()=>{
466
+ registerEntry({
467
+ type: 'claude'
468
+ });
469
+ const processes = [
470
+ {
471
+ pid: 100,
472
+ command: 'codex',
473
+ cwd: '/repo-a',
474
+ tty: 'ttys001',
475
+ startTime: new Date()
476
+ }
477
+ ];
478
+ mockedListAgentProcesses.mockReturnValue(processes);
479
+ mockedEnrichProcesses.mockReturnValue(processes);
480
+ cachedAdapter.codexSessionsDir = '/nonexistent';
481
+ const agents = await cachedAdapter.detectAgents();
482
+ expect(agents[0].sessionId).toBe('pid-100');
483
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
484
+ });
485
+ it('falls through when the cached session file no longer exists', async ()=>{
486
+ registerEntry({
487
+ sessionFilePath: path.join(tmpDir, 'deleted.jsonl')
488
+ });
489
+ const processes = [
490
+ {
491
+ pid: 100,
492
+ command: 'codex',
493
+ cwd: '/repo-a',
494
+ tty: 'ttys001',
495
+ startTime: new Date()
496
+ }
497
+ ];
498
+ mockedListAgentProcesses.mockReturnValue(processes);
499
+ mockedEnrichProcesses.mockReturnValue(processes);
500
+ cachedAdapter.codexSessionsDir = '/nonexistent';
501
+ const agents = await cachedAdapter.detectAgents();
502
+ expect(agents[0].sessionId).toBe('pid-100');
503
+ });
504
+ });
505
+ describe('discoverSessions', ()=>{
506
+ let tmpDir;
507
+ beforeEach(()=>{
508
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
509
+ });
510
+ afterEach(()=>{
511
+ fs.rmSync(tmpDir, {
512
+ recursive: true,
513
+ force: true
514
+ });
515
+ });
516
+ it('should return empty when sessions dir does not exist', ()=>{
517
+ adapter.codexSessionsDir = path.join(tmpDir, 'nonexistent');
518
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
519
+ const { sessions } = discoverSessions([
520
+ {
521
+ pid: 1,
522
+ command: 'codex',
523
+ cwd: '/repo',
524
+ tty: '',
525
+ startTime: new Date()
526
+ }
527
+ ]);
528
+ expect(sessions).toEqual([]);
529
+ });
530
+ it('should scan date directories based on process start times', ()=>{
531
+ const sessionsDir = path.join(tmpDir, 'sessions');
532
+ adapter.codexSessionsDir = sessionsDir;
533
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
534
+ // Create date dir for 2026-03-18
535
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
536
+ fs.mkdirSync(dateDir, {
537
+ recursive: true
538
+ });
539
+ // Create session file with meta
540
+ const sessionFile = path.join(dateDir, 'sess1.jsonl');
541
+ fs.writeFileSync(sessionFile, JSON.stringify({
542
+ type: 'session_meta',
543
+ payload: {
544
+ id: 'sess1',
545
+ cwd: '/repo-a'
546
+ }
547
+ }));
548
+ const mockFiles = [
549
+ {
550
+ sessionId: 'sess1',
551
+ filePath: sessionFile,
552
+ projectDir: dateDir,
553
+ birthtimeMs: 1710800324000,
554
+ resolvedCwd: ''
555
+ }
556
+ ];
557
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
558
+ const processes = [
559
+ {
560
+ pid: 1,
561
+ command: 'codex',
562
+ cwd: '/repo-a',
563
+ tty: '',
564
+ startTime: new Date('2026-03-18T15:00:00Z')
565
+ }
566
+ ];
567
+ const { sessions, contentCache } = discoverSessions(processes);
568
+ expect(sessions).toHaveLength(1);
569
+ expect(sessions[0].resolvedCwd).toBe('/repo-a');
570
+ expect(contentCache.has(sessionFile)).toBe(true);
571
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
572
+ });
573
+ it('should scan ±1 day window around process start time', ()=>{
574
+ const sessionsDir = path.join(tmpDir, 'sessions');
575
+ adapter.codexSessionsDir = sessionsDir;
576
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
577
+ // Create date dirs for 17, 18, 19
578
+ for (const day of [
579
+ '17',
580
+ '18',
581
+ '19'
582
+ ]){
583
+ fs.mkdirSync(path.join(sessionsDir, '2026', '03', day), {
584
+ recursive: true
585
+ });
586
+ }
587
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
588
+ const processes = [
589
+ {
590
+ pid: 1,
591
+ command: 'codex',
592
+ cwd: '/repo',
593
+ tty: '',
594
+ startTime: new Date('2026-03-18T15:00:00Z')
595
+ }
596
+ ];
597
+ discoverSessions(processes);
598
+ expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
599
+ // Should scan all 3 date dirs
600
+ const dirs = mockedBatchGetSessionFileBirthtimes.mock.calls[0][0];
601
+ expect(dirs).toHaveLength(3);
602
+ });
603
+ it('should handle session files without session_meta', ()=>{
604
+ const sessionsDir = path.join(tmpDir, 'sessions');
605
+ adapter.codexSessionsDir = sessionsDir;
606
+ const discoverSessions = adapter.discoverSessions.bind(adapter);
607
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
608
+ fs.mkdirSync(dateDir, {
609
+ recursive: true
610
+ });
611
+ const sessionFile = path.join(dateDir, 'bad.jsonl');
612
+ fs.writeFileSync(sessionFile, JSON.stringify({
613
+ type: 'event',
614
+ payload: {}
615
+ }));
616
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
617
+ {
618
+ sessionId: 'bad',
619
+ filePath: sessionFile,
620
+ projectDir: dateDir,
621
+ birthtimeMs: 1710800324000,
622
+ resolvedCwd: ''
623
+ }
624
+ ]);
625
+ const processes = [
626
+ {
627
+ pid: 1,
628
+ command: 'codex',
629
+ cwd: '/repo',
630
+ tty: '',
631
+ startTime: new Date('2026-03-18T15:00:00Z')
632
+ }
633
+ ];
634
+ const { sessions } = discoverSessions(processes);
635
+ expect(sessions[0].resolvedCwd).toBe('');
636
+ });
637
+ });
638
+ describe('helper methods', ()=>{
639
+ describe('determineStatus', ()=>{
640
+ it('should return "waiting" for agent_message events', ()=>{
641
+ const determineStatus = adapter.determineStatus.bind(adapter);
642
+ expect(determineStatus({
643
+ lastActive: new Date(),
644
+ lastPayloadType: 'agent_message'
645
+ })).toBe(AgentStatus.WAITING);
646
+ });
647
+ it('should return "waiting" for task_complete events', ()=>{
648
+ const determineStatus = adapter.determineStatus.bind(adapter);
649
+ expect(determineStatus({
650
+ lastActive: new Date(),
651
+ lastPayloadType: 'task_complete'
652
+ })).toBe(AgentStatus.WAITING);
653
+ });
654
+ it('should return "waiting" for turn_aborted events', ()=>{
655
+ const determineStatus = adapter.determineStatus.bind(adapter);
656
+ expect(determineStatus({
657
+ lastActive: new Date(),
658
+ lastPayloadType: 'turn_aborted'
659
+ })).toBe(AgentStatus.WAITING);
660
+ });
661
+ it('should return "running" for active events', ()=>{
662
+ const determineStatus = adapter.determineStatus.bind(adapter);
663
+ expect(determineStatus({
664
+ lastActive: new Date(),
665
+ lastPayloadType: 'token_count'
666
+ })).toBe(AgentStatus.RUNNING);
667
+ });
668
+ it('should return "idle" when session exceeds threshold', ()=>{
669
+ const determineStatus = adapter.determineStatus.bind(adapter);
670
+ expect(determineStatus({
671
+ lastActive: new Date(Date.now() - 10 * 60 * 1000),
672
+ lastPayloadType: 'token_count'
673
+ })).toBe(AgentStatus.IDLE);
674
+ });
675
+ });
676
+ describe('parseSession', ()=>{
677
+ let tmpDir;
678
+ beforeEach(()=>{
679
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
680
+ });
681
+ afterEach(()=>{
682
+ fs.rmSync(tmpDir, {
683
+ recursive: true,
684
+ force: true
685
+ });
686
+ });
687
+ it('should parse session file with meta and events', ()=>{
688
+ const parseSession = adapter.parseSession.bind(adapter);
689
+ const filePath = path.join(tmpDir, 'session.jsonl');
690
+ fs.writeFileSync(filePath, [
691
+ JSON.stringify({
692
+ type: 'session_meta',
693
+ payload: {
694
+ id: 'sess-1',
695
+ timestamp: '2026-03-18T15:00:00Z',
696
+ cwd: '/repo'
697
+ }
698
+ }),
699
+ JSON.stringify({
700
+ type: 'event',
701
+ timestamp: '2026-03-18T15:01:00Z',
702
+ payload: {
703
+ type: 'agent_reasoning',
704
+ message: 'Working on feature'
705
+ }
706
+ })
707
+ ].join('\n'));
708
+ const session = parseSession(undefined, filePath);
709
+ expect(session).toMatchObject({
710
+ sessionId: 'sess-1',
711
+ projectPath: '/repo',
712
+ summary: 'Working on feature',
713
+ lastPayloadType: 'agent_reasoning'
714
+ });
715
+ expect(session.sessionStart.toISOString()).toBe('2026-03-18T15:00:00.000Z');
716
+ });
717
+ it('should parse from cached content without reading disk', ()=>{
718
+ const parseSession = adapter.parseSession.bind(adapter);
719
+ const content = [
720
+ JSON.stringify({
721
+ type: 'session_meta',
722
+ payload: {
723
+ id: 'cached-1',
724
+ timestamp: '2026-03-18T15:00:00Z',
725
+ cwd: '/cached'
726
+ }
727
+ }),
728
+ JSON.stringify({
729
+ type: 'event',
730
+ timestamp: '2026-03-18T15:01:00Z',
731
+ payload: {
732
+ type: 'agent_message',
733
+ message: 'Cached result'
734
+ }
735
+ })
736
+ ].join('\n');
737
+ const session = parseSession(content, '/nonexistent/path.jsonl');
738
+ expect(session).toMatchObject({
739
+ sessionId: 'cached-1',
740
+ projectPath: '/cached',
741
+ summary: 'Cached result'
742
+ });
743
+ });
744
+ it('should return null for non-existent file', ()=>{
745
+ const parseSession = adapter.parseSession.bind(adapter);
746
+ expect(parseSession(undefined, path.join(tmpDir, 'nonexistent.jsonl'))).toBeNull();
747
+ });
748
+ it('should return null when first line is not session_meta', ()=>{
749
+ const parseSession = adapter.parseSession.bind(adapter);
750
+ const filePath = path.join(tmpDir, 'bad.jsonl');
751
+ fs.writeFileSync(filePath, JSON.stringify({
752
+ type: 'event',
753
+ payload: {}
754
+ }));
755
+ expect(parseSession(undefined, filePath)).toBeNull();
756
+ });
757
+ it('should return null when session_meta has no id', ()=>{
758
+ const parseSession = adapter.parseSession.bind(adapter);
759
+ const filePath = path.join(tmpDir, 'no-id.jsonl');
760
+ fs.writeFileSync(filePath, JSON.stringify({
761
+ type: 'session_meta',
762
+ payload: {
763
+ cwd: '/repo'
764
+ }
765
+ }));
766
+ expect(parseSession(undefined, filePath)).toBeNull();
767
+ });
768
+ it('should extract summary from last event message', ()=>{
769
+ const parseSession = adapter.parseSession.bind(adapter);
770
+ const filePath = path.join(tmpDir, 'summary.jsonl');
771
+ fs.writeFileSync(filePath, [
772
+ JSON.stringify({
773
+ type: 'session_meta',
774
+ payload: {
775
+ id: 'sess-2',
776
+ timestamp: '2026-03-18T15:00:00Z',
777
+ cwd: '/repo'
778
+ }
779
+ }),
780
+ JSON.stringify({
781
+ type: 'event',
782
+ timestamp: '2026-03-18T15:01:00Z',
783
+ payload: {
784
+ type: 'agent_reasoning',
785
+ message: 'First message'
786
+ }
787
+ }),
788
+ JSON.stringify({
789
+ type: 'event',
790
+ timestamp: '2026-03-18T15:02:00Z',
791
+ payload: {
792
+ type: 'agent_message',
793
+ message: 'Last message'
794
+ }
795
+ })
796
+ ].join('\n'));
797
+ const session = parseSession(undefined, filePath);
798
+ expect(session.summary).toBe('Last message');
799
+ });
800
+ it('should handle malformed JSON lines gracefully', ()=>{
801
+ const parseSession = adapter.parseSession.bind(adapter);
802
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
803
+ fs.writeFileSync(filePath, [
804
+ JSON.stringify({
805
+ type: 'session_meta',
806
+ payload: {
807
+ id: 'sess-m',
808
+ timestamp: '2026-03-18T15:00:00Z',
809
+ cwd: '/repo'
810
+ }
811
+ }),
812
+ 'not valid json',
813
+ '{"incomplete": true',
814
+ JSON.stringify({
815
+ type: 'event',
816
+ timestamp: '2026-03-18T15:01:00Z',
817
+ payload: {
818
+ type: 'agent_message',
819
+ message: 'Valid message'
820
+ }
821
+ })
822
+ ].join('\n'));
823
+ const session = parseSession(undefined, filePath);
824
+ expect(session).not.toBeNull();
825
+ expect(session.sessionId).toBe('sess-m');
826
+ expect(session.summary).toBe('Valid message');
827
+ });
828
+ it('should default summary when no messages found', ()=>{
829
+ const parseSession = adapter.parseSession.bind(adapter);
830
+ const filePath = path.join(tmpDir, 'no-msg.jsonl');
831
+ fs.writeFileSync(filePath, [
832
+ JSON.stringify({
833
+ type: 'session_meta',
834
+ payload: {
835
+ id: 'sess-3',
836
+ timestamp: '2026-03-18T15:00:00Z',
837
+ cwd: '/repo'
838
+ }
839
+ }),
840
+ JSON.stringify({
841
+ type: 'event',
842
+ timestamp: '2026-03-18T15:01:00Z',
843
+ payload: {
844
+ type: 'token_count'
845
+ }
846
+ })
847
+ ].join('\n'));
848
+ const session = parseSession(undefined, filePath);
849
+ expect(session.summary).toBe('Codex session active');
850
+ });
851
+ it('should return null for empty content', ()=>{
852
+ const parseSession = adapter.parseSession.bind(adapter);
853
+ expect(parseSession('', '/fake/path.jsonl')).toBeNull();
854
+ expect(parseSession(' \n \n ', '/fake/path.jsonl')).toBeNull();
855
+ });
856
+ it('should truncate long summary to 120 chars', ()=>{
857
+ const parseSession = adapter.parseSession.bind(adapter);
858
+ const longMsg = 'A'.repeat(200);
859
+ const content = [
860
+ JSON.stringify({
861
+ type: 'session_meta',
862
+ payload: {
863
+ id: 'sess-t',
864
+ timestamp: '2026-03-18T15:00:00Z',
865
+ cwd: '/repo'
866
+ }
867
+ }),
868
+ JSON.stringify({
869
+ type: 'event',
870
+ timestamp: '2026-03-18T15:01:00Z',
871
+ payload: {
872
+ type: 'agent_message',
873
+ message: longMsg
874
+ }
875
+ })
876
+ ].join('\n');
877
+ const session = parseSession(content, '/fake/path.jsonl');
878
+ expect(session.summary).toHaveLength(120);
879
+ expect(session.summary.endsWith('...')).toBe(true);
880
+ });
881
+ });
882
+ });
883
+ describe('getConversation', ()=>{
884
+ let tmpDir;
885
+ beforeEach(()=>{
886
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-conv-'));
887
+ });
888
+ afterEach(()=>{
889
+ fs.rmSync(tmpDir, {
890
+ recursive: true,
891
+ force: true
892
+ });
893
+ });
894
+ function writeJsonl(lines) {
895
+ const filePath = path.join(tmpDir, 'session.jsonl');
896
+ fs.writeFileSync(filePath, lines.map((l)=>JSON.stringify(l)).join('\n'));
897
+ return filePath;
898
+ }
899
+ it('should parse user and agent messages', ()=>{
900
+ const filePath = writeJsonl([
901
+ {
902
+ type: 'session_meta',
903
+ payload: {
904
+ id: 'sess-1',
905
+ cwd: '/repo',
906
+ timestamp: '2026-03-27T10:00:00Z'
907
+ }
908
+ },
909
+ {
910
+ type: 'event',
911
+ timestamp: '2026-03-27T10:00:01Z',
912
+ payload: {
913
+ type: 'user_message',
914
+ message: 'Fix the bug'
915
+ }
916
+ },
917
+ {
918
+ type: 'event',
919
+ timestamp: '2026-03-27T10:00:05Z',
920
+ payload: {
921
+ type: 'agent_message',
922
+ message: 'I found the issue'
923
+ }
924
+ }
925
+ ]);
926
+ const messages = adapter.getConversation(filePath);
927
+ expect(messages).toHaveLength(2);
928
+ expect(messages[0]).toEqual({
929
+ role: 'user',
930
+ content: 'Fix the bug',
931
+ timestamp: '2026-03-27T10:00:01Z'
932
+ });
933
+ expect(messages[1]).toEqual({
934
+ role: 'assistant',
935
+ content: 'I found the issue',
936
+ timestamp: '2026-03-27T10:00:05Z'
937
+ });
938
+ });
939
+ it('should skip session_meta entry', ()=>{
940
+ const filePath = writeJsonl([
941
+ {
942
+ type: 'session_meta',
943
+ payload: {
944
+ id: 'sess-1',
945
+ cwd: '/repo',
946
+ timestamp: '2026-03-27T10:00:00Z'
947
+ }
948
+ },
949
+ {
950
+ type: 'event',
951
+ timestamp: '2026-03-27T10:00:01Z',
952
+ payload: {
953
+ type: 'user_message',
954
+ message: 'Hello'
955
+ }
956
+ }
957
+ ]);
958
+ const messages = adapter.getConversation(filePath);
959
+ expect(messages).toHaveLength(1);
960
+ expect(messages[0].role).toBe('user');
961
+ });
962
+ it('should map task_complete to assistant role', ()=>{
963
+ const filePath = writeJsonl([
964
+ {
965
+ type: 'session_meta',
966
+ payload: {
967
+ id: 'sess-1',
968
+ cwd: '/repo',
969
+ timestamp: '2026-03-27T10:00:00Z'
970
+ }
971
+ },
972
+ {
973
+ type: 'event',
974
+ timestamp: '2026-03-27T10:00:05Z',
975
+ payload: {
976
+ type: 'task_complete',
977
+ message: 'Task finished successfully'
978
+ }
979
+ }
980
+ ]);
981
+ const messages = adapter.getConversation(filePath);
982
+ expect(messages).toHaveLength(1);
983
+ expect(messages[0].role).toBe('assistant');
984
+ expect(messages[0].content).toBe('Task finished successfully');
985
+ });
986
+ it('should skip non-conversation types in default mode', ()=>{
987
+ const filePath = writeJsonl([
988
+ {
989
+ type: 'session_meta',
990
+ payload: {
991
+ id: 'sess-1',
992
+ cwd: '/repo',
993
+ timestamp: '2026-03-27T10:00:00Z'
994
+ }
995
+ },
996
+ {
997
+ type: 'event',
998
+ timestamp: '2026-03-27T10:00:01Z',
999
+ payload: {
1000
+ type: 'user_message',
1001
+ message: 'Hello'
1002
+ }
1003
+ },
1004
+ {
1005
+ type: 'event',
1006
+ timestamp: '2026-03-27T10:00:02Z',
1007
+ payload: {
1008
+ type: 'exec_command',
1009
+ message: 'Running npm test'
1010
+ }
1011
+ },
1012
+ {
1013
+ type: 'event',
1014
+ timestamp: '2026-03-27T10:00:03Z',
1015
+ payload: {
1016
+ type: 'agent_message',
1017
+ message: 'Done'
1018
+ }
1019
+ }
1020
+ ]);
1021
+ const messages = adapter.getConversation(filePath);
1022
+ expect(messages).toHaveLength(2);
1023
+ });
1024
+ it('should include non-conversation types as system in verbose mode', ()=>{
1025
+ const filePath = writeJsonl([
1026
+ {
1027
+ type: 'session_meta',
1028
+ payload: {
1029
+ id: 'sess-1',
1030
+ cwd: '/repo',
1031
+ timestamp: '2026-03-27T10:00:00Z'
1032
+ }
1033
+ },
1034
+ {
1035
+ type: 'event',
1036
+ timestamp: '2026-03-27T10:00:02Z',
1037
+ payload: {
1038
+ type: 'exec_command',
1039
+ message: 'Running npm test'
1040
+ }
1041
+ }
1042
+ ]);
1043
+ const messages = adapter.getConversation(filePath, {
1044
+ verbose: true
1045
+ });
1046
+ expect(messages).toHaveLength(1);
1047
+ expect(messages[0].role).toBe('system');
1048
+ expect(messages[0].content).toBe('Running npm test');
1049
+ });
1050
+ it('should handle malformed JSON lines gracefully', ()=>{
1051
+ const filePath = path.join(tmpDir, 'malformed.jsonl');
1052
+ fs.writeFileSync(filePath, [
1053
+ JSON.stringify({
1054
+ type: 'session_meta',
1055
+ payload: {
1056
+ id: 'sess-1',
1057
+ cwd: '/repo',
1058
+ timestamp: '2026-03-27T10:00:00Z'
1059
+ }
1060
+ }),
1061
+ 'invalid json line',
1062
+ JSON.stringify({
1063
+ type: 'event',
1064
+ timestamp: '2026-03-27T10:00:01Z',
1065
+ payload: {
1066
+ type: 'user_message',
1067
+ message: 'Hello'
1068
+ }
1069
+ })
1070
+ ].join('\n'));
1071
+ const messages = adapter.getConversation(filePath);
1072
+ expect(messages).toHaveLength(1);
1073
+ });
1074
+ it('should return empty array for missing file', ()=>{
1075
+ const messages = adapter.getConversation('/nonexistent/path.jsonl');
1076
+ expect(messages).toEqual([]);
1077
+ });
1078
+ it('should return empty array for empty file', ()=>{
1079
+ const filePath = path.join(tmpDir, 'empty.jsonl');
1080
+ fs.writeFileSync(filePath, '');
1081
+ const messages = adapter.getConversation(filePath);
1082
+ expect(messages).toEqual([]);
1083
+ });
1084
+ it('should skip entries with empty payload message', ()=>{
1085
+ const filePath = writeJsonl([
1086
+ {
1087
+ type: 'session_meta',
1088
+ payload: {
1089
+ id: 'sess-1',
1090
+ cwd: '/repo',
1091
+ timestamp: '2026-03-27T10:00:00Z'
1092
+ }
1093
+ },
1094
+ {
1095
+ type: 'event',
1096
+ timestamp: '2026-03-27T10:00:01Z',
1097
+ payload: {
1098
+ type: 'user_message',
1099
+ message: ''
1100
+ }
1101
+ },
1102
+ {
1103
+ type: 'event',
1104
+ timestamp: '2026-03-27T10:00:02Z',
1105
+ payload: {
1106
+ type: 'agent_message',
1107
+ message: 'Response'
1108
+ }
1109
+ }
1110
+ ]);
1111
+ const messages = adapter.getConversation(filePath);
1112
+ expect(messages).toHaveLength(1);
1113
+ expect(messages[0].content).toBe('Response');
1114
+ });
1115
+ });
1116
+ describe('listSessions', ()=>{
1117
+ let tmpDir;
1118
+ let sessionsDir;
1119
+ beforeEach(()=>{
1120
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-list-'));
1121
+ sessionsDir = path.join(tmpDir, 'sessions');
1122
+ fs.mkdirSync(sessionsDir, {
1123
+ recursive: true
1124
+ });
1125
+ adapter.codexSessionsDir = sessionsDir;
1126
+ });
1127
+ afterEach(()=>{
1128
+ fs.rmSync(tmpDir, {
1129
+ recursive: true,
1130
+ force: true
1131
+ });
1132
+ });
1133
+ function writeCodexSession(dateDir, sessionId, lines) {
1134
+ fs.mkdirSync(dateDir, {
1135
+ recursive: true
1136
+ });
1137
+ const filePath = path.join(dateDir, `${sessionId}.jsonl`);
1138
+ fs.writeFileSync(filePath, lines.map((l)=>JSON.stringify(l)).join('\n'));
1139
+ return filePath;
1140
+ }
1141
+ it('returns empty when sessions dir does not exist', async ()=>{
1142
+ fs.rmSync(sessionsDir, {
1143
+ recursive: true,
1144
+ force: true
1145
+ });
1146
+ const result = await adapter.listSessions();
1147
+ expect(result).toEqual([]);
1148
+ });
1149
+ it('walks every YYYY/MM/DD dir and returns all sessions', async ()=>{
1150
+ const dayA = path.join(sessionsDir, '2025', '01', '01');
1151
+ const dayB = path.join(sessionsDir, '2025', '02', '03');
1152
+ writeCodexSession(dayA, 'sess-a', [
1153
+ {
1154
+ type: 'session_meta',
1155
+ payload: {
1156
+ id: 'sess-a',
1157
+ cwd: '/repo-a',
1158
+ timestamp: '2025-01-01T00:00:00Z'
1159
+ }
1160
+ },
1161
+ {
1162
+ type: 'event',
1163
+ timestamp: '2025-01-01T00:00:01Z',
1164
+ payload: {
1165
+ type: 'user_message',
1166
+ message: 'msg-a'
1167
+ }
1168
+ }
1169
+ ]);
1170
+ writeCodexSession(dayB, 'sess-b', [
1171
+ {
1172
+ type: 'session_meta',
1173
+ payload: {
1174
+ id: 'sess-b',
1175
+ cwd: '/repo-b',
1176
+ timestamp: '2025-02-03T00:00:00Z'
1177
+ }
1178
+ },
1179
+ {
1180
+ type: 'event',
1181
+ timestamp: '2025-02-03T00:00:01Z',
1182
+ payload: {
1183
+ type: 'user_message',
1184
+ message: 'msg-b'
1185
+ }
1186
+ }
1187
+ ]);
1188
+ const result = await adapter.listSessions();
1189
+ expect(result).toHaveLength(2);
1190
+ const byId = Object.fromEntries(result.map((r)=>[
1191
+ r.sessionId,
1192
+ r
1193
+ ]));
1194
+ expect(byId['sess-a']).toMatchObject({
1195
+ type: 'codex',
1196
+ cwd: '/repo-a',
1197
+ firstUserMessage: 'msg-a'
1198
+ });
1199
+ expect(byId['sess-b']).toMatchObject({
1200
+ type: 'codex',
1201
+ cwd: '/repo-b',
1202
+ firstUserMessage: 'msg-b'
1203
+ });
1204
+ });
1205
+ it('applies strict-equality cwd filter against session_meta cwd', async ()=>{
1206
+ const day = path.join(sessionsDir, '2025', '01', '01');
1207
+ writeCodexSession(day, 'keep', [
1208
+ {
1209
+ type: 'session_meta',
1210
+ payload: {
1211
+ id: 'keep',
1212
+ cwd: '/repo',
1213
+ timestamp: '2025-01-01T00:00:00Z'
1214
+ }
1215
+ },
1216
+ {
1217
+ type: 'event',
1218
+ timestamp: '2025-01-01T00:00:01Z',
1219
+ payload: {
1220
+ type: 'user_message',
1221
+ message: 'yes'
1222
+ }
1223
+ }
1224
+ ]);
1225
+ writeCodexSession(day, 'drop', [
1226
+ {
1227
+ type: 'session_meta',
1228
+ payload: {
1229
+ id: 'drop',
1230
+ cwd: '/other',
1231
+ timestamp: '2025-01-01T00:01:00Z'
1232
+ }
1233
+ },
1234
+ {
1235
+ type: 'event',
1236
+ timestamp: '2025-01-01T00:01:01Z',
1237
+ payload: {
1238
+ type: 'user_message',
1239
+ message: 'no'
1240
+ }
1241
+ }
1242
+ ]);
1243
+ const result = await adapter.listSessions({
1244
+ cwd: '/repo'
1245
+ });
1246
+ expect(result).toHaveLength(1);
1247
+ expect(result[0].sessionId).toBe('keep');
1248
+ });
1249
+ it('skips files without a session_meta first line', async ()=>{
1250
+ const day = path.join(sessionsDir, '2025', '01', '01');
1251
+ writeCodexSession(day, 'bad', [
1252
+ {
1253
+ type: 'event',
1254
+ timestamp: '2025-01-01T00:00:00Z',
1255
+ payload: {
1256
+ type: 'user_message',
1257
+ message: 'orphan'
1258
+ }
1259
+ }
1260
+ ]);
1261
+ writeCodexSession(day, 'good', [
1262
+ {
1263
+ type: 'session_meta',
1264
+ payload: {
1265
+ id: 'good',
1266
+ cwd: '/repo',
1267
+ timestamp: '2025-01-01T00:00:00Z'
1268
+ }
1269
+ },
1270
+ {
1271
+ type: 'event',
1272
+ timestamp: '2025-01-01T00:00:01Z',
1273
+ payload: {
1274
+ type: 'user_message',
1275
+ message: 'ok'
1276
+ }
1277
+ }
1278
+ ]);
1279
+ const result = await adapter.listSessions();
1280
+ expect(result).toHaveLength(1);
1281
+ expect(result[0].sessionId).toBe('good');
1282
+ });
1283
+ it('captures the first user_message as firstUserMessage', async ()=>{
1284
+ const day = path.join(sessionsDir, '2025', '01', '01');
1285
+ writeCodexSession(day, 's', [
1286
+ {
1287
+ type: 'session_meta',
1288
+ payload: {
1289
+ id: 's',
1290
+ cwd: '/repo',
1291
+ timestamp: '2025-01-01T00:00:00Z'
1292
+ }
1293
+ },
1294
+ {
1295
+ type: 'event',
1296
+ timestamp: '2025-01-01T00:00:01Z',
1297
+ payload: {
1298
+ type: 'agent_message',
1299
+ message: 'preamble'
1300
+ }
1301
+ },
1302
+ {
1303
+ type: 'event',
1304
+ timestamp: '2025-01-01T00:00:02Z',
1305
+ payload: {
1306
+ type: 'user_message',
1307
+ message: 'first user'
1308
+ }
1309
+ },
1310
+ {
1311
+ type: 'event',
1312
+ timestamp: '2025-01-01T00:00:03Z',
1313
+ payload: {
1314
+ type: 'user_message',
1315
+ message: 'second user'
1316
+ }
1317
+ }
1318
+ ]);
1319
+ const result = await adapter.listSessions({
1320
+ cwd: '/repo'
1321
+ });
1322
+ expect(result).toHaveLength(1);
1323
+ expect(result[0].firstUserMessage).toBe('first user');
1324
+ });
1325
+ it('returns empty firstUserMessage when no user_message exists', async ()=>{
1326
+ const day = path.join(sessionsDir, '2025', '01', '01');
1327
+ writeCodexSession(day, 's', [
1328
+ {
1329
+ type: 'session_meta',
1330
+ payload: {
1331
+ id: 's',
1332
+ cwd: '/repo',
1333
+ timestamp: '2025-01-01T00:00:00Z'
1334
+ }
1335
+ },
1336
+ {
1337
+ type: 'event',
1338
+ timestamp: '2025-01-01T00:00:01Z',
1339
+ payload: {
1340
+ type: 'agent_message',
1341
+ message: 'agent only'
1342
+ }
1343
+ }
1344
+ ]);
1345
+ const result = await adapter.listSessions({
1346
+ cwd: '/repo'
1347
+ });
1348
+ expect(result).toHaveLength(1);
1349
+ expect(result[0].firstUserMessage).toBe('');
1350
+ });
1351
+ });
1352
+ });
1353
+
1354
+ //# sourceMappingURL=CodexAdapter.test.js.map