@ai-devkit/agent-manager 0.9.0 → 0.11.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 +21 -1
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +47 -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 +14 -43
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +60 -275
- 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 +36 -27
- 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 +114 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
- package/dist/utils/ClaudeSessionParser.js +377 -0
- package/dist/utils/ClaudeSessionParser.js.map +1 -0
- 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/process.d.ts +3 -4
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +11 -15
- package/dist/utils/process.js.map +1 -1
- 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 +66 -4
- package/src/__tests__/AgentManager.test.ts +134 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +188 -32
- 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/process.test.ts +27 -27
- package/src/__tests__/utils/session.test.ts +79 -43
- package/src/adapters/AgentAdapter.ts +76 -0
- package/src/adapters/ClaudeCodeAdapter.ts +82 -356
- 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 +35 -26
- package/src/terminal/TtyWriter.ts +1 -11
- package/src/utils/ClaudeSessionParser.ts +437 -0
- package/src/utils/applescript.ts +10 -0
- package/src/utils/process.ts +21 -24
- 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(),
|
|
@@ -361,7 +365,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
361
365
|
(adapter as any).projectsDir = projectsDir;
|
|
362
366
|
|
|
363
367
|
// Simulate JSONL disappearing between existence check and read
|
|
364
|
-
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
368
|
+
jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
|
|
365
369
|
|
|
366
370
|
const agents = await adapter.detectAgents();
|
|
367
371
|
|
|
@@ -408,7 +412,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
408
412
|
]);
|
|
409
413
|
|
|
410
414
|
// Simulate JSONL disappearing between match and read
|
|
411
|
-
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
415
|
+
jest.spyOn((adapter as any).parser, 'readSession').mockReturnValueOnce(null);
|
|
412
416
|
|
|
413
417
|
const agents = await adapter.detectAgents();
|
|
414
418
|
|
|
@@ -601,7 +605,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
601
605
|
describe('helper methods', () => {
|
|
602
606
|
describe('determineStatus', () => {
|
|
603
607
|
it('should return "unknown" for sessions with no last entry type', () => {
|
|
604
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
608
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
605
609
|
|
|
606
610
|
const session = {
|
|
607
611
|
sessionId: 'test',
|
|
@@ -615,7 +619,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
615
619
|
});
|
|
616
620
|
|
|
617
621
|
it('should return "waiting" for assistant entries', () => {
|
|
618
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
622
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
619
623
|
|
|
620
624
|
const session = {
|
|
621
625
|
sessionId: 'test',
|
|
@@ -630,7 +634,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
630
634
|
});
|
|
631
635
|
|
|
632
636
|
it('should return "waiting" for user interruption', () => {
|
|
633
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
637
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
634
638
|
|
|
635
639
|
const session = {
|
|
636
640
|
sessionId: 'test',
|
|
@@ -645,7 +649,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
645
649
|
});
|
|
646
650
|
|
|
647
651
|
it('should return "running" for user/progress entries', () => {
|
|
648
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
652
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
649
653
|
|
|
650
654
|
const session = {
|
|
651
655
|
sessionId: 'test',
|
|
@@ -660,7 +664,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
660
664
|
});
|
|
661
665
|
|
|
662
666
|
it('should not override status based on age (process is running)', () => {
|
|
663
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
667
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
664
668
|
|
|
665
669
|
const oldDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
666
670
|
const session = {
|
|
@@ -676,7 +680,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
676
680
|
});
|
|
677
681
|
|
|
678
682
|
it('should return "idle" for system entries', () => {
|
|
679
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
683
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
680
684
|
|
|
681
685
|
const session = {
|
|
682
686
|
sessionId: 'test',
|
|
@@ -691,7 +695,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
691
695
|
});
|
|
692
696
|
|
|
693
697
|
it('should return "running" for thinking entries', () => {
|
|
694
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
698
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
695
699
|
|
|
696
700
|
const session = {
|
|
697
701
|
sessionId: 'test',
|
|
@@ -706,7 +710,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
706
710
|
});
|
|
707
711
|
|
|
708
712
|
it('should return "running" for progress entries', () => {
|
|
709
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
713
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
710
714
|
|
|
711
715
|
const session = {
|
|
712
716
|
sessionId: 'test',
|
|
@@ -721,7 +725,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
721
725
|
});
|
|
722
726
|
|
|
723
727
|
it('should return "unknown" for unrecognized entry types', () => {
|
|
724
|
-
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
728
|
+
const determineStatus = (adapter as any).parser.determineStatus.bind((adapter as any).parser);
|
|
725
729
|
|
|
726
730
|
const session = {
|
|
727
731
|
sessionId: 'test',
|
|
@@ -738,12 +742,12 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
738
742
|
|
|
739
743
|
describe('extractUserMessageText', () => {
|
|
740
744
|
it('should extract plain string content', () => {
|
|
741
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
745
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
742
746
|
expect(extract('hello world')).toBe('hello world');
|
|
743
747
|
});
|
|
744
748
|
|
|
745
749
|
it('should extract text from array content blocks', () => {
|
|
746
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
750
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
747
751
|
|
|
748
752
|
const content = [
|
|
749
753
|
{ type: 'tool_result', content: 'some result' },
|
|
@@ -753,7 +757,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
753
757
|
});
|
|
754
758
|
|
|
755
759
|
it('should return undefined for empty/null content', () => {
|
|
756
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
760
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
757
761
|
|
|
758
762
|
expect(extract(undefined)).toBeUndefined();
|
|
759
763
|
expect(extract('')).toBeUndefined();
|
|
@@ -761,35 +765,35 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
761
765
|
});
|
|
762
766
|
|
|
763
767
|
it('should parse command-message tags', () => {
|
|
764
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
768
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
765
769
|
|
|
766
770
|
const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
|
|
767
771
|
expect(extract(msg)).toBe('commit fix bug');
|
|
768
772
|
});
|
|
769
773
|
|
|
770
774
|
it('should parse command-message without args', () => {
|
|
771
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
775
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
772
776
|
|
|
773
777
|
const msg = '<command-message><command-name>help</command-name></command-message>';
|
|
774
778
|
expect(extract(msg)).toBe('help');
|
|
775
779
|
});
|
|
776
780
|
|
|
777
781
|
it('should extract ARGUMENTS from skill expansion', () => {
|
|
778
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
782
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
779
783
|
|
|
780
784
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
|
|
781
785
|
expect(extract(msg)).toBe('implement the feature');
|
|
782
786
|
});
|
|
783
787
|
|
|
784
788
|
it('should return undefined for skill expansion without ARGUMENTS', () => {
|
|
785
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
789
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
786
790
|
|
|
787
791
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
|
|
788
792
|
expect(extract(msg)).toBeUndefined();
|
|
789
793
|
});
|
|
790
794
|
|
|
791
795
|
it('should filter noise messages', () => {
|
|
792
|
-
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
796
|
+
const extract = (adapter as any).parser['extractUserMessageText'].bind((adapter as any).parser);
|
|
793
797
|
|
|
794
798
|
expect(extract('[Request interrupted by user]')).toBeUndefined();
|
|
795
799
|
expect(extract('Tool loaded.')).toBeUndefined();
|
|
@@ -799,7 +803,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
799
803
|
|
|
800
804
|
describe('parseCommandMessage', () => {
|
|
801
805
|
it('should return undefined for malformed command-message', () => {
|
|
802
|
-
const parse = (adapter as any).parseCommandMessage.bind(adapter);
|
|
806
|
+
const parse = (adapter as any).parser['parseCommandMessage'].bind((adapter as any).parser);
|
|
803
807
|
expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
|
|
804
808
|
});
|
|
805
809
|
});
|
|
@@ -977,7 +981,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
977
981
|
|
|
978
982
|
describe('readSession', () => {
|
|
979
983
|
it('should parse session file with timestamps, cwd, and entry type', () => {
|
|
980
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
984
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
981
985
|
|
|
982
986
|
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
983
987
|
const lines = [
|
|
@@ -999,7 +1003,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
999
1003
|
});
|
|
1000
1004
|
|
|
1001
1005
|
it('should detect user interruption', () => {
|
|
1002
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1006
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1003
1007
|
|
|
1004
1008
|
const filePath = path.join(tmpDir, 'interrupted.jsonl');
|
|
1005
1009
|
const lines = [
|
|
@@ -1019,7 +1023,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1019
1023
|
});
|
|
1020
1024
|
|
|
1021
1025
|
it('should return session with defaults for empty file', () => {
|
|
1022
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1026
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1023
1027
|
|
|
1024
1028
|
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1025
1029
|
fs.writeFileSync(filePath, '');
|
|
@@ -1030,12 +1034,12 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1030
1034
|
});
|
|
1031
1035
|
|
|
1032
1036
|
it('should return null for non-existent file', () => {
|
|
1033
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1037
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1034
1038
|
expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
|
|
1035
1039
|
});
|
|
1036
1040
|
|
|
1037
1041
|
it('should skip metadata entry types for lastEntryType', () => {
|
|
1038
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1042
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1039
1043
|
|
|
1040
1044
|
const filePath = path.join(tmpDir, 'metadata-test.jsonl');
|
|
1041
1045
|
const lines = [
|
|
@@ -1051,7 +1055,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1051
1055
|
});
|
|
1052
1056
|
|
|
1053
1057
|
it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
|
|
1054
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1058
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1055
1059
|
|
|
1056
1060
|
const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
|
|
1057
1061
|
const lines = [
|
|
@@ -1070,7 +1074,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1070
1074
|
});
|
|
1071
1075
|
|
|
1072
1076
|
it('should extract lastUserMessage from session entries', () => {
|
|
1073
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1077
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1074
1078
|
|
|
1075
1079
|
const filePath = path.join(tmpDir, 'user-msg.jsonl');
|
|
1076
1080
|
const lines = [
|
|
@@ -1086,7 +1090,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1086
1090
|
});
|
|
1087
1091
|
|
|
1088
1092
|
it('should use lastCwd as projectPath when projectPath is empty', () => {
|
|
1089
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1093
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1090
1094
|
|
|
1091
1095
|
const filePath = path.join(tmpDir, 'no-project.jsonl');
|
|
1092
1096
|
const lines = [
|
|
@@ -1099,7 +1103,7 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1099
1103
|
});
|
|
1100
1104
|
|
|
1101
1105
|
it('should handle malformed JSON lines gracefully', () => {
|
|
1102
|
-
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1106
|
+
const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);
|
|
1103
1107
|
|
|
1104
1108
|
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
1105
1109
|
const lines = [
|
|
@@ -1288,4 +1292,156 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1288
1292
|
expect(messages[0].content).toBe('Real question');
|
|
1289
1293
|
});
|
|
1290
1294
|
});
|
|
1295
|
+
|
|
1296
|
+
describe('listSessions', () => {
|
|
1297
|
+
let tmpDir: string;
|
|
1298
|
+
let projectsDir: string;
|
|
1299
|
+
|
|
1300
|
+
beforeEach(() => {
|
|
1301
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
|
|
1302
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
1303
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
1304
|
+
(adapter as any).projectsDir = projectsDir;
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
afterEach(() => {
|
|
1308
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
function writeSession(projectDir: string, sessionId: string, lines: object[]): string {
|
|
1312
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
1313
|
+
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
1314
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
1315
|
+
return filePath;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
it('returns empty when projects dir does not exist', async () => {
|
|
1319
|
+
fs.rmSync(projectsDir, { recursive: true, force: true });
|
|
1320
|
+
const result = await adapter.listSessions();
|
|
1321
|
+
expect(result).toEqual([]);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it('returns sessions from a single cwd-scoped project dir', async () => {
|
|
1325
|
+
const cwd = '/Users/test/proj';
|
|
1326
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1327
|
+
const filePath = writeSession(projDir, 'sess-1', [
|
|
1328
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } },
|
|
1329
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:01:00Z' },
|
|
1330
|
+
]);
|
|
1331
|
+
|
|
1332
|
+
const result = await adapter.listSessions({ cwd });
|
|
1333
|
+
|
|
1334
|
+
expect(result).toHaveLength(1);
|
|
1335
|
+
expect(result[0]).toMatchObject({
|
|
1336
|
+
type: 'claude',
|
|
1337
|
+
sessionId: 'sess-1',
|
|
1338
|
+
cwd,
|
|
1339
|
+
firstUserMessage: 'first prompt',
|
|
1340
|
+
sessionFilePath: filePath,
|
|
1341
|
+
});
|
|
1342
|
+
expect(result[0].lastActive).toBeInstanceOf(Date);
|
|
1343
|
+
expect(result[0].startedAt).toBeInstanceOf(Date);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('lists sessions from all project dirs when no cwd filter', async () => {
|
|
1347
|
+
const cwdA = '/Users/test/proj-a';
|
|
1348
|
+
const cwdB = '/Users/test/proj-b';
|
|
1349
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
|
|
1350
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } },
|
|
1351
|
+
]);
|
|
1352
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
|
|
1353
|
+
{ type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } },
|
|
1354
|
+
]);
|
|
1355
|
+
|
|
1356
|
+
const result = await adapter.listSessions();
|
|
1357
|
+
|
|
1358
|
+
expect(result).toHaveLength(2);
|
|
1359
|
+
expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']);
|
|
1360
|
+
const cwds = result.map((r) => r.cwd).sort();
|
|
1361
|
+
expect(cwds).toEqual([cwdA, cwdB]);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => {
|
|
1365
|
+
const cwdReal = '/Users/test/foo';
|
|
1366
|
+
const cwdRequested = '/Users/test/foo/sub';
|
|
1367
|
+
writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
|
|
1368
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } },
|
|
1369
|
+
]);
|
|
1370
|
+
|
|
1371
|
+
// Encoded dir for the requested cwd doesn't exist → return []
|
|
1372
|
+
const result = await adapter.listSessions({ cwd: cwdRequested });
|
|
1373
|
+
expect(result).toEqual([]);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => {
|
|
1377
|
+
// Edge case: encoded dir lookup matches, but session content
|
|
1378
|
+
// records a different cwd. Strict-equality filter must reject.
|
|
1379
|
+
const requested = '/Users/test/proj';
|
|
1380
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1381
|
+
writeSession(projDir, 's', [
|
|
1382
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } },
|
|
1383
|
+
]);
|
|
1384
|
+
|
|
1385
|
+
const result = await adapter.listSessions({ cwd: requested });
|
|
1386
|
+
expect(result).toEqual([]);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => {
|
|
1390
|
+
// Real-world case: Claude Code is launched in /repo, then chdirs into
|
|
1391
|
+
// /repo/.worktrees/feature. The session file is stored under the
|
|
1392
|
+
// ENCODED launch dir, but its content records the worktree path.
|
|
1393
|
+
// listSessions({ cwd: worktree }) must still find it.
|
|
1394
|
+
const launchDir = path.join(projectsDir, '-repo');
|
|
1395
|
+
const worktreeCwd = '/repo/.worktrees/feature';
|
|
1396
|
+
writeSession(launchDir, 'wt', [
|
|
1397
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } },
|
|
1398
|
+
]);
|
|
1399
|
+
|
|
1400
|
+
const result = await adapter.listSessions({ cwd: worktreeCwd });
|
|
1401
|
+
expect(result).toHaveLength(1);
|
|
1402
|
+
expect(result[0]).toMatchObject({
|
|
1403
|
+
sessionId: 'wt',
|
|
1404
|
+
cwd: worktreeCwd,
|
|
1405
|
+
firstUserMessage: 'in worktree',
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('skips malformed session files', async () => {
|
|
1410
|
+
const cwd = '/Users/test/p';
|
|
1411
|
+
const projDir = path.join(projectsDir, '-Users-test-p');
|
|
1412
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
1413
|
+
fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
|
|
1414
|
+
writeSession(projDir, 'good', [
|
|
1415
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } },
|
|
1416
|
+
]);
|
|
1417
|
+
|
|
1418
|
+
const result = await adapter.listSessions({ cwd });
|
|
1419
|
+
expect(result).toHaveLength(1);
|
|
1420
|
+
expect(result[0].sessionId).toBe('good');
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
it('captures first user message after filtering noise', async () => {
|
|
1424
|
+
const cwd = '/Users/test/q';
|
|
1425
|
+
writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
|
|
1426
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } },
|
|
1427
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } },
|
|
1428
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } },
|
|
1429
|
+
]);
|
|
1430
|
+
|
|
1431
|
+
const result = await adapter.listSessions({ cwd });
|
|
1432
|
+
expect(result).toHaveLength(1);
|
|
1433
|
+
expect(result[0].firstUserMessage).toBe('real first prompt');
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('returns empty firstUserMessage when no user message exists', async () => {
|
|
1437
|
+
const cwd = '/Users/test/empty';
|
|
1438
|
+
writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
|
|
1439
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:00:00Z' },
|
|
1440
|
+
]);
|
|
1441
|
+
|
|
1442
|
+
const result = await adapter.listSessions({ cwd });
|
|
1443
|
+
expect(result).toHaveLength(1);
|
|
1444
|
+
expect(result[0].firstUserMessage).toBe('');
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1291
1447
|
});
|
|
@@ -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
|
});
|