@ai-devkit/agent-manager 0.2.0 → 0.4.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/ClaudeCodeAdapter.d.ts +44 -28
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +383 -234
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +1 -3
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts +23 -0
- package/dist/terminal/TtyWriter.d.ts.map +1 -0
- package/dist/terminal/TtyWriter.js +106 -0
- package/dist/terminal/TtyWriter.js.map +1 -0
- package/dist/terminal/index.d.ts +1 -0
- package/dist/terminal/index.d.ts.map +1 -1
- package/dist/terminal/index.js +3 -1
- package/dist/terminal/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +1045 -82
- package/src/__tests__/adapters/CodexAdapter.test.ts +7 -1
- package/src/__tests__/terminal/TtyWriter.test.ts +154 -0
- package/src/adapters/ClaudeCodeAdapter.ts +498 -327
- package/src/adapters/CodexAdapter.ts +2 -13
- package/src/index.ts +1 -0
- package/src/terminal/TtyWriter.ts +112 -0
- package/src/terminal/index.ts +1 -0
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Tests for ClaudeCodeAdapter
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
6
8
|
import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
|
|
7
9
|
import type { AgentInfo, ProcessInfo } from '../../adapters/AgentAdapter';
|
|
8
10
|
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
@@ -17,8 +19,7 @@ const mockedListProcesses = listProcesses as jest.MockedFunction<typeof listProc
|
|
|
17
19
|
type PrivateMethod<T extends (...args: never[]) => unknown> = T;
|
|
18
20
|
|
|
19
21
|
interface AdapterPrivates {
|
|
20
|
-
readSessions: PrivateMethod<() => unknown[]>;
|
|
21
|
-
readHistory: PrivateMethod<() => unknown[]>;
|
|
22
|
+
readSessions: PrivateMethod<(limit: number) => unknown[]>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
describe('ClaudeCodeAdapter', () => {
|
|
@@ -47,10 +48,21 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
47
48
|
expect(adapter.canHandle(processInfo)).toBe(true);
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
it('should return true for
|
|
51
|
+
it('should return true for claude executable with full path', () => {
|
|
51
52
|
const processInfo = {
|
|
52
53
|
pid: 12345,
|
|
53
|
-
command: '/usr/local/bin/
|
|
54
|
+
command: '/usr/local/bin/claude --some-flag',
|
|
55
|
+
cwd: '/test',
|
|
56
|
+
tty: 'ttys001',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
expect(adapter.canHandle(processInfo)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return true for CLAUDE (case-insensitive)', () => {
|
|
63
|
+
const processInfo = {
|
|
64
|
+
pid: 12345,
|
|
65
|
+
command: '/usr/local/bin/CLAUDE --continue',
|
|
54
66
|
cwd: '/test',
|
|
55
67
|
tty: 'ttys001',
|
|
56
68
|
};
|
|
@@ -68,6 +80,17 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
68
80
|
|
|
69
81
|
expect(adapter.canHandle(processInfo)).toBe(false);
|
|
70
82
|
});
|
|
83
|
+
|
|
84
|
+
it('should return false for processes with "claude" only in path arguments', () => {
|
|
85
|
+
const processInfo = {
|
|
86
|
+
pid: 12345,
|
|
87
|
+
command: '/usr/local/bin/node /path/to/claude-worktree/node_modules/nx/start.js',
|
|
88
|
+
cwd: '/test',
|
|
89
|
+
tty: 'ttys001',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
expect(adapter.canHandle(processInfo)).toBe(false);
|
|
93
|
+
});
|
|
71
94
|
});
|
|
72
95
|
|
|
73
96
|
describe('detectAgents', () => {
|
|
@@ -78,7 +101,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
78
101
|
expect(agents).toEqual([]);
|
|
79
102
|
});
|
|
80
103
|
|
|
81
|
-
it('should detect agents using mocked process/session
|
|
104
|
+
it('should detect agents using mocked process/session data', async () => {
|
|
82
105
|
const processData: ProcessInfo[] = [
|
|
83
106
|
{
|
|
84
107
|
pid: 12345,
|
|
@@ -92,25 +115,17 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
92
115
|
{
|
|
93
116
|
sessionId: 'session-1',
|
|
94
117
|
projectPath: '/Users/test/my-project',
|
|
95
|
-
sessionLogPath: '/mock/path/session-1.jsonl',
|
|
96
118
|
slug: 'merry-dog',
|
|
97
|
-
|
|
119
|
+
sessionStart: new Date(),
|
|
98
120
|
lastActive: new Date(),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const historyData = [
|
|
103
|
-
{
|
|
104
|
-
display: 'Investigate failing tests in package',
|
|
105
|
-
timestamp: Date.now(),
|
|
106
|
-
project: '/Users/test/my-project',
|
|
107
|
-
sessionId: 'session-1',
|
|
121
|
+
lastEntryType: 'assistant',
|
|
122
|
+
isInterrupted: false,
|
|
123
|
+
lastUserMessage: 'Investigate failing tests in package',
|
|
108
124
|
},
|
|
109
125
|
];
|
|
110
126
|
|
|
111
127
|
mockedListProcesses.mockReturnValue(processData);
|
|
112
128
|
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData);
|
|
113
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue(historyData);
|
|
114
129
|
|
|
115
130
|
const agents = await adapter.detectAgents();
|
|
116
131
|
|
|
@@ -127,7 +142,31 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
127
142
|
expect(agents[0].summary).toContain('Investigate failing tests in package');
|
|
128
143
|
});
|
|
129
144
|
|
|
130
|
-
it('should include process-only entry when
|
|
145
|
+
it('should include process-only entry when no sessions exist', async () => {
|
|
146
|
+
mockedListProcesses.mockReturnValue([
|
|
147
|
+
{
|
|
148
|
+
pid: 777,
|
|
149
|
+
command: 'claude',
|
|
150
|
+
cwd: '/project/without-session',
|
|
151
|
+
tty: 'ttys008',
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
const agents = await adapter.detectAgents();
|
|
158
|
+
expect(agents).toHaveLength(1);
|
|
159
|
+
expect(agents[0]).toMatchObject({
|
|
160
|
+
type: 'claude',
|
|
161
|
+
status: AgentStatus.IDLE,
|
|
162
|
+
pid: 777,
|
|
163
|
+
projectPath: '/project/without-session',
|
|
164
|
+
sessionId: 'pid-777',
|
|
165
|
+
summary: 'Unknown',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should not match process to unrelated session from different project', async () => {
|
|
131
170
|
mockedListProcesses.mockReturnValue([
|
|
132
171
|
{
|
|
133
172
|
pid: 777,
|
|
@@ -140,26 +179,27 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
140
179
|
{
|
|
141
180
|
sessionId: 'session-2',
|
|
142
181
|
projectPath: '/other/project',
|
|
143
|
-
|
|
144
|
-
lastEntry: { type: 'assistant' },
|
|
182
|
+
sessionStart: new Date(),
|
|
145
183
|
lastActive: new Date(),
|
|
184
|
+
lastEntryType: 'assistant',
|
|
185
|
+
isInterrupted: false,
|
|
146
186
|
},
|
|
147
187
|
]);
|
|
148
|
-
|
|
188
|
+
|
|
149
189
|
|
|
150
190
|
const agents = await adapter.detectAgents();
|
|
151
191
|
expect(agents).toHaveLength(1);
|
|
192
|
+
// Unrelated session should NOT match — falls to process-only
|
|
152
193
|
expect(agents[0]).toMatchObject({
|
|
153
194
|
type: 'claude',
|
|
154
|
-
status: AgentStatus.RUNNING,
|
|
155
195
|
pid: 777,
|
|
156
|
-
projectPath: '/project/without-session',
|
|
157
196
|
sessionId: 'pid-777',
|
|
158
|
-
|
|
197
|
+
projectPath: '/project/without-session',
|
|
198
|
+
status: AgentStatus.IDLE,
|
|
159
199
|
});
|
|
160
200
|
});
|
|
161
201
|
|
|
162
|
-
it('should match process in subdirectory to project-root session', async () => {
|
|
202
|
+
it('should match process in subdirectory to project-root session via parent-child mode', async () => {
|
|
163
203
|
mockedListProcesses.mockReturnValue([
|
|
164
204
|
{
|
|
165
205
|
pid: 888,
|
|
@@ -172,18 +212,11 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
172
212
|
{
|
|
173
213
|
sessionId: 'session-3',
|
|
174
214
|
projectPath: '/Users/test/my-project',
|
|
175
|
-
sessionLogPath: '/mock/path/session-3.jsonl',
|
|
176
215
|
slug: 'gentle-otter',
|
|
177
|
-
|
|
216
|
+
sessionStart: new Date(),
|
|
178
217
|
lastActive: new Date(),
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
|
|
182
|
-
{
|
|
183
|
-
display: 'Refactor CLI command flow',
|
|
184
|
-
timestamp: Date.now(),
|
|
185
|
-
project: '/Users/test/my-project',
|
|
186
|
-
sessionId: 'session-3',
|
|
218
|
+
lastEntryType: 'assistant',
|
|
219
|
+
isInterrupted: false,
|
|
187
220
|
},
|
|
188
221
|
]);
|
|
189
222
|
|
|
@@ -194,11 +227,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
194
227
|
pid: 888,
|
|
195
228
|
sessionId: 'session-3',
|
|
196
229
|
projectPath: '/Users/test/my-project',
|
|
197
|
-
summary: 'Refactor CLI command flow',
|
|
198
230
|
});
|
|
199
231
|
});
|
|
200
232
|
|
|
201
|
-
it('should
|
|
233
|
+
it('should show idle status with Unknown summary for process-only fallback when no sessions exist', async () => {
|
|
202
234
|
mockedListProcesses.mockReturnValue([
|
|
203
235
|
{
|
|
204
236
|
pid: 97529,
|
|
@@ -208,14 +240,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
208
240
|
},
|
|
209
241
|
]);
|
|
210
242
|
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
211
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
|
|
212
|
-
{
|
|
213
|
-
display: '/status',
|
|
214
|
-
timestamp: 1772122701536,
|
|
215
|
-
project: '/Users/test/my-project/packages/cli',
|
|
216
|
-
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
|
|
217
|
-
},
|
|
218
|
-
]);
|
|
219
243
|
|
|
220
244
|
const agents = await adapter.detectAgents();
|
|
221
245
|
expect(agents).toHaveLength(1);
|
|
@@ -223,14 +247,13 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
223
247
|
type: 'claude',
|
|
224
248
|
pid: 97529,
|
|
225
249
|
projectPath: '/Users/test/my-project/packages/cli',
|
|
226
|
-
sessionId: '
|
|
227
|
-
summary: '
|
|
228
|
-
status: AgentStatus.
|
|
250
|
+
sessionId: 'pid-97529',
|
|
251
|
+
summary: 'Unknown',
|
|
252
|
+
status: AgentStatus.IDLE,
|
|
229
253
|
});
|
|
230
|
-
expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z');
|
|
231
254
|
});
|
|
232
255
|
|
|
233
|
-
it('should
|
|
256
|
+
it('should match session via parent-child mode when process cwd is under session project path', async () => {
|
|
234
257
|
mockedListProcesses.mockReturnValue([
|
|
235
258
|
{
|
|
236
259
|
pid: 97529,
|
|
@@ -241,45 +264,142 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
241
264
|
]);
|
|
242
265
|
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
243
266
|
{
|
|
244
|
-
sessionId: '
|
|
267
|
+
sessionId: 'parent-session',
|
|
245
268
|
projectPath: '/Users/test/my-project',
|
|
246
|
-
sessionLogPath: '/mock/path/old-parent-session.jsonl',
|
|
247
269
|
slug: 'fluffy-brewing-kazoo',
|
|
248
|
-
|
|
270
|
+
sessionStart: new Date('2026-02-23T17:24:50.996Z'),
|
|
249
271
|
lastActive: new Date('2026-02-23T17:24:50.996Z'),
|
|
272
|
+
lastEntryType: 'assistant',
|
|
273
|
+
isInterrupted: false,
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const agents = await adapter.detectAgents();
|
|
278
|
+
expect(agents).toHaveLength(1);
|
|
279
|
+
// Session matched via parent-child mode
|
|
280
|
+
expect(agents[0]).toMatchObject({
|
|
281
|
+
type: 'claude',
|
|
282
|
+
pid: 97529,
|
|
283
|
+
sessionId: 'parent-session',
|
|
284
|
+
projectPath: '/Users/test/my-project',
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should fall back to process-only when sessions exist but all are used', async () => {
|
|
289
|
+
mockedListProcesses.mockReturnValue([
|
|
290
|
+
{
|
|
291
|
+
pid: 100,
|
|
292
|
+
command: 'claude',
|
|
293
|
+
cwd: '/project-a',
|
|
294
|
+
tty: 'ttys001',
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
pid: 200,
|
|
298
|
+
command: 'claude',
|
|
299
|
+
cwd: '/project-b',
|
|
300
|
+
tty: 'ttys002',
|
|
250
301
|
},
|
|
251
302
|
]);
|
|
252
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, '
|
|
303
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
253
304
|
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
305
|
+
sessionId: 'only-session',
|
|
306
|
+
projectPath: '/project-a',
|
|
307
|
+
sessionStart: new Date(),
|
|
308
|
+
lastActive: new Date(),
|
|
309
|
+
lastEntryType: 'assistant',
|
|
310
|
+
isInterrupted: false,
|
|
258
311
|
},
|
|
259
312
|
]);
|
|
260
313
|
|
|
314
|
+
|
|
315
|
+
const agents = await adapter.detectAgents();
|
|
316
|
+
expect(agents).toHaveLength(2);
|
|
317
|
+
// First process matched via cwd
|
|
318
|
+
expect(agents[0]).toMatchObject({
|
|
319
|
+
pid: 100,
|
|
320
|
+
sessionId: 'only-session',
|
|
321
|
+
});
|
|
322
|
+
// Second process: session used, falls to process-only
|
|
323
|
+
expect(agents[1]).toMatchObject({
|
|
324
|
+
pid: 200,
|
|
325
|
+
sessionId: 'pid-200',
|
|
326
|
+
status: AgentStatus.IDLE,
|
|
327
|
+
summary: 'Unknown',
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should handle process with empty cwd in process-only fallback', async () => {
|
|
332
|
+
mockedListProcesses.mockReturnValue([
|
|
333
|
+
{
|
|
334
|
+
pid: 300,
|
|
335
|
+
command: 'claude',
|
|
336
|
+
cwd: '',
|
|
337
|
+
tty: 'ttys003',
|
|
338
|
+
},
|
|
339
|
+
]);
|
|
340
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
341
|
+
|
|
261
342
|
const agents = await adapter.detectAgents();
|
|
262
343
|
expect(agents).toHaveLength(1);
|
|
263
344
|
expect(agents[0]).toMatchObject({
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
projectPath: '
|
|
268
|
-
|
|
345
|
+
pid: 300,
|
|
346
|
+
sessionId: 'pid-300',
|
|
347
|
+
summary: 'Unknown',
|
|
348
|
+
projectPath: '',
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should prefer cwd-matched session over any-mode session', async () => {
|
|
353
|
+
const now = new Date();
|
|
354
|
+
mockedListProcesses.mockReturnValue([
|
|
355
|
+
{
|
|
356
|
+
pid: 100,
|
|
357
|
+
command: 'claude',
|
|
358
|
+
cwd: '/Users/test/project-a',
|
|
359
|
+
tty: 'ttys001',
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
363
|
+
{
|
|
364
|
+
sessionId: 'exact-match',
|
|
365
|
+
projectPath: '/Users/test/project-a',
|
|
366
|
+
sessionStart: now,
|
|
367
|
+
lastActive: now,
|
|
368
|
+
lastEntryType: 'assistant',
|
|
369
|
+
isInterrupted: false,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
sessionId: 'other-project',
|
|
373
|
+
projectPath: '/Users/test/project-b',
|
|
374
|
+
sessionStart: now,
|
|
375
|
+
lastActive: new Date(now.getTime() + 1000), // more recent
|
|
376
|
+
lastEntryType: 'user',
|
|
377
|
+
isInterrupted: false,
|
|
378
|
+
},
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
const agents = await adapter.detectAgents();
|
|
383
|
+
expect(agents).toHaveLength(1);
|
|
384
|
+
expect(agents[0]).toMatchObject({
|
|
385
|
+
sessionId: 'exact-match',
|
|
386
|
+
projectPath: '/Users/test/project-a',
|
|
269
387
|
});
|
|
270
388
|
});
|
|
271
389
|
});
|
|
272
390
|
|
|
273
391
|
describe('helper methods', () => {
|
|
274
392
|
describe('determineStatus', () => {
|
|
275
|
-
it('should return "unknown" for sessions with no last entry', () => {
|
|
393
|
+
it('should return "unknown" for sessions with no last entry type', () => {
|
|
276
394
|
const adapter = new ClaudeCodeAdapter();
|
|
277
395
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
278
396
|
|
|
279
397
|
const session = {
|
|
280
398
|
sessionId: 'test',
|
|
281
399
|
projectPath: '/test',
|
|
282
|
-
|
|
400
|
+
sessionStart: new Date(),
|
|
401
|
+
lastActive: new Date(),
|
|
402
|
+
isInterrupted: false,
|
|
283
403
|
};
|
|
284
404
|
|
|
285
405
|
const status = determineStatus(session);
|
|
@@ -293,9 +413,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
293
413
|
const session = {
|
|
294
414
|
sessionId: 'test',
|
|
295
415
|
projectPath: '/test',
|
|
296
|
-
|
|
297
|
-
lastEntry: { type: 'assistant' },
|
|
416
|
+
sessionStart: new Date(),
|
|
298
417
|
lastActive: new Date(),
|
|
418
|
+
lastEntryType: 'assistant',
|
|
419
|
+
isInterrupted: false,
|
|
299
420
|
};
|
|
300
421
|
|
|
301
422
|
const status = determineStatus(session);
|
|
@@ -309,14 +430,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
309
430
|
const session = {
|
|
310
431
|
sessionId: 'test',
|
|
311
432
|
projectPath: '/test',
|
|
312
|
-
|
|
313
|
-
lastEntry: {
|
|
314
|
-
type: 'user',
|
|
315
|
-
message: {
|
|
316
|
-
content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }],
|
|
317
|
-
},
|
|
318
|
-
},
|
|
433
|
+
sessionStart: new Date(),
|
|
319
434
|
lastActive: new Date(),
|
|
435
|
+
lastEntryType: 'user',
|
|
436
|
+
isInterrupted: true,
|
|
320
437
|
};
|
|
321
438
|
|
|
322
439
|
const status = determineStatus(session);
|
|
@@ -330,16 +447,17 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
330
447
|
const session = {
|
|
331
448
|
sessionId: 'test',
|
|
332
449
|
projectPath: '/test',
|
|
333
|
-
|
|
334
|
-
lastEntry: { type: 'user' },
|
|
450
|
+
sessionStart: new Date(),
|
|
335
451
|
lastActive: new Date(),
|
|
452
|
+
lastEntryType: 'user',
|
|
453
|
+
isInterrupted: false,
|
|
336
454
|
};
|
|
337
455
|
|
|
338
456
|
const status = determineStatus(session);
|
|
339
457
|
expect(status).toBe(AgentStatus.RUNNING);
|
|
340
458
|
});
|
|
341
459
|
|
|
342
|
-
it('should
|
|
460
|
+
it('should not override status based on age (process is running)', () => {
|
|
343
461
|
const adapter = new ClaudeCodeAdapter();
|
|
344
462
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
345
463
|
|
|
@@ -348,14 +466,85 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
348
466
|
const session = {
|
|
349
467
|
sessionId: 'test',
|
|
350
468
|
projectPath: '/test',
|
|
351
|
-
|
|
352
|
-
lastEntry: { type: 'assistant' },
|
|
469
|
+
sessionStart: oldDate,
|
|
353
470
|
lastActive: oldDate,
|
|
471
|
+
lastEntryType: 'assistant',
|
|
472
|
+
isInterrupted: false,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// Even with old lastActive, entry type determines status
|
|
476
|
+
// because the process is known to be running
|
|
477
|
+
const status = determineStatus(session);
|
|
478
|
+
expect(status).toBe(AgentStatus.WAITING);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should return "idle" for system entries', () => {
|
|
482
|
+
const adapter = new ClaudeCodeAdapter();
|
|
483
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
484
|
+
|
|
485
|
+
const session = {
|
|
486
|
+
sessionId: 'test',
|
|
487
|
+
projectPath: '/test',
|
|
488
|
+
sessionStart: new Date(),
|
|
489
|
+
lastActive: new Date(),
|
|
490
|
+
lastEntryType: 'system',
|
|
491
|
+
isInterrupted: false,
|
|
354
492
|
};
|
|
355
493
|
|
|
356
494
|
const status = determineStatus(session);
|
|
357
495
|
expect(status).toBe(AgentStatus.IDLE);
|
|
358
496
|
});
|
|
497
|
+
|
|
498
|
+
it('should return "running" for thinking entries', () => {
|
|
499
|
+
const adapter = new ClaudeCodeAdapter();
|
|
500
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
501
|
+
|
|
502
|
+
const session = {
|
|
503
|
+
sessionId: 'test',
|
|
504
|
+
projectPath: '/test',
|
|
505
|
+
sessionStart: new Date(),
|
|
506
|
+
lastActive: new Date(),
|
|
507
|
+
lastEntryType: 'thinking',
|
|
508
|
+
isInterrupted: false,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const status = determineStatus(session);
|
|
512
|
+
expect(status).toBe(AgentStatus.RUNNING);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should return "running" for progress entries', () => {
|
|
516
|
+
const adapter = new ClaudeCodeAdapter();
|
|
517
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
518
|
+
|
|
519
|
+
const session = {
|
|
520
|
+
sessionId: 'test',
|
|
521
|
+
projectPath: '/test',
|
|
522
|
+
sessionStart: new Date(),
|
|
523
|
+
lastActive: new Date(),
|
|
524
|
+
lastEntryType: 'progress',
|
|
525
|
+
isInterrupted: false,
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const status = determineStatus(session);
|
|
529
|
+
expect(status).toBe(AgentStatus.RUNNING);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should return "unknown" for unrecognized entry types', () => {
|
|
533
|
+
const adapter = new ClaudeCodeAdapter();
|
|
534
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
535
|
+
|
|
536
|
+
const session = {
|
|
537
|
+
sessionId: 'test',
|
|
538
|
+
projectPath: '/test',
|
|
539
|
+
sessionStart: new Date(),
|
|
540
|
+
lastActive: new Date(),
|
|
541
|
+
lastEntryType: 'some_other_type',
|
|
542
|
+
isInterrupted: false,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const status = determineStatus(session);
|
|
546
|
+
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
547
|
+
});
|
|
359
548
|
});
|
|
360
549
|
|
|
361
550
|
describe('generateAgentName', () => {
|
|
@@ -366,7 +555,9 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
366
555
|
const session = {
|
|
367
556
|
sessionId: 'test-123',
|
|
368
557
|
projectPath: '/Users/test/my-project',
|
|
369
|
-
|
|
558
|
+
sessionStart: new Date(),
|
|
559
|
+
lastActive: new Date(),
|
|
560
|
+
isInterrupted: false,
|
|
370
561
|
};
|
|
371
562
|
|
|
372
563
|
const name = generateAgentName(session, []);
|
|
@@ -392,13 +583,785 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
392
583
|
const session = {
|
|
393
584
|
sessionId: 'test-456',
|
|
394
585
|
projectPath: '/Users/test/my-project',
|
|
395
|
-
sessionLogPath: '/test/log',
|
|
396
586
|
slug: 'merry-dog',
|
|
587
|
+
sessionStart: new Date(),
|
|
588
|
+
lastActive: new Date(),
|
|
589
|
+
isInterrupted: false,
|
|
397
590
|
};
|
|
398
591
|
|
|
399
592
|
const name = generateAgentName(session, [existingAgent]);
|
|
400
593
|
expect(name).toBe('my-project (merry)');
|
|
401
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);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe('extractUserMessageText', () => {
|
|
910
|
+
it('should extract plain string content', () => {
|
|
911
|
+
const adapter = new ClaudeCodeAdapter();
|
|
912
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
913
|
+
|
|
914
|
+
expect(extract('hello world')).toBe('hello world');
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('should extract text from array content blocks', () => {
|
|
918
|
+
const adapter = new ClaudeCodeAdapter();
|
|
919
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
920
|
+
|
|
921
|
+
const content = [
|
|
922
|
+
{ type: 'tool_result', content: 'some result' },
|
|
923
|
+
{ type: 'text', text: 'user question' },
|
|
924
|
+
];
|
|
925
|
+
expect(extract(content)).toBe('user question');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('should return undefined for empty/null content', () => {
|
|
929
|
+
const adapter = new ClaudeCodeAdapter();
|
|
930
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
931
|
+
|
|
932
|
+
expect(extract(undefined)).toBeUndefined();
|
|
933
|
+
expect(extract('')).toBeUndefined();
|
|
934
|
+
expect(extract([])).toBeUndefined();
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('should parse command-message tags', () => {
|
|
938
|
+
const adapter = new ClaudeCodeAdapter();
|
|
939
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
940
|
+
|
|
941
|
+
const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
|
|
942
|
+
expect(extract(msg)).toBe('commit fix bug');
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('should parse command-message without args', () => {
|
|
946
|
+
const adapter = new ClaudeCodeAdapter();
|
|
947
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
948
|
+
|
|
949
|
+
const msg = '<command-message><command-name>help</command-name></command-message>';
|
|
950
|
+
expect(extract(msg)).toBe('help');
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('should extract ARGUMENTS from skill expansion', () => {
|
|
954
|
+
const adapter = new ClaudeCodeAdapter();
|
|
955
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
956
|
+
|
|
957
|
+
const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
|
|
958
|
+
expect(extract(msg)).toBe('implement the feature');
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('should return undefined for skill expansion without ARGUMENTS', () => {
|
|
962
|
+
const adapter = new ClaudeCodeAdapter();
|
|
963
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
964
|
+
|
|
965
|
+
const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
|
|
966
|
+
expect(extract(msg)).toBeUndefined();
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('should filter noise messages', () => {
|
|
970
|
+
const adapter = new ClaudeCodeAdapter();
|
|
971
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
972
|
+
|
|
973
|
+
expect(extract('[Request interrupted by user]')).toBeUndefined();
|
|
974
|
+
expect(extract('Tool loaded.')).toBeUndefined();
|
|
975
|
+
expect(extract('This session is being continued from a previous conversation')).toBeUndefined();
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe('parseCommandMessage', () => {
|
|
980
|
+
it('should return undefined for malformed command-message', () => {
|
|
981
|
+
const adapter = new ClaudeCodeAdapter();
|
|
982
|
+
const parse = (adapter as any).parseCommandMessage.bind(adapter);
|
|
983
|
+
|
|
984
|
+
expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
describe('selectBestSession', () => {
|
|
990
|
+
it('should defer in cwd mode when best candidate is outside tolerance', () => {
|
|
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
|
+
];
|
|
1008
|
+
|
|
1009
|
+
// In cwd mode, should defer (return undefined) because outside tolerance
|
|
1010
|
+
const cwdResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
|
|
1011
|
+
expect(cwdResult).toBeUndefined();
|
|
1012
|
+
|
|
1013
|
+
// In parent-child mode, should accept the same candidate (no tolerance gate)
|
|
1014
|
+
const parentChildResult = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'parent-child');
|
|
1015
|
+
expect(parentChildResult).toBeDefined();
|
|
1016
|
+
expect(parentChildResult.sessionId).toBe('stale-exact-cwd');
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it('should fall back to recency when no processStart available', () => {
|
|
1020
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1021
|
+
const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
|
|
1022
|
+
|
|
1023
|
+
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
1024
|
+
const processStartByPid = new Map<number, Date>(); // empty — no start time
|
|
1025
|
+
|
|
1026
|
+
const sessions = [
|
|
1027
|
+
{
|
|
1028
|
+
sessionId: 'older',
|
|
1029
|
+
projectPath: '/my/project',
|
|
1030
|
+
lastCwd: '/my/project',
|
|
1031
|
+
sessionStart: new Date('2026-03-10T09:00:00Z'),
|
|
1032
|
+
lastActive: new Date('2026-03-10T09:30:00Z'),
|
|
1033
|
+
isInterrupted: false,
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
sessionId: 'newer',
|
|
1037
|
+
projectPath: '/my/project',
|
|
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
|
+
];
|
|
1044
|
+
|
|
1045
|
+
const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
|
|
1046
|
+
expect(result).toBeDefined();
|
|
1047
|
+
expect(result.sessionId).toBe('newer');
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('should accept in cwd mode when best candidate is within tolerance', () => {
|
|
1051
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1052
|
+
const selectBestSession = (adapter as any).selectBestSession.bind(adapter);
|
|
1053
|
+
|
|
1054
|
+
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
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
|
+
];
|
|
1068
|
+
|
|
1069
|
+
const result = selectBestSession(processInfo, sessions, new Set(), processStartByPid, 'cwd');
|
|
1070
|
+
expect(result).toBeDefined();
|
|
1071
|
+
expect(result.sessionId).toBe('fresh-exact-cwd');
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
describe('file I/O methods', () => {
|
|
1076
|
+
let tmpDir: string;
|
|
1077
|
+
|
|
1078
|
+
beforeEach(() => {
|
|
1079
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
afterEach(() => {
|
|
1083
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
describe('readSession', () => {
|
|
1087
|
+
it('should parse session file with timestamps, slug, cwd, and entry type', () => {
|
|
1088
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1089
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1090
|
+
|
|
1091
|
+
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
1092
|
+
const lines = [
|
|
1093
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project', slug: 'happy-dog' }),
|
|
1094
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1095
|
+
];
|
|
1096
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1097
|
+
|
|
1098
|
+
const session = readSession(filePath, '/my/project');
|
|
1099
|
+
expect(session).toMatchObject({
|
|
1100
|
+
sessionId: 'test-session',
|
|
1101
|
+
projectPath: '/my/project',
|
|
1102
|
+
slug: 'happy-dog',
|
|
1103
|
+
lastCwd: '/my/project',
|
|
1104
|
+
lastEntryType: 'assistant',
|
|
1105
|
+
isInterrupted: false,
|
|
1106
|
+
});
|
|
1107
|
+
expect(session.sessionStart.toISOString()).toBe('2026-03-10T10:00:00.000Z');
|
|
1108
|
+
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should detect user interruption', () => {
|
|
1112
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1113
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1114
|
+
|
|
1115
|
+
const filePath = path.join(tmpDir, 'interrupted.jsonl');
|
|
1116
|
+
const lines = [
|
|
1117
|
+
JSON.stringify({
|
|
1118
|
+
type: 'user',
|
|
1119
|
+
timestamp: '2026-03-10T10:00:00Z',
|
|
1120
|
+
message: {
|
|
1121
|
+
content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }],
|
|
1122
|
+
},
|
|
1123
|
+
}),
|
|
1124
|
+
];
|
|
1125
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1126
|
+
|
|
1127
|
+
const session = readSession(filePath, '/test');
|
|
1128
|
+
expect(session.isInterrupted).toBe(true);
|
|
1129
|
+
expect(session.lastEntryType).toBe('user');
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('should return session with defaults for empty file', () => {
|
|
1133
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1134
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1135
|
+
|
|
1136
|
+
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1137
|
+
fs.writeFileSync(filePath, '');
|
|
1138
|
+
|
|
1139
|
+
const session = readSession(filePath, '/test');
|
|
1140
|
+
// Empty file content trims to '' which splits to [''] — no valid entries parsed
|
|
1141
|
+
expect(session).not.toBeNull();
|
|
1142
|
+
expect(session.lastEntryType).toBeUndefined();
|
|
1143
|
+
expect(session.slug).toBeUndefined();
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it('should return null for non-existent file', () => {
|
|
1147
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1148
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1149
|
+
|
|
1150
|
+
expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it('should skip metadata entry types for lastEntryType', () => {
|
|
1154
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1155
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1156
|
+
|
|
1157
|
+
const filePath = path.join(tmpDir, 'metadata-test.jsonl');
|
|
1158
|
+
const lines = [
|
|
1159
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'hello' } }),
|
|
1160
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1161
|
+
JSON.stringify({ type: 'last-prompt', timestamp: '2026-03-10T10:02:00Z' }),
|
|
1162
|
+
JSON.stringify({ type: 'file-history-snapshot', timestamp: '2026-03-10T10:03:00Z' }),
|
|
1163
|
+
];
|
|
1164
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1165
|
+
|
|
1166
|
+
const session = readSession(filePath, '/test');
|
|
1167
|
+
// lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot'
|
|
1168
|
+
expect(session.lastEntryType).toBe('assistant');
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
|
|
1172
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1173
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1174
|
+
|
|
1175
|
+
const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
|
|
1176
|
+
const lines = [
|
|
1177
|
+
JSON.stringify({
|
|
1178
|
+
type: 'file-history-snapshot',
|
|
1179
|
+
snapshot: { timestamp: '2026-03-10T09:55:00Z', files: [] },
|
|
1180
|
+
}),
|
|
1181
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'test' } }),
|
|
1182
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1183
|
+
];
|
|
1184
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1185
|
+
|
|
1186
|
+
const session = readSession(filePath, '/test');
|
|
1187
|
+
// sessionStart should come from snapshot.timestamp, not lastActive
|
|
1188
|
+
expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
|
|
1189
|
+
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('should extract lastUserMessage from session entries', () => {
|
|
1193
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1194
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1195
|
+
|
|
1196
|
+
const filePath = path.join(tmpDir, 'user-msg.jsonl');
|
|
1197
|
+
const lines = [
|
|
1198
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'first question' } }),
|
|
1199
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1200
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:02:00Z', message: { content: [{ type: 'text', text: 'second question' }] } }),
|
|
1201
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:03:00Z' }),
|
|
1202
|
+
];
|
|
1203
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1204
|
+
|
|
1205
|
+
const session = readSession(filePath, '/test');
|
|
1206
|
+
// Last user message should be the most recent one
|
|
1207
|
+
expect(session.lastUserMessage).toBe('second question');
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('should use lastCwd as projectPath when projectPath is empty', () => {
|
|
1211
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1212
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1213
|
+
|
|
1214
|
+
const filePath = path.join(tmpDir, 'no-project.jsonl');
|
|
1215
|
+
const lines = [
|
|
1216
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/derived/path', message: { content: 'test' } }),
|
|
1217
|
+
];
|
|
1218
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1219
|
+
|
|
1220
|
+
const session = readSession(filePath, '');
|
|
1221
|
+
expect(session.projectPath).toBe('/derived/path');
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
1225
|
+
const adapter = new ClaudeCodeAdapter();
|
|
1226
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1227
|
+
|
|
1228
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
1229
|
+
const lines = [
|
|
1230
|
+
'not json',
|
|
1231
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }),
|
|
1232
|
+
];
|
|
1233
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1234
|
+
|
|
1235
|
+
const session = readSession(filePath, '/test');
|
|
1236
|
+
expect(session).not.toBeNull();
|
|
1237
|
+
expect(session.lastEntryType).toBe('assistant');
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
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
|
+
});
|
|
402
1365
|
});
|
|
403
1366
|
});
|
|
404
1367
|
});
|