@ai-devkit/agent-manager 0.4.0 → 0.6.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/adapters/AgentAdapter.d.ts +21 -2
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +44 -35
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +230 -298
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +41 -31
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +198 -278
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +6 -3
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +12 -11
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +39 -0
- package/dist/utils/matching.d.ts.map +1 -0
- package/dist/utils/matching.js +107 -0
- package/dist/utils/matching.js.map +1 -0
- package/dist/utils/process.d.ts +25 -40
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +151 -105
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.d.ts +30 -0
- package/dist/utils/session.d.ts.map +1 -0
- package/dist/utils/session.js +101 -0
- package/dist/utils/session.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/AgentManager.test.ts +5 -27
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +754 -830
- package/src/__tests__/adapters/CodexAdapter.test.ts +581 -273
- package/src/__tests__/utils/matching.test.ts +199 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +23 -4
- package/src/adapters/ClaudeCodeAdapter.ts +285 -437
- package/src/adapters/CodexAdapter.ts +202 -400
- package/src/index.ts +2 -4
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +96 -0
- package/src/utils/process.ts +133 -119
- package/src/utils/session.ts +92 -0
- package/dist/utils/file.d.ts +0 -52
- package/dist/utils/file.d.ts.map +0 -1
- package/dist/utils/file.js +0 -135
- package/dist/utils/file.js.map +0 -1
- package/src/utils/file.ts +0 -100
|
@@ -6,28 +6,49 @@ import * as fs from 'fs';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
8
8
|
import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
|
|
9
|
-
import type {
|
|
9
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
10
10
|
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import { listAgentProcesses, enrichProcesses } from '../../utils/process';
|
|
12
|
+
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
13
|
+
import type { SessionFile } from '../../utils/session';
|
|
14
|
+
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
|
|
15
|
+
import type { MatchResult } from '../../utils/matching';
|
|
13
16
|
jest.mock('../../utils/process', () => ({
|
|
14
|
-
|
|
17
|
+
listAgentProcesses: jest.fn(),
|
|
18
|
+
enrichProcesses: jest.fn(),
|
|
15
19
|
}));
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
jest.mock('../../utils/session', () => ({
|
|
22
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
23
|
+
}));
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
jest.mock('../../utils/matching', () => ({
|
|
26
|
+
matchProcessesToSessions: jest.fn(),
|
|
27
|
+
generateAgentName: jest.fn(),
|
|
28
|
+
}));
|
|
24
29
|
|
|
30
|
+
const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction<typeof listAgentProcesses>;
|
|
31
|
+
const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction<typeof enrichProcesses>;
|
|
32
|
+
const mockedBatchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes as jest.MockedFunction<typeof batchGetSessionFileBirthtimes>;
|
|
33
|
+
const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction<typeof matchProcessesToSessions>;
|
|
34
|
+
const mockedGenerateAgentName = generateAgentName as jest.MockedFunction<typeof generateAgentName>;
|
|
25
35
|
describe('ClaudeCodeAdapter', () => {
|
|
26
36
|
let adapter: ClaudeCodeAdapter;
|
|
27
37
|
|
|
28
38
|
beforeEach(() => {
|
|
29
39
|
adapter = new ClaudeCodeAdapter();
|
|
30
|
-
|
|
40
|
+
mockedListAgentProcesses.mockReset();
|
|
41
|
+
mockedEnrichProcesses.mockReset();
|
|
42
|
+
mockedBatchGetSessionFileBirthtimes.mockReset();
|
|
43
|
+
mockedMatchProcessesToSessions.mockReset();
|
|
44
|
+
mockedGenerateAgentName.mockReset();
|
|
45
|
+
// Default: enrichProcesses returns what it receives
|
|
46
|
+
mockedEnrichProcesses.mockImplementation((procs) => procs);
|
|
47
|
+
// Default: generateAgentName returns "folder (pid)"
|
|
48
|
+
mockedGenerateAgentName.mockImplementation((cwd, pid) => {
|
|
49
|
+
const folder = path.basename(cwd) || 'unknown';
|
|
50
|
+
return `${folder} (${pid})`;
|
|
51
|
+
});
|
|
31
52
|
});
|
|
32
53
|
|
|
33
54
|
describe('initialization', () => {
|
|
@@ -95,303 +116,491 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
95
116
|
|
|
96
117
|
describe('detectAgents', () => {
|
|
97
118
|
it('should return empty array if no claude processes running', async () => {
|
|
98
|
-
|
|
119
|
+
mockedListAgentProcesses.mockReturnValue([]);
|
|
99
120
|
|
|
100
121
|
const agents = await adapter.detectAgents();
|
|
101
122
|
expect(agents).toEqual([]);
|
|
123
|
+
expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude');
|
|
102
124
|
});
|
|
103
125
|
|
|
104
|
-
it('should
|
|
105
|
-
const
|
|
126
|
+
it('should return process-only agents when no sessions discovered', async () => {
|
|
127
|
+
const processes: ProcessInfo[] = [
|
|
128
|
+
{ pid: 777, command: 'claude', cwd: '/project/app', tty: 'ttys001' },
|
|
129
|
+
];
|
|
130
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
131
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
132
|
+
|
|
133
|
+
// No projects dir → discoverSessions returns []
|
|
134
|
+
(adapter as any).projectsDir = '/nonexistent/path';
|
|
135
|
+
|
|
136
|
+
const agents = await adapter.detectAgents();
|
|
137
|
+
expect(agents).toHaveLength(1);
|
|
138
|
+
expect(agents[0]).toMatchObject({
|
|
139
|
+
type: 'claude',
|
|
140
|
+
status: AgentStatus.IDLE,
|
|
141
|
+
pid: 777,
|
|
142
|
+
projectPath: '/project/app',
|
|
143
|
+
sessionId: 'pid-777',
|
|
144
|
+
summary: 'Unknown',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should detect agents with matched sessions', async () => {
|
|
149
|
+
const processes: ProcessInfo[] = [
|
|
106
150
|
{
|
|
107
151
|
pid: 12345,
|
|
108
|
-
command: 'claude
|
|
152
|
+
command: 'claude',
|
|
109
153
|
cwd: '/Users/test/my-project',
|
|
110
154
|
tty: 'ttys001',
|
|
155
|
+
startTime: new Date('2026-03-18T23:18:01.000Z'),
|
|
111
156
|
},
|
|
112
157
|
];
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
159
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
160
|
+
|
|
161
|
+
// Set up projects dir with encoded directory name
|
|
162
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
163
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
164
|
+
// Claude encodes /Users/test/my-project → -Users-test-my-project
|
|
165
|
+
const projDir = path.join(projectsDir, '-Users-test-my-project');
|
|
166
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
167
|
+
|
|
168
|
+
// Create session file
|
|
169
|
+
const sessionFile = path.join(projDir, 'session-1.jsonl');
|
|
170
|
+
fs.writeFileSync(sessionFile, [
|
|
171
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-18T23:18:44Z', cwd: '/Users/test/my-project', message: { content: 'Investigate failing tests' } }),
|
|
172
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
173
|
+
].join('\n'));
|
|
174
|
+
|
|
175
|
+
(adapter as any).projectsDir = projectsDir;
|
|
176
|
+
|
|
177
|
+
const sessionFiles: SessionFile[] = [
|
|
115
178
|
{
|
|
116
179
|
sessionId: 'session-1',
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
lastEntryType: 'assistant',
|
|
122
|
-
isInterrupted: false,
|
|
123
|
-
lastUserMessage: 'Investigate failing tests in package',
|
|
180
|
+
filePath: sessionFile,
|
|
181
|
+
projectDir: projDir,
|
|
182
|
+
birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(),
|
|
183
|
+
resolvedCwd: '',
|
|
124
184
|
},
|
|
125
185
|
];
|
|
186
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
126
187
|
|
|
127
|
-
|
|
128
|
-
|
|
188
|
+
const matches: MatchResult[] = [
|
|
189
|
+
{
|
|
190
|
+
process: processes[0],
|
|
191
|
+
session: { ...sessionFiles[0], resolvedCwd: '/Users/test/my-project' },
|
|
192
|
+
deltaMs: 43000,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
129
196
|
|
|
130
197
|
const agents = await adapter.detectAgents();
|
|
131
198
|
|
|
132
199
|
expect(agents).toHaveLength(1);
|
|
133
200
|
expect(agents[0]).toMatchObject({
|
|
134
|
-
name: 'my-project',
|
|
135
201
|
type: 'claude',
|
|
136
202
|
status: AgentStatus.WAITING,
|
|
137
203
|
pid: 12345,
|
|
138
204
|
projectPath: '/Users/test/my-project',
|
|
139
205
|
sessionId: 'session-1',
|
|
140
|
-
slug: 'merry-dog',
|
|
141
206
|
});
|
|
142
|
-
expect(agents[0].summary).toContain('Investigate failing tests
|
|
207
|
+
expect(agents[0].summary).toContain('Investigate failing tests');
|
|
208
|
+
|
|
209
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
210
|
});
|
|
144
211
|
|
|
145
|
-
it('should
|
|
146
|
-
|
|
212
|
+
it('should fall back to process-only for unmatched processes', async () => {
|
|
213
|
+
const processes: ProcessInfo[] = [
|
|
214
|
+
{ pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() },
|
|
215
|
+
{ pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() },
|
|
216
|
+
];
|
|
217
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
218
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
219
|
+
|
|
220
|
+
// Set up projects dir with encoded directory names
|
|
221
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
222
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
223
|
+
// /project-a → -project-a, /project-b → -project-b
|
|
224
|
+
const projDirA = path.join(projectsDir, '-project-a');
|
|
225
|
+
const projDirB = path.join(projectsDir, '-project-b');
|
|
226
|
+
fs.mkdirSync(projDirA, { recursive: true });
|
|
227
|
+
fs.mkdirSync(projDirB, { recursive: true });
|
|
228
|
+
|
|
229
|
+
const sessionFile = path.join(projDirA, 'only-session.jsonl');
|
|
230
|
+
fs.writeFileSync(sessionFile,
|
|
231
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
(adapter as any).projectsDir = projectsDir;
|
|
235
|
+
|
|
236
|
+
const sessionFiles: SessionFile[] = [
|
|
147
237
|
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
238
|
+
sessionId: 'only-session',
|
|
239
|
+
filePath: sessionFile,
|
|
240
|
+
projectDir: projDirA,
|
|
241
|
+
birthtimeMs: Date.now(),
|
|
242
|
+
resolvedCwd: '',
|
|
152
243
|
},
|
|
153
|
-
]
|
|
154
|
-
|
|
244
|
+
];
|
|
245
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
155
246
|
|
|
247
|
+
// Only process 100 matches
|
|
248
|
+
const matches: MatchResult[] = [
|
|
249
|
+
{
|
|
250
|
+
process: processes[0],
|
|
251
|
+
session: { ...sessionFiles[0], resolvedCwd: '/project-a' },
|
|
252
|
+
deltaMs: 5000,
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
156
256
|
|
|
157
257
|
const agents = await adapter.detectAgents();
|
|
158
|
-
expect(agents).toHaveLength(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
258
|
+
expect(agents).toHaveLength(2);
|
|
259
|
+
|
|
260
|
+
const matched = agents.find(a => a.pid === 100);
|
|
261
|
+
const unmatched = agents.find(a => a.pid === 200);
|
|
262
|
+
expect(matched?.sessionId).toBe('only-session');
|
|
263
|
+
expect(unmatched?.sessionId).toBe('pid-200');
|
|
264
|
+
expect(unmatched?.status).toBe(AgentStatus.IDLE);
|
|
265
|
+
|
|
266
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
167
267
|
});
|
|
168
268
|
|
|
169
|
-
it('should
|
|
170
|
-
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
tty: 'ttys008',
|
|
176
|
-
},
|
|
177
|
-
]);
|
|
178
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
|
|
179
|
-
{
|
|
180
|
-
sessionId: 'session-2',
|
|
181
|
-
projectPath: '/other/project',
|
|
182
|
-
sessionStart: new Date(),
|
|
183
|
-
lastActive: new Date(),
|
|
184
|
-
lastEntryType: 'assistant',
|
|
185
|
-
isInterrupted: false,
|
|
186
|
-
},
|
|
187
|
-
]);
|
|
269
|
+
it('should handle process with empty cwd in process-only fallback', async () => {
|
|
270
|
+
const processes: ProcessInfo[] = [
|
|
271
|
+
{ pid: 300, command: 'claude', cwd: '', tty: 'ttys003' },
|
|
272
|
+
];
|
|
273
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
274
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
188
275
|
|
|
276
|
+
(adapter as any).projectsDir = '/nonexistent';
|
|
189
277
|
|
|
190
278
|
const agents = await adapter.detectAgents();
|
|
191
279
|
expect(agents).toHaveLength(1);
|
|
192
|
-
// Unrelated session should NOT match — falls to process-only
|
|
193
280
|
expect(agents[0]).toMatchObject({
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
projectPath: '
|
|
198
|
-
status: AgentStatus.IDLE,
|
|
281
|
+
pid: 300,
|
|
282
|
+
sessionId: 'pid-300',
|
|
283
|
+
summary: 'Unknown',
|
|
284
|
+
projectPath: '',
|
|
199
285
|
});
|
|
200
286
|
});
|
|
201
287
|
|
|
202
|
-
it('should
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
},
|
|
221
|
-
|
|
288
|
+
it('should use PID file for direct match and skip legacy matching for that process', async () => {
|
|
289
|
+
const startTime = new Date();
|
|
290
|
+
const processes: ProcessInfo[] = [
|
|
291
|
+
{ pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime },
|
|
292
|
+
];
|
|
293
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
294
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
295
|
+
|
|
296
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
|
|
297
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
298
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
299
|
+
const projDir = path.join(projectsDir, '-project-direct');
|
|
300
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
301
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
302
|
+
|
|
303
|
+
const sessionId = 'pid-file-session';
|
|
304
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
305
|
+
fs.writeFileSync(jsonlPath, [
|
|
306
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }),
|
|
307
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
308
|
+
].join('\n'));
|
|
309
|
+
|
|
310
|
+
fs.writeFileSync(
|
|
311
|
+
path.join(sessionsDir, '55001.json'),
|
|
312
|
+
JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
316
|
+
(adapter as any).projectsDir = projectsDir;
|
|
222
317
|
|
|
223
318
|
const agents = await adapter.detectAgents();
|
|
319
|
+
|
|
320
|
+
// Legacy matching utilities should NOT have been called (all processes matched via PID file)
|
|
321
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
322
|
+
expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
|
|
323
|
+
|
|
224
324
|
expect(agents).toHaveLength(1);
|
|
225
325
|
expect(agents[0]).toMatchObject({
|
|
226
326
|
type: 'claude',
|
|
227
|
-
pid:
|
|
228
|
-
sessionId
|
|
229
|
-
projectPath: '/
|
|
327
|
+
pid: 55001,
|
|
328
|
+
sessionId,
|
|
329
|
+
projectPath: '/project/direct',
|
|
330
|
+
status: AgentStatus.WAITING,
|
|
230
331
|
});
|
|
332
|
+
expect(agents[0].summary).toContain('hello from pid file');
|
|
333
|
+
|
|
334
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
231
335
|
});
|
|
232
336
|
|
|
233
|
-
it('should
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
337
|
+
it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
|
|
338
|
+
const startTime = new Date();
|
|
339
|
+
const processes: ProcessInfo[] = [
|
|
340
|
+
{ pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime },
|
|
341
|
+
];
|
|
342
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
343
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
344
|
+
|
|
345
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
|
|
346
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
347
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
348
|
+
const projDir = path.join(projectsDir, '-project-gone');
|
|
349
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
350
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
351
|
+
|
|
352
|
+
const sessionId = 'gone-session';
|
|
353
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
354
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
355
|
+
fs.writeFileSync(
|
|
356
|
+
path.join(sessionsDir, '66001.json'),
|
|
357
|
+
JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
361
|
+
(adapter as any).projectsDir = projectsDir;
|
|
362
|
+
|
|
363
|
+
// Simulate JSONL disappearing between existence check and read
|
|
364
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
243
365
|
|
|
244
366
|
const agents = await adapter.detectAgents();
|
|
367
|
+
|
|
368
|
+
// matchedPids.delete called → process falls back to IDLE
|
|
245
369
|
expect(agents).toHaveLength(1);
|
|
246
|
-
expect(agents[0]).
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
summary: 'Unknown',
|
|
252
|
-
status: AgentStatus.IDLE,
|
|
253
|
-
});
|
|
370
|
+
expect(agents[0].sessionId).toBe('pid-66001');
|
|
371
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
372
|
+
|
|
373
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
374
|
+
jest.restoreAllMocks();
|
|
254
375
|
});
|
|
255
376
|
|
|
256
|
-
it('should
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
377
|
+
it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
|
|
378
|
+
const startTime = new Date();
|
|
379
|
+
const processes: ProcessInfo[] = [
|
|
380
|
+
{ pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime },
|
|
381
|
+
];
|
|
382
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
383
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
384
|
+
|
|
385
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
|
|
386
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
387
|
+
const projDir = path.join(projectsDir, '-project-legacy-gone');
|
|
388
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
389
|
+
|
|
390
|
+
const sessionId = 'legacy-gone-session';
|
|
391
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
392
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
393
|
+
|
|
394
|
+
// No PID file → process goes to legacy fallback
|
|
395
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions');
|
|
396
|
+
(adapter as any).projectsDir = projectsDir;
|
|
397
|
+
|
|
398
|
+
const legacySessionFile = {
|
|
399
|
+
sessionId,
|
|
400
|
+
filePath: jsonlPath,
|
|
401
|
+
projectDir: projDir,
|
|
402
|
+
birthtimeMs: startTime.getTime(),
|
|
403
|
+
resolvedCwd: '/project/legacy-gone',
|
|
404
|
+
};
|
|
405
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
406
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
407
|
+
{ process: processes[0], session: legacySessionFile, deltaMs: 500 },
|
|
275
408
|
]);
|
|
276
409
|
|
|
410
|
+
// Simulate JSONL disappearing between match and read
|
|
411
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
412
|
+
|
|
277
413
|
const agents = await adapter.detectAgents();
|
|
414
|
+
|
|
278
415
|
expect(agents).toHaveLength(1);
|
|
279
|
-
|
|
280
|
-
expect(agents[0]).
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
projectPath: '/Users/test/my-project',
|
|
285
|
-
});
|
|
416
|
+
expect(agents[0].sessionId).toBe('pid-66002');
|
|
417
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
418
|
+
|
|
419
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
420
|
+
jest.restoreAllMocks();
|
|
286
421
|
});
|
|
287
422
|
|
|
288
|
-
it('should
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
},
|
|
423
|
+
it('should mix direct PID-file matches and legacy matches across processes', async () => {
|
|
424
|
+
const startTime = new Date();
|
|
425
|
+
const processes: ProcessInfo[] = [
|
|
426
|
+
{ pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime },
|
|
427
|
+
{ pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime },
|
|
428
|
+
];
|
|
429
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
430
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
431
|
+
|
|
432
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
|
|
433
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
434
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
435
|
+
const projAlpha = path.join(projectsDir, '-project-alpha');
|
|
436
|
+
const projBeta = path.join(projectsDir, '-project-beta');
|
|
437
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
438
|
+
fs.mkdirSync(projAlpha, { recursive: true });
|
|
439
|
+
fs.mkdirSync(projBeta, { recursive: true });
|
|
440
|
+
|
|
441
|
+
// PID file only for process 55002
|
|
442
|
+
const directSessionId = 'direct-session';
|
|
443
|
+
const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
|
|
444
|
+
fs.writeFileSync(directJsonl, [
|
|
445
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }),
|
|
446
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
447
|
+
].join('\n'));
|
|
448
|
+
fs.writeFileSync(
|
|
449
|
+
path.join(sessionsDir, '55002.json'),
|
|
450
|
+
JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Legacy session file for process 55003
|
|
454
|
+
const legacySessionId = 'legacy-session';
|
|
455
|
+
const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
|
|
456
|
+
fs.writeFileSync(legacyJsonl, [
|
|
457
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }),
|
|
458
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
459
|
+
].join('\n'));
|
|
460
|
+
|
|
461
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
462
|
+
(adapter as any).projectsDir = projectsDir;
|
|
463
|
+
|
|
464
|
+
// Mock legacy matching for process 55003
|
|
465
|
+
const legacySessionFile = {
|
|
466
|
+
sessionId: legacySessionId,
|
|
467
|
+
filePath: legacyJsonl,
|
|
468
|
+
projectDir: projBeta,
|
|
469
|
+
birthtimeMs: startTime.getTime(),
|
|
470
|
+
resolvedCwd: '/project/beta',
|
|
471
|
+
};
|
|
472
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
473
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
474
|
+
{ process: processes[1], session: legacySessionFile, deltaMs: 1000 },
|
|
312
475
|
]);
|
|
313
476
|
|
|
314
|
-
|
|
315
477
|
const agents = await adapter.detectAgents();
|
|
478
|
+
|
|
479
|
+
// Legacy matching called only for fallback process (55003)
|
|
480
|
+
expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
|
|
481
|
+
expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]);
|
|
482
|
+
|
|
316
483
|
expect(agents).toHaveLength(2);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
expect(agents[1]).toMatchObject({
|
|
324
|
-
pid: 200,
|
|
325
|
-
sessionId: 'pid-200',
|
|
326
|
-
status: AgentStatus.IDLE,
|
|
327
|
-
summary: 'Unknown',
|
|
328
|
-
});
|
|
484
|
+
const alpha = agents.find(a => a.pid === 55002);
|
|
485
|
+
const beta = agents.find(a => a.pid === 55003);
|
|
486
|
+
expect(alpha?.sessionId).toBe(directSessionId);
|
|
487
|
+
expect(beta?.sessionId).toBe(legacySessionId);
|
|
488
|
+
|
|
489
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
329
490
|
});
|
|
491
|
+
});
|
|
330
492
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
{
|
|
334
|
-
pid: 300,
|
|
335
|
-
command: 'claude',
|
|
336
|
-
cwd: '',
|
|
337
|
-
tty: 'ttys003',
|
|
338
|
-
},
|
|
339
|
-
]);
|
|
340
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
|
|
493
|
+
describe('discoverSessions', () => {
|
|
494
|
+
let tmpDir: string;
|
|
341
495
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
expect(agents[0]).toMatchObject({
|
|
345
|
-
pid: 300,
|
|
346
|
-
sessionId: 'pid-300',
|
|
347
|
-
summary: 'Unknown',
|
|
348
|
-
projectPath: '',
|
|
349
|
-
});
|
|
496
|
+
beforeEach(() => {
|
|
497
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
350
498
|
});
|
|
351
499
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
500
|
+
afterEach(() => {
|
|
501
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should return empty when projects dir does not exist', () => {
|
|
505
|
+
(adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
|
|
506
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
507
|
+
|
|
508
|
+
const result = discoverSessions([
|
|
509
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
361
510
|
]);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
511
|
+
expect(result).toEqual([]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('should scan only directories matching process CWDs', () => {
|
|
515
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
516
|
+
(adapter as any).projectsDir = projectsDir;
|
|
517
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
518
|
+
|
|
519
|
+
// /my/project → -my-project (encoded dir)
|
|
520
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
521
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
522
|
+
|
|
523
|
+
// Also create another dir that should NOT be scanned
|
|
524
|
+
const otherDir = path.join(projectsDir, '-other-project');
|
|
525
|
+
fs.mkdirSync(otherDir, { recursive: true });
|
|
526
|
+
|
|
527
|
+
const mockFiles: SessionFile[] = [
|
|
371
528
|
{
|
|
372
|
-
sessionId: '
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
isInterrupted: false,
|
|
529
|
+
sessionId: 's1',
|
|
530
|
+
filePath: path.join(encodedDir, 's1.jsonl'),
|
|
531
|
+
projectDir: encodedDir,
|
|
532
|
+
birthtimeMs: 1710800324000,
|
|
533
|
+
resolvedCwd: '',
|
|
378
534
|
},
|
|
535
|
+
];
|
|
536
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
|
|
537
|
+
|
|
538
|
+
const processes = [
|
|
539
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
const result = discoverSessions(processes);
|
|
543
|
+
expect(result).toHaveLength(1);
|
|
544
|
+
expect(result[0].resolvedCwd).toBe('/my/project');
|
|
545
|
+
// batchGetSessionFileBirthtimes called once with all dirs
|
|
546
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
547
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should return empty when encoded dir does not exist', () => {
|
|
551
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
552
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
553
|
+
(adapter as any).projectsDir = projectsDir;
|
|
554
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
555
|
+
|
|
556
|
+
// Process CWD /test encodes to -test, but that dir doesn't exist
|
|
557
|
+
const result = discoverSessions([
|
|
558
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
379
559
|
]);
|
|
560
|
+
expect(result).toEqual([]);
|
|
561
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
562
|
+
});
|
|
380
563
|
|
|
564
|
+
it('should deduplicate when multiple processes share same CWD', () => {
|
|
565
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
566
|
+
(adapter as any).projectsDir = projectsDir;
|
|
567
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
381
568
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
569
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
570
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
571
|
+
|
|
572
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
573
|
+
{ sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
|
|
574
|
+
]);
|
|
575
|
+
|
|
576
|
+
const processes = [
|
|
577
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
578
|
+
{ pid: 2, command: 'claude', cwd: '/my/project', tty: '' },
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
const result = discoverSessions(processes);
|
|
582
|
+
// Should only call batch once with deduplicated dir
|
|
583
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
584
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
585
|
+
expect(result).toHaveLength(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('should skip processes with empty cwd', () => {
|
|
589
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
590
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
591
|
+
(adapter as any).projectsDir = projectsDir;
|
|
592
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
593
|
+
|
|
594
|
+
const result = discoverSessions([
|
|
595
|
+
{ pid: 1, command: 'claude', cwd: '', tty: '' },
|
|
596
|
+
]);
|
|
597
|
+
expect(result).toEqual([]);
|
|
388
598
|
});
|
|
389
599
|
});
|
|
390
600
|
|
|
391
601
|
describe('helper methods', () => {
|
|
392
602
|
describe('determineStatus', () => {
|
|
393
603
|
it('should return "unknown" for sessions with no last entry type', () => {
|
|
394
|
-
const adapter = new ClaudeCodeAdapter();
|
|
395
604
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
396
605
|
|
|
397
606
|
const session = {
|
|
@@ -402,12 +611,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
402
611
|
isInterrupted: false,
|
|
403
612
|
};
|
|
404
613
|
|
|
405
|
-
|
|
406
|
-
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
614
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
407
615
|
});
|
|
408
616
|
|
|
409
617
|
it('should return "waiting" for assistant entries', () => {
|
|
410
|
-
const adapter = new ClaudeCodeAdapter();
|
|
411
618
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
412
619
|
|
|
413
620
|
const session = {
|
|
@@ -419,12 +626,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
419
626
|
isInterrupted: false,
|
|
420
627
|
};
|
|
421
628
|
|
|
422
|
-
|
|
423
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
629
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
424
630
|
});
|
|
425
631
|
|
|
426
632
|
it('should return "waiting" for user interruption', () => {
|
|
427
|
-
const adapter = new ClaudeCodeAdapter();
|
|
428
633
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
429
634
|
|
|
430
635
|
const session = {
|
|
@@ -436,12 +641,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
436
641
|
isInterrupted: true,
|
|
437
642
|
};
|
|
438
643
|
|
|
439
|
-
|
|
440
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
644
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
441
645
|
});
|
|
442
646
|
|
|
443
647
|
it('should return "running" for user/progress entries', () => {
|
|
444
|
-
const adapter = new ClaudeCodeAdapter();
|
|
445
648
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
446
649
|
|
|
447
650
|
const session = {
|
|
@@ -453,16 +656,13 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
453
656
|
isInterrupted: false,
|
|
454
657
|
};
|
|
455
658
|
|
|
456
|
-
|
|
457
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
659
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
458
660
|
});
|
|
459
661
|
|
|
460
662
|
it('should not override status based on age (process is running)', () => {
|
|
461
|
-
const adapter = new ClaudeCodeAdapter();
|
|
462
663
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
463
664
|
|
|
464
665
|
const oldDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
465
|
-
|
|
466
666
|
const session = {
|
|
467
667
|
sessionId: 'test',
|
|
468
668
|
projectPath: '/test',
|
|
@@ -472,14 +672,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
472
672
|
isInterrupted: false,
|
|
473
673
|
};
|
|
474
674
|
|
|
475
|
-
|
|
476
|
-
// because the process is known to be running
|
|
477
|
-
const status = determineStatus(session);
|
|
478
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
675
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
479
676
|
});
|
|
480
677
|
|
|
481
678
|
it('should return "idle" for system entries', () => {
|
|
482
|
-
const adapter = new ClaudeCodeAdapter();
|
|
483
679
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
484
680
|
|
|
485
681
|
const session = {
|
|
@@ -491,12 +687,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
491
687
|
isInterrupted: false,
|
|
492
688
|
};
|
|
493
689
|
|
|
494
|
-
|
|
495
|
-
expect(status).toBe(AgentStatus.IDLE);
|
|
690
|
+
expect(determineStatus(session)).toBe(AgentStatus.IDLE);
|
|
496
691
|
});
|
|
497
692
|
|
|
498
693
|
it('should return "running" for thinking entries', () => {
|
|
499
|
-
const adapter = new ClaudeCodeAdapter();
|
|
500
694
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
501
695
|
|
|
502
696
|
const session = {
|
|
@@ -508,12 +702,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
508
702
|
isInterrupted: false,
|
|
509
703
|
};
|
|
510
704
|
|
|
511
|
-
|
|
512
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
705
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
513
706
|
});
|
|
514
707
|
|
|
515
708
|
it('should return "running" for progress entries', () => {
|
|
516
|
-
const adapter = new ClaudeCodeAdapter();
|
|
517
709
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
518
710
|
|
|
519
711
|
const session = {
|
|
@@ -525,12 +717,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
525
717
|
isInterrupted: false,
|
|
526
718
|
};
|
|
527
719
|
|
|
528
|
-
|
|
529
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
720
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
530
721
|
});
|
|
531
722
|
|
|
532
723
|
it('should return "unknown" for unrecognized entry types', () => {
|
|
533
|
-
const adapter = new ClaudeCodeAdapter();
|
|
534
724
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
535
725
|
|
|
536
726
|
const session = {
|
|
@@ -542,380 +732,17 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
542
732
|
isInterrupted: false,
|
|
543
733
|
};
|
|
544
734
|
|
|
545
|
-
|
|
546
|
-
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
547
|
-
});
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
describe('generateAgentName', () => {
|
|
551
|
-
it('should use project name for first session', () => {
|
|
552
|
-
const adapter = new ClaudeCodeAdapter();
|
|
553
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
554
|
-
|
|
555
|
-
const session = {
|
|
556
|
-
sessionId: 'test-123',
|
|
557
|
-
projectPath: '/Users/test/my-project',
|
|
558
|
-
sessionStart: new Date(),
|
|
559
|
-
lastActive: new Date(),
|
|
560
|
-
isInterrupted: false,
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
const name = generateAgentName(session, []);
|
|
564
|
-
expect(name).toBe('my-project');
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
it('should append slug for duplicate projects', () => {
|
|
568
|
-
const adapter = new ClaudeCodeAdapter();
|
|
569
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
570
|
-
|
|
571
|
-
const existingAgent: AgentInfo = {
|
|
572
|
-
name: 'my-project',
|
|
573
|
-
projectPath: '/Users/test/my-project',
|
|
574
|
-
type: 'claude',
|
|
575
|
-
status: AgentStatus.RUNNING,
|
|
576
|
-
summary: 'Test',
|
|
577
|
-
pid: 123,
|
|
578
|
-
sessionId: 'existing-123',
|
|
579
|
-
slug: 'happy-cat',
|
|
580
|
-
lastActive: new Date(),
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
const session = {
|
|
584
|
-
sessionId: 'test-456',
|
|
585
|
-
projectPath: '/Users/test/my-project',
|
|
586
|
-
slug: 'merry-dog',
|
|
587
|
-
sessionStart: new Date(),
|
|
588
|
-
lastActive: new Date(),
|
|
589
|
-
isInterrupted: false,
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const name = generateAgentName(session, [existingAgent]);
|
|
593
|
-
expect(name).toBe('my-project (merry)');
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it('should use session ID prefix when no slug available', () => {
|
|
597
|
-
const adapter = new ClaudeCodeAdapter();
|
|
598
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
599
|
-
|
|
600
|
-
const existingAgent: AgentInfo = {
|
|
601
|
-
name: 'my-project',
|
|
602
|
-
projectPath: '/Users/test/my-project',
|
|
603
|
-
type: 'claude',
|
|
604
|
-
status: AgentStatus.RUNNING,
|
|
605
|
-
summary: 'Test',
|
|
606
|
-
pid: 123,
|
|
607
|
-
sessionId: 'existing-123',
|
|
608
|
-
lastActive: new Date(),
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
const session = {
|
|
612
|
-
sessionId: 'abcdef12-3456-7890',
|
|
613
|
-
projectPath: '/Users/test/my-project',
|
|
614
|
-
sessionStart: new Date(),
|
|
615
|
-
lastActive: new Date(),
|
|
616
|
-
isInterrupted: false,
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
const name = generateAgentName(session, [existingAgent]);
|
|
620
|
-
expect(name).toBe('my-project (abcdef12)');
|
|
621
|
-
});
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
describe('parseElapsedSeconds', () => {
|
|
625
|
-
it('should parse MM:SS format', () => {
|
|
626
|
-
const adapter = new ClaudeCodeAdapter();
|
|
627
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
628
|
-
|
|
629
|
-
expect(parseElapsedSeconds('05:30')).toBe(330);
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
it('should parse HH:MM:SS format', () => {
|
|
633
|
-
const adapter = new ClaudeCodeAdapter();
|
|
634
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
635
|
-
|
|
636
|
-
expect(parseElapsedSeconds('02:30:15')).toBe(9015);
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it('should parse D-HH:MM:SS format', () => {
|
|
640
|
-
const adapter = new ClaudeCodeAdapter();
|
|
641
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
642
|
-
|
|
643
|
-
expect(parseElapsedSeconds('3-12:00:00')).toBe(302400);
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
it('should return null for invalid format', () => {
|
|
647
|
-
const adapter = new ClaudeCodeAdapter();
|
|
648
|
-
const parseElapsedSeconds = (adapter as any).parseElapsedSeconds.bind(adapter);
|
|
649
|
-
|
|
650
|
-
expect(parseElapsedSeconds('invalid')).toBeNull();
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
describe('calculateSessionScanLimit', () => {
|
|
655
|
-
it('should return minimum for small process count', () => {
|
|
656
|
-
const adapter = new ClaudeCodeAdapter();
|
|
657
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
658
|
-
|
|
659
|
-
// 1 process * 4 = 4, min(max(4, 12), 40) = 12
|
|
660
|
-
expect(calculateSessionScanLimit(1)).toBe(12);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('should scale with process count', () => {
|
|
664
|
-
const adapter = new ClaudeCodeAdapter();
|
|
665
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
666
|
-
|
|
667
|
-
// 5 processes * 4 = 20, min(max(20, 12), 40) = 20
|
|
668
|
-
expect(calculateSessionScanLimit(5)).toBe(20);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
it('should cap at maximum', () => {
|
|
672
|
-
const adapter = new ClaudeCodeAdapter();
|
|
673
|
-
const calculateSessionScanLimit = (adapter as any).calculateSessionScanLimit.bind(adapter);
|
|
674
|
-
|
|
675
|
-
// 15 processes * 4 = 60, min(max(60, 12), 40) = 40
|
|
676
|
-
expect(calculateSessionScanLimit(15)).toBe(40);
|
|
677
|
-
});
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
describe('rankCandidatesByStartTime', () => {
|
|
681
|
-
it('should prefer sessions within tolerance window', () => {
|
|
682
|
-
const adapter = new ClaudeCodeAdapter();
|
|
683
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
684
|
-
|
|
685
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
686
|
-
const candidates = [
|
|
687
|
-
{
|
|
688
|
-
sessionId: 'far',
|
|
689
|
-
projectPath: '/test',
|
|
690
|
-
sessionStart: new Date('2026-03-10T09:50:00Z'), // 10 min diff
|
|
691
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
692
|
-
isInterrupted: false,
|
|
693
|
-
},
|
|
694
|
-
{
|
|
695
|
-
sessionId: 'close',
|
|
696
|
-
projectPath: '/test',
|
|
697
|
-
sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s diff
|
|
698
|
-
lastActive: new Date('2026-03-10T10:03:00Z'),
|
|
699
|
-
isInterrupted: false,
|
|
700
|
-
},
|
|
701
|
-
];
|
|
702
|
-
|
|
703
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
704
|
-
expect(ranked[0].sessionId).toBe('close');
|
|
705
|
-
expect(ranked[1].sessionId).toBe('far');
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it('should prefer recency over diffMs when both within tolerance', () => {
|
|
709
|
-
const adapter = new ClaudeCodeAdapter();
|
|
710
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
711
|
-
|
|
712
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
713
|
-
const candidates = [
|
|
714
|
-
{
|
|
715
|
-
sessionId: 'closer-but-stale',
|
|
716
|
-
projectPath: '/test',
|
|
717
|
-
sessionStart: new Date('2026-03-10T10:00:06Z'), // 6s diff
|
|
718
|
-
lastActive: new Date('2026-03-10T10:00:10Z'), // older activity
|
|
719
|
-
isInterrupted: false,
|
|
720
|
-
},
|
|
721
|
-
{
|
|
722
|
-
sessionId: 'farther-but-active',
|
|
723
|
-
projectPath: '/test',
|
|
724
|
-
sessionStart: new Date('2026-03-10T10:00:45Z'), // 45s diff
|
|
725
|
-
lastActive: new Date('2026-03-10T10:30:00Z'), // much more recent
|
|
726
|
-
isInterrupted: false,
|
|
727
|
-
},
|
|
728
|
-
];
|
|
729
|
-
|
|
730
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
731
|
-
// Both within tolerance — recency wins over smaller diffMs
|
|
732
|
-
expect(ranked[0].sessionId).toBe('farther-but-active');
|
|
733
|
-
expect(ranked[1].sessionId).toBe('closer-but-stale');
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it('should break ties by recency when outside tolerance with same diffMs', () => {
|
|
737
|
-
const adapter = new ClaudeCodeAdapter();
|
|
738
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
739
|
-
|
|
740
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
741
|
-
const candidates = [
|
|
742
|
-
{
|
|
743
|
-
sessionId: 'older-activity',
|
|
744
|
-
projectPath: '/test',
|
|
745
|
-
sessionStart: new Date('2026-03-10T09:50:00Z'), // 10min diff
|
|
746
|
-
lastActive: new Date('2026-03-10T10:01:00Z'),
|
|
747
|
-
isInterrupted: false,
|
|
748
|
-
},
|
|
749
|
-
{
|
|
750
|
-
sessionId: 'newer-activity',
|
|
751
|
-
projectPath: '/test',
|
|
752
|
-
sessionStart: new Date('2026-03-10T10:10:00Z'), // 10min diff (same abs)
|
|
753
|
-
lastActive: new Date('2026-03-10T10:30:00Z'),
|
|
754
|
-
isInterrupted: false,
|
|
755
|
-
},
|
|
756
|
-
];
|
|
757
|
-
|
|
758
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
759
|
-
// Both outside tolerance, same diffMs — recency wins
|
|
760
|
-
expect(ranked[0].sessionId).toBe('newer-activity');
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
it('should fall back to recency when both outside tolerance', () => {
|
|
764
|
-
const adapter = new ClaudeCodeAdapter();
|
|
765
|
-
const rankCandidatesByStartTime = (adapter as any).rankCandidatesByStartTime.bind(adapter);
|
|
766
|
-
|
|
767
|
-
const processStart = new Date('2026-03-10T10:00:00Z');
|
|
768
|
-
const candidates = [
|
|
769
|
-
{
|
|
770
|
-
sessionId: 'older',
|
|
771
|
-
projectPath: '/test',
|
|
772
|
-
sessionStart: new Date('2026-03-10T09:30:00Z'),
|
|
773
|
-
lastActive: new Date('2026-03-10T10:01:00Z'),
|
|
774
|
-
isInterrupted: false,
|
|
775
|
-
},
|
|
776
|
-
{
|
|
777
|
-
sessionId: 'newer',
|
|
778
|
-
projectPath: '/test',
|
|
779
|
-
sessionStart: new Date('2026-03-10T09:40:00Z'),
|
|
780
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
781
|
-
isInterrupted: false,
|
|
782
|
-
},
|
|
783
|
-
];
|
|
784
|
-
|
|
785
|
-
const ranked = rankCandidatesByStartTime(candidates, processStart);
|
|
786
|
-
// Both outside tolerance (rank=1), newer has smaller diffMs
|
|
787
|
-
expect(ranked[0].sessionId).toBe('newer');
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
describe('filterCandidateSessions', () => {
|
|
792
|
-
it('should match by lastCwd in cwd mode', () => {
|
|
793
|
-
const adapter = new ClaudeCodeAdapter();
|
|
794
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
795
|
-
|
|
796
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
797
|
-
const sessions = [
|
|
798
|
-
{
|
|
799
|
-
sessionId: 's1',
|
|
800
|
-
projectPath: '/different/path',
|
|
801
|
-
lastCwd: '/my/project',
|
|
802
|
-
sessionStart: new Date(),
|
|
803
|
-
lastActive: new Date(),
|
|
804
|
-
isInterrupted: false,
|
|
805
|
-
},
|
|
806
|
-
];
|
|
807
|
-
|
|
808
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'cwd');
|
|
809
|
-
expect(result).toHaveLength(1);
|
|
810
|
-
expect(result[0].sessionId).toBe('s1');
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
it('should match sessions with no projectPath in missing-cwd mode', () => {
|
|
814
|
-
const adapter = new ClaudeCodeAdapter();
|
|
815
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
816
|
-
|
|
817
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
818
|
-
const sessions = [
|
|
819
|
-
{
|
|
820
|
-
sessionId: 's1',
|
|
821
|
-
projectPath: '',
|
|
822
|
-
sessionStart: new Date(),
|
|
823
|
-
lastActive: new Date(),
|
|
824
|
-
isInterrupted: false,
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
sessionId: 's2',
|
|
828
|
-
projectPath: '/has/path',
|
|
829
|
-
sessionStart: new Date(),
|
|
830
|
-
lastActive: new Date(),
|
|
831
|
-
isInterrupted: false,
|
|
832
|
-
},
|
|
833
|
-
];
|
|
834
|
-
|
|
835
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'missing-cwd');
|
|
836
|
-
expect(result).toHaveLength(1);
|
|
837
|
-
expect(result[0].sessionId).toBe('s1');
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
it('should include exact CWD matches in parent-child mode', () => {
|
|
841
|
-
const adapter = new ClaudeCodeAdapter();
|
|
842
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
843
|
-
|
|
844
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
845
|
-
const sessions = [
|
|
846
|
-
{
|
|
847
|
-
sessionId: 's1',
|
|
848
|
-
projectPath: '/my/project',
|
|
849
|
-
lastCwd: '/my/project',
|
|
850
|
-
sessionStart: new Date(),
|
|
851
|
-
lastActive: new Date(),
|
|
852
|
-
isInterrupted: false,
|
|
853
|
-
},
|
|
854
|
-
];
|
|
855
|
-
|
|
856
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
|
|
857
|
-
expect(result).toHaveLength(1);
|
|
858
|
-
expect(result[0].sessionId).toBe('s1');
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
it('should match parent-child relationships', () => {
|
|
862
|
-
const adapter = new ClaudeCodeAdapter();
|
|
863
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
864
|
-
|
|
865
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
866
|
-
const sessions = [
|
|
867
|
-
{
|
|
868
|
-
sessionId: 'child-session',
|
|
869
|
-
projectPath: '/my/project/packages/sub',
|
|
870
|
-
lastCwd: '/my/project/packages/sub',
|
|
871
|
-
sessionStart: new Date(),
|
|
872
|
-
lastActive: new Date(),
|
|
873
|
-
isInterrupted: false,
|
|
874
|
-
},
|
|
875
|
-
{
|
|
876
|
-
sessionId: 'parent-session',
|
|
877
|
-
projectPath: '/my',
|
|
878
|
-
lastCwd: '/my',
|
|
879
|
-
sessionStart: new Date(),
|
|
880
|
-
lastActive: new Date(),
|
|
881
|
-
isInterrupted: false,
|
|
882
|
-
},
|
|
883
|
-
];
|
|
884
|
-
|
|
885
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(), 'parent-child');
|
|
886
|
-
expect(result).toHaveLength(2);
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
it('should skip used sessions', () => {
|
|
890
|
-
const adapter = new ClaudeCodeAdapter();
|
|
891
|
-
const filterCandidateSessions = (adapter as any).filterCandidateSessions.bind(adapter);
|
|
892
|
-
|
|
893
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
894
|
-
const sessions = [
|
|
895
|
-
{
|
|
896
|
-
sessionId: 's1',
|
|
897
|
-
projectPath: '/my/project',
|
|
898
|
-
sessionStart: new Date(),
|
|
899
|
-
lastActive: new Date(),
|
|
900
|
-
isInterrupted: false,
|
|
901
|
-
},
|
|
902
|
-
];
|
|
903
|
-
|
|
904
|
-
const result = filterCandidateSessions(processInfo, sessions, new Set(['s1']), 'cwd');
|
|
905
|
-
expect(result).toHaveLength(0);
|
|
735
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
906
736
|
});
|
|
907
737
|
});
|
|
908
738
|
|
|
909
739
|
describe('extractUserMessageText', () => {
|
|
910
740
|
it('should extract plain string content', () => {
|
|
911
|
-
const adapter = new ClaudeCodeAdapter();
|
|
912
741
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
913
|
-
|
|
914
742
|
expect(extract('hello world')).toBe('hello world');
|
|
915
743
|
});
|
|
916
744
|
|
|
917
745
|
it('should extract text from array content blocks', () => {
|
|
918
|
-
const adapter = new ClaudeCodeAdapter();
|
|
919
746
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
920
747
|
|
|
921
748
|
const content = [
|
|
@@ -926,7 +753,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
926
753
|
});
|
|
927
754
|
|
|
928
755
|
it('should return undefined for empty/null content', () => {
|
|
929
|
-
const adapter = new ClaudeCodeAdapter();
|
|
930
756
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
931
757
|
|
|
932
758
|
expect(extract(undefined)).toBeUndefined();
|
|
@@ -935,7 +761,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
935
761
|
});
|
|
936
762
|
|
|
937
763
|
it('should parse command-message tags', () => {
|
|
938
|
-
const adapter = new ClaudeCodeAdapter();
|
|
939
764
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
940
765
|
|
|
941
766
|
const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
|
|
@@ -943,7 +768,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
943
768
|
});
|
|
944
769
|
|
|
945
770
|
it('should parse command-message without args', () => {
|
|
946
|
-
const adapter = new ClaudeCodeAdapter();
|
|
947
771
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
948
772
|
|
|
949
773
|
const msg = '<command-message><command-name>help</command-name></command-message>';
|
|
@@ -951,7 +775,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
951
775
|
});
|
|
952
776
|
|
|
953
777
|
it('should extract ARGUMENTS from skill expansion', () => {
|
|
954
|
-
const adapter = new ClaudeCodeAdapter();
|
|
955
778
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
956
779
|
|
|
957
780
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
|
|
@@ -959,7 +782,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
959
782
|
});
|
|
960
783
|
|
|
961
784
|
it('should return undefined for skill expansion without ARGUMENTS', () => {
|
|
962
|
-
const adapter = new ClaudeCodeAdapter();
|
|
963
785
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
964
786
|
|
|
965
787
|
const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
|
|
@@ -967,7 +789,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
967
789
|
});
|
|
968
790
|
|
|
969
791
|
it('should filter noise messages', () => {
|
|
970
|
-
const adapter = new ClaudeCodeAdapter();
|
|
971
792
|
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
972
793
|
|
|
973
794
|
expect(extract('[Request interrupted by user]')).toBeUndefined();
|
|
@@ -978,119 +799,189 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
978
799
|
|
|
979
800
|
describe('parseCommandMessage', () => {
|
|
980
801
|
it('should return undefined for malformed command-message', () => {
|
|
981
|
-
const adapter = new ClaudeCodeAdapter();
|
|
982
802
|
const parse = (adapter as any).parseCommandMessage.bind(adapter);
|
|
983
|
-
|
|
984
803
|
expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
|
|
985
804
|
});
|
|
986
805
|
});
|
|
987
806
|
});
|
|
988
807
|
|
|
989
|
-
describe('
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
{
|
|
1000
|
-
sessionId: 'stale-exact-cwd',
|
|
1001
|
-
projectPath: '/my/project',
|
|
1002
|
-
lastCwd: '/my/project',
|
|
1003
|
-
sessionStart: new Date('2026-03-07T10:00:00Z'), // 3 days old — outside tolerance
|
|
1004
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
1005
|
-
isInterrupted: false,
|
|
1006
|
-
},
|
|
1007
|
-
];
|
|
808
|
+
describe('file I/O methods', () => {
|
|
809
|
+
let tmpDir: string;
|
|
810
|
+
|
|
811
|
+
beforeEach(() => {
|
|
812
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
afterEach(() => {
|
|
816
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
817
|
+
});
|
|
1008
818
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
819
|
+
describe('tryPidFileMatching', () => {
|
|
820
|
+
let sessionsDir: string;
|
|
821
|
+
let projectsDir: string;
|
|
1012
822
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
823
|
+
beforeEach(() => {
|
|
824
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
825
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
826
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
827
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
828
|
+
(adapter as any).projectsDir = projectsDir;
|
|
1017
829
|
});
|
|
1018
830
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const processInfo = { pid: 1, command: 'claude', cwd: '/my/project', tty: '' };
|
|
1024
|
-
const processStartByPid = new Map<number, Date>(); // empty — no start time
|
|
1025
|
-
|
|
1026
|
-
const sessions = [
|
|
1027
|
-
{
|
|
1028
|
-
sessionId: 'older',
|
|
1029
|
-
projectPath: '/my/project',
|
|
1030
|
-
lastCwd: '/my/project',
|
|
1031
|
-
sessionStart: new Date('2026-03-10T09:00:00Z'),
|
|
1032
|
-
lastActive: new Date('2026-03-10T09:30:00Z'),
|
|
1033
|
-
isInterrupted: false,
|
|
1034
|
-
},
|
|
1035
|
-
{
|
|
1036
|
-
sessionId: 'newer',
|
|
1037
|
-
projectPath: '/my/project',
|
|
1038
|
-
lastCwd: '/my/project',
|
|
1039
|
-
sessionStart: new Date('2026-03-10T10:00:00Z'),
|
|
1040
|
-
lastActive: new Date('2026-03-10T10:30:00Z'),
|
|
1041
|
-
isInterrupted: false,
|
|
1042
|
-
},
|
|
1043
|
-
];
|
|
831
|
+
const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({
|
|
832
|
+
pid, command: 'claude', cwd, tty: 'ttys001', startTime,
|
|
833
|
+
});
|
|
1044
834
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
835
|
+
const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => {
|
|
836
|
+
fs.writeFileSync(
|
|
837
|
+
path.join(sessionsDir, `${pid}.json`),
|
|
838
|
+
JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }),
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const writeJsonl = (cwd: string, sessionId: string) => {
|
|
843
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
844
|
+
const projDir = path.join(projectsDir, encoded);
|
|
845
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
846
|
+
const filePath = path.join(projDir, `${sessionId}.jsonl`);
|
|
847
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
848
|
+
return filePath;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
it('should return direct match when PID file and JSONL both exist within time tolerance', () => {
|
|
852
|
+
const startTime = new Date();
|
|
853
|
+
const proc = makeProc(1001, '/project/test', startTime);
|
|
854
|
+
writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
|
|
855
|
+
writeJsonl('/project/test', 'session-abc');
|
|
856
|
+
|
|
857
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
858
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
859
|
+
|
|
860
|
+
expect(direct).toHaveLength(1);
|
|
861
|
+
expect(fallback).toHaveLength(0);
|
|
862
|
+
expect(direct[0].sessionFile.sessionId).toBe('session-abc');
|
|
863
|
+
expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
|
|
864
|
+
expect(direct[0].process.pid).toBe(1001);
|
|
1048
865
|
});
|
|
1049
866
|
|
|
1050
|
-
it('should
|
|
1051
|
-
const
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
const sessions = [
|
|
1059
|
-
{
|
|
1060
|
-
sessionId: 'fresh-exact-cwd',
|
|
1061
|
-
projectPath: '/my/project',
|
|
1062
|
-
lastCwd: '/my/project',
|
|
1063
|
-
sessionStart: new Date('2026-03-10T10:00:30Z'), // 30s — within tolerance
|
|
1064
|
-
lastActive: new Date('2026-03-10T10:05:00Z'),
|
|
1065
|
-
isInterrupted: false,
|
|
1066
|
-
},
|
|
1067
|
-
];
|
|
867
|
+
it('should fall back when PID file exists but JSONL is missing', () => {
|
|
868
|
+
const startTime = new Date();
|
|
869
|
+
const proc = makeProc(1002, '/project/test', startTime);
|
|
870
|
+
writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
|
|
871
|
+
// No JSONL file written
|
|
872
|
+
|
|
873
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
874
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
1068
875
|
|
|
1069
|
-
|
|
1070
|
-
expect(
|
|
1071
|
-
expect(
|
|
876
|
+
expect(direct).toHaveLength(0);
|
|
877
|
+
expect(fallback).toHaveLength(1);
|
|
878
|
+
expect(fallback[0].pid).toBe(1002);
|
|
1072
879
|
});
|
|
1073
|
-
});
|
|
1074
880
|
|
|
1075
|
-
|
|
1076
|
-
|
|
881
|
+
it('should fall back when startedAt is stale (>60s from proc.startTime)', () => {
|
|
882
|
+
const startTime = new Date();
|
|
883
|
+
const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
|
|
884
|
+
const proc = makeProc(1003, '/project/test', startTime);
|
|
885
|
+
writePidFile(1003, 'stale-session', '/project/test', staleTime);
|
|
886
|
+
writeJsonl('/project/test', 'stale-session');
|
|
1077
887
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
});
|
|
888
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
889
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
1081
890
|
|
|
1082
|
-
|
|
1083
|
-
|
|
891
|
+
expect(direct).toHaveLength(0);
|
|
892
|
+
expect(fallback).toHaveLength(1);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('should accept PID file when startedAt is within 60s tolerance', () => {
|
|
896
|
+
const startTime = new Date();
|
|
897
|
+
const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
|
|
898
|
+
const proc = makeProc(1004, '/project/test', startTime);
|
|
899
|
+
writePidFile(1004, 'close-session', '/project/test', closeTime);
|
|
900
|
+
writeJsonl('/project/test', 'close-session');
|
|
901
|
+
|
|
902
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
903
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
904
|
+
|
|
905
|
+
expect(direct).toHaveLength(1);
|
|
906
|
+
expect(fallback).toHaveLength(0);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('should fall back when PID file is absent', () => {
|
|
910
|
+
const proc = makeProc(1005, '/project/test', new Date());
|
|
911
|
+
// No PID file written
|
|
912
|
+
|
|
913
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
914
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
915
|
+
|
|
916
|
+
expect(direct).toHaveLength(0);
|
|
917
|
+
expect(fallback).toHaveLength(1);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('should fall back when PID file contains malformed JSON', () => {
|
|
921
|
+
const proc = makeProc(1006, '/project/test', new Date());
|
|
922
|
+
fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
|
|
923
|
+
|
|
924
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
925
|
+
expect(() => {
|
|
926
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
927
|
+
expect(direct).toHaveLength(0);
|
|
928
|
+
expect(fallback).toHaveLength(1);
|
|
929
|
+
}).not.toThrow();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('should fall back for all processes when sessions dir does not exist', () => {
|
|
933
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
|
|
934
|
+
const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())];
|
|
935
|
+
|
|
936
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
937
|
+
const { direct, fallback } = tryMatch(processes);
|
|
938
|
+
|
|
939
|
+
expect(direct).toHaveLength(0);
|
|
940
|
+
expect(fallback).toHaveLength(2);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('should correctly split mixed processes (some with PID files, some without)', () => {
|
|
944
|
+
const startTime = new Date();
|
|
945
|
+
const proc1 = makeProc(3001, '/project/one', startTime);
|
|
946
|
+
const proc2 = makeProc(3002, '/project/two', startTime);
|
|
947
|
+
const proc3 = makeProc(3003, '/project/three', startTime);
|
|
948
|
+
|
|
949
|
+
writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
|
|
950
|
+
writeJsonl('/project/one', 'session-one');
|
|
951
|
+
writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
|
|
952
|
+
writeJsonl('/project/three', 'session-three');
|
|
953
|
+
// proc2 has no PID file
|
|
954
|
+
|
|
955
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
956
|
+
const { direct, fallback } = tryMatch([proc1, proc2, proc3]);
|
|
957
|
+
|
|
958
|
+
expect(direct).toHaveLength(2);
|
|
959
|
+
expect(fallback).toHaveLength(1);
|
|
960
|
+
expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]);
|
|
961
|
+
expect(fallback[0].pid).toBe(3002);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('should skip stale-file check when proc.startTime is undefined', () => {
|
|
965
|
+
const proc = makeProc(4001, '/project/test', undefined); // no startTime
|
|
966
|
+
writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
|
|
967
|
+
writeJsonl('/project/test', 'no-time-session');
|
|
968
|
+
|
|
969
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
970
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
971
|
+
|
|
972
|
+
// startTime undefined → stale check skipped → direct match
|
|
973
|
+
expect(direct).toHaveLength(1);
|
|
974
|
+
expect(fallback).toHaveLength(0);
|
|
975
|
+
});
|
|
1084
976
|
});
|
|
1085
977
|
|
|
1086
978
|
describe('readSession', () => {
|
|
1087
|
-
it('should parse session file with timestamps,
|
|
1088
|
-
const adapter = new ClaudeCodeAdapter();
|
|
979
|
+
it('should parse session file with timestamps, cwd, and entry type', () => {
|
|
1089
980
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1090
981
|
|
|
1091
982
|
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
1092
983
|
const lines = [
|
|
1093
|
-
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project'
|
|
984
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project' }),
|
|
1094
985
|
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1095
986
|
];
|
|
1096
987
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
@@ -1099,7 +990,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1099
990
|
expect(session).toMatchObject({
|
|
1100
991
|
sessionId: 'test-session',
|
|
1101
992
|
projectPath: '/my/project',
|
|
1102
|
-
slug: 'happy-dog',
|
|
1103
993
|
lastCwd: '/my/project',
|
|
1104
994
|
lastEntryType: 'assistant',
|
|
1105
995
|
isInterrupted: false,
|
|
@@ -1109,7 +999,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1109
999
|
});
|
|
1110
1000
|
|
|
1111
1001
|
it('should detect user interruption', () => {
|
|
1112
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1113
1002
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1114
1003
|
|
|
1115
1004
|
const filePath = path.join(tmpDir, 'interrupted.jsonl');
|
|
@@ -1130,28 +1019,22 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1130
1019
|
});
|
|
1131
1020
|
|
|
1132
1021
|
it('should return session with defaults for empty file', () => {
|
|
1133
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1134
1022
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1135
1023
|
|
|
1136
1024
|
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1137
1025
|
fs.writeFileSync(filePath, '');
|
|
1138
1026
|
|
|
1139
1027
|
const session = readSession(filePath, '/test');
|
|
1140
|
-
// Empty file content trims to '' which splits to [''] — no valid entries parsed
|
|
1141
1028
|
expect(session).not.toBeNull();
|
|
1142
1029
|
expect(session.lastEntryType).toBeUndefined();
|
|
1143
|
-
expect(session.slug).toBeUndefined();
|
|
1144
1030
|
});
|
|
1145
1031
|
|
|
1146
1032
|
it('should return null for non-existent file', () => {
|
|
1147
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1148
1033
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1149
|
-
|
|
1150
1034
|
expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
|
|
1151
1035
|
});
|
|
1152
1036
|
|
|
1153
1037
|
it('should skip metadata entry types for lastEntryType', () => {
|
|
1154
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1155
1038
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1156
1039
|
|
|
1157
1040
|
const filePath = path.join(tmpDir, 'metadata-test.jsonl');
|
|
@@ -1164,12 +1047,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1164
1047
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1165
1048
|
|
|
1166
1049
|
const session = readSession(filePath, '/test');
|
|
1167
|
-
// lastEntryType should be 'assistant', not 'last-prompt' or 'file-history-snapshot'
|
|
1168
1050
|
expect(session.lastEntryType).toBe('assistant');
|
|
1169
1051
|
});
|
|
1170
1052
|
|
|
1171
1053
|
it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
|
|
1172
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1173
1054
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1174
1055
|
|
|
1175
1056
|
const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
|
|
@@ -1184,13 +1065,11 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1184
1065
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1185
1066
|
|
|
1186
1067
|
const session = readSession(filePath, '/test');
|
|
1187
|
-
// sessionStart should come from snapshot.timestamp, not lastActive
|
|
1188
1068
|
expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
|
|
1189
1069
|
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1190
1070
|
});
|
|
1191
1071
|
|
|
1192
1072
|
it('should extract lastUserMessage from session entries', () => {
|
|
1193
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1194
1073
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1195
1074
|
|
|
1196
1075
|
const filePath = path.join(tmpDir, 'user-msg.jsonl');
|
|
@@ -1203,12 +1082,10 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1203
1082
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1204
1083
|
|
|
1205
1084
|
const session = readSession(filePath, '/test');
|
|
1206
|
-
// Last user message should be the most recent one
|
|
1207
1085
|
expect(session.lastUserMessage).toBe('second question');
|
|
1208
1086
|
});
|
|
1209
1087
|
|
|
1210
1088
|
it('should use lastCwd as projectPath when projectPath is empty', () => {
|
|
1211
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1212
1089
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1213
1090
|
|
|
1214
1091
|
const filePath = path.join(tmpDir, 'no-project.jsonl');
|
|
@@ -1222,7 +1099,6 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1222
1099
|
});
|
|
1223
1100
|
|
|
1224
1101
|
it('should handle malformed JSON lines gracefully', () => {
|
|
1225
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1226
1102
|
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1227
1103
|
|
|
1228
1104
|
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
@@ -1237,131 +1113,179 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1237
1113
|
expect(session.lastEntryType).toBe('assistant');
|
|
1238
1114
|
});
|
|
1239
1115
|
});
|
|
1116
|
+
});
|
|
1240
1117
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const adapter = new ClaudeCodeAdapter();
|
|
1244
|
-
(adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
|
|
1245
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1118
|
+
describe('getConversation', () => {
|
|
1119
|
+
let tmpDir: string;
|
|
1246
1120
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1121
|
+
beforeEach(() => {
|
|
1122
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-'));
|
|
1123
|
+
});
|
|
1249
1124
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
(adapter as any).projectsDir = projectsDir;
|
|
1254
|
-
const findSessionFiles = (adapter as any).findSessionFiles.bind(adapter);
|
|
1125
|
+
afterEach(() => {
|
|
1126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1127
|
+
});
|
|
1255
1128
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
JSON.stringify({ originalPath: '/my/project' }),
|
|
1262
|
-
);
|
|
1129
|
+
function writeJsonl(lines: object[]): string {
|
|
1130
|
+
const filePath = path.join(tmpDir, 'session.jsonl');
|
|
1131
|
+
fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
|
|
1132
|
+
return filePath;
|
|
1133
|
+
}
|
|
1263
1134
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
fs.utimesSync(file1, past, past);
|
|
1270
|
-
fs.writeFileSync(file2, '{}');
|
|
1271
|
-
|
|
1272
|
-
const files = findSessionFiles(10);
|
|
1273
|
-
expect(files).toHaveLength(2);
|
|
1274
|
-
// Sorted by mtime desc — new first
|
|
1275
|
-
expect(files[0].filePath).toContain('session-new');
|
|
1276
|
-
expect(files[0].projectPath).toBe('/my/project');
|
|
1277
|
-
expect(files[1].filePath).toContain('session-old');
|
|
1278
|
-
});
|
|
1135
|
+
it('should parse user and assistant text messages', () => {
|
|
1136
|
+
const filePath = writeJsonl([
|
|
1137
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
|
|
1138
|
+
{ type: 'assistant', timestamp: '2026-03-27T10:00:05Z', message: { content: [{ type: 'text', text: 'Hi there!' }] } },
|
|
1139
|
+
]);
|
|
1279
1140
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1141
|
+
const messages = adapter.getConversation(filePath);
|
|
1142
|
+
expect(messages).toHaveLength(2);
|
|
1143
|
+
expect(messages[0]).toEqual({ role: 'user', content: 'Hello', timestamp: '2026-03-27T10:00:00Z' });
|
|
1144
|
+
expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi there!', timestamp: '2026-03-27T10:00:05Z' });
|
|
1145
|
+
});
|
|
1285
1146
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1147
|
+
it('should skip metadata entry types', () => {
|
|
1148
|
+
const filePath = writeJsonl([
|
|
1149
|
+
{ type: 'file-history-snapshot', timestamp: '2026-03-27T10:00:00Z', snapshot: {} },
|
|
1150
|
+
{ type: 'last-prompt', timestamp: '2026-03-27T10:00:00Z' },
|
|
1151
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Fix bug' } },
|
|
1152
|
+
]);
|
|
1292
1153
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1154
|
+
const messages = adapter.getConversation(filePath);
|
|
1155
|
+
expect(messages).toHaveLength(1);
|
|
1156
|
+
expect(messages[0].content).toBe('Fix bug');
|
|
1157
|
+
});
|
|
1296
1158
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1159
|
+
it('should skip progress and thinking entries', () => {
|
|
1160
|
+
const filePath = writeJsonl([
|
|
1161
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } },
|
|
1162
|
+
{ type: 'progress', timestamp: '2026-03-27T10:00:01Z', data: {} },
|
|
1163
|
+
{ type: 'thinking', timestamp: '2026-03-27T10:00:02Z' },
|
|
1164
|
+
{ type: 'assistant', timestamp: '2026-03-27T10:00:03Z', message: { content: [{ type: 'text', text: 'Done' }] } },
|
|
1165
|
+
]);
|
|
1300
1166
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1167
|
+
const messages = adapter.getConversation(filePath);
|
|
1168
|
+
expect(messages).toHaveLength(2);
|
|
1169
|
+
expect(messages[0].role).toBe('user');
|
|
1170
|
+
expect(messages[1].role).toBe('assistant');
|
|
1171
|
+
});
|
|
1306
1172
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
JSON.stringify({ originalPath: '/hidden' }),
|
|
1312
|
-
);
|
|
1313
|
-
fs.writeFileSync(path.join(hiddenDir, 'session.jsonl'), '{}');
|
|
1173
|
+
it('should include system messages', () => {
|
|
1174
|
+
const filePath = writeJsonl([
|
|
1175
|
+
{ type: 'system', timestamp: '2026-03-27T10:00:00Z', message: { content: 'System initialized' } },
|
|
1176
|
+
]);
|
|
1314
1177
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
});
|
|
1178
|
+
const messages = adapter.getConversation(filePath);
|
|
1179
|
+
expect(messages).toHaveLength(1);
|
|
1180
|
+
expect(messages[0]).toEqual({ role: 'system', content: 'System initialized', timestamp: '2026-03-27T10:00:00Z' });
|
|
1181
|
+
});
|
|
1318
1182
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1183
|
+
it('should skip tool_use and tool_result blocks in default mode', () => {
|
|
1184
|
+
const filePath = writeJsonl([
|
|
1185
|
+
{
|
|
1186
|
+
type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
|
|
1187
|
+
message: {
|
|
1188
|
+
content: [
|
|
1189
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
1190
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
|
|
1191
|
+
],
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
type: 'user', timestamp: '2026-03-27T10:00:01Z',
|
|
1196
|
+
message: {
|
|
1197
|
+
content: [
|
|
1198
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
|
|
1199
|
+
],
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
]);
|
|
1324
1203
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1204
|
+
const messages = adapter.getConversation(filePath);
|
|
1205
|
+
expect(messages).toHaveLength(1);
|
|
1206
|
+
expect(messages[0].content).toBe('Let me read the file.');
|
|
1207
|
+
});
|
|
1328
1208
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1209
|
+
it('should include tool_use and tool_result blocks in verbose mode', () => {
|
|
1210
|
+
const filePath = writeJsonl([
|
|
1211
|
+
{
|
|
1212
|
+
type: 'assistant', timestamp: '2026-03-27T10:00:00Z',
|
|
1213
|
+
message: {
|
|
1214
|
+
content: [
|
|
1215
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
1216
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } },
|
|
1217
|
+
],
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
type: 'user', timestamp: '2026-03-27T10:00:01Z',
|
|
1222
|
+
message: {
|
|
1223
|
+
content: [
|
|
1224
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' },
|
|
1225
|
+
],
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
]);
|
|
1229
|
+
|
|
1230
|
+
const messages = adapter.getConversation(filePath, { verbose: true });
|
|
1231
|
+
expect(messages).toHaveLength(2);
|
|
1232
|
+
expect(messages[0].content).toContain('[Tool: Read]');
|
|
1233
|
+
expect(messages[0].content).toContain('/src/app.ts');
|
|
1234
|
+
expect(messages[1].content).toContain('[Tool Result]');
|
|
1334
1235
|
});
|
|
1335
1236
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1237
|
+
it('should handle tool_result errors in verbose mode', () => {
|
|
1238
|
+
const filePath = writeJsonl([
|
|
1239
|
+
{
|
|
1240
|
+
type: 'user', timestamp: '2026-03-27T10:00:00Z',
|
|
1241
|
+
message: {
|
|
1242
|
+
content: [
|
|
1243
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'Something went wrong', is_error: true },
|
|
1244
|
+
],
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
]);
|
|
1342
1248
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
JSON.stringify({ originalPath: '/my/project' }),
|
|
1348
|
-
);
|
|
1249
|
+
const messages = adapter.getConversation(filePath, { verbose: true });
|
|
1250
|
+
expect(messages).toHaveLength(1);
|
|
1251
|
+
expect(messages[0].content).toContain('[Tool Error]');
|
|
1252
|
+
});
|
|
1349
1253
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1254
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
1255
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
1256
|
+
fs.writeFileSync(filePath, [
|
|
1257
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }),
|
|
1258
|
+
'this is not valid json',
|
|
1259
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-27T10:00:01Z', message: { content: [{ type: 'text', text: 'World' }] } }),
|
|
1260
|
+
].join('\n'));
|
|
1261
|
+
|
|
1262
|
+
const messages = adapter.getConversation(filePath);
|
|
1263
|
+
expect(messages).toHaveLength(2);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it('should return empty array for missing file', () => {
|
|
1267
|
+
const messages = adapter.getConversation('/nonexistent/path.jsonl');
|
|
1268
|
+
expect(messages).toEqual([]);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('should return empty array for empty file', () => {
|
|
1272
|
+
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1273
|
+
fs.writeFileSync(filePath, '');
|
|
1274
|
+
|
|
1275
|
+
const messages = adapter.getConversation(filePath);
|
|
1276
|
+
expect(messages).toEqual([]);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('should filter noise messages from user entries', () => {
|
|
1280
|
+
const filePath = writeJsonl([
|
|
1281
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
|
|
1282
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Tool loaded.' } },
|
|
1283
|
+
{ type: 'user', timestamp: '2026-03-27T10:00:02Z', message: { content: 'Real question' } },
|
|
1284
|
+
]);
|
|
1285
|
+
|
|
1286
|
+
const messages = adapter.getConversation(filePath);
|
|
1287
|
+
expect(messages).toHaveLength(1);
|
|
1288
|
+
expect(messages[0].content).toBe('Real question');
|
|
1365
1289
|
});
|
|
1366
1290
|
});
|
|
1367
1291
|
});
|