@ai-devkit/agent-manager 0.4.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 +29 -34
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +138 -294
- 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 -282
- 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 +1 -1
- package/src/__tests__/AgentManager.test.ts +0 -25
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +598 -845
- package/src/__tests__/adapters/CodexAdapter.test.ts +467 -274
- 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 +177 -425
- package/src/adapters/CodexAdapter.ts +155 -409
- 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
|
@@ -6,28 +6,49 @@ import * as fs from 'fs';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
8
8
|
import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
|
|
9
|
-
import type {
|
|
9
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
10
10
|
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
11
|
-
import {
|
|
12
|
-
|
|
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';
|
|
13
16
|
jest.mock('../../utils/process', () => ({
|
|
14
|
-
|
|
17
|
+
listAgentProcesses: jest.fn(),
|
|
18
|
+
enrichProcesses: jest.fn(),
|
|
15
19
|
}));
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
jest.mock('../../utils/session', () => ({
|
|
22
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
23
|
+
}));
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
jest.mock('../../utils/matching', () => ({
|
|
26
|
+
matchProcessesToSessions: jest.fn(),
|
|
27
|
+
generateAgentName: jest.fn(),
|
|
28
|
+
}));
|
|
24
29
|
|
|
30
|
+
const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
|
|
31
|
+
const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
|
|
32
|
+
const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
|
|
33
|
+
const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
|
|
34
|
+
const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
|
|
25
35
|
describe('ClaudeCodeAdapter', () => {
|
|
26
36
|
let adapter: ClaudeCodeAdapter;
|
|
27
37
|
|
|
28
38
|
beforeEach(() => {
|
|
29
39
|
adapter = new ClaudeCodeAdapter();
|
|
30
|
-
|
|
40
|
+
mockedListAgentProcesses.mockReset();
|
|
41
|
+
mockedEnrichProcesses.mockReset();
|
|
42
|
+
mockedBatchGetSessionFileBirthtimes.mockReset();
|
|
43
|
+
mockedMatchProcessesToSessions.mockReset();
|
|
44
|
+
mockedGenerateAgentName.mockReset();
|
|
45
|
+
// Default: enrichProcesses returns what it receives
|
|
46
|
+
mockedEnrichProcesses.mockImplementation((procs) => procs);
|
|
47
|
+
// Default: generateAgentName returns "folder (pid)"
|
|
48
|
+
mockedGenerateAgentName.mockImplementation((cwd, pid) => {
|
|
49
|
+
const folder = path.basename(cwd) || 'unknown';
|
|
50
|
+
return `${folder} (${pid})`;
|
|
51
|
+
});
|
|
31
52
|
});
|
|
32
53
|
|
|
33
54
|
describe('initialization', () => {
|
|
@@ -95,43 +116,88 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
95
116
|
|
|
96
117
|
describe('detectAgents', () => {
|
|
97
118
|
it('should return empty array if no claude processes running', async () => {
|
|
98
|
-
|
|
119
|
+
mockedListAgentProcesses.mockReturnValue([]);
|
|
99
120
|
|
|
100
121
|
const agents = await adapter.detectAgents();
|
|
101
122
|
expect(agents).toEqual([]);
|
|
123
|
+
expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return process-only agents when no sessions discovered', async () => {
|
|
127
|
+
const processes: ProcessInfo[] = [
|
|
128
|
+
{ pid: 777, command: 'claude', cwd: '/project/app', tty: 'ttys001' },
|
|
129
|
+
];
|
|
130
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
131
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
132
|
+
|
|
133
|
+
// No projects dir → discoverSessions returns []
|
|
134
|
+
(adapter as any).projectsDir = '/nonexistent/path';
|
|
135
|
+
|
|
136
|
+
const agents = await adapter.detectAgents();
|
|
137
|
+
expect(agents).toHaveLength(1);
|
|
138
|
+
expect(agents[0]).toMatchObject({
|
|
139
|
+
type: 'claude',
|
|
140
|
+
status: AgentStatus.IDLE,
|
|
141
|
+
pid: 777,
|
|
142
|
+
projectPath: '/project/app',
|
|
143
|
+
sessionId: 'pid-777',
|
|
144
|
+
summary: 'Unknown',
|
|
145
|
+
});
|
|
102
146
|
});
|
|
103
147
|
|
|
104
|
-
it('should detect agents
|
|
105
|
-
const
|
|
148
|
+
it('should detect agents with matched sessions', async () => {
|
|
149
|
+
const processes: ProcessInfo[] = [
|
|
106
150
|
{
|
|
107
151
|
pid: 12345,
|
|
108
|
-
command: 'claude
|
|
152
|
+
command: 'claude',
|
|
109
153
|
cwd: '/Users/test/my-project',
|
|
110
154
|
tty: 'ttys001',
|
|
155
|
+
startTime: new Date('2026-03-18T23:18:01.000Z'),
|
|
111
156
|
},
|
|
112
157
|
];
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
159
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
160
|
+
|
|
161
|
+
// Set up projects dir with encoded directory name
|
|
162
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
163
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
164
|
+
// Claude encodes /Users/test/my-project → -Users-test-my-project
|
|
165
|
+
const projDir = path.join(projectsDir, '-Users-test-my-project');
|
|
166
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
167
|
+
|
|
168
|
+
// Create session file
|
|
169
|
+
const sessionFile = path.join(projDir, 'session-1.jsonl');
|
|
170
|
+
fs.writeFileSync(sessionFile, [
|
|
171
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', slug: 'merry-dog', message: { content: 'Investigate failing tests' } }),
|
|
172
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
173
|
+
].join('\n'));
|
|
174
|
+
|
|
175
|
+
(adapter as any).projectsDir = projectsDir;
|
|
176
|
+
|
|
177
|
+
const sessionFiles: SessionFile[] = [
|
|
115
178
|
{
|
|
116
179
|
sessionId: 'session-1',
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
lastEntryType: 'assistant',
|
|
122
|
-
isInterrupted: false,
|
|
123
|
-
lastUserMessage: 'Investigate failing tests in package',
|
|
180
|
+
filePath: sessionFile,
|
|
181
|
+
projectDir: projDir,
|
|
182
|
+
birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(),
|
|
183
|
+
resolvedCwd: '',
|
|
124
184
|
},
|
|
125
185
|
];
|
|
186
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
126
187
|
|
|
127
|
-
|
|
128
|
-
|
|
188
|
+
const matches: MatchResult[] = [
|
|
189
|
+
{
|
|
190
|
+
process: processes[0],
|
|
191
|
+
session: { ...sessionFiles[0], resolvedCwd: '/Users/test/my-project' },
|
|
192
|
+
deltaMs: 43000,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
129
196
|
|
|
130
197
|
const agents = await adapter.detectAgents();
|
|
131
198
|
|
|
132
199
|
expect(agents).toHaveLength(1);
|
|
133
200
|
expect(agents[0]).toMatchObject({
|
|
134
|
-
name: 'my-project',
|
|
135
201
|
type: 'claude',
|
|
136
202
|
status: AgentStatus.WAITING,
|
|
137
203
|
pid: 12345,
|
|
@@ -139,259 +205,403 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
139
205
|
sessionId: 'session-1',
|
|
140
206
|
slug: 'merry-dog',
|
|
141
207
|
});
|
|
142
|
-
expect(agents[0].summary).toContain('Investigate failing tests
|
|
208
|
+
expect(agents[0].summary).toContain('Investigate failing tests');
|
|
209
|
+
|
|
210
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
211
|
});
|
|
144
212
|
|
|
145
|
-
it('should
|
|
146
|
-
|
|
213
|
+
it('should fall back to process-only for unmatched processes', async () => {
|
|
214
|
+
const processes: ProcessInfo[] = [
|
|
215
|
+
{ pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() },
|
|
216
|
+
{ pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() },
|
|
217
|
+
];
|
|
218
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
219
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
220
|
+
|
|
221
|
+
// Set up projects dir with encoded directory names
|
|
222
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
223
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
224
|
+
// /project-a → -project-a, /project-b → -project-b
|
|
225
|
+
const projDirA = path.join(projectsDir, '-project-a');
|
|
226
|
+
const projDirB = path.join(projectsDir, '-project-b');
|
|
227
|
+
fs.mkdirSync(projDirA, { recursive: true });
|
|
228
|
+
fs.mkdirSync(projDirB, { recursive: true });
|
|
229
|
+
|
|
230
|
+
const sessionFile = path.join(projDirA, 'only-session.jsonl');
|
|
231
|
+
fs.writeFileSync(sessionFile,
|
|
232
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
(adapter as any).projectsDir = projectsDir;
|
|
236
|
+
|
|
237
|
+
const sessionFiles: SessionFile[] = [
|
|
147
238
|
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
239
|
+
sessionId: 'only-session',
|
|
240
|
+
filePath: sessionFile,
|
|
241
|
+
projectDir: projDirA,
|
|
242
|
+
birthtimeMs: Date.now(),
|
|
243
|
+
resolvedCwd: '',
|
|
152
244
|
},
|
|
153
|
-
]
|
|
154
|
-
|
|
245
|
+
];
|
|
246
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
155
247
|
|
|
248
|
+
// Only process 100 matches
|
|
249
|
+
const matches: MatchResult[] = [
|
|
250
|
+
{
|
|
251
|
+
process: processes[0],
|
|
252
|
+
session: { ...sessionFiles[0], resolvedCwd: '/project-a' },
|
|
253
|
+
deltaMs: 5000,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
156
257
|
|
|
157
258
|
const agents = await adapter.detectAgents();
|
|
158
|
-
expect(agents).toHaveLength(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
259
|
+
expect(agents).toHaveLength(2);
|
|
260
|
+
|
|
261
|
+
const matched = agents.find(a => a.pid === 100);
|
|
262
|
+
const unmatched = agents.find(a => a.pid === 200);
|
|
263
|
+
expect(matched?.sessionId).toBe('only-session');
|
|
264
|
+
expect(unmatched?.sessionId).toBe('pid-200');
|
|
265
|
+
expect(unmatched?.status).toBe(AgentStatus.IDLE);
|
|
266
|
+
|
|
267
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
167
268
|
});
|
|
168
269
|
|
|
169
|
-
it('should
|
|
170
|
-
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
tty: 'ttys008',
|
|
176
|
-
},
|
|
177
|
-
]);
|
|
178
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
179
|
-
{
|
|
180
|
-
sessionId: 'session-2',
|
|
181
|
-
projectPath: '/other/project',
|
|
182
|
-
sessionStart: new Date(),
|
|
183
|
-
lastActive: new Date(),
|
|
184
|
-
lastEntryType: 'assistant',
|
|
185
|
-
isInterrupted: false,
|
|
186
|
-
},
|
|
187
|
-
]);
|
|
270
|
+
it('should handle process with empty cwd in process-only fallback', async () => {
|
|
271
|
+
const processes: ProcessInfo[] = [
|
|
272
|
+
{ pid: 300, command: 'claude', cwd: '', tty: 'ttys003' },
|
|
273
|
+
];
|
|
274
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
275
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
188
276
|
|
|
277
|
+
(adapter as any).projectsDir = '/nonexistent';
|
|
189
278
|
|
|
190
279
|
const agents = await adapter.detectAgents();
|
|
191
280
|
expect(agents).toHaveLength(1);
|
|
192
|
-
// Unrelated session should NOT match — falls to process-only
|
|
193
281
|
expect(agents[0]).toMatchObject({
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
projectPath: '
|
|
198
|
-
status: AgentStatus.IDLE,
|
|
282
|
+
pid: 300,
|
|
283
|
+
sessionId: 'pid-300',
|
|
284
|
+
summary: 'Unknown',
|
|
285
|
+
projectPath: '',
|
|
199
286
|
});
|
|
200
287
|
});
|
|
201
288
|
|
|
202
|
-
it('should
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
},
|
|
221
|
-
|
|
289
|
+
it('should use PID file for direct match and skip legacy matching for that process', async () => {
|
|
290
|
+
const startTime = new Date();
|
|
291
|
+
const processes: ProcessInfo[] = [
|
|
292
|
+
{ pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime },
|
|
293
|
+
];
|
|
294
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
295
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
296
|
+
|
|
297
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
|
|
298
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
299
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
300
|
+
const projDir = path.join(projectsDir, '-project-direct');
|
|
301
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
302
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
303
|
+
|
|
304
|
+
const sessionId = 'pid-file-session';
|
|
305
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
306
|
+
fs.writeFileSync(jsonlPath, [
|
|
307
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }),
|
|
308
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
309
|
+
].join('\n'));
|
|
310
|
+
|
|
311
|
+
fs.writeFileSync(
|
|
312
|
+
path.join(sessionsDir, '55001.json'),
|
|
313
|
+
JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
317
|
+
(adapter as any).projectsDir = projectsDir;
|
|
222
318
|
|
|
223
319
|
const agents = await adapter.detectAgents();
|
|
320
|
+
|
|
321
|
+
// Legacy matching utilities should NOT have been called (all processes matched via PID file)
|
|
322
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
323
|
+
expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
|
|
324
|
+
|
|
224
325
|
expect(agents).toHaveLength(1);
|
|
225
326
|
expect(agents[0]).toMatchObject({
|
|
226
327
|
type: 'claude',
|
|
227
|
-
pid:
|
|
228
|
-
sessionId
|
|
229
|
-
projectPath: '/
|
|
328
|
+
pid: 55001,
|
|
329
|
+
sessionId,
|
|
330
|
+
projectPath: '/project/direct',
|
|
331
|
+
status: AgentStatus.WAITING,
|
|
230
332
|
});
|
|
333
|
+
expect(agents[0].summary).toContain('hello from pid file');
|
|
334
|
+
|
|
335
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
231
336
|
});
|
|
232
337
|
|
|
233
|
-
it('should
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
338
|
+
it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
|
|
339
|
+
const startTime = new Date();
|
|
340
|
+
const processes: ProcessInfo[] = [
|
|
341
|
+
{ pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime },
|
|
342
|
+
];
|
|
343
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
344
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
345
|
+
|
|
346
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
|
|
347
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
348
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
349
|
+
const projDir = path.join(projectsDir, '-project-gone');
|
|
350
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
351
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
352
|
+
|
|
353
|
+
const sessionId = 'gone-session';
|
|
354
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
355
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
356
|
+
fs.writeFileSync(
|
|
357
|
+
path.join(sessionsDir, '66001.json'),
|
|
358
|
+
JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
362
|
+
(adapter as any).projectsDir = projectsDir;
|
|
363
|
+
|
|
364
|
+
// Simulate JSONL disappearing between existence check and read
|
|
365
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
243
366
|
|
|
244
367
|
const agents = await adapter.detectAgents();
|
|
368
|
+
|
|
369
|
+
// matchedPids.delete called → process falls back to IDLE
|
|
245
370
|
expect(agents).toHaveLength(1);
|
|
246
|
-
expect(agents[0]).
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
summary: 'Unknown',
|
|
252
|
-
status: AgentStatus.IDLE,
|
|
253
|
-
});
|
|
371
|
+
expect(agents[0].sessionId).toBe('pid-66001');
|
|
372
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
373
|
+
|
|
374
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
375
|
+
jest.restoreAllMocks();
|
|
254
376
|
});
|
|
255
377
|
|
|
256
|
-
it('should
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
378
|
+
it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
|
|
379
|
+
const startTime = new Date();
|
|
380
|
+
const processes: ProcessInfo[] = [
|
|
381
|
+
{ pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime },
|
|
382
|
+
];
|
|
383
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
384
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
385
|
+
|
|
386
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
|
|
387
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
388
|
+
const projDir = path.join(projectsDir, '-project-legacy-gone');
|
|
389
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
390
|
+
|
|
391
|
+
const sessionId = 'legacy-gone-session';
|
|
392
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
393
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
394
|
+
|
|
395
|
+
// No PID file → process goes to legacy fallback
|
|
396
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions');
|
|
397
|
+
(adapter as any).projectsDir = projectsDir;
|
|
398
|
+
|
|
399
|
+
const legacySessionFile = {
|
|
400
|
+
sessionId,
|
|
401
|
+
filePath: jsonlPath,
|
|
402
|
+
projectDir: projDir,
|
|
403
|
+
birthtimeMs: startTime.getTime(),
|
|
404
|
+
resolvedCwd: '/project/legacy-gone',
|
|
405
|
+
};
|
|
406
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
407
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
408
|
+
{ process: processes[0], session: legacySessionFile, deltaMs: 500 },
|
|
275
409
|
]);
|
|
276
410
|
|
|
411
|
+
// Simulate JSONL disappearing between match and read
|
|
412
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
413
|
+
|
|
277
414
|
const agents = await adapter.detectAgents();
|
|
415
|
+
|
|
278
416
|
expect(agents).toHaveLength(1);
|
|
279
|
-
|
|
280
|
-
expect(agents[0]).
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
projectPath: '/Users/test/my-project',
|
|
285
|
-
});
|
|
417
|
+
expect(agents[0].sessionId).toBe('pid-66002');
|
|
418
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
419
|
+
|
|
420
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
421
|
+
jest.restoreAllMocks();
|
|
286
422
|
});
|
|
287
423
|
|
|
288
|
-
it('should
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
},
|
|
424
|
+
it('should mix direct PID-file matches and legacy matches across processes', async () => {
|
|
425
|
+
const startTime = new Date();
|
|
426
|
+
const processes: ProcessInfo[] = [
|
|
427
|
+
{ pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime },
|
|
428
|
+
{ pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime },
|
|
429
|
+
];
|
|
430
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
431
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
432
|
+
|
|
433
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
|
|
434
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
435
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
436
|
+
const projAlpha = path.join(projectsDir, '-project-alpha');
|
|
437
|
+
const projBeta = path.join(projectsDir, '-project-beta');
|
|
438
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
439
|
+
fs.mkdirSync(projAlpha, { recursive: true });
|
|
440
|
+
fs.mkdirSync(projBeta, { recursive: true });
|
|
441
|
+
|
|
442
|
+
// PID file only for process 55002
|
|
443
|
+
const directSessionId = 'direct-session';
|
|
444
|
+
const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
|
|
445
|
+
fs.writeFileSync(directJsonl, [
|
|
446
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }),
|
|
447
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
448
|
+
].join('\n'));
|
|
449
|
+
fs.writeFileSync(
|
|
450
|
+
path.join(sessionsDir, '55002.json'),
|
|
451
|
+
JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Legacy session file for process 55003
|
|
455
|
+
const legacySessionId = 'legacy-session';
|
|
456
|
+
const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
|
|
457
|
+
fs.writeFileSync(legacyJsonl, [
|
|
458
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }),
|
|
459
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
460
|
+
].join('\n'));
|
|
461
|
+
|
|
462
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
463
|
+
(adapter as any).projectsDir = projectsDir;
|
|
464
|
+
|
|
465
|
+
// Mock legacy matching for process 55003
|
|
466
|
+
const legacySessionFile = {
|
|
467
|
+
sessionId: legacySessionId,
|
|
468
|
+
filePath: legacyJsonl,
|
|
469
|
+
projectDir: projBeta,
|
|
470
|
+
birthtimeMs: startTime.getTime(),
|
|
471
|
+
resolvedCwd: '/project/beta',
|
|
472
|
+
};
|
|
473
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
474
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
475
|
+
{ process: processes[1], session: legacySessionFile, deltaMs: 1000 },
|
|
312
476
|
]);
|
|
313
477
|
|
|
314
|
-
|
|
315
478
|
const agents = await adapter.detectAgents();
|
|
479
|
+
|
|
480
|
+
// Legacy matching called only for fallback process (55003)
|
|
481
|
+
expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
|
|
482
|
+
expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]);
|
|
483
|
+
|
|
316
484
|
expect(agents).toHaveLength(2);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
expect(agents[1]).toMatchObject({
|
|
324
|
-
pid: 200,
|
|
325
|
-
sessionId: 'pid-200',
|
|
326
|
-
status: AgentStatus.IDLE,
|
|
327
|
-
summary: 'Unknown',
|
|
328
|
-
});
|
|
485
|
+
const alpha = agents.find(a => a.pid === 55002);
|
|
486
|
+
const beta = agents.find(a => a.pid === 55003);
|
|
487
|
+
expect(alpha?.sessionId).toBe(directSessionId);
|
|
488
|
+
expect(beta?.sessionId).toBe(legacySessionId);
|
|
489
|
+
|
|
490
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
329
491
|
});
|
|
492
|
+
});
|
|
330
493
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
{
|
|
334
|
-
pid: 300,
|
|
335
|
-
command: 'claude',
|
|
336
|
-
cwd: '',
|
|
337
|
-
tty: 'ttys003',
|
|
338
|
-
},
|
|
339
|
-
]);
|
|
340
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
494
|
+
describe('discoverSessions', () => {
|
|
495
|
+
let tmpDir: string;
|
|
341
496
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
expect(agents[0]).toMatchObject({
|
|
345
|
-
pid: 300,
|
|
346
|
-
sessionId: 'pid-300',
|
|
347
|
-
summary: 'Unknown',
|
|
348
|
-
projectPath: '',
|
|
349
|
-
});
|
|
497
|
+
beforeEach(() => {
|
|
498
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
350
499
|
});
|
|
351
500
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
501
|
+
afterEach(() => {
|
|
502
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should return empty when projects dir does not exist', () => {
|
|
506
|
+
(adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
|
|
507
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
508
|
+
|
|
509
|
+
const result = discoverSessions([
|
|
510
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
361
511
|
]);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
512
|
+
expect(result).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should scan only directories matching process CWDs', () => {
|
|
516
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
517
|
+
(adapter as any).projectsDir = projectsDir;
|
|
518
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
519
|
+
|
|
520
|
+
// /my/project → -my-project (encoded dir)
|
|
521
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
522
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
523
|
+
|
|
524
|
+
// Also create another dir that should NOT be scanned
|
|
525
|
+
const otherDir = path.join(projectsDir, '-other-project');
|
|
526
|
+
fs.mkdirSync(otherDir, { recursive: true });
|
|
527
|
+
|
|
528
|
+
const mockFiles: SessionFile[] = [
|
|
371
529
|
{
|
|
372
|
-
sessionId: '
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
isInterrupted: false,
|
|
530
|
+
sessionId: 's1',
|
|
531
|
+
filePath: path.join(encodedDir, 's1.jsonl'),
|
|
532
|
+
projectDir: encodedDir,
|
|
533
|
+
birthtimeMs: 1710800324000,
|
|
534
|
+
resolvedCwd: '',
|
|
378
535
|
},
|
|
536
|
+
];
|
|
537
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
|
|
538
|
+
|
|
539
|
+
const processes = [
|
|
540
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
541
|
+
];
|
|
542
|
+
|
|
543
|
+
const result = discoverSessions(processes);
|
|
544
|
+
expect(result).toHaveLength(1);
|
|
545
|
+
expect(result[0].resolvedCwd).toBe('/my/project');
|
|
546
|
+
// batchGetSessionFileBirthtimes called once with all dirs
|
|
547
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
548
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should return empty when encoded dir does not exist', () => {
|
|
552
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
553
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
554
|
+
(adapter as any).projectsDir = projectsDir;
|
|
555
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
556
|
+
|
|
557
|
+
// Process CWD /test encodes to -test, but that dir doesn't exist
|
|
558
|
+
const result = discoverSessions([
|
|
559
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
379
560
|
]);
|
|
561
|
+
expect(result).toEqual([]);
|
|
562
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
380
564
|
|
|
565
|
+
it('should deduplicate when multiple processes share same CWD', () => {
|
|
566
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
567
|
+
(adapter as any).projectsDir = projectsDir;
|
|
568
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
381
569
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
570
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
571
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
572
|
+
|
|
573
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
574
|
+
{ sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
|
|
575
|
+
]);
|
|
576
|
+
|
|
577
|
+
const processes = [
|
|
578
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
579
|
+
{ pid: 2, command: 'claude', cwd: '/my/project', tty: '' },
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
const result = discoverSessions(processes);
|
|
583
|
+
// Should only call batch once with deduplicated dir
|
|
584
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
586
|
+
expect(result).toHaveLength(1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('should skip processes with empty cwd', () => {
|
|
590
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
591
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
592
|
+
(adapter as any).projectsDir = projectsDir;
|
|
593
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
594
|
+
|
|
595
|
+
const result = discoverSessions([
|
|
596
|
+
{ pid: 1, command: 'claude', cwd: '', tty: '' },
|
|
597
|
+
]);
|
|
598
|
+
expect(result).toEqual([]);
|
|
388
599
|
});
|
|
389
600
|
});
|
|
390
601
|
|
|
391
602
|
describe('helper methods', () => {
|
|
392
603
|
describe('determineStatus', () => {
|
|
393
604
|
it('should return "unknown" for sessions with no last entry type', () => {
|
|
394
|
-
const adapter = new ClaudeCodeAdapter();
|
|
395
605
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
396
606
|
|
|
397
607
|
const session = {
|
|
@@ -402,12 +612,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
402
612
|
isInterrupted: false,
|
|
403
613
|
};
|
|
404
614
|
|
|
405
|
-
|
|
406
|
-
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
615
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
407
616
|
});
|
|
408
617
|
|
|
409
618
|
it('should return "waiting" for assistant entries', () => {
|
|
410
|
-
const adapter = new ClaudeCodeAdapter();
|
|
411
619
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
412
620
|
|
|
413
621
|
const session = {
|
|
@@ -419,12 +627,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
419
627
|
isInterrupted: false,
|
|
420
628
|
};
|
|
421
629
|
|
|
422
|
-
|
|
423
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
630
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
424
631
|
});
|
|
425
632
|
|
|
426
633
|
it('should return "waiting" for user interruption', () => {
|
|
427
|
-
const adapter = new ClaudeCodeAdapter();
|
|
428
634
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
429
635
|
|
|
430
636
|
const session = {
|
|
@@ -436,12 +642,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
436
642
|
isInterrupted: true,
|
|
437
643
|
};
|
|
438
644
|
|
|
439
|
-
|
|
440
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
645
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
441
646
|
});
|
|
442
647
|
|
|
443
648
|
it('should return "running" for user/progress entries', () => {
|
|
444
|
-
const adapter = new ClaudeCodeAdapter();
|
|
445
649
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
446
650
|
|
|
447
651
|
const session = {
|
|
@@ -453,16 +657,13 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
453
657
|
isInterrupted: false,
|
|
454
658
|
};
|
|
455
659
|
|
|
456
|
-
|
|
457
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
660
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
458
661
|
});
|
|
459
662
|
|
|
460
663
|
it('should not override status based on age (process is running)', () => {
|
|
461
|
-
const adapter = new ClaudeCodeAdapter();
|
|
462
664
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
463
665
|
|
|
464
666
|
const oldDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
465
|
-
|
|
466
667
|
const session = {
|
|
467
668
|
sessionId: 'test',
|
|
468
669
|
projectPath: '/test',
|
|
@@ -472,14 +673,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
472
673
|
isInterrupted: false,
|
|
473
674
|
};
|
|
474
675
|
|
|
475
|
-
|
|
476
|
-
// because the process is known to be running
|
|
477
|
-
const status = determineStatus(session);
|
|
478
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
676
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
479
677
|
});
|
|
480
678
|
|
|
481
679
|
it('should return "idle" for system entries', () => {
|
|
482
|
-
const adapter = new ClaudeCodeAdapter();
|
|
483
680
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
484
681
|
|
|
485
682
|
const session = {
|
|
@@ -491,12 +688,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
491
688
|
isInterrupted: false,
|
|
492
689
|
};
|
|
493
690
|
|
|
494
|
-
|
|
495
|
-
expect(status).toBe(AgentStatus.IDLE);
|
|
691
|
+
expect(determineStatus(session)).toBe(AgentStatus.IDLE);
|
|
496
692
|
});
|
|
497
693
|
|
|
498
694
|
it('should return "running" for thinking entries', () => {
|
|
499
|
-
const adapter = new ClaudeCodeAdapter();
|
|
500
695
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
501
696
|
|
|
502
697
|
const session = {
|
|
@@ -508,12 +703,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
508
703
|
isInterrupted: false,
|
|
509
704
|
};
|
|
510
705
|
|
|
511
|
-
|
|
512
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
706
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
513
707
|
});
|
|
514
708
|
|
|
515
709
|
it('should return "running" for progress entries', () => {
|
|
516
|
-
const adapter = new ClaudeCodeAdapter();
|
|
517
710
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
518
711
|
|
|
519
712
|
const session = {
|
|
@@ -525,12 +718,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
525
718
|
isInterrupted: false,
|
|
526
719
|
};
|
|
527
720
|
|
|
528
|
-
|
|
529
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
721
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
530
722
|
});
|
|
531
723
|
|
|
532
724
|
it('should return "unknown" for unrecognized entry types', () => {
|
|
533
|
-
const adapter = new ClaudeCodeAdapter();
|
|
534
725
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
535
726
|
|
|
536
727
|
const session = {
|
|
@@ -542,380 +733,17 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
542
733
|
isInterrupted: false,
|
|
543
734
|
};
|
|
544
735
|
|
|
545
|
-
|
|
546
|
-
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
547
|
-
});
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
describe('generateAgentName', () => {
|
|
551
|
-
it('should use project name for first session', () => {
|
|
552
|
-
const adapter = new ClaudeCodeAdapter();
|
|
553
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
554
|
-
|
|
555
|
-
const session = {
|
|
556
|
-
sessionId: 'test-123',
|
|
557
|
-
projectPath: '/Users/test/my-project',
|
|
558
|
-
sessionStart: new Date(),
|
|
559
|
-
lastActive: new Date(),
|
|
560
|
-
isInterrupted: false,
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
const name = generateAgentName(session, []);
|
|
564
|
-
expect(name).toBe('my-project');
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
it('should append slug for duplicate projects', () => {
|
|
568
|
-
const adapter = new ClaudeCodeAdapter();
|
|
569
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
570
|
-
|
|
571
|
-
const existingAgent: AgentInfo = {
|
|
572
|
-
name: 'my-project',
|
|
573
|
-
projectPath: '/Users/test/my-project',
|
|
574
|
-
type: 'claude',
|
|
575
|
-
status: AgentStatus.RUNNING,
|
|
576
|
-
summary: 'Test',
|
|
577
|
-
pid: 123,
|
|
578
|
-
sessionId: 'existing-123',
|
|
579
|
-
slug: 'happy-cat',
|
|
580
|
-
lastActive: new Date(),
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
const session = {
|
|
584
|
-
sessionId: 'test-456',
|
|
585
|
-
projectPath: '/Users/test/my-project',
|
|
586
|
-
slug: 'merry-dog',
|
|
587
|
-
sessionStart: new Date(),
|
|
588
|
-
lastActive: new Date(),
|
|
589
|
-
isInterrupted: false,
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const name = generateAgentName(session, [existingAgent]);
|
|
593
|
-
expect(name).toBe('my-project (merry)');
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it('should use session ID prefix when no slug available', () => {
|
|
597
|
-
const adapter = new ClaudeCodeAdapter();
|
|
598
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
599
|
-
|
|
600
|
-
const existingAgent: AgentInfo = {
|
|
601
|
-
name: 'my-project',
|
|
602
|
-
projectPath: '/Users/test/my-project',
|
|
603
|
-
type: 'claude',
|
|
604
|
-
status: AgentStatus.RUNNING,
|
|
605
|
-
summary: 'Test',
|
|
606
|
-
pid: 123,
|
|
607
|
-
sessionId: 'existing-123',
|
|
608
|
-
lastActive: new Date(),
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
const session = {
|
|
612
|
-
sessionId: 'abcdef12-3456-7890',
|
|
613
|
-
projectPath: '/Users/test/my-project',
|
|
614
|
-
sessionStart: new Date(),
|
|
615
|
-
lastActive: new Date(),
|
|
616
|
-
isInterrupted: false,
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
const name = generateAgentName(session, [existingAgent]);
|
|
620
|
-
expect(name).toBe('my-project (abcdef12)');
|
|
621
|
-
});
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
describe('parseElapsedSeconds', () => {
|
|
625
|
-
it('should parse MM:SS format', () => {
|
|
626
|
-
const adapter = new ClaudeCodeAdapter();
|
|
627
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
628
|
-
|
|
629
|
-
expect(parseElapsedSeconds('05:30')).toBe(330);
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
it('should parse HH:MM:SS format', () => {
|
|
633
|
-
const adapter = new ClaudeCodeAdapter();
|
|
634
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
635
|
-
|
|
636
|
-
expect(parseElapsedSeconds('02:30:15')).toBe(9015);
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it('should parse D-HH:MM:SS format', () => {
|
|
640
|
-
const adapter = new ClaudeCodeAdapter();
|
|
641
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
642
|
-
|
|
643
|
-
expect(parseElapsedSeconds('3-12:00:00')).toBe(302400);
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
it('should return null for invalid format', () => {
|
|
647
|
-
const adapter = new ClaudeCodeAdapter();
|
|
648
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
649
|
-
|
|
650
|
-
expect(parseElapsedSeconds('invalid')).toBeNull();
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
describe('calculateSessionScanLimit', () => {
|
|
655
|
-
it('should return minimum for small process count', () => {
|
|
656
|
-
const adapter = new ClaudeCodeAdapter();
|
|
657
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
658
|
-
|
|
659
|
-
// 1 process * 4 = 4, min(max(4, 12), 40) = 12
|
|
660
|
-
expect(calculateSessionScanLimit(1)).toBe(12);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('should scale with process count', () => {
|
|
664
|
-
const adapter = new ClaudeCodeAdapter();
|
|
665
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
666
|
-
|
|
667
|
-
// 5 processes * 4 = 20, min(max(20, 12), 40) = 20
|
|
668
|
-
expect(calculateSessionScanLimit(5)).toBe(20);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
it('should cap at maximum', () => {
|
|
672
|
-
const adapter = new ClaudeCodeAdapter();
|
|
673
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
674
|
-
|
|
675
|
-
// 15 processes * 4 = 60, min(max(60, 12), 40) = 40
|
|
676
|
-
expect(calculateSessionScanLimit(15)).toBe(40);
|
|
677
|
-
});
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
describe('rankCandidatesByStartTime', () => {
|
|
681
|
-
it('should prefer sessions within tolerance window', () => {
|
|
682
|
-
const adapter = new ClaudeCodeAdapter();
|
|
683
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
684
|
-
|
|
685
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
686
|
-
const candidates = [
|
|
687
|
-
{
|
|
688
|
-
sessionId: 'far',
|
|
689
|
-
projectPath: '/test',
|
|
690
|
-
sessionStart: new Date('2026-03-10T09:50:00Z'), // 10 min diff
|
|
691
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
692
|
-
isInterrupted: false,
|
|
693
|
-
},
|
|
694
|
-
{
|
|
695
|
-
sessionId: 'close',
|
|
696
|
-
projectPath: '/test',
|
|
697
|
-
sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s diff
|
|
698
|
-
lastActive: new Date('2026-03-10T10:03:00Z'),
|
|
699
|
-
isInterrupted: false,
|
|
700
|
-
},
|
|
701
|
-
];
|
|
702
|
-
|
|
703
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
704
|
-
expect(ranked[0].sessionId).toBe('close');
|
|
705
|
-
expect(ranked[1].sessionId).toBe('far');
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it('should prefer recency over diffMs when both within tolerance', () => {
|
|
709
|
-
const adapter = new ClaudeCodeAdapter();
|
|
710
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
711
|
-
|
|
712
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
713
|
-
const candidates = [
|
|
714
|
-
{
|
|
715
|
-
sessionId: 'closer-but-stale',
|
|
716
|
-
projectPath: '/test',
|
|
717
|
-
sessionStart: new Date('2026-03-10T10:00:06Z'), // 6s diff
|
|
718
|
-
lastActive: new Date('2026-03-10T10:00:10Z'), // older activity
|
|
719
|
-
isInterrupted: false,
|
|
720
|
-
},
|
|
721
|
-
{
|
|
722
|
-
sessionId: 'farther-but-active',
|
|
723
|
-
projectPath: '/test',
|
|
724
|
-
sessionStart: new Date('2026-03-10T10:00:45Z'), // 45s diff
|
|
725
|
-
lastActive: new Date('2026-03-10T10:30:00Z'), // much more recent
|
|
726
|
-
isInterrupted: false,
|
|
727
|
-
},
|
|
728
|
-
];
|
|
729
|
-
|
|
730
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
731
|
-
// Both within tolerance — recency wins over smaller diffMs
|
|
732
|
-
expect(ranked[0].sessionId).toBe('farther-but-active');
|
|
733
|
-
expect(ranked[1].sessionId).toBe('closer-but-stale');
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it('should break ties by recency when outside tolerance with same diffMs', () => {
|
|
737
|
-
const adapter = new ClaudeCodeAdapter();
|
|
738
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
739
|
-
|
|
740
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
741
|
-
const candidates = [
|
|
742
|
-
{
|
|
743
|
-
sessionId: 'older-activity',
|
|
744
|
-
projectPath: '/test',
|
|
745
|
-
sessionStart: new Date('2026-03-10T09:50:00Z'), // 10min diff
|
|
746
|
-
lastActive: new Date('2026-03-10T10:01:00Z'),
|
|
747
|
-
isInterrupted: false,
|
|
748
|
-
},
|
|
749
|
-
{
|
|
750
|
-
sessionId: 'newer-activity',
|
|
751
|
-
projectPath: '/test',
|
|
752
|
-
sessionStart: new Date('2026-03-10T10:10:00Z'), // 10min diff (same abs)
|
|
753
|
-
lastActive: new Date('2026-03-10T10:30:00Z'),
|
|
754
|
-
isInterrupted: false,
|
|
755
|
-
},
|
|
756
|
-
];
|
|
757
|
-
|
|
758
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
759
|
-
// Both outside tolerance, same diffMs — recency wins
|
|
760
|
-
expect(ranked[0].sessionId).toBe('newer-activity');
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
it('should fall back to recency when both outside tolerance', () => {
|
|
764
|
-
const adapter = new ClaudeCodeAdapter();
|
|
765
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
766
|
-
|
|
767
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
768
|
-
const candidates = [
|
|
769
|
-
{
|
|
770
|
-
sessionId: 'older',
|
|
771
|
-
projectPath: '/test',
|
|
772
|
-
sessionStart: new Date('2026-03-10T09:30:00Z'),
|
|
773
|
-
lastActive: new Date('2026-03-10T10:01:00Z'),
|
|
774
|
-
isInterrupted: false,
|
|
775
|
-
},
|
|
776
|
-
{
|
|
777
|
-
sessionId: 'newer',
|
|
778
|
-
projectPath: '/test',
|
|
779
|
-
sessionStart: new Date('2026-03-10T09:40:00Z'),
|
|
780
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
781
|
-
isInterrupted: false,
|
|
782
|
-
},
|
|
783
|
-
];
|
|
784
|
-
|
|
785
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
786
|
-
// Both outside tolerance (rank=1), newer has smaller diffMs
|
|
787
|
-
expect(ranked[0].sessionId).toBe('newer');
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
describe('filterCandidateSessions', () => {
|
|
792
|
-
it('should match by lastCwd in cwd mode', () => {
|
|
793
|
-
const adapter = new ClaudeCodeAdapter();
|
|
794
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
795
|
-
|
|
796
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
797
|
-
const sessions = [
|
|
798
|
-
{
|
|
799
|
-
sessionId: 's1',
|
|
800
|
-
projectPath: '/different/path',
|
|
801
|
-
lastCwd: '/my/project',
|
|
802
|
-
sessionStart: new Date(),
|
|
803
|
-
lastActive: new Date(),
|
|
804
|
-
isInterrupted: false,
|
|
805
|
-
},
|
|
806
|
-
];
|
|
807
|
-
|
|
808
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'cwd');
|
|
809
|
-
expect(result).toHaveLength(1);
|
|
810
|
-
expect(result[0].sessionId).toBe('s1');
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
it('should match sessions with no projectPath in missing-cwd mode', () => {
|
|
814
|
-
const adapter = new ClaudeCodeAdapter();
|
|
815
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
816
|
-
|
|
817
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
818
|
-
const sessions = [
|
|
819
|
-
{
|
|
820
|
-
sessionId: 's1',
|
|
821
|
-
projectPath: '',
|
|
822
|
-
sessionStart: new Date(),
|
|
823
|
-
lastActive: new Date(),
|
|
824
|
-
isInterrupted: false,
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
sessionId: 's2',
|
|
828
|
-
projectPath: '/has/path',
|
|
829
|
-
sessionStart: new Date(),
|
|
830
|
-
lastActive: new Date(),
|
|
831
|
-
isInterrupted: false,
|
|
832
|
-
},
|
|
833
|
-
];
|
|
834
|
-
|
|
835
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'missing-cwd');
|
|
836
|
-
expect(result).toHaveLength(1);
|
|
837
|
-
expect(result[0].sessionId).toBe('s1');
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
it('should include exact CWD matches in parent-child mode', () => {
|
|
841
|
-
const adapter = new ClaudeCodeAdapter();
|
|
842
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
843
|
-
|
|
844
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
845
|
-
const sessions = [
|
|
846
|
-
{
|
|
847
|
-
sessionId: 's1',
|
|
848
|
-
projectPath: '/my/project',
|
|
849
|
-
lastCwd: '/my/project',
|
|
850
|
-
sessionStart: new Date(),
|
|
851
|
-
lastActive: new Date(),
|
|
852
|
-
isInterrupted: false,
|
|
853
|
-
},
|
|
854
|
-
];
|
|
855
|
-
|
|
856
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
|
|
857
|
-
expect(result).toHaveLength(1);
|
|
858
|
-
expect(result[0].sessionId).toBe('s1');
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
it('should match parent-child relationships', () => {
|
|
862
|
-
const adapter = new ClaudeCodeAdapter();
|
|
863
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
864
|
-
|
|
865
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
866
|
-
const sessions = [
|
|
867
|
-
{
|
|
868
|
-
sessionId: 'child-session',
|
|
869
|
-
projectPath: '/my/project/packages/sub',
|
|
870
|
-
lastCwd: '/my/project/packages/sub',
|
|
871
|
-
sessionStart: new Date(),
|
|
872
|
-
lastActive: new Date(),
|
|
873
|
-
isInterrupted: false,
|
|
874
|
-
},
|
|
875
|
-
{
|
|
876
|
-
sessionId: 'parent-session',
|
|
877
|
-
projectPath: '/my',
|
|
878
|
-
lastCwd: '/my',
|
|
879
|
-
sessionStart: new Date(),
|
|
880
|
-
lastActive: new Date(),
|
|
881
|
-
isInterrupted: false,
|
|
882
|
-
},
|
|
883
|
-
];
|
|
884
|
-
|
|
885
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
|
|
886
|
-
expect(result).toHaveLength(2);
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
it('should skip used sessions', () => {
|
|
890
|
-
const adapter = new ClaudeCodeAdapter();
|
|
891
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
892
|
-
|
|
893
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
894
|
-
const sessions = [
|
|
895
|
-
{
|
|
896
|
-
sessionId: 's1',
|
|
897
|
-
projectPath: '/my/project',
|
|
898
|
-
sessionStart: new Date(),
|
|
899
|
-
lastActive: new Date(),
|
|
900
|
-
isInterrupted: false,
|
|
901
|
-
},
|
|
902
|
-
];
|
|
903
|
-
|
|
904
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(['s1']), 'cwd');
|
|
905
|
-
expect(result).toHaveLength(0);
|
|
736
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
906
737
|
});
|
|
907
738
|
});
|
|
908
739
|
|
|
909
740
|
describe('extractUserMessageText', () => {
|
|
910
741
|
it('should extract plain string content', () => {
|
|
911
|
-
const adapter = new ClaudeCodeAdapter();
|
|
912
742
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
913
|
-
|
|
914
743
|
expect(extract('hello world')).toBe('hello world');
|
|
915
744
|
});
|
|
916
745
|
|
|
917
746
|
it('should extract text from array content blocks', () => {
|
|
918
|
-
const adapter = new ClaudeCodeAdapter();
|
|
919
747
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
920
748
|
|
|
921
749
|
const content = [
|
|
@@ -926,7 +754,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
926
754
|
});
|
|
927
755
|
|
|
928
756
|
it('should return undefined for empty/null content', () => {
|
|
929
|
-
const adapter = new ClaudeCodeAdapter();
|
|
930
757
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
931
758
|
|
|
932
759
|
expect(extract(undefined)).toBeUndefined();
|
|
@@ -935,7 +762,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
935
762
|
});
|
|
936
763
|
|
|
937
764
|
it('should parse command-message tags', () => {
|
|
938
|
-
const adapter = new ClaudeCodeAdapter();
|
|
939
765
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
940
766
|
|
|
941
767
|
const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
|
|
@@ -943,7 +769,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
943
769
|
});
|
|
944
770
|
|
|
945
771
|
it('should parse command-message without args', () => {
|
|
946
|
-
const adapter = new ClaudeCodeAdapter();
|
|
947
772
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
948
773
|
|
|
949
774
|
const msg = '<command-message><command-name>help</command-name></command-message>';
|
|
@@ -951,7 +776,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
951
776
|
});
|
|
952
777
|
|
|
953
778
|
it('should extract ARGUMENTS from skill expansion', () => {
|
|
954
|
-
const adapter = new ClaudeCodeAdapter();
|
|
955
779
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
956
780
|
|
|
957
781
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
|
|
@@ -959,7 +783,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
959
783
|
});
|
|
960
784
|
|
|
961
785
|
it('should return undefined for skill expansion without ARGUMENTS', () => {
|
|
962
|
-
const adapter = new ClaudeCodeAdapter();
|
|
963
786
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
964
787
|
|
|
965
788
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
|
|
@@ -967,7 +790,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
967
790
|
});
|
|
968
791
|
|
|
969
792
|
it('should filter noise messages', () => {
|
|
970
|
-
const adapter = new ClaudeCodeAdapter();
|
|
971
793
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
972
794
|
|
|
973
795
|
expect(extract('[Request interrupted by user]')).toBeUndefined();
|
|
@@ -978,114 +800,184 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
978
800
|
|
|
979
801
|
describe('parseCommandMessage', () => {
|
|
980
802
|
it('should return undefined for malformed command-message', () => {
|
|
981
|
-
const adapter = new ClaudeCodeAdapter();
|
|
982
803
|
const parse = (adapter as any).parseCommandMessage.bind(adapter);
|
|
983
|
-
|
|
984
804
|
expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
|
|
985
805
|
});
|
|
986
806
|
});
|
|
987
807
|
});
|
|
988
808
|
|
|
989
|
-
describe('
|
|
990
|
-
|
|
991
|
-
const adapter = new ClaudeCodeAdapter();
|
|
992
|
-
const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
|
|
993
|
-
|
|
994
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
995
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
996
|
-
const processStartByPid = new Map([[1, processStart]]);
|
|
997
|
-
|
|
998
|
-
const sessions = [
|
|
999
|
-
{
|
|
1000
|
-
sessionId: 'stale-exact-cwd',
|
|
1001
|
-
projectPath: '/my/project',
|
|
1002
|
-
lastCwd: '/my/project',
|
|
1003
|
-
sessionStart: new Date('2026-03-07T10:00:00Z'), // 3 days old — outside tolerance
|
|
1004
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
1005
|
-
isInterrupted: false,
|
|
1006
|
-
},
|
|
1007
|
-
];
|
|
809
|
+
describe('file I/O methods', () => {
|
|
810
|
+
let tmpDir: string;
|
|
1008
811
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
812
|
+
beforeEach(() => {
|
|
813
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
afterEach(() => {
|
|
817
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('tryPidFileMatching', () => {
|
|
821
|
+
let sessionsDir: string;
|
|
822
|
+
let projectsDir: string;
|
|
1012
823
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
824
|
+
beforeEach(() => {
|
|
825
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
826
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
827
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
828
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
829
|
+
(adapter as any).projectsDir = projectsDir;
|
|
1017
830
|
});
|
|
1018
831
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
lastCwd: '/my/project',
|
|
1039
|
-
sessionStart: new Date('2026-03-10T10:00:00Z'),
|
|
1040
|
-
lastActive: new Date('2026-03-10T10:30:00Z'),
|
|
1041
|
-
isInterrupted: false,
|
|
1042
|
-
},
|
|
1043
|
-
];
|
|
832
|
+
const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({
|
|
833
|
+
pid, command: 'claude', cwd, tty: 'ttys001', startTime,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => {
|
|
837
|
+
fs.writeFileSync(
|
|
838
|
+
path.join(sessionsDir, `${pid}.json`),
|
|
839
|
+
JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }),
|
|
840
|
+
);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const writeJsonl = (cwd: string, sessionId: string) => {
|
|
844
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
845
|
+
const projDir = path.join(projectsDir, encoded);
|
|
846
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
847
|
+
const filePath = path.join(projDir, `${sessionId}.jsonl`);
|
|
848
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
849
|
+
return filePath;
|
|
850
|
+
};
|
|
1044
851
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
852
|
+
it('should return direct match when PID file and JSONL both exist within time tolerance', () => {
|
|
853
|
+
const startTime = new Date();
|
|
854
|
+
const proc = makeProc(1001, '/project/test', startTime);
|
|
855
|
+
writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
|
|
856
|
+
writeJsonl('/project/test', 'session-abc');
|
|
857
|
+
|
|
858
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
859
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
860
|
+
|
|
861
|
+
expect(direct).toHaveLength(1);
|
|
862
|
+
expect(fallback).toHaveLength(0);
|
|
863
|
+
expect(direct[0].sessionFile.sessionId).toBe('session-abc');
|
|
864
|
+
expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
|
|
865
|
+
expect(direct[0].process.pid).toBe(1001);
|
|
1048
866
|
});
|
|
1049
867
|
|
|
1050
|
-
it('should
|
|
1051
|
-
const
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
1056
|
-
const processStartByPid = new Map([[1, processStart]]);
|
|
1057
|
-
|
|
1058
|
-
const sessions = [
|
|
1059
|
-
{
|
|
1060
|
-
sessionId: 'fresh-exact-cwd',
|
|
1061
|
-
projectPath: '/my/project',
|
|
1062
|
-
lastCwd: '/my/project',
|
|
1063
|
-
sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s — within tolerance
|
|
1064
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
1065
|
-
isInterrupted: false,
|
|
1066
|
-
},
|
|
1067
|
-
];
|
|
868
|
+
it('should fall back when PID file exists but JSONL is missing', () => {
|
|
869
|
+
const startTime = new Date();
|
|
870
|
+
const proc = makeProc(1002, '/project/test', startTime);
|
|
871
|
+
writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
|
|
872
|
+
// No JSONL file written
|
|
1068
873
|
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
874
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
875
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
876
|
+
|
|
877
|
+
expect(direct).toHaveLength(0);
|
|
878
|
+
expect(fallback).toHaveLength(1);
|
|
879
|
+
expect(fallback[0].pid).toBe(1002);
|
|
1072
880
|
});
|
|
1073
|
-
});
|
|
1074
881
|
|
|
1075
|
-
|
|
1076
|
-
|
|
882
|
+
it('should fall back when startedAt is stale (>60s from proc.startTime)', () => {
|
|
883
|
+
const startTime = new Date();
|
|
884
|
+
const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
|
|
885
|
+
const proc = makeProc(1003, '/project/test', startTime);
|
|
886
|
+
writePidFile(1003, 'stale-session', '/project/test', staleTime);
|
|
887
|
+
writeJsonl('/project/test', 'stale-session');
|
|
1077
888
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
});
|
|
889
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
890
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
1081
891
|
|
|
1082
|
-
|
|
1083
|
-
|
|
892
|
+
expect(direct).toHaveLength(0);
|
|
893
|
+
expect(fallback).toHaveLength(1);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should accept PID file when startedAt is within 60s tolerance', () => {
|
|
897
|
+
const startTime = new Date();
|
|
898
|
+
const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
|
|
899
|
+
const proc = makeProc(1004, '/project/test', startTime);
|
|
900
|
+
writePidFile(1004, 'close-session', '/project/test', closeTime);
|
|
901
|
+
writeJsonl('/project/test', 'close-session');
|
|
902
|
+
|
|
903
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
904
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
905
|
+
|
|
906
|
+
expect(direct).toHaveLength(1);
|
|
907
|
+
expect(fallback).toHaveLength(0);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('should fall back when PID file is absent', () => {
|
|
911
|
+
const proc = makeProc(1005, '/project/test', new Date());
|
|
912
|
+
// No PID file written
|
|
913
|
+
|
|
914
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
915
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
916
|
+
|
|
917
|
+
expect(direct).toHaveLength(0);
|
|
918
|
+
expect(fallback).toHaveLength(1);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('should fall back when PID file contains malformed JSON', () => {
|
|
922
|
+
const proc = makeProc(1006, '/project/test', new Date());
|
|
923
|
+
fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
|
|
924
|
+
|
|
925
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
926
|
+
expect(() => {
|
|
927
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
928
|
+
expect(direct).toHaveLength(0);
|
|
929
|
+
expect(fallback).toHaveLength(1);
|
|
930
|
+
}).not.toThrow();
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should fall back for all processes when sessions dir does not exist', () => {
|
|
934
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
|
|
935
|
+
const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())];
|
|
936
|
+
|
|
937
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
938
|
+
const { direct, fallback } = tryMatch(processes);
|
|
939
|
+
|
|
940
|
+
expect(direct).toHaveLength(0);
|
|
941
|
+
expect(fallback).toHaveLength(2);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('should correctly split mixed processes (some with PID files, some without)', () => {
|
|
945
|
+
const startTime = new Date();
|
|
946
|
+
const proc1 = makeProc(3001, '/project/one', startTime);
|
|
947
|
+
const proc2 = makeProc(3002, '/project/two', startTime);
|
|
948
|
+
const proc3 = makeProc(3003, '/project/three', startTime);
|
|
949
|
+
|
|
950
|
+
writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
|
|
951
|
+
writeJsonl('/project/one', 'session-one');
|
|
952
|
+
writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
|
|
953
|
+
writeJsonl('/project/three', 'session-three');
|
|
954
|
+
// proc2 has no PID file
|
|
955
|
+
|
|
956
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
957
|
+
const { direct, fallback } = tryMatch([proc1, proc2, proc3]);
|
|
958
|
+
|
|
959
|
+
expect(direct).toHaveLength(2);
|
|
960
|
+
expect(fallback).toHaveLength(1);
|
|
961
|
+
expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]);
|
|
962
|
+
expect(fallback[0].pid).toBe(3002);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('should skip stale-file check when proc.startTime is undefined', () => {
|
|
966
|
+
const proc = makeProc(4001, '/project/test', undefined); // no startTime
|
|
967
|
+
writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
|
|
968
|
+
writeJsonl('/project/test', 'no-time-session');
|
|
969
|
+
|
|
970
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
971
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
972
|
+
|
|
973
|
+
// startTime undefined → stale check skipped → direct match
|
|
974
|
+
expect(direct).toHaveLength(1);
|
|
975
|
+
expect(fallback).toHaveLength(0);
|
|
976
|
+
});
|
|
1084
977
|
});
|
|
1085
978
|
|
|
1086
979
|
describe('readSession', () => {
|
|
1087
980
|
it('should parse session file with timestamps, slug, cwd, and entry type', () => {
|
|
1088
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1089
981
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1090
982
|
|
|
1091
983
|
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
@@ -1109,7 +1001,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1109
1001
|
});
|
|
1110
1002
|
|
|
1111
1003
|
it('should detect user interruption', () => {
|
|
1112
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1113
1004
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1114
1005
|
|
|
1115
1006
|
const filePath = path.join(tmpDir, 'interrupted.jsonl');
|
|
@@ -1130,28 +1021,23 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1130
1021
|
});
|
|
1131
1022
|
|
|
1132
1023
|
it('should return session with defaults for empty file', () => {
|
|
1133
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1134
1024
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1135
1025
|
|
|
1136
1026
|
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1137
1027
|
fs.writeFileSync(filePath, '');
|
|
1138
1028
|
|
|
1139
1029
|
const session = readSession(filePath, '/test');
|
|
1140
|
-
// Empty file content trims to '' which splits to [''] — no valid entries parsed
|
|
1141
1030
|
expect(session).not.toBeNull();
|
|
1142
1031
|
expect(session.lastEntryType).toBeUndefined();
|
|
1143
1032
|
expect(session.slug).toBeUndefined();
|
|
1144
1033
|
});
|
|
1145
1034
|
|
|
1146
1035
|
it('should return null for non-existent file', () => {
|
|
1147
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1148
1036
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1149
|
-
|
|
1150
1037
|
expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
|
|
1151
1038
|
});
|
|
1152
1039
|
|
|
1153
1040
|
it('should skip metadata entry types for lastEntryType', () => {
|
|
1154
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1155
1041
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1156
1042
|
|
|
1157
1043
|
const filePath = path.join(tmpDir, 'metadata-test.jsonl');
|
|
@@ -1164,12 +1050,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1164
1050
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1165
1051
|
|
|
1166
1052
|
const session = readSession(filePath, '/test');
|
|
1167
|
-
// lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot'
|
|
1168
1053
|
expect(session.lastEntryType).toBe('assistant');
|
|
1169
1054
|
});
|
|
1170
1055
|
|
|
1171
1056
|
it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
|
|
1172
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1173
1057
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1174
1058
|
|
|
1175
1059
|
const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
|
|
@@ -1184,13 +1068,11 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1184
1068
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1185
1069
|
|
|
1186
1070
|
const session = readSession(filePath, '/test');
|
|
1187
|
-
// sessionStart should come from snapshot.timestamp, not lastActive
|
|
1188
1071
|
expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
|
|
1189
1072
|
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1190
1073
|
});
|
|
1191
1074
|
|
|
1192
1075
|
it('should extract lastUserMessage from session entries', () => {
|
|
1193
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1194
1076
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1195
1077
|
|
|
1196
1078
|
const filePath = path.join(tmpDir, 'user-msg.jsonl');
|
|
@@ -1203,12 +1085,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1203
1085
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1204
1086
|
|
|
1205
1087
|
const session = readSession(filePath, '/test');
|
|
1206
|
-
// Last user message should be the most recent one
|
|
1207
1088
|
expect(session.lastUserMessage).toBe('second question');
|
|
1208
1089
|
});
|
|
1209
1090
|
|
|
1210
1091
|
it('should use lastCwd as projectPath when projectPath is empty', () => {
|
|
1211
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1212
1092
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1213
1093
|
|
|
1214
1094
|
const filePath = path.join(tmpDir, 'no-project.jsonl');
|
|
@@ -1222,7 +1102,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1222
1102
|
});
|
|
1223
1103
|
|
|
1224
1104
|
it('should handle malformed JSON lines gracefully', () => {
|
|
1225
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1226
1105
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1227
1106
|
|
|
1228
1107
|
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
@@ -1237,131 +1116,5 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1237
1116
|
expect(session.lastEntryType).toBe('assistant');
|
|
1238
1117
|
});
|
|
1239
1118
|
});
|
|
1240
|
-
|
|
1241
|
-
describe('findSessionFiles', () => {
|
|
1242
|
-
it('should return empty when projects dir does not exist', () => {
|
|
1243
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1244
|
-
(adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
|
|
1245
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1246
|
-
|
|
1247
|
-
expect(findSessionFiles(10)).toEqual([]);
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
it('should find and sort session files by mtime', () => {
|
|
1251
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1252
|
-
const projectsDir = path.join(tmpDir, 'projects');
|
|
1253
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1254
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1255
|
-
|
|
1256
|
-
// Create project dir with sessions-index.json and JSONL files
|
|
1257
|
-
const projDir = path.join(projectsDir, 'encoded-path');
|
|
1258
|
-
fs.mkdirSync(projDir, { recursive: true });
|
|
1259
|
-
fs.writeFileSync(
|
|
1260
|
-
path.join(projDir, 'sessions-index.json'),
|
|
1261
|
-
JSON.stringify({ originalPath: '/my/project' }),
|
|
1262
|
-
);
|
|
1263
|
-
|
|
1264
|
-
const file1 = path.join(projDir, 'session-old.jsonl');
|
|
1265
|
-
const file2 = path.join(projDir, 'session-new.jsonl');
|
|
1266
|
-
fs.writeFileSync(file1, '{}');
|
|
1267
|
-
// Ensure different mtime
|
|
1268
|
-
const past = new Date(Date.now() - 10000);
|
|
1269
|
-
fs.utimesSync(file1, past, past);
|
|
1270
|
-
fs.writeFileSync(file2, '{}');
|
|
1271
|
-
|
|
1272
|
-
const files = findSessionFiles(10);
|
|
1273
|
-
expect(files).toHaveLength(2);
|
|
1274
|
-
// Sorted by mtime desc — new first
|
|
1275
|
-
expect(files[0].filePath).toContain('session-new');
|
|
1276
|
-
expect(files[0].projectPath).toBe('/my/project');
|
|
1277
|
-
expect(files[1].filePath).toContain('session-old');
|
|
1278
|
-
});
|
|
1279
|
-
|
|
1280
|
-
it('should respect scan limit', () => {
|
|
1281
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1282
|
-
const projectsDir = path.join(tmpDir, 'projects');
|
|
1283
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1284
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1285
|
-
|
|
1286
|
-
const projDir = path.join(projectsDir, 'proj');
|
|
1287
|
-
fs.mkdirSync(projDir, { recursive: true });
|
|
1288
|
-
fs.writeFileSync(
|
|
1289
|
-
path.join(projDir, 'sessions-index.json'),
|
|
1290
|
-
JSON.stringify({ originalPath: '/proj' }),
|
|
1291
|
-
);
|
|
1292
|
-
|
|
1293
|
-
for (let i = 0; i < 5; i++) {
|
|
1294
|
-
fs.writeFileSync(path.join(projDir, `session-${i}.jsonl`), '{}');
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
const files = findSessionFiles(3);
|
|
1298
|
-
expect(files).toHaveLength(3);
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
|
-
it('should skip directories starting with dot', () => {
|
|
1302
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1303
|
-
const projectsDir = path.join(tmpDir, 'projects');
|
|
1304
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1305
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1306
|
-
|
|
1307
|
-
const hiddenDir = path.join(projectsDir, '.hidden');
|
|
1308
|
-
fs.mkdirSync(hiddenDir, { recursive: true });
|
|
1309
|
-
fs.writeFileSync(
|
|
1310
|
-
path.join(hiddenDir, 'sessions-index.json'),
|
|
1311
|
-
JSON.stringify({ originalPath: '/hidden' }),
|
|
1312
|
-
);
|
|
1313
|
-
fs.writeFileSync(path.join(hiddenDir, 'session.jsonl'), '{}');
|
|
1314
|
-
|
|
1315
|
-
const files = findSessionFiles(10);
|
|
1316
|
-
expect(files).toEqual([]);
|
|
1317
|
-
});
|
|
1318
|
-
|
|
1319
|
-
it('should include project dirs without sessions-index.json using empty projectPath', () => {
|
|
1320
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1321
|
-
const projectsDir = path.join(tmpDir, 'projects');
|
|
1322
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1323
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1324
|
-
|
|
1325
|
-
const projDir = path.join(projectsDir, 'no-index');
|
|
1326
|
-
fs.mkdirSync(projDir, { recursive: true });
|
|
1327
|
-
fs.writeFileSync(path.join(projDir, 'session.jsonl'), '{}');
|
|
1328
|
-
|
|
1329
|
-
const files = findSessionFiles(10);
|
|
1330
|
-
expect(files).toHaveLength(1);
|
|
1331
|
-
expect(files[0].projectPath).toBe('');
|
|
1332
|
-
expect(files[0].filePath).toContain('session.jsonl');
|
|
1333
|
-
});
|
|
1334
|
-
});
|
|
1335
|
-
|
|
1336
|
-
describe('readSessions', () => {
|
|
1337
|
-
it('should parse valid sessions and skip invalid ones', () => {
|
|
1338
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1339
|
-
const projectsDir = path.join(tmpDir, 'projects');
|
|
1340
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1341
|
-
const readSessions = (adapter as any).readSessions.bind(adapter);
|
|
1342
|
-
|
|
1343
|
-
const projDir = path.join(projectsDir, 'proj');
|
|
1344
|
-
fs.mkdirSync(projDir, { recursive: true });
|
|
1345
|
-
fs.writeFileSync(
|
|
1346
|
-
path.join(projDir, 'sessions-index.json'),
|
|
1347
|
-
JSON.stringify({ originalPath: '/my/project' }),
|
|
1348
|
-
);
|
|
1349
|
-
|
|
1350
|
-
// Valid session
|
|
1351
|
-
fs.writeFileSync(
|
|
1352
|
-
path.join(projDir, 'valid.jsonl'),
|
|
1353
|
-
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }),
|
|
1354
|
-
);
|
|
1355
|
-
// Empty session (will return null from readSession)
|
|
1356
|
-
fs.writeFileSync(path.join(projDir, 'empty.jsonl'), '');
|
|
1357
|
-
|
|
1358
|
-
const sessions = readSessions(10);
|
|
1359
|
-
expect(sessions).toHaveLength(2);
|
|
1360
|
-
// Both are valid (empty file still produces a session with defaults)
|
|
1361
|
-
const validSession = sessions.find((s: any) => s.sessionId === 'valid');
|
|
1362
|
-
expect(validSession).toBeDefined();
|
|
1363
|
-
expect(validSession.lastEntryType).toBe('assistant');
|
|
1364
|
-
});
|
|
1365
|
-
});
|
|
1366
1119
|
});
|
|
1367
1120
|
});
|