@ai-devkit/agent-manager 0.10.0 → 0.12.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/AgentManager.d.ts +14 -1
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +38 -0
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +66 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +22 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +108 -4
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +14 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +105 -6
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +77 -6
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +5 -7
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +3 -12
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/utils/ClaudeSessionParser.d.ts +2 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
- package/dist/utils/ClaudeSessionParser.js +48 -5
- package/dist/utils/ClaudeSessionParser.js.map +1 -1
- package/dist/utils/applescript.d.ts +6 -0
- package/dist/utils/applescript.d.ts.map +1 -0
- package/dist/utils/applescript.js +14 -0
- package/dist/utils/applescript.js.map +1 -0
- package/dist/utils/session.d.ts +34 -5
- package/dist/utils/session.d.ts.map +1 -1
- package/dist/utils/session.js +90 -44
- package/dist/utils/session.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +55 -3
- package/src/__tests__/AgentManager.test.ts +134 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +229 -3
- package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
- package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
- package/src/__tests__/utils/session.test.ts +79 -43
- package/src/adapters/AgentAdapter.ts +76 -0
- package/src/adapters/ClaudeCodeAdapter.ts +136 -6
- package/src/adapters/CodexAdapter.ts +126 -8
- package/src/adapters/GeminiCliAdapter.ts +102 -7
- package/src/index.ts +9 -1
- package/src/terminal/TerminalFocusManager.ts +1 -4
- package/src/terminal/TtyWriter.ts +1 -11
- package/src/utils/ClaudeSessionParser.ts +59 -5
- package/src/utils/applescript.ts +10 -0
- package/src/utils/session.ts +86 -45
|
@@ -18,9 +18,13 @@ jest.mock('../../utils/process', () => ({
|
|
|
18
18
|
enrichProcesses: jest.fn(),
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
jest.mock('../../utils/session', () =>
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
jest.mock('../../utils/session', () => {
|
|
22
|
+
const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
24
28
|
|
|
25
29
|
jest.mock('../../utils/matching', () => ({
|
|
26
30
|
matchProcessesToSessions: jest.fn(),
|
|
@@ -285,6 +289,76 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
285
289
|
});
|
|
286
290
|
});
|
|
287
291
|
|
|
292
|
+
it('should match via --resume <uuid> in command line and skip PID-file/legacy', async () => {
|
|
293
|
+
const sessionId = '0555f803-7eca-4fc6-a1e0-34dbf86b33b2';
|
|
294
|
+
const processes: ProcessInfo[] = [
|
|
295
|
+
{
|
|
296
|
+
pid: 41920,
|
|
297
|
+
command: `claude --resume ${sessionId}`,
|
|
298
|
+
cwd: '/project/resumed',
|
|
299
|
+
tty: 'ttys001',
|
|
300
|
+
startTime: new Date(),
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
304
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
305
|
+
|
|
306
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-'));
|
|
307
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
308
|
+
const projDir = path.join(projectsDir, '-project-resumed');
|
|
309
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
310
|
+
|
|
311
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
312
|
+
fs.writeFileSync(jsonlPath, [
|
|
313
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/resumed', message: { content: 'resumed conversation' } }),
|
|
314
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
315
|
+
].join('\n'));
|
|
316
|
+
|
|
317
|
+
(adapter as any).projectsDir = projectsDir;
|
|
318
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'sessions'); // empty — no PID file
|
|
319
|
+
|
|
320
|
+
const agents = await adapter.detectAgents();
|
|
321
|
+
|
|
322
|
+
// Legacy matching helpers must NOT have been consulted — resume match was authoritative
|
|
323
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
324
|
+
expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
|
|
325
|
+
|
|
326
|
+
expect(agents).toHaveLength(1);
|
|
327
|
+
expect(agents[0]).toMatchObject({
|
|
328
|
+
type: 'claude',
|
|
329
|
+
pid: 41920,
|
|
330
|
+
sessionId,
|
|
331
|
+
projectPath: '/project/resumed',
|
|
332
|
+
});
|
|
333
|
+
expect(agents[0].summary).toContain('resumed conversation');
|
|
334
|
+
|
|
335
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should fall through when --resume points to a JSONL that does not exist', async () => {
|
|
339
|
+
const processes: ProcessInfo[] = [
|
|
340
|
+
{
|
|
341
|
+
pid: 41921,
|
|
342
|
+
command: 'claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
343
|
+
cwd: '/project/missing',
|
|
344
|
+
tty: 'ttys001',
|
|
345
|
+
startTime: new Date(),
|
|
346
|
+
},
|
|
347
|
+
];
|
|
348
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
349
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
350
|
+
|
|
351
|
+
// No projects dir, no PID file → both matchers fail → process-only
|
|
352
|
+
(adapter as any).projectsDir = '/nonexistent';
|
|
353
|
+
(adapter as any).sessionsDir = '/nonexistent';
|
|
354
|
+
|
|
355
|
+
const agents = await adapter.detectAgents();
|
|
356
|
+
|
|
357
|
+
expect(agents).toHaveLength(1);
|
|
358
|
+
expect(agents[0].sessionId).toBe('pid-41921');
|
|
359
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
360
|
+
});
|
|
361
|
+
|
|
288
362
|
it('should use PID file for direct match and skip legacy matching for that process', async () => {
|
|
289
363
|
const startTime = new Date();
|
|
290
364
|
const processes: ProcessInfo[] = [
|
|
@@ -1288,4 +1362,156 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1288
1362
|
expect(messages[0].content).toBe('Real question');
|
|
1289
1363
|
});
|
|
1290
1364
|
});
|
|
1365
|
+
|
|
1366
|
+
describe('listSessions', () => {
|
|
1367
|
+
let tmpDir: string;
|
|
1368
|
+
let projectsDir: string;
|
|
1369
|
+
|
|
1370
|
+
beforeEach(() => {
|
|
1371
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
|
|
1372
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
1373
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
1374
|
+
(adapter as any).projectsDir = projectsDir;
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
afterEach(() => {
|
|
1378
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
function writeSession(projectDir: string, sessionId: string, lines: object[]): string {
|
|
1382
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
1383
|
+
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
1384
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
1385
|
+
return filePath;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
it('returns empty when projects dir does not exist', async () => {
|
|
1389
|
+
fs.rmSync(projectsDir, { recursive: true, force: true });
|
|
1390
|
+
const result = await adapter.listSessions();
|
|
1391
|
+
expect(result).toEqual([]);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('returns sessions from a single cwd-scoped project dir', async () => {
|
|
1395
|
+
const cwd = '/Users/test/proj';
|
|
1396
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1397
|
+
const filePath = writeSession(projDir, 'sess-1', [
|
|
1398
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } },
|
|
1399
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:01:00Z' },
|
|
1400
|
+
]);
|
|
1401
|
+
|
|
1402
|
+
const result = await adapter.listSessions({ cwd });
|
|
1403
|
+
|
|
1404
|
+
expect(result).toHaveLength(1);
|
|
1405
|
+
expect(result[0]).toMatchObject({
|
|
1406
|
+
type: 'claude',
|
|
1407
|
+
sessionId: 'sess-1',
|
|
1408
|
+
cwd,
|
|
1409
|
+
firstUserMessage: 'first prompt',
|
|
1410
|
+
sessionFilePath: filePath,
|
|
1411
|
+
});
|
|
1412
|
+
expect(result[0].lastActive).toBeInstanceOf(Date);
|
|
1413
|
+
expect(result[0].startedAt).toBeInstanceOf(Date);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it('lists sessions from all project dirs when no cwd filter', async () => {
|
|
1417
|
+
const cwdA = '/Users/test/proj-a';
|
|
1418
|
+
const cwdB = '/Users/test/proj-b';
|
|
1419
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
|
|
1420
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } },
|
|
1421
|
+
]);
|
|
1422
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
|
|
1423
|
+
{ type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } },
|
|
1424
|
+
]);
|
|
1425
|
+
|
|
1426
|
+
const result = await adapter.listSessions();
|
|
1427
|
+
|
|
1428
|
+
expect(result).toHaveLength(2);
|
|
1429
|
+
expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']);
|
|
1430
|
+
const cwds = result.map((r) => r.cwd).sort();
|
|
1431
|
+
expect(cwds).toEqual([cwdA, cwdB]);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => {
|
|
1435
|
+
const cwdReal = '/Users/test/foo';
|
|
1436
|
+
const cwdRequested = '/Users/test/foo/sub';
|
|
1437
|
+
writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
|
|
1438
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } },
|
|
1439
|
+
]);
|
|
1440
|
+
|
|
1441
|
+
// Encoded dir for the requested cwd doesn't exist → return []
|
|
1442
|
+
const result = await adapter.listSessions({ cwd: cwdRequested });
|
|
1443
|
+
expect(result).toEqual([]);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => {
|
|
1447
|
+
// Edge case: encoded dir lookup matches, but session content
|
|
1448
|
+
// records a different cwd. Strict-equality filter must reject.
|
|
1449
|
+
const requested = '/Users/test/proj';
|
|
1450
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1451
|
+
writeSession(projDir, 's', [
|
|
1452
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } },
|
|
1453
|
+
]);
|
|
1454
|
+
|
|
1455
|
+
const result = await adapter.listSessions({ cwd: requested });
|
|
1456
|
+
expect(result).toEqual([]);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => {
|
|
1460
|
+
// Real-world case: Claude Code is launched in /repo, then chdirs into
|
|
1461
|
+
// /repo/.worktrees/feature. The session file is stored under the
|
|
1462
|
+
// ENCODED launch dir, but its content records the worktree path.
|
|
1463
|
+
// listSessions({ cwd: worktree }) must still find it.
|
|
1464
|
+
const launchDir = path.join(projectsDir, '-repo');
|
|
1465
|
+
const worktreeCwd = '/repo/.worktrees/feature';
|
|
1466
|
+
writeSession(launchDir, 'wt', [
|
|
1467
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } },
|
|
1468
|
+
]);
|
|
1469
|
+
|
|
1470
|
+
const result = await adapter.listSessions({ cwd: worktreeCwd });
|
|
1471
|
+
expect(result).toHaveLength(1);
|
|
1472
|
+
expect(result[0]).toMatchObject({
|
|
1473
|
+
sessionId: 'wt',
|
|
1474
|
+
cwd: worktreeCwd,
|
|
1475
|
+
firstUserMessage: 'in worktree',
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it('skips malformed session files', async () => {
|
|
1480
|
+
const cwd = '/Users/test/p';
|
|
1481
|
+
const projDir = path.join(projectsDir, '-Users-test-p');
|
|
1482
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
1483
|
+
fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
|
|
1484
|
+
writeSession(projDir, 'good', [
|
|
1485
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } },
|
|
1486
|
+
]);
|
|
1487
|
+
|
|
1488
|
+
const result = await adapter.listSessions({ cwd });
|
|
1489
|
+
expect(result).toHaveLength(1);
|
|
1490
|
+
expect(result[0].sessionId).toBe('good');
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it('captures first user message after filtering noise', async () => {
|
|
1494
|
+
const cwd = '/Users/test/q';
|
|
1495
|
+
writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
|
|
1496
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } },
|
|
1497
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } },
|
|
1498
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } },
|
|
1499
|
+
]);
|
|
1500
|
+
|
|
1501
|
+
const result = await adapter.listSessions({ cwd });
|
|
1502
|
+
expect(result).toHaveLength(1);
|
|
1503
|
+
expect(result[0].firstUserMessage).toBe('real first prompt');
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it('returns empty firstUserMessage when no user message exists', async () => {
|
|
1507
|
+
const cwd = '/Users/test/empty';
|
|
1508
|
+
writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
|
|
1509
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:00:00Z' },
|
|
1510
|
+
]);
|
|
1511
|
+
|
|
1512
|
+
const result = await adapter.listSessions({ cwd });
|
|
1513
|
+
expect(result).toHaveLength(1);
|
|
1514
|
+
expect(result[0].firstUserMessage).toBe('');
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1291
1517
|
});
|
|
@@ -19,9 +19,13 @@ jest.mock('../../utils/process', () => ({
|
|
|
19
19
|
enrichProcesses: jest.fn(),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
-
jest.mock('../../utils/session', () =>
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
jest.mock('../../utils/session', () => {
|
|
23
|
+
const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
25
29
|
|
|
26
30
|
jest.mock('../../utils/matching', () => ({
|
|
27
31
|
matchProcessesToSessions: jest.fn(),
|
|
@@ -630,4 +634,120 @@ describe('CodexAdapter', () => {
|
|
|
630
634
|
expect(messages[0].content).toBe('Response');
|
|
631
635
|
});
|
|
632
636
|
});
|
|
637
|
+
|
|
638
|
+
describe('listSessions', () => {
|
|
639
|
+
let tmpDir: string;
|
|
640
|
+
let sessionsDir: string;
|
|
641
|
+
|
|
642
|
+
beforeEach(() => {
|
|
643
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-list-'));
|
|
644
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
645
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
646
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
afterEach(() => {
|
|
650
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
function writeCodexSession(dateDir: string, sessionId: string, lines: object[]): string {
|
|
654
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
655
|
+
const filePath = path.join(dateDir, `${sessionId}.jsonl`);
|
|
656
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
657
|
+
return filePath;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
it('returns empty when sessions dir does not exist', async () => {
|
|
661
|
+
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
|
662
|
+
const result = await adapter.listSessions();
|
|
663
|
+
expect(result).toEqual([]);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('walks every YYYY/MM/DD dir and returns all sessions', async () => {
|
|
667
|
+
const dayA = path.join(sessionsDir, '2025', '01', '01');
|
|
668
|
+
const dayB = path.join(sessionsDir, '2025', '02', '03');
|
|
669
|
+
writeCodexSession(dayA, 'sess-a', [
|
|
670
|
+
{ type: 'session_meta', payload: { id: 'sess-a', cwd: '/repo-a', timestamp: '2025-01-01T00:00:00Z' } },
|
|
671
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'msg-a' } },
|
|
672
|
+
]);
|
|
673
|
+
writeCodexSession(dayB, 'sess-b', [
|
|
674
|
+
{ type: 'session_meta', payload: { id: 'sess-b', cwd: '/repo-b', timestamp: '2025-02-03T00:00:00Z' } },
|
|
675
|
+
{ type: 'event', timestamp: '2025-02-03T00:00:01Z', payload: { type: 'user_message', message: 'msg-b' } },
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
const result = await adapter.listSessions();
|
|
679
|
+
|
|
680
|
+
expect(result).toHaveLength(2);
|
|
681
|
+
const byId = Object.fromEntries(result.map((r) => [r.sessionId, r]));
|
|
682
|
+
expect(byId['sess-a']).toMatchObject({
|
|
683
|
+
type: 'codex',
|
|
684
|
+
cwd: '/repo-a',
|
|
685
|
+
firstUserMessage: 'msg-a',
|
|
686
|
+
});
|
|
687
|
+
expect(byId['sess-b']).toMatchObject({
|
|
688
|
+
type: 'codex',
|
|
689
|
+
cwd: '/repo-b',
|
|
690
|
+
firstUserMessage: 'msg-b',
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('applies strict-equality cwd filter against session_meta cwd', async () => {
|
|
695
|
+
const day = path.join(sessionsDir, '2025', '01', '01');
|
|
696
|
+
writeCodexSession(day, 'keep', [
|
|
697
|
+
{ type: 'session_meta', payload: { id: 'keep', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
|
|
698
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'yes' } },
|
|
699
|
+
]);
|
|
700
|
+
writeCodexSession(day, 'drop', [
|
|
701
|
+
{ type: 'session_meta', payload: { id: 'drop', cwd: '/other', timestamp: '2025-01-01T00:01:00Z' } },
|
|
702
|
+
{ type: 'event', timestamp: '2025-01-01T00:01:01Z', payload: { type: 'user_message', message: 'no' } },
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
706
|
+
|
|
707
|
+
expect(result).toHaveLength(1);
|
|
708
|
+
expect(result[0].sessionId).toBe('keep');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('skips files without a session_meta first line', async () => {
|
|
712
|
+
const day = path.join(sessionsDir, '2025', '01', '01');
|
|
713
|
+
writeCodexSession(day, 'bad', [
|
|
714
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:00Z', payload: { type: 'user_message', message: 'orphan' } },
|
|
715
|
+
]);
|
|
716
|
+
writeCodexSession(day, 'good', [
|
|
717
|
+
{ type: 'session_meta', payload: { id: 'good', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
|
|
718
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'user_message', message: 'ok' } },
|
|
719
|
+
]);
|
|
720
|
+
|
|
721
|
+
const result = await adapter.listSessions();
|
|
722
|
+
expect(result).toHaveLength(1);
|
|
723
|
+
expect(result[0].sessionId).toBe('good');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('captures the first user_message as firstUserMessage', async () => {
|
|
727
|
+
const day = path.join(sessionsDir, '2025', '01', '01');
|
|
728
|
+
writeCodexSession(day, 's', [
|
|
729
|
+
{ type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
|
|
730
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'preamble' } },
|
|
731
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:02Z', payload: { type: 'user_message', message: 'first user' } },
|
|
732
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:03Z', payload: { type: 'user_message', message: 'second user' } },
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
736
|
+
|
|
737
|
+
expect(result).toHaveLength(1);
|
|
738
|
+
expect(result[0].firstUserMessage).toBe('first user');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('returns empty firstUserMessage when no user_message exists', async () => {
|
|
742
|
+
const day = path.join(sessionsDir, '2025', '01', '01');
|
|
743
|
+
writeCodexSession(day, 's', [
|
|
744
|
+
{ type: 'session_meta', payload: { id: 's', cwd: '/repo', timestamp: '2025-01-01T00:00:00Z' } },
|
|
745
|
+
{ type: 'event', timestamp: '2025-01-01T00:00:01Z', payload: { type: 'agent_message', message: 'agent only' } },
|
|
746
|
+
]);
|
|
747
|
+
|
|
748
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
749
|
+
expect(result).toHaveLength(1);
|
|
750
|
+
expect(result[0].firstUserMessage).toBe('');
|
|
751
|
+
});
|
|
752
|
+
});
|
|
633
753
|
});
|
|
@@ -760,6 +760,139 @@ describe('GeminiCliAdapter', () => {
|
|
|
760
760
|
expect(adapter.getConversation(sessionPath)).toEqual([]);
|
|
761
761
|
});
|
|
762
762
|
});
|
|
763
|
+
|
|
764
|
+
describe('listSessions', () => {
|
|
765
|
+
it('returns empty when ~/.gemini/tmp does not exist', async () => {
|
|
766
|
+
// tmpHome has no .gemini dir by default
|
|
767
|
+
const result = await adapter.listSessions();
|
|
768
|
+
expect(result).toEqual([]);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('walks every shortId/chats dir and returns sessions', async () => {
|
|
772
|
+
writeSession(tmpHome, 'aaa', 'session-1', {
|
|
773
|
+
sessionId: 's-1',
|
|
774
|
+
projectHash: hashProjectRoot('/repo-a'),
|
|
775
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
776
|
+
lastUpdated: '2025-01-01T00:01:00Z',
|
|
777
|
+
directories: ['/repo-a'],
|
|
778
|
+
messages: [
|
|
779
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: [{ text: 'hello a' }] },
|
|
780
|
+
],
|
|
781
|
+
});
|
|
782
|
+
writeSession(tmpHome, 'bbb', 'session-2', {
|
|
783
|
+
sessionId: 's-2',
|
|
784
|
+
projectHash: hashProjectRoot('/repo-b'),
|
|
785
|
+
startTime: '2025-01-02T00:00:00Z',
|
|
786
|
+
lastUpdated: '2025-01-02T00:01:00Z',
|
|
787
|
+
directories: ['/repo-b'],
|
|
788
|
+
messages: [
|
|
789
|
+
{ type: 'user', timestamp: '2025-01-02T00:00:00Z', content: [{ text: 'hello b' }] },
|
|
790
|
+
],
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const result = await adapter.listSessions();
|
|
794
|
+
|
|
795
|
+
expect(result).toHaveLength(2);
|
|
796
|
+
const byId = Object.fromEntries(result.map((r) => [r.sessionId, r]));
|
|
797
|
+
expect(byId['s-1']).toMatchObject({
|
|
798
|
+
type: 'gemini_cli',
|
|
799
|
+
cwd: '/repo-a',
|
|
800
|
+
firstUserMessage: 'hello a',
|
|
801
|
+
});
|
|
802
|
+
expect(byId['s-2']).toMatchObject({
|
|
803
|
+
type: 'gemini_cli',
|
|
804
|
+
cwd: '/repo-b',
|
|
805
|
+
firstUserMessage: 'hello b',
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('applies strict-equality cwd filter against directories[0]', async () => {
|
|
810
|
+
writeSession(tmpHome, 'aaa', 'session-keep', {
|
|
811
|
+
sessionId: 'keep',
|
|
812
|
+
projectHash: hashProjectRoot('/repo'),
|
|
813
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
814
|
+
directories: ['/repo'],
|
|
815
|
+
messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'yes' }],
|
|
816
|
+
});
|
|
817
|
+
writeSession(tmpHome, 'bbb', 'session-drop', {
|
|
818
|
+
sessionId: 'drop',
|
|
819
|
+
projectHash: hashProjectRoot('/other'),
|
|
820
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
821
|
+
directories: ['/other'],
|
|
822
|
+
messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'no' }],
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
826
|
+
|
|
827
|
+
expect(result).toHaveLength(1);
|
|
828
|
+
expect(result[0].sessionId).toBe('keep');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('skips malformed JSON files', async () => {
|
|
832
|
+
const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'aaa', 'chats');
|
|
833
|
+
fs.mkdirSync(chatsDir, { recursive: true });
|
|
834
|
+
fs.writeFileSync(path.join(chatsDir, 'session-bad.json'), '{ not json');
|
|
835
|
+
writeSession(tmpHome, 'aaa', 'session-good', {
|
|
836
|
+
sessionId: 'good',
|
|
837
|
+
projectHash: hashProjectRoot('/repo'),
|
|
838
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
839
|
+
directories: ['/repo'],
|
|
840
|
+
messages: [{ type: 'user', timestamp: '2025-01-01T00:00:00Z', content: 'ok' }],
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const result = await adapter.listSessions();
|
|
844
|
+
expect(result).toHaveLength(1);
|
|
845
|
+
expect(result[0].sessionId).toBe('good');
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('skips files missing sessionId', async () => {
|
|
849
|
+
writeSession(tmpHome, 'aaa', 'session-no-id', {
|
|
850
|
+
projectHash: hashProjectRoot('/repo'),
|
|
851
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
852
|
+
directories: ['/repo'],
|
|
853
|
+
messages: [],
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const result = await adapter.listSessions();
|
|
857
|
+
expect(result).toEqual([]);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('captures the first user-typed message as firstUserMessage', async () => {
|
|
861
|
+
writeSession(tmpHome, 'aaa', 'session-x', {
|
|
862
|
+
sessionId: 's',
|
|
863
|
+
projectHash: hashProjectRoot('/repo'),
|
|
864
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
865
|
+
directories: ['/repo'],
|
|
866
|
+
messages: [
|
|
867
|
+
{ type: 'gemini', timestamp: '2025-01-01T00:00:00Z', content: 'preamble' },
|
|
868
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:01Z', content: [{ text: 'first user' }] },
|
|
869
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:02Z', content: 'second user' },
|
|
870
|
+
],
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
874
|
+
|
|
875
|
+
expect(result).toHaveLength(1);
|
|
876
|
+
expect(result[0].firstUserMessage).toBe('first user');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('returns empty firstUserMessage when no user message exists', async () => {
|
|
880
|
+
writeSession(tmpHome, 'aaa', 'session-x', {
|
|
881
|
+
sessionId: 's',
|
|
882
|
+
projectHash: hashProjectRoot('/repo'),
|
|
883
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
884
|
+
directories: ['/repo'],
|
|
885
|
+
messages: [
|
|
886
|
+
{ type: 'gemini', timestamp: '2025-01-01T00:00:00Z', content: 'agent only' },
|
|
887
|
+
],
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
const result = await adapter.listSessions({ cwd: '/repo' });
|
|
891
|
+
|
|
892
|
+
expect(result).toHaveLength(1);
|
|
893
|
+
expect(result[0].firstUserMessage).toBe('');
|
|
894
|
+
});
|
|
895
|
+
});
|
|
763
896
|
});
|
|
764
897
|
|
|
765
898
|
/**
|