@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.
@@ -2,7 +2,9 @@
2
2
  * Tests for ClaudeCodeAdapter
3
3
  */
4
4
 
5
- import { describe, it, expect, beforeEach, jest } from '@jest/globals';
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 processes with "claude" in command (case-insensitive)', () => {
51
+ it('should return true for claude executable with full path', () => {
51
52
  const processInfo = {
52
53
  pid: 12345,
53
- command: '/usr/local/bin/CLAUDE --some-flag',
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/history data', async () => {
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
- lastEntry: { type: 'assistant' },
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 process cwd has no matching session', async () => {
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
- sessionLogPath: '/mock/path/session-2.jsonl',
144
- lastEntry: { type: 'assistant' },
182
+ sessionStart: new Date(),
145
183
  lastActive: new Date(),
184
+ lastEntryType: 'assistant',
185
+ isInterrupted: false,
146
186
  },
147
187
  ]);
148
- jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]);
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
- summary: 'Claude process running',
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
- lastEntry: { type: 'assistant' },
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 use latest history entry for process-only fallback session id', async () => {
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: '69237415-b0c3-4990-ba53-15882616509e',
227
- summary: '/status',
228
- status: AgentStatus.RUNNING,
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 prefer exact-cwd history session over parent-project session match', async () => {
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: 'old-parent-session',
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
- lastEntry: { type: 'assistant' },
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, 'readHistory').mockReturnValue([
303
+ jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
253
304
  {
254
- display: '/status',
255
- timestamp: 1772122701536,
256
- project: '/Users/test/my-project/packages/cli',
257
- sessionId: '69237415-b0c3-4990-ba53-15882616509e',
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
- type: 'claude',
265
- pid: 97529,
266
- sessionId: '69237415-b0c3-4990-ba53-15882616509e',
267
- projectPath: '/Users/test/my-project/packages/cli',
268
- summary: '/status',
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
- sessionLogPath: '/test/log',
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
- sessionLogPath: '/test/log',
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
- sessionLogPath: '/test/log',
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
- sessionLogPath: '/test/log',
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 return "idle" for old sessions', () => {
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
- sessionLogPath: '/test/log',
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
- sessionLogPath: '/test/log',
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
  });