@ai-devkit/agent-manager 0.10.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.
Files changed (57) hide show
  1. package/dist/AgentManager.d.ts +14 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +38 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +14 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -0
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +5 -7
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +2 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  30. package/dist/utils/ClaudeSessionParser.js +48 -5
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/session.d.ts +34 -5
  37. package/dist/utils/session.d.ts.map +1 -1
  38. package/dist/utils/session.js +90 -44
  39. package/dist/utils/session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/AgentManager.ts +55 -3
  42. package/src/__tests__/AgentManager.test.ts +134 -2
  43. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +159 -3
  44. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  45. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  46. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  47. package/src/__tests__/utils/session.test.ts +79 -43
  48. package/src/adapters/AgentAdapter.ts +76 -0
  49. package/src/adapters/ClaudeCodeAdapter.ts +76 -2
  50. package/src/adapters/CodexAdapter.ts +126 -8
  51. package/src/adapters/GeminiCliAdapter.ts +102 -7
  52. package/src/index.ts +9 -1
  53. package/src/terminal/TerminalFocusManager.ts +1 -4
  54. package/src/terminal/TtyWriter.ts +1 -11
  55. package/src/utils/ClaudeSessionParser.ts +59 -5
  56. package/src/utils/applescript.ts +10 -0
  57. package/src/utils/session.ts +86 -45
@@ -19,9 +19,13 @@ jest.mock('../../utils/process', () => ({
19
19
  enrichProcesses: jest.fn(),
20
20
  }));
21
21
 
22
- jest.mock('../../utils/session', () => ({
23
- batchGetSessionFileBirthtimes: jest.fn(),
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
  /**
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tests for utils/ClaudeSessionParser.ts — focused on stripping
3
+ * harness-injected XML tags from conversation content.
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser';
11
+
12
+ interface JsonlEntry {
13
+ type: 'user' | 'assistant' | 'system';
14
+ message: { content: string | Array<{ type: string; text?: string; content?: string; name?: string; input?: unknown; is_error?: boolean }> };
15
+ timestamp?: string;
16
+ }
17
+
18
+ function writeSession(entries: JsonlEntry[]): string {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-test-'));
20
+ const filePath = path.join(dir, 'session.jsonl');
21
+ fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n'));
22
+ return filePath;
23
+ }
24
+
25
+ describe('ClaudeSessionParser.getConversation — harness tag stripping', () => {
26
+ let parser: ClaudeSessionParser;
27
+ const tempFiles: string[] = [];
28
+
29
+ beforeEach(() => {
30
+ parser = new ClaudeSessionParser();
31
+ });
32
+
33
+ afterEach(() => {
34
+ for (const f of tempFiles) {
35
+ try {
36
+ fs.rmSync(path.dirname(f), { recursive: true, force: true });
37
+ } catch { /* best effort */ }
38
+ }
39
+ tempFiles.length = 0;
40
+ });
41
+
42
+ function makeSession(entries: JsonlEntry[]): string {
43
+ const f = writeSession(entries);
44
+ tempFiles.push(f);
45
+ return f;
46
+ }
47
+
48
+ it('strips <system-reminder> blocks from text content', () => {
49
+ const file = makeSession([
50
+ {
51
+ type: 'assistant',
52
+ message: {
53
+ content: [{
54
+ type: 'text',
55
+ text: 'Real response here.\n<system-reminder>\nDo not mention this reminder.\n</system-reminder>\nMore response.',
56
+ }],
57
+ },
58
+ },
59
+ ]);
60
+
61
+ const conv = parser.getConversation(file);
62
+ expect(conv).toHaveLength(1);
63
+ expect(conv[0].content).toBe('Real response here.\n\nMore response.');
64
+ });
65
+
66
+ it('strips <local-command-stdout> blocks', () => {
67
+ const file = makeSession([
68
+ {
69
+ type: 'system',
70
+ message: { content: 'before <local-command-stdout>\nRunning...\nDone\n</local-command-stdout> after' },
71
+ },
72
+ ]);
73
+
74
+ const conv = parser.getConversation(file);
75
+ expect(conv[0].content).toBe('before after');
76
+ });
77
+
78
+ it('strips <user-prompt-submit-hook> blocks', () => {
79
+ const file = makeSession([
80
+ {
81
+ type: 'user',
82
+ message: { content: '<user-prompt-submit-hook>hook output</user-prompt-submit-hook>\nactual question' },
83
+ },
84
+ ]);
85
+
86
+ const conv = parser.getConversation(file);
87
+ expect(conv[0].content).toBe('actual question');
88
+ });
89
+
90
+ it('strips bash and command stdout/stderr blocks', () => {
91
+ const file = makeSession([
92
+ {
93
+ type: 'assistant',
94
+ message: {
95
+ content: [{
96
+ type: 'text',
97
+ text: 'Output:\n<bash-input>ls</bash-input>\n<bash-stdout>file.txt</bash-stdout>\n<bash-stderr></bash-stderr>\n<command-stdout>x</command-stdout>\n<command-stderr>y</command-stderr>\nEnd.',
98
+ }],
99
+ },
100
+ },
101
+ ]);
102
+
103
+ const conv = parser.getConversation(file);
104
+ expect(conv[0].content).toBe('Output:\n\n\n\n\n\nEnd.'.replace(/\n{3,}/g, '\n\n'));
105
+ expect(conv[0].content).not.toMatch(/<bash-/);
106
+ expect(conv[0].content).not.toMatch(/<command-stdout>|<command-stderr>/);
107
+ });
108
+
109
+ it('collapses <command-name>/<command-args> into "/name args" shorthand', () => {
110
+ const file = makeSession([
111
+ {
112
+ type: 'user',
113
+ message: {
114
+ content: '<command-message>debug</command-message>\n<command-name>/debug</command-name>\n<command-args>fix the bug</command-args>',
115
+ },
116
+ },
117
+ ]);
118
+
119
+ const conv = parser.getConversation(file);
120
+ expect(conv[0].content).toBe('/debug fix the bug');
121
+ });
122
+
123
+ it('handles <command-name> without <command-args>', () => {
124
+ const file = makeSession([
125
+ {
126
+ type: 'user',
127
+ message: {
128
+ content: '<command-message>clear</command-message>\n<command-name>/clear</command-name>',
129
+ },
130
+ },
131
+ ]);
132
+
133
+ const conv = parser.getConversation(file);
134
+ expect(conv[0].content).toBe('/clear');
135
+ });
136
+
137
+ it('handles multiple harness tags mixed together', () => {
138
+ const file = makeSession([
139
+ {
140
+ type: 'user',
141
+ message: {
142
+ content: [{
143
+ type: 'text',
144
+ text: '<system-reminder>ignore me</system-reminder>\n<command-message>build</command-message>\n<command-name>/build</command-name>\n<command-args>--watch</command-args>\n<local-command-stdout>build output here</local-command-stdout>',
145
+ }],
146
+ },
147
+ },
148
+ ]);
149
+
150
+ const conv = parser.getConversation(file);
151
+ expect(conv[0].content).toBe('/build --watch');
152
+ });
153
+
154
+ it('leaves text without harness tags unchanged', () => {
155
+ const file = makeSession([
156
+ {
157
+ type: 'assistant',
158
+ message: { content: [{ type: 'text', text: 'Hello, world!' }] },
159
+ },
160
+ ]);
161
+
162
+ const conv = parser.getConversation(file);
163
+ expect(conv[0].content).toBe('Hello, world!');
164
+ });
165
+
166
+ it('drops a message that becomes empty after stripping', () => {
167
+ const file = makeSession([
168
+ {
169
+ type: 'system',
170
+ message: { content: '<system-reminder>only this</system-reminder>' },
171
+ },
172
+ {
173
+ type: 'assistant',
174
+ message: { content: [{ type: 'text', text: 'kept' }] },
175
+ },
176
+ ]);
177
+
178
+ const conv = parser.getConversation(file);
179
+ expect(conv).toHaveLength(1);
180
+ expect(conv[0].content).toBe('kept');
181
+ });
182
+
183
+ it('strips tags spanning multiple lines', () => {
184
+ const multilineReminder = '<system-reminder>\nLine 1\nLine 2\nLine 3\n</system-reminder>';
185
+ const file = makeSession([
186
+ {
187
+ type: 'assistant',
188
+ message: { content: [{ type: 'text', text: `before\n${multilineReminder}\nafter` }] },
189
+ },
190
+ ]);
191
+
192
+ const conv = parser.getConversation(file);
193
+ expect(conv[0].content).toBe('before\n\nafter');
194
+ });
195
+ });
@@ -3,25 +3,31 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, jest, beforeEach } from '@jest/globals';
6
- import { execSync } from 'child_process';
6
+ import * as fs from 'fs';
7
7
  import { batchGetSessionFileBirthtimes } from '../../utils/session';
8
8
 
9
- jest.mock('child_process', () => ({
10
- execSync: jest.fn(),
9
+ jest.mock('fs', () => ({
10
+ readdirSync: jest.fn(),
11
+ statSync: jest.fn(),
11
12
  }));
12
13
 
13
- const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
14
+ const mockedReaddirSync = fs.readdirSync as jest.MockedFunction<typeof fs.readdirSync>;
15
+ const mockedStatSync = fs.statSync as jest.MockedFunction<typeof fs.statSync>;
14
16
 
15
17
  describe('batchGetSessionFileBirthtimes', () => {
16
18
  beforeEach(() => {
17
- mockedExecSync.mockReset();
19
+ mockedReaddirSync.mockReset();
20
+ mockedStatSync.mockReset();
18
21
  });
19
22
 
20
- it('should parse stat output correctly', () => {
21
- mockedExecSync.mockReturnValue(
22
- '1710800324 /home/.claude/projects/my-app/abc123.jsonl\n' +
23
- '1710800500 /home/.claude/projects/my-app/def456.jsonl\n',
24
- );
23
+ it('should parse session files correctly', () => {
24
+ mockedReaddirSync.mockReturnValue([
25
+ 'abc123.jsonl',
26
+ 'def456.jsonl',
27
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
28
+ mockedStatSync
29
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
30
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats);
25
31
 
26
32
  const results = batchGetSessionFileBirthtimes(['/home/.claude/projects/my-app']);
27
33
 
@@ -39,49 +45,57 @@ describe('batchGetSessionFileBirthtimes', () => {
39
45
 
40
46
  it('should return empty array for empty dirs list', () => {
41
47
  expect(batchGetSessionFileBirthtimes([])).toEqual([]);
42
- expect(mockedExecSync).not.toHaveBeenCalled();
48
+ expect(mockedReaddirSync).not.toHaveBeenCalled();
43
49
  });
44
50
 
45
- it('should return empty array on command failure', () => {
46
- mockedExecSync.mockImplementation(() => {
47
- throw new Error('Command failed');
51
+ it('should return empty array on readdir failure', () => {
52
+ mockedReaddirSync.mockImplementation(() => {
53
+ throw new Error('ENOENT');
48
54
  });
49
55
 
50
56
  expect(batchGetSessionFileBirthtimes(['/some/dir'])).toEqual([]);
51
57
  });
52
58
 
53
- it('should skip lines with invalid epoch (0 or negative)', () => {
54
- mockedExecSync.mockReturnValue(
55
- '0 /dir/bad.jsonl\n' +
56
- '-1 /dir/negative.jsonl\n' +
57
- '1710800324 /dir/good.jsonl\n',
58
- );
59
+ it('should skip files with invalid birthtime (0 or negative)', () => {
60
+ mockedReaddirSync.mockReturnValue([
61
+ 'bad.jsonl',
62
+ 'negative.jsonl',
63
+ 'good.jsonl',
64
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
65
+ mockedStatSync
66
+ .mockReturnValueOnce({ birthtimeMs: 0 } as fs.Stats)
67
+ .mockReturnValueOnce({ birthtimeMs: -1 } as fs.Stats)
68
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
59
69
 
60
70
  const results = batchGetSessionFileBirthtimes(['/dir']);
61
71
  expect(results).toHaveLength(1);
62
72
  expect(results[0].sessionId).toBe('good');
63
73
  });
64
74
 
65
- it('should skip non-jsonl files in output', () => {
66
- mockedExecSync.mockReturnValue(
67
- '1710800324 /dir/sessions-index.json\n' +
68
- '1710800500 /dir/abc123.jsonl\n',
69
- );
75
+ it('should skip non-jsonl files', () => {
76
+ mockedReaddirSync.mockReturnValue([
77
+ 'sessions-index.json',
78
+ 'abc123.jsonl',
79
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
80
+ mockedStatSync
81
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats);
70
82
 
71
83
  const results = batchGetSessionFileBirthtimes(['/dir']);
72
84
  expect(results).toHaveLength(1);
73
85
  expect(results[0].sessionId).toBe('abc123');
74
86
  });
75
87
 
76
- it('should handle empty output', () => {
77
- mockedExecSync.mockReturnValue('');
88
+ it('should handle empty directory', () => {
89
+ mockedReaddirSync.mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
78
90
  expect(batchGetSessionFileBirthtimes(['/dir'])).toEqual([]);
79
91
  });
80
92
 
81
93
  it('should handle UUID session IDs', () => {
82
- mockedExecSync.mockReturnValue(
83
- '1710800324 /dir/068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl\n',
84
- );
94
+ mockedReaddirSync.mockReturnValue([
95
+ '068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl',
96
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
97
+ mockedStatSync
98
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
85
99
 
86
100
  const results = batchGetSessionFileBirthtimes(['/dir']);
87
101
  expect(results).toHaveLength(1);
@@ -89,29 +103,51 @@ describe('batchGetSessionFileBirthtimes', () => {
89
103
  });
90
104
 
91
105
  it('should leave resolvedCwd empty', () => {
92
- mockedExecSync.mockReturnValue('1710800324 /dir/abc.jsonl\n');
106
+ mockedReaddirSync.mockReturnValue([
107
+ 'abc.jsonl',
108
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
109
+ mockedStatSync
110
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
93
111
 
94
112
  const results = batchGetSessionFileBirthtimes(['/dir']);
95
113
  expect(results[0].resolvedCwd).toBe('');
96
114
  });
97
115
 
98
- it('should combine multiple directories into a single stat call', () => {
99
- mockedExecSync.mockReturnValue(
100
- '1710800324 /projects/app-a/sess1.jsonl\n' +
101
- '1710800400 /projects/app-b/sess2.jsonl\n' +
102
- '1710800500 /projects/app-a/sess3.jsonl\n',
103
- );
116
+ it('should enumerate multiple directories', () => {
117
+ mockedReaddirSync
118
+ .mockReturnValueOnce([
119
+ 'sess1.jsonl',
120
+ 'sess3.jsonl',
121
+ ] as unknown as ReturnType<typeof fs.readdirSync>)
122
+ .mockReturnValueOnce([
123
+ 'sess2.jsonl',
124
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
125
+ mockedStatSync
126
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
127
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats)
128
+ .mockReturnValueOnce({ birthtimeMs: 1710800400000 } as fs.Stats);
104
129
 
105
130
  const results = batchGetSessionFileBirthtimes(['/projects/app-a', '/projects/app-b']);
106
131
 
107
- expect(mockedExecSync).toHaveBeenCalledTimes(1);
108
- const cmd = mockedExecSync.mock.calls[0][0] as string;
109
- expect(cmd).toContain('"/projects/app-a"/*.jsonl');
110
- expect(cmd).toContain('"/projects/app-b"/*.jsonl');
132
+ expect(mockedReaddirSync).toHaveBeenCalledTimes(2);
111
133
 
112
134
  expect(results).toHaveLength(3);
113
135
  expect(results[0].projectDir).toBe('/projects/app-a');
114
- expect(results[1].projectDir).toBe('/projects/app-b');
115
- expect(results[2].projectDir).toBe('/projects/app-a');
136
+ expect(results[1].projectDir).toBe('/projects/app-a');
137
+ expect(results[2].projectDir).toBe('/projects/app-b');
138
+ });
139
+
140
+ it('should skip files where statSync fails', () => {
141
+ mockedReaddirSync.mockReturnValue([
142
+ 'good.jsonl',
143
+ 'gone.jsonl',
144
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
145
+ mockedStatSync
146
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
147
+ .mockImplementationOnce(() => { throw new Error('ENOENT'); });
148
+
149
+ const results = batchGetSessionFileBirthtimes(['/dir']);
150
+ expect(results).toHaveLength(1);
151
+ expect(results[0].sessionId).toBe('good');
116
152
  });
117
153
  });