@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.
- 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 +14 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +60 -0
- 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 +159 -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 +76 -2
- 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
|
@@ -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
|
/**
|
|
@@ -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
|
|
6
|
+
import * as fs from 'fs';
|
|
7
7
|
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
8
8
|
|
|
9
|
-
jest.mock('
|
|
10
|
-
|
|
9
|
+
jest.mock('fs', () => ({
|
|
10
|
+
readdirSync: jest.fn(),
|
|
11
|
+
statSync: jest.fn(),
|
|
11
12
|
}));
|
|
12
13
|
|
|
13
|
-
const
|
|
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
|
-
|
|
19
|
+
mockedReaddirSync.mockReset();
|
|
20
|
+
mockedStatSync.mockReset();
|
|
18
21
|
});
|
|
19
22
|
|
|
20
|
-
it('should parse
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
'
|
|
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(
|
|
48
|
+
expect(mockedReaddirSync).not.toHaveBeenCalled();
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
it('should return empty array on
|
|
46
|
-
|
|
47
|
-
throw new Error('
|
|
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
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
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
|
|
66
|
-
|
|
67
|
-
'
|
|
68
|
-
'
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
'
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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-
|
|
115
|
-
expect(results[2].projectDir).toBe('/projects/app-
|
|
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
|
});
|