@ai-devkit/agent-manager 0.3.0 → 0.5.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.
- package/dist/adapters/AgentAdapter.d.ts +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +49 -38
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +286 -293
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +32 -30
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +148 -284
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +6 -3
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +12 -11
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +39 -0
- package/dist/utils/matching.d.ts.map +1 -0
- package/dist/utils/matching.js +103 -0
- package/dist/utils/matching.js.map +1 -0
- package/dist/utils/process.d.ts +25 -40
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +151 -105
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +30 -0
- package/dist/utils/session.d.ts.map +1 -0
- package/dist/utils/session.js +101 -0
- package/dist/utils/session.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/AgentManager.test.ts +0 -25
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
- package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
- package/src/__tests__/utils/matching.test.ts +191 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/ClaudeCodeAdapter.ts +341 -418
- package/src/adapters/CodexAdapter.ts +155 -420
- package/src/index.ts +1 -3
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +92 -0
- package/src/utils/process.ts +133 -119
- package/src/utils/session.ts +92 -0
- package/dist/utils/file.d.ts +0 -52
- package/dist/utils/file.d.ts.map +0 -1
- package/dist/utils/file.js +0 -135
- package/dist/utils/file.js.map +0 -1
- package/src/utils/file.ts +0 -100
|
@@ -1,319 +1,518 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CodexAdapter
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals';
|
|
2
8
|
import { CodexAdapter } from '../../adapters/CodexAdapter';
|
|
3
9
|
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
4
10
|
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
5
|
-
import {
|
|
11
|
+
import { listAgentProcesses, enrichProcesses } from '../../utils/process';
|
|
12
|
+
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
13
|
+
import type { SessionFile } from '../../utils/session';
|
|
14
|
+
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
|
|
15
|
+
import type { MatchResult } from '../../utils/matching';
|
|
6
16
|
|
|
7
17
|
jest.mock('../../utils/process', () => ({
|
|
8
|
-
|
|
18
|
+
listAgentProcesses: jest.fn(),
|
|
19
|
+
enrichProcesses: jest.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
jest.mock('../../utils/session', () => ({
|
|
23
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
9
24
|
}));
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
jest.mock('../../utils/matching', () => ({
|
|
27
|
+
matchProcessesToSessions: jest.fn(),
|
|
28
|
+
generateAgentName: jest.fn(),
|
|
29
|
+
}));
|
|
12
30
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
lastActive: Date;
|
|
19
|
-
lastPayloadType?: string;
|
|
20
|
-
}
|
|
31
|
+
const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
|
|
32
|
+
const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
|
|
33
|
+
const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
|
|
34
|
+
const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
|
|
35
|
+
const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
|
|
21
36
|
|
|
22
37
|
describe('CodexAdapter', () => {
|
|
23
38
|
let adapter: CodexAdapter;
|
|
24
39
|
|
|
25
40
|
beforeEach(() => {
|
|
26
41
|
adapter = new CodexAdapter();
|
|
27
|
-
|
|
42
|
+
mockedListAgentProcesses.mockReset();
|
|
43
|
+
mockedEnrichProcesses.mockReset();
|
|
44
|
+
mockedBatchGetSessionFileBirthtimes.mockReset();
|
|
45
|
+
mockedMatchProcessesToSessions.mockReset();
|
|
46
|
+
mockedGenerateAgentName.mockReset();
|
|
47
|
+
// Default: enrichProcesses returns what it receives
|
|
48
|
+
mockedEnrichProcesses.mockImplementation((procs) => procs);
|
|
49
|
+
// Default: generateAgentName returns "folder (pid)"
|
|
50
|
+
mockedGenerateAgentName.mockImplementation((cwd, pid) => {
|
|
51
|
+
const folder = path.basename(cwd) || 'unknown';
|
|
52
|
+
return `${folder} (${pid})`;
|
|
53
|
+
});
|
|
28
54
|
});
|
|
29
55
|
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
describe('initialization', () => {
|
|
57
|
+
it('should expose codex type', () => {
|
|
58
|
+
expect(adapter.type).toBe('codex');
|
|
59
|
+
});
|
|
32
60
|
});
|
|
33
61
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
adapter.canHandle({
|
|
37
|
-
|
|
38
|
-
command: 'codex',
|
|
39
|
-
cwd: '/repo',
|
|
40
|
-
tty: 'ttys001',
|
|
41
|
-
}),
|
|
42
|
-
).toBe(true);
|
|
62
|
+
describe('canHandle', () => {
|
|
63
|
+
it('should return true for codex commands', () => {
|
|
64
|
+
expect(adapter.canHandle({ pid: 1, command: 'codex', cwd: '/repo', tty: 'ttys001' })).toBe(true);
|
|
65
|
+
});
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
adapter.canHandle({
|
|
67
|
+
it('should return true for codex with full path (case-insensitive)', () => {
|
|
68
|
+
expect(adapter.canHandle({
|
|
46
69
|
pid: 2,
|
|
47
70
|
command: '/usr/local/bin/CODEX --sandbox workspace-write',
|
|
48
71
|
cwd: '/repo',
|
|
49
72
|
tty: 'ttys002',
|
|
50
|
-
})
|
|
51
|
-
)
|
|
73
|
+
})).toBe(true);
|
|
74
|
+
});
|
|
52
75
|
|
|
53
|
-
|
|
54
|
-
adapter.canHandle({
|
|
76
|
+
it('should return false for non-codex processes', () => {
|
|
77
|
+
expect(adapter.canHandle({ pid: 3, command: 'node app.js', cwd: '/repo', tty: 'ttys003' })).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return false for processes with "codex" only in path arguments', () => {
|
|
81
|
+
expect(adapter.canHandle({
|
|
55
82
|
pid: 4,
|
|
56
83
|
command: 'node /worktrees/feature-codex-adapter-agent-manager-package/node_modules/nx/src/daemon/server/start.js',
|
|
57
84
|
cwd: '/repo',
|
|
58
85
|
tty: 'ttys004',
|
|
59
|
-
})
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
expect(
|
|
63
|
-
adapter.canHandle({
|
|
64
|
-
pid: 3,
|
|
65
|
-
command: 'node app.js',
|
|
66
|
-
cwd: '/repo',
|
|
67
|
-
tty: 'ttys003',
|
|
68
|
-
}),
|
|
69
|
-
).toBe(false);
|
|
86
|
+
})).toBe(false);
|
|
87
|
+
});
|
|
70
88
|
});
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
describe('detectAgents', () => {
|
|
91
|
+
it('should return empty list when no codex process is running', async () => {
|
|
92
|
+
mockedListAgentProcesses.mockReturnValue([]);
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
const agents = await adapter.detectAgents();
|
|
95
|
+
expect(agents).toEqual([]);
|
|
96
|
+
expect(mockedListAgentProcesses).toHaveBeenCalledWith('codex');
|
|
97
|
+
});
|
|
78
98
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
it('should return process-only agents when no sessions discovered', async () => {
|
|
100
|
+
const processes: ProcessInfo[] = [
|
|
101
|
+
{ pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001' },
|
|
102
|
+
];
|
|
103
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
104
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
105
|
+
|
|
106
|
+
// No sessions dir → discoverSessions returns []
|
|
107
|
+
(adapter as any).codexSessionsDir = '/nonexistent/sessions';
|
|
108
|
+
|
|
109
|
+
const agents = await adapter.detectAgents();
|
|
110
|
+
expect(agents).toHaveLength(1);
|
|
111
|
+
expect(agents[0]).toMatchObject({
|
|
112
|
+
type: 'codex',
|
|
113
|
+
status: AgentStatus.RUNNING,
|
|
114
|
+
pid: 100,
|
|
115
|
+
projectPath: '/repo-a',
|
|
116
|
+
sessionId: 'pid-100',
|
|
117
|
+
summary: 'Codex process running',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
83
120
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
121
|
+
it('should detect agents with matched sessions', async () => {
|
|
122
|
+
const processes: ProcessInfo[] = [
|
|
123
|
+
{
|
|
124
|
+
pid: 100,
|
|
125
|
+
command: 'codex',
|
|
126
|
+
cwd: '/repo-a',
|
|
127
|
+
tty: 'ttys001',
|
|
128
|
+
startTime: new Date('2026-03-18T15:00:00.000Z'),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
132
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
133
|
+
|
|
134
|
+
// Set up sessions dir with date directory
|
|
135
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
|
|
136
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
137
|
+
const dateDir = path.join(sessionsDir, '2026', '03', '18');
|
|
138
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
139
|
+
|
|
140
|
+
// Create session file with recent timestamps so status isn't idle
|
|
141
|
+
const now = new Date();
|
|
142
|
+
const recentTs = now.toISOString();
|
|
143
|
+
const sessionFile = path.join(dateDir, 'sess-abc.jsonl');
|
|
144
|
+
fs.writeFileSync(sessionFile, [
|
|
145
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-abc', timestamp: recentTs, cwd: '/repo-a' } }),
|
|
146
|
+
JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'token_count', message: 'Implement adapter flow' } }),
|
|
147
|
+
].join('\n'));
|
|
148
|
+
|
|
149
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
150
|
+
|
|
151
|
+
const sessionFiles: SessionFile[] = [
|
|
152
|
+
{
|
|
153
|
+
sessionId: 'sess-abc',
|
|
154
|
+
filePath: sessionFile,
|
|
155
|
+
projectDir: dateDir,
|
|
156
|
+
birthtimeMs: new Date('2026-03-18T15:00:05Z').getTime(),
|
|
157
|
+
resolvedCwd: '',
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
161
|
+
|
|
162
|
+
const matches: MatchResult[] = [
|
|
163
|
+
{
|
|
164
|
+
process: processes[0],
|
|
165
|
+
session: { ...sessionFiles[0], resolvedCwd: '/repo-a' },
|
|
166
|
+
deltaMs: 5000,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
170
|
+
|
|
171
|
+
const agents = await adapter.detectAgents();
|
|
172
|
+
|
|
173
|
+
expect(agents).toHaveLength(1);
|
|
174
|
+
expect(agents[0]).toMatchObject({
|
|
175
|
+
type: 'codex',
|
|
176
|
+
status: AgentStatus.RUNNING,
|
|
177
|
+
pid: 100,
|
|
87
178
|
projectPath: '/repo-a',
|
|
179
|
+
sessionId: 'sess-abc',
|
|
88
180
|
summary: 'Implement adapter flow',
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
} as MockSession,
|
|
93
|
-
]);
|
|
94
|
-
|
|
95
|
-
const agents = await adapter.detectAgents();
|
|
96
|
-
expect(agents).toHaveLength(1);
|
|
97
|
-
expect(agents[0]).toMatchObject({
|
|
98
|
-
name: 'repo-a',
|
|
99
|
-
type: 'codex',
|
|
100
|
-
status: AgentStatus.RUNNING,
|
|
101
|
-
summary: 'Implement adapter flow',
|
|
102
|
-
pid: 100,
|
|
103
|
-
projectPath: '/repo-a',
|
|
104
|
-
sessionId: 'abc12345-session',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
105
184
|
});
|
|
106
|
-
});
|
|
107
185
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
186
|
+
it('should fall back to process-only for unmatched processes', async () => {
|
|
187
|
+
const processes: ProcessInfo[] = [
|
|
188
|
+
{ pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
|
|
189
|
+
{ pid: 200, command: 'codex', cwd: '/repo-b', tty: 'ttys002', startTime: new Date() },
|
|
190
|
+
];
|
|
191
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
192
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
193
|
+
|
|
194
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
|
|
195
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
196
|
+
const now = new Date();
|
|
197
|
+
const dateDir = path.join(
|
|
198
|
+
sessionsDir,
|
|
199
|
+
String(now.getFullYear()),
|
|
200
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
201
|
+
String(now.getDate()).padStart(2, '0'),
|
|
202
|
+
);
|
|
203
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
204
|
+
|
|
205
|
+
const sessionFile = path.join(dateDir, 'only-session.jsonl');
|
|
206
|
+
fs.writeFileSync(sessionFile,
|
|
207
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'only-session', timestamp: now.toISOString(), cwd: '/repo-a' } }),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
211
|
+
|
|
212
|
+
const sessionFiles: SessionFile[] = [
|
|
213
|
+
{
|
|
214
|
+
sessionId: 'only-session',
|
|
215
|
+
filePath: sessionFile,
|
|
216
|
+
projectDir: dateDir,
|
|
217
|
+
birthtimeMs: Date.now(),
|
|
218
|
+
resolvedCwd: '',
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
222
|
+
|
|
223
|
+
// Only process 100 matches
|
|
224
|
+
const matches: MatchResult[] = [
|
|
225
|
+
{
|
|
226
|
+
process: processes[0],
|
|
227
|
+
session: { ...sessionFiles[0], resolvedCwd: '/repo-a' },
|
|
228
|
+
deltaMs: 5000,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
232
|
+
|
|
233
|
+
const agents = await adapter.detectAgents();
|
|
234
|
+
expect(agents).toHaveLength(2);
|
|
235
|
+
|
|
236
|
+
const matched = agents.find((a) => a.pid === 100);
|
|
237
|
+
const unmatched = agents.find((a) => a.pid === 200);
|
|
238
|
+
expect(matched?.sessionId).toBe('only-session');
|
|
239
|
+
expect(unmatched?.sessionId).toBe('pid-200');
|
|
240
|
+
expect(unmatched?.status).toBe(AgentStatus.RUNNING);
|
|
241
|
+
|
|
242
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
243
|
+
});
|
|
128
244
|
});
|
|
129
245
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{ pid: 102, command: 'codex', cwd: '', tty: 'ttys009' },
|
|
133
|
-
] as ProcessInfo[]);
|
|
134
|
-
|
|
135
|
-
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
136
|
-
{
|
|
137
|
-
sessionId: 'abcdef123456',
|
|
138
|
-
projectPath: '',
|
|
139
|
-
summary: 'No cwd available',
|
|
140
|
-
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
141
|
-
lastActive: new Date(),
|
|
142
|
-
lastPayloadType: 'agent_reasoning',
|
|
143
|
-
} as MockSession,
|
|
144
|
-
]);
|
|
145
|
-
|
|
146
|
-
const agents = await adapter.detectAgents();
|
|
147
|
-
expect(agents).toHaveLength(1);
|
|
148
|
-
expect(agents[0].name).toBe('codex-abcdef12');
|
|
149
|
-
});
|
|
246
|
+
describe('discoverSessions', () => {
|
|
247
|
+
let tmpDir: string;
|
|
150
248
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
] as ProcessInfo[]);
|
|
155
|
-
|
|
156
|
-
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
157
|
-
{
|
|
158
|
-
sessionId: 'waiting-1234',
|
|
159
|
-
projectPath: '/repo-c',
|
|
160
|
-
summary: 'Waiting',
|
|
161
|
-
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
162
|
-
lastActive: new Date(),
|
|
163
|
-
lastPayloadType: 'agent_message',
|
|
164
|
-
} as MockSession,
|
|
165
|
-
]);
|
|
166
|
-
|
|
167
|
-
const agents = await adapter.detectAgents();
|
|
168
|
-
expect(agents).toHaveLength(1);
|
|
169
|
-
expect(agents[0].status).toBe(AgentStatus.WAITING);
|
|
170
|
-
});
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
|
|
251
|
+
});
|
|
171
252
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
] as ProcessInfo[]);
|
|
176
|
-
|
|
177
|
-
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
178
|
-
{
|
|
179
|
-
sessionId: 'idle-5678',
|
|
180
|
-
projectPath: '/repo-d',
|
|
181
|
-
summary: 'Idle',
|
|
182
|
-
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
183
|
-
lastActive: new Date(Date.now() - 10 * 60 * 1000),
|
|
184
|
-
lastPayloadType: 'token_count',
|
|
185
|
-
} as MockSession,
|
|
186
|
-
]);
|
|
187
|
-
|
|
188
|
-
const agents = await adapter.detectAgents();
|
|
189
|
-
expect(agents).toHaveLength(1);
|
|
190
|
-
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
191
|
-
});
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
255
|
+
});
|
|
192
256
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
] as ProcessInfo[]);
|
|
197
|
-
|
|
198
|
-
jest.spyOn(adapter as any, 'readSessions').mockReturnValue([
|
|
199
|
-
{
|
|
200
|
-
sessionId: 'other-session',
|
|
201
|
-
projectPath: '/repo-y',
|
|
202
|
-
summary: 'Other repo',
|
|
203
|
-
sessionStart: new Date('2026-02-26T15:00:00.000Z'),
|
|
204
|
-
lastActive: new Date(),
|
|
205
|
-
lastPayloadType: 'agent_message',
|
|
206
|
-
} as MockSession,
|
|
207
|
-
]);
|
|
208
|
-
|
|
209
|
-
const agents = await adapter.detectAgents();
|
|
210
|
-
expect(agents).toHaveLength(1);
|
|
211
|
-
expect(agents[0].pid).toBe(105);
|
|
212
|
-
});
|
|
257
|
+
it('should return empty when sessions dir does not exist', () => {
|
|
258
|
+
(adapter as any).codexSessionsDir = path.join(tmpDir, 'nonexistent');
|
|
259
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
213
260
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
261
|
+
const { sessions } = discoverSessions([
|
|
262
|
+
{ pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date() },
|
|
263
|
+
]);
|
|
264
|
+
expect(sessions).toEqual([]);
|
|
265
|
+
});
|
|
219
266
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
267
|
+
it('should scan date directories based on process start times', () => {
|
|
268
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
269
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
270
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
271
|
+
|
|
272
|
+
// Create date dir for 2026-03-18
|
|
273
|
+
const dateDir = path.join(sessionsDir, '2026', '03', '18');
|
|
274
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
275
|
+
|
|
276
|
+
// Create session file with meta
|
|
277
|
+
const sessionFile = path.join(dateDir, 'sess1.jsonl');
|
|
278
|
+
fs.writeFileSync(sessionFile,
|
|
279
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess1', cwd: '/repo-a' } }),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const mockFiles: SessionFile[] = [
|
|
283
|
+
{
|
|
284
|
+
sessionId: 'sess1',
|
|
285
|
+
filePath: sessionFile,
|
|
286
|
+
projectDir: dateDir,
|
|
287
|
+
birthtimeMs: 1710800324000,
|
|
288
|
+
resolvedCwd: '',
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
|
|
292
|
+
|
|
293
|
+
const processes = [
|
|
294
|
+
{ pid: 1, command: 'codex', cwd: '/repo-a', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const { sessions, contentCache } = discoverSessions(processes);
|
|
298
|
+
expect(sessions).toHaveLength(1);
|
|
299
|
+
expect(sessions[0].resolvedCwd).toBe('/repo-a');
|
|
300
|
+
expect(contentCache.has(sessionFile)).toBe(true);
|
|
301
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
225
303
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
sessionId: 'near-session',
|
|
242
|
-
projectPath: '/repo-time',
|
|
243
|
-
summary: 'Near start time',
|
|
244
|
-
sessionStart: new Date('2026-02-26T15:00:20.000Z'),
|
|
245
|
-
lastActive: new Date('2026-02-26T15:11:00.000Z'),
|
|
246
|
-
lastPayloadType: 'agent_message',
|
|
247
|
-
} as MockSession,
|
|
248
|
-
]);
|
|
249
|
-
jest.spyOn(adapter as any, 'getProcessStartTimes').mockReturnValue(
|
|
250
|
-
new Map([[107, new Date('2026-02-26T15:00:00.000Z')]]),
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
const agents = await adapter.detectAgents();
|
|
254
|
-
expect(agents).toHaveLength(1);
|
|
255
|
-
expect(agents[0].sessionId).toBe('near-session');
|
|
256
|
-
});
|
|
304
|
+
it('should scan ±1 day window around process start time', () => {
|
|
305
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
306
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
307
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
308
|
+
|
|
309
|
+
// Create date dirs for 17, 18, 19
|
|
310
|
+
for (const day of ['17', '18', '19']) {
|
|
311
|
+
fs.mkdirSync(path.join(sessionsDir, '2026', '03', day), { recursive: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
|
|
315
|
+
|
|
316
|
+
const processes = [
|
|
317
|
+
{ pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
|
|
318
|
+
];
|
|
257
319
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
expect(agents[0].sessionId).toBe('missing-cwd-session');
|
|
320
|
+
discoverSessions(processes);
|
|
321
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
322
|
+
// Should scan all 3 date dirs
|
|
323
|
+
const dirs = mockedBatchGetSessionFileBirthtimes.mock.calls[0][0] as string[];
|
|
324
|
+
expect(dirs).toHaveLength(3);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should handle session files without session_meta', () => {
|
|
328
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
329
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
330
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
331
|
+
|
|
332
|
+
const dateDir = path.join(sessionsDir, '2026', '03', '18');
|
|
333
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
334
|
+
|
|
335
|
+
const sessionFile = path.join(dateDir, 'bad.jsonl');
|
|
336
|
+
fs.writeFileSync(sessionFile, JSON.stringify({ type: 'event', payload: {} }));
|
|
337
|
+
|
|
338
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
339
|
+
{ sessionId: 'bad', filePath: sessionFile, projectDir: dateDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
const processes = [
|
|
343
|
+
{ pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const { sessions } = discoverSessions(processes);
|
|
347
|
+
expect(sessions[0].resolvedCwd).toBe('');
|
|
348
|
+
});
|
|
288
349
|
});
|
|
289
350
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
351
|
+
describe('helper methods', () => {
|
|
352
|
+
describe('determineStatus', () => {
|
|
353
|
+
it('should return "waiting" for agent_message events', () => {
|
|
354
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
355
|
+
expect(determineStatus({
|
|
356
|
+
lastActive: new Date(),
|
|
357
|
+
lastPayloadType: 'agent_message',
|
|
358
|
+
})).toBe(AgentStatus.WAITING);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should return "waiting" for task_complete events', () => {
|
|
362
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
363
|
+
expect(determineStatus({
|
|
364
|
+
lastActive: new Date(),
|
|
365
|
+
lastPayloadType: 'task_complete',
|
|
366
|
+
})).toBe(AgentStatus.WAITING);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should return "waiting" for turn_aborted events', () => {
|
|
370
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
371
|
+
expect(determineStatus({
|
|
372
|
+
lastActive: new Date(),
|
|
373
|
+
lastPayloadType: 'turn_aborted',
|
|
374
|
+
})).toBe(AgentStatus.WAITING);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should return "running" for active events', () => {
|
|
378
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
379
|
+
expect(determineStatus({
|
|
380
|
+
lastActive: new Date(),
|
|
381
|
+
lastPayloadType: 'token_count',
|
|
382
|
+
})).toBe(AgentStatus.RUNNING);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should return "idle" when session exceeds threshold', () => {
|
|
386
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
387
|
+
expect(determineStatus({
|
|
388
|
+
lastActive: new Date(Date.now() - 10 * 60 * 1000),
|
|
389
|
+
lastPayloadType: 'token_count',
|
|
390
|
+
})).toBe(AgentStatus.IDLE);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('parseSession', () => {
|
|
395
|
+
let tmpDir: string;
|
|
396
|
+
|
|
397
|
+
beforeEach(() => {
|
|
398
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-test-'));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
afterEach(() => {
|
|
402
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should parse session file with meta and events', () => {
|
|
406
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
407
|
+
const filePath = path.join(tmpDir, 'session.jsonl');
|
|
408
|
+
fs.writeFileSync(filePath, [
|
|
409
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-1', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
410
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_reasoning', message: 'Working on feature' } }),
|
|
411
|
+
].join('\n'));
|
|
412
|
+
|
|
413
|
+
const session = parseSession(undefined, filePath);
|
|
414
|
+
expect(session).toMatchObject({
|
|
415
|
+
sessionId: 'sess-1',
|
|
416
|
+
projectPath: '/repo',
|
|
417
|
+
summary: 'Working on feature',
|
|
418
|
+
lastPayloadType: 'agent_reasoning',
|
|
419
|
+
});
|
|
420
|
+
expect(session.sessionStart.toISOString()).toBe('2026-03-18T15:00:00.000Z');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should parse from cached content without reading disk', () => {
|
|
424
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
425
|
+
const content = [
|
|
426
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'cached-1', timestamp: '2026-03-18T15:00:00Z', cwd: '/cached' } }),
|
|
427
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: 'Cached result' } }),
|
|
428
|
+
].join('\n');
|
|
429
|
+
|
|
430
|
+
const session = parseSession(content, '/nonexistent/path.jsonl');
|
|
431
|
+
expect(session).toMatchObject({
|
|
432
|
+
sessionId: 'cached-1',
|
|
433
|
+
projectPath: '/cached',
|
|
434
|
+
summary: 'Cached result',
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should return null for non-existent file', () => {
|
|
439
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
440
|
+
expect(parseSession(undefined, path.join(tmpDir, 'nonexistent.jsonl'))).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should return null when first line is not session_meta', () => {
|
|
444
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
445
|
+
const filePath = path.join(tmpDir, 'bad.jsonl');
|
|
446
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'event', payload: {} }));
|
|
447
|
+
expect(parseSession(undefined, filePath)).toBeNull();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should return null when session_meta has no id', () => {
|
|
451
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
452
|
+
const filePath = path.join(tmpDir, 'no-id.jsonl');
|
|
453
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'session_meta', payload: { cwd: '/repo' } }));
|
|
454
|
+
expect(parseSession(undefined, filePath)).toBeNull();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should extract summary from last event message', () => {
|
|
458
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
459
|
+
const filePath = path.join(tmpDir, 'summary.jsonl');
|
|
460
|
+
fs.writeFileSync(filePath, [
|
|
461
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-2', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
462
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_reasoning', message: 'First message' } }),
|
|
463
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:02:00Z', payload: { type: 'agent_message', message: 'Last message' } }),
|
|
464
|
+
].join('\n'));
|
|
465
|
+
|
|
466
|
+
const session = parseSession(undefined, filePath);
|
|
467
|
+
expect(session.summary).toBe('Last message');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
471
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
472
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
473
|
+
fs.writeFileSync(filePath, [
|
|
474
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-m', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
475
|
+
'not valid json',
|
|
476
|
+
'{"incomplete": true',
|
|
477
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: 'Valid message' } }),
|
|
478
|
+
].join('\n'));
|
|
479
|
+
|
|
480
|
+
const session = parseSession(undefined, filePath);
|
|
481
|
+
expect(session).not.toBeNull();
|
|
482
|
+
expect(session.sessionId).toBe('sess-m');
|
|
483
|
+
expect(session.summary).toBe('Valid message');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should default summary when no messages found', () => {
|
|
487
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
488
|
+
const filePath = path.join(tmpDir, 'no-msg.jsonl');
|
|
489
|
+
fs.writeFileSync(filePath, [
|
|
490
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-3', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
491
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'token_count' } }),
|
|
492
|
+
].join('\n'));
|
|
493
|
+
|
|
494
|
+
const session = parseSession(undefined, filePath);
|
|
495
|
+
expect(session.summary).toBe('Codex session active');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should return null for empty content', () => {
|
|
499
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
500
|
+
expect(parseSession('', '/fake/path.jsonl')).toBeNull();
|
|
501
|
+
expect(parseSession(' \n \n ', '/fake/path.jsonl')).toBeNull();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should truncate long summary to 120 chars', () => {
|
|
505
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
506
|
+
const longMsg = 'A'.repeat(200);
|
|
507
|
+
const content = [
|
|
508
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-t', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
509
|
+
JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:01:00Z', payload: { type: 'agent_message', message: longMsg } }),
|
|
510
|
+
].join('\n');
|
|
511
|
+
|
|
512
|
+
const session = parseSession(content, '/fake/path.jsonl');
|
|
513
|
+
expect(session.summary).toHaveLength(120);
|
|
514
|
+
expect(session.summary.endsWith('...')).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
318
517
|
});
|
|
319
518
|
});
|