@ai-devkit/agent-manager 0.3.0 → 0.5.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 +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +49 -38
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +286 -293
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +32 -30
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +148 -284
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -3
- 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 +103 -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 +2 -2
- package/src/__tests__/AgentManager.test.ts +0 -25
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
- package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
- package/src/__tests__/utils/matching.test.ts +191 -0
- package/src/__tests__/utils/process.test.ts +202 -0
- package/src/__tests__/utils/session.test.ts +117 -0
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/ClaudeCodeAdapter.ts +341 -418
- package/src/adapters/CodexAdapter.ts +155 -420
- package/src/index.ts +1 -3
- package/src/utils/index.ts +6 -3
- package/src/utils/matching.ts +92 -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
|
@@ -2,31 +2,53 @@
|
|
|
2
2
|
* Tests for ClaudeCodeAdapter
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
6
8
|
import { ClaudeCodeAdapter } from '../../adapters/ClaudeCodeAdapter';
|
|
7
|
-
import type {
|
|
9
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
8
10
|
import { AgentStatus } from '../../adapters/AgentAdapter';
|
|
9
|
-
import {
|
|
10
|
-
|
|
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';
|
|
11
16
|
jest.mock('../../utils/process', () => ({
|
|
12
|
-
|
|
17
|
+
listAgentProcesses: jest.fn(),
|
|
18
|
+
enrichProcesses: jest.fn(),
|
|
13
19
|
}));
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
jest.mock('../../utils/session', () => ({
|
|
22
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
23
|
+
}));
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
25
|
+
jest.mock('../../utils/matching', () => ({
|
|
26
|
+
matchProcessesToSessions: jest.fn(),
|
|
27
|
+
generateAgentName: jest.fn(),
|
|
28
|
+
}));
|
|
23
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>;
|
|
24
35
|
describe('ClaudeCodeAdapter', () => {
|
|
25
36
|
let adapter: ClaudeCodeAdapter;
|
|
26
37
|
|
|
27
38
|
beforeEach(() => {
|
|
28
39
|
adapter = new ClaudeCodeAdapter();
|
|
29
|
-
|
|
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
|
+
});
|
|
30
52
|
});
|
|
31
53
|
|
|
32
54
|
describe('initialization', () => {
|
|
@@ -47,10 +69,21 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
47
69
|
expect(adapter.canHandle(processInfo)).toBe(true);
|
|
48
70
|
});
|
|
49
71
|
|
|
50
|
-
it('should return true for
|
|
72
|
+
it('should return true for claude executable with full path', () => {
|
|
51
73
|
const processInfo = {
|
|
52
74
|
pid: 12345,
|
|
53
|
-
command: '/usr/local/bin/
|
|
75
|
+
command: '/usr/local/bin/claude --some-flag',
|
|
76
|
+
cwd: '/test',
|
|
77
|
+
tty: 'ttys001',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(adapter.canHandle(processInfo)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return true for CLAUDE (case-insensitive)', () => {
|
|
84
|
+
const processInfo = {
|
|
85
|
+
pid: 12345,
|
|
86
|
+
command: '/usr/local/bin/CLAUDE --continue',
|
|
54
87
|
cwd: '/test',
|
|
55
88
|
tty: 'ttys001',
|
|
56
89
|
};
|
|
@@ -68,55 +101,103 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
68
101
|
|
|
69
102
|
expect(adapter.canHandle(processInfo)).toBe(false);
|
|
70
103
|
});
|
|
104
|
+
|
|
105
|
+
it('should return false for processes with "claude" only in path arguments', () => {
|
|
106
|
+
const processInfo = {
|
|
107
|
+
pid: 12345,
|
|
108
|
+
command: '/usr/local/bin/node /path/to/claude-worktree/node_modules/nx/start.js',
|
|
109
|
+
cwd: '/test',
|
|
110
|
+
tty: 'ttys001',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
expect(adapter.canHandle(processInfo)).toBe(false);
|
|
114
|
+
});
|
|
71
115
|
});
|
|
72
116
|
|
|
73
117
|
describe('detectAgents', () => {
|
|
74
118
|
it('should return empty array if no claude processes running', async () => {
|
|
75
|
-
|
|
119
|
+
mockedListAgentProcesses.mockReturnValue([]);
|
|
76
120
|
|
|
77
121
|
const agents = await adapter.detectAgents();
|
|
78
122
|
expect(agents).toEqual([]);
|
|
123
|
+
expect(mockedListAgentProcesses).toHaveBeenCalledWith('claude');
|
|
124
|
+
});
|
|
125
|
+
|
|
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
|
+
});
|
|
79
146
|
});
|
|
80
147
|
|
|
81
|
-
it('should detect agents
|
|
82
|
-
const
|
|
148
|
+
it('should detect agents with matched sessions', async () => {
|
|
149
|
+
const processes: ProcessInfo[] = [
|
|
83
150
|
{
|
|
84
151
|
pid: 12345,
|
|
85
|
-
command: 'claude
|
|
152
|
+
command: 'claude',
|
|
86
153
|
cwd: '/Users/test/my-project',
|
|
87
154
|
tty: 'ttys001',
|
|
155
|
+
startTime: new Date('2026-03-18T23:18:01.000Z'),
|
|
88
156
|
},
|
|
89
157
|
];
|
|
158
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
159
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
90
160
|
|
|
91
|
-
|
|
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', slug: 'merry-dog', 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[] = [
|
|
92
178
|
{
|
|
93
179
|
sessionId: 'session-1',
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
lastActive: new Date(),
|
|
180
|
+
filePath: sessionFile,
|
|
181
|
+
projectDir: projDir,
|
|
182
|
+
birthtimeMs: new Date('2026-03-18T23:18:44Z').getTime(),
|
|
183
|
+
resolvedCwd: '',
|
|
99
184
|
},
|
|
100
185
|
];
|
|
186
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
101
187
|
|
|
102
|
-
const
|
|
188
|
+
const matches: MatchResult[] = [
|
|
103
189
|
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sessionId: 'session-1',
|
|
190
|
+
process: processes[0],
|
|
191
|
+
session: { ...sessionFiles[0], resolvedCwd: '/Users/test/my-project' },
|
|
192
|
+
deltaMs: 43000,
|
|
108
193
|
},
|
|
109
194
|
];
|
|
110
|
-
|
|
111
|
-
mockedListProcesses.mockReturnValue(processData);
|
|
112
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue(sessionData);
|
|
113
|
-
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue(historyData);
|
|
195
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
114
196
|
|
|
115
197
|
const agents = await adapter.detectAgents();
|
|
116
198
|
|
|
117
199
|
expect(agents).toHaveLength(1);
|
|
118
200
|
expect(agents[0]).toMatchObject({
|
|
119
|
-
name: 'my-project',
|
|
120
201
|
type: 'claude',
|
|
121
202
|
status: AgentStatus.WAITING,
|
|
122
203
|
pid: 12345,
|
|
@@ -124,280 +205,915 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
124
205
|
sessionId: 'session-1',
|
|
125
206
|
slug: 'merry-dog',
|
|
126
207
|
});
|
|
127
|
-
expect(agents[0].summary).toContain('Investigate failing tests
|
|
208
|
+
expect(agents[0].summary).toContain('Investigate failing tests');
|
|
209
|
+
|
|
210
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
128
211
|
});
|
|
129
212
|
|
|
130
|
-
it('should
|
|
131
|
-
|
|
213
|
+
it('should fall back to process-only for unmatched processes', async () => {
|
|
214
|
+
const processes: ProcessInfo[] = [
|
|
215
|
+
{ pid: 100, command: 'claude', cwd: '/project-a', tty: 'ttys001', startTime: new Date() },
|
|
216
|
+
{ pid: 200, command: 'claude', cwd: '/project-b', tty: 'ttys002', startTime: new Date() },
|
|
217
|
+
];
|
|
218
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
219
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
220
|
+
|
|
221
|
+
// Set up projects dir with encoded directory names
|
|
222
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
223
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
224
|
+
// /project-a → -project-a, /project-b → -project-b
|
|
225
|
+
const projDirA = path.join(projectsDir, '-project-a');
|
|
226
|
+
const projDirB = path.join(projectsDir, '-project-b');
|
|
227
|
+
fs.mkdirSync(projDirA, { recursive: true });
|
|
228
|
+
fs.mkdirSync(projDirB, { recursive: true });
|
|
229
|
+
|
|
230
|
+
const sessionFile = path.join(projDirA, 'only-session.jsonl');
|
|
231
|
+
fs.writeFileSync(sessionFile,
|
|
232
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-18T23:19:00Z' }),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
(adapter as any).projectsDir = projectsDir;
|
|
236
|
+
|
|
237
|
+
const sessionFiles: SessionFile[] = [
|
|
132
238
|
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
239
|
+
sessionId: 'only-session',
|
|
240
|
+
filePath: sessionFile,
|
|
241
|
+
projectDir: projDirA,
|
|
242
|
+
birthtimeMs: Date.now(),
|
|
243
|
+
resolvedCwd: '',
|
|
137
244
|
},
|
|
138
|
-
]
|
|
139
|
-
|
|
245
|
+
];
|
|
246
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(sessionFiles);
|
|
247
|
+
|
|
248
|
+
// Only process 100 matches
|
|
249
|
+
const matches: MatchResult[] = [
|
|
140
250
|
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
lastEntry: { type: 'assistant' },
|
|
145
|
-
lastActive: new Date(),
|
|
251
|
+
process: processes[0],
|
|
252
|
+
session: { ...sessionFiles[0], resolvedCwd: '/project-a' },
|
|
253
|
+
deltaMs: 5000,
|
|
146
254
|
},
|
|
147
|
-
]
|
|
148
|
-
|
|
255
|
+
];
|
|
256
|
+
mockedMatchProcessesToSessions.mockReturnValue(matches);
|
|
257
|
+
|
|
258
|
+
const agents = await adapter.detectAgents();
|
|
259
|
+
expect(agents).toHaveLength(2);
|
|
260
|
+
|
|
261
|
+
const matched = agents.find(a => a.pid === 100);
|
|
262
|
+
const unmatched = agents.find(a => a.pid === 200);
|
|
263
|
+
expect(matched?.sessionId).toBe('only-session');
|
|
264
|
+
expect(unmatched?.sessionId).toBe('pid-200');
|
|
265
|
+
expect(unmatched?.status).toBe(AgentStatus.IDLE);
|
|
266
|
+
|
|
267
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle process with empty cwd in process-only fallback', async () => {
|
|
271
|
+
const processes: ProcessInfo[] = [
|
|
272
|
+
{ pid: 300, command: 'claude', cwd: '', tty: 'ttys003' },
|
|
273
|
+
];
|
|
274
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
275
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
276
|
+
|
|
277
|
+
(adapter as any).projectsDir = '/nonexistent';
|
|
149
278
|
|
|
150
279
|
const agents = await adapter.detectAgents();
|
|
151
280
|
expect(agents).toHaveLength(1);
|
|
152
281
|
expect(agents[0]).toMatchObject({
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
projectPath: '
|
|
157
|
-
sessionId: 'pid-777',
|
|
158
|
-
summary: 'Claude process running',
|
|
282
|
+
pid: 300,
|
|
283
|
+
sessionId: 'pid-300',
|
|
284
|
+
summary: 'Unknown',
|
|
285
|
+
projectPath: '',
|
|
159
286
|
});
|
|
160
287
|
});
|
|
161
288
|
|
|
162
|
-
it('should match
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
289
|
+
it('should use PID file for direct match and skip legacy matching for that process', async () => {
|
|
290
|
+
const startTime = new Date();
|
|
291
|
+
const processes: ProcessInfo[] = [
|
|
292
|
+
{ pid: 55001, command: 'claude', cwd: '/project/direct', tty: 'ttys001', startTime },
|
|
293
|
+
];
|
|
294
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
295
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
296
|
+
|
|
297
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-test-'));
|
|
298
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
299
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
300
|
+
const projDir = path.join(projectsDir, '-project-direct');
|
|
301
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
302
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
303
|
+
|
|
304
|
+
const sessionId = 'pid-file-session';
|
|
305
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
306
|
+
fs.writeFileSync(jsonlPath, [
|
|
307
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/direct', message: { content: 'hello from pid file' } }),
|
|
308
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
309
|
+
].join('\n'));
|
|
310
|
+
|
|
311
|
+
fs.writeFileSync(
|
|
312
|
+
path.join(sessionsDir, '55001.json'),
|
|
313
|
+
JSON.stringify({ pid: 55001, sessionId, cwd: '/project/direct', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
317
|
+
(adapter as any).projectsDir = projectsDir;
|
|
189
318
|
|
|
190
319
|
const agents = await adapter.detectAgents();
|
|
320
|
+
|
|
321
|
+
// Legacy matching utilities should NOT have been called (all processes matched via PID file)
|
|
322
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
323
|
+
expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
|
|
324
|
+
|
|
191
325
|
expect(agents).toHaveLength(1);
|
|
192
326
|
expect(agents[0]).toMatchObject({
|
|
193
327
|
type: 'claude',
|
|
194
|
-
pid:
|
|
195
|
-
sessionId
|
|
196
|
-
projectPath: '/
|
|
197
|
-
|
|
328
|
+
pid: 55001,
|
|
329
|
+
sessionId,
|
|
330
|
+
projectPath: '/project/direct',
|
|
331
|
+
status: AgentStatus.WAITING,
|
|
198
332
|
});
|
|
333
|
+
expect(agents[0].summary).toContain('hello from pid file');
|
|
334
|
+
|
|
335
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
199
336
|
});
|
|
200
337
|
|
|
201
|
-
it('should
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
338
|
+
it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
|
|
339
|
+
const startTime = new Date();
|
|
340
|
+
const processes: ProcessInfo[] = [
|
|
341
|
+
{ pid: 66001, command: 'claude', cwd: '/project/gone', tty: 'ttys001', startTime },
|
|
342
|
+
];
|
|
343
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
344
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
345
|
+
|
|
346
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-gone-'));
|
|
347
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
348
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
349
|
+
const projDir = path.join(projectsDir, '-project-gone');
|
|
350
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
351
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
352
|
+
|
|
353
|
+
const sessionId = 'gone-session';
|
|
354
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
355
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
356
|
+
fs.writeFileSync(
|
|
357
|
+
path.join(sessionsDir, '66001.json'),
|
|
358
|
+
JSON.stringify({ pid: 66001, sessionId, cwd: '/project/gone', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
362
|
+
(adapter as any).projectsDir = projectsDir;
|
|
363
|
+
|
|
364
|
+
// Simulate JSONL disappearing between existence check and read
|
|
365
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
366
|
+
|
|
367
|
+
const agents = await adapter.detectAgents();
|
|
368
|
+
|
|
369
|
+
// matchedPids.delete called → process falls back to IDLE
|
|
370
|
+
expect(agents).toHaveLength(1);
|
|
371
|
+
expect(agents[0].sessionId).toBe('pid-66001');
|
|
372
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
373
|
+
|
|
374
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
375
|
+
jest.restoreAllMocks();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should fall back to process-only when legacy-matched JSONL becomes unreadable', async () => {
|
|
379
|
+
const startTime = new Date();
|
|
380
|
+
const processes: ProcessInfo[] = [
|
|
381
|
+
{ pid: 66002, command: 'claude', cwd: '/project/legacy-gone', tty: 'ttys001', startTime },
|
|
382
|
+
];
|
|
383
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
384
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
385
|
+
|
|
386
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-lgone-'));
|
|
387
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
388
|
+
const projDir = path.join(projectsDir, '-project-legacy-gone');
|
|
389
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
390
|
+
|
|
391
|
+
const sessionId = 'legacy-gone-session';
|
|
392
|
+
const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
393
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
394
|
+
|
|
395
|
+
// No PID file → process goes to legacy fallback
|
|
396
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'no-sessions');
|
|
397
|
+
(adapter as any).projectsDir = projectsDir;
|
|
398
|
+
|
|
399
|
+
const legacySessionFile = {
|
|
400
|
+
sessionId,
|
|
401
|
+
filePath: jsonlPath,
|
|
402
|
+
projectDir: projDir,
|
|
403
|
+
birthtimeMs: startTime.getTime(),
|
|
404
|
+
resolvedCwd: '/project/legacy-gone',
|
|
405
|
+
};
|
|
406
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
407
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
408
|
+
{ process: processes[0], session: legacySessionFile, deltaMs: 500 },
|
|
218
409
|
]);
|
|
219
410
|
|
|
411
|
+
// Simulate JSONL disappearing between match and read
|
|
412
|
+
jest.spyOn(adapter as any, 'readSession').mockReturnValueOnce(null);
|
|
413
|
+
|
|
220
414
|
const agents = await adapter.detectAgents();
|
|
415
|
+
|
|
221
416
|
expect(agents).toHaveLength(1);
|
|
222
|
-
expect(agents[0]).
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
summary: '/status',
|
|
228
|
-
status: AgentStatus.RUNNING,
|
|
229
|
-
});
|
|
230
|
-
expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z');
|
|
417
|
+
expect(agents[0].sessionId).toBe('pid-66002');
|
|
418
|
+
expect(agents[0].status).toBe(AgentStatus.IDLE);
|
|
419
|
+
|
|
420
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
421
|
+
jest.restoreAllMocks();
|
|
231
422
|
});
|
|
232
423
|
|
|
233
|
-
it('should
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
424
|
+
it('should mix direct PID-file matches and legacy matches across processes', async () => {
|
|
425
|
+
const startTime = new Date();
|
|
426
|
+
const processes: ProcessInfo[] = [
|
|
427
|
+
{ pid: 55002, command: 'claude', cwd: '/project/alpha', tty: 'ttys001', startTime },
|
|
428
|
+
{ pid: 55003, command: 'claude', cwd: '/project/beta', tty: 'ttys002', startTime },
|
|
429
|
+
];
|
|
430
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
431
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
432
|
+
|
|
433
|
+
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-mix-test-'));
|
|
434
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
435
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
436
|
+
const projAlpha = path.join(projectsDir, '-project-alpha');
|
|
437
|
+
const projBeta = path.join(projectsDir, '-project-beta');
|
|
438
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
439
|
+
fs.mkdirSync(projAlpha, { recursive: true });
|
|
440
|
+
fs.mkdirSync(projBeta, { recursive: true });
|
|
441
|
+
|
|
442
|
+
// PID file only for process 55002
|
|
443
|
+
const directSessionId = 'direct-session';
|
|
444
|
+
const directJsonl = path.join(projAlpha, `${directSessionId}.jsonl`);
|
|
445
|
+
fs.writeFileSync(directJsonl, [
|
|
446
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/alpha', message: { content: 'direct question' } }),
|
|
447
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
448
|
+
].join('\n'));
|
|
449
|
+
fs.writeFileSync(
|
|
450
|
+
path.join(sessionsDir, '55002.json'),
|
|
451
|
+
JSON.stringify({ pid: 55002, sessionId: directSessionId, cwd: '/project/alpha', startedAt: startTime.getTime(), kind: 'interactive', entrypoint: 'cli' }),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Legacy session file for process 55003
|
|
455
|
+
const legacySessionId = 'legacy-session';
|
|
456
|
+
const legacyJsonl = path.join(projBeta, `${legacySessionId}.jsonl`);
|
|
457
|
+
fs.writeFileSync(legacyJsonl, [
|
|
458
|
+
JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/beta', message: { content: 'legacy question' } }),
|
|
459
|
+
JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
|
|
460
|
+
].join('\n'));
|
|
461
|
+
|
|
462
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
463
|
+
(adapter as any).projectsDir = projectsDir;
|
|
464
|
+
|
|
465
|
+
// Mock legacy matching for process 55003
|
|
466
|
+
const legacySessionFile = {
|
|
467
|
+
sessionId: legacySessionId,
|
|
468
|
+
filePath: legacyJsonl,
|
|
469
|
+
projectDir: projBeta,
|
|
470
|
+
birthtimeMs: startTime.getTime(),
|
|
471
|
+
resolvedCwd: '/project/beta',
|
|
472
|
+
};
|
|
473
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([legacySessionFile]);
|
|
474
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
475
|
+
{ process: processes[1], session: legacySessionFile, deltaMs: 1000 },
|
|
241
476
|
]);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
477
|
+
|
|
478
|
+
const agents = await adapter.detectAgents();
|
|
479
|
+
|
|
480
|
+
// Legacy matching called only for fallback process (55003)
|
|
481
|
+
expect(mockedMatchProcessesToSessions).toHaveBeenCalledTimes(1);
|
|
482
|
+
expect(mockedMatchProcessesToSessions.mock.calls[0][0]).toEqual([processes[1]]);
|
|
483
|
+
|
|
484
|
+
expect(agents).toHaveLength(2);
|
|
485
|
+
const alpha = agents.find(a => a.pid === 55002);
|
|
486
|
+
const beta = agents.find(a => a.pid === 55003);
|
|
487
|
+
expect(alpha?.sessionId).toBe(directSessionId);
|
|
488
|
+
expect(beta?.sessionId).toBe(legacySessionId);
|
|
489
|
+
|
|
490
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe('discoverSessions', () => {
|
|
495
|
+
let tmpDir: string;
|
|
496
|
+
|
|
497
|
+
beforeEach(() => {
|
|
498
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
afterEach(() => {
|
|
502
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should return empty when projects dir does not exist', () => {
|
|
506
|
+
(adapter as any).projectsDir = path.join(tmpDir, 'nonexistent');
|
|
507
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
508
|
+
|
|
509
|
+
const result = discoverSessions([
|
|
510
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
251
511
|
]);
|
|
252
|
-
|
|
512
|
+
expect(result).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should scan only directories matching process CWDs', () => {
|
|
516
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
517
|
+
(adapter as any).projectsDir = projectsDir;
|
|
518
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
519
|
+
|
|
520
|
+
// /my/project → -my-project (encoded dir)
|
|
521
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
522
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
523
|
+
|
|
524
|
+
// Also create another dir that should NOT be scanned
|
|
525
|
+
const otherDir = path.join(projectsDir, '-other-project');
|
|
526
|
+
fs.mkdirSync(otherDir, { recursive: true });
|
|
527
|
+
|
|
528
|
+
const mockFiles: SessionFile[] = [
|
|
253
529
|
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
530
|
+
sessionId: 's1',
|
|
531
|
+
filePath: path.join(encodedDir, 's1.jsonl'),
|
|
532
|
+
projectDir: encodedDir,
|
|
533
|
+
birthtimeMs: 1710800324000,
|
|
534
|
+
resolvedCwd: '',
|
|
258
535
|
},
|
|
536
|
+
];
|
|
537
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue(mockFiles);
|
|
538
|
+
|
|
539
|
+
const processes = [
|
|
540
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
541
|
+
];
|
|
542
|
+
|
|
543
|
+
const result = discoverSessions(processes);
|
|
544
|
+
expect(result).toHaveLength(1);
|
|
545
|
+
expect(result[0].resolvedCwd).toBe('/my/project');
|
|
546
|
+
// batchGetSessionFileBirthtimes called once with all dirs
|
|
547
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
548
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should return empty when encoded dir does not exist', () => {
|
|
552
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
553
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
554
|
+
(adapter as any).projectsDir = projectsDir;
|
|
555
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
556
|
+
|
|
557
|
+
// Process CWD /test encodes to -test, but that dir doesn't exist
|
|
558
|
+
const result = discoverSessions([
|
|
559
|
+
{ pid: 1, command: 'claude', cwd: '/test', tty: '' },
|
|
259
560
|
]);
|
|
561
|
+
expect(result).toEqual([]);
|
|
562
|
+
expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
260
564
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
565
|
+
it('should deduplicate when multiple processes share same CWD', () => {
|
|
566
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
567
|
+
(adapter as any).projectsDir = projectsDir;
|
|
568
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
569
|
+
|
|
570
|
+
const encodedDir = path.join(projectsDir, '-my-project');
|
|
571
|
+
fs.mkdirSync(encodedDir, { recursive: true });
|
|
572
|
+
|
|
573
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
574
|
+
{ sessionId: 's1', filePath: path.join(encodedDir, 's1.jsonl'), projectDir: encodedDir, birthtimeMs: 1710800324000, resolvedCwd: '' },
|
|
575
|
+
]);
|
|
576
|
+
|
|
577
|
+
const processes = [
|
|
578
|
+
{ pid: 1, command: 'claude', cwd: '/my/project', tty: '' },
|
|
579
|
+
{ pid: 2, command: 'claude', cwd: '/my/project', tty: '' },
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
const result = discoverSessions(processes);
|
|
583
|
+
// Should only call batch once with deduplicated dir
|
|
584
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(mockedBatchGetSessionFileBirthtimes).toHaveBeenCalledWith([encodedDir]);
|
|
586
|
+
expect(result).toHaveLength(1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('should skip processes with empty cwd', () => {
|
|
590
|
+
const projectsDir = path.join(tmpDir, 'projects');
|
|
591
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
592
|
+
(adapter as any).projectsDir = projectsDir;
|
|
593
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
594
|
+
|
|
595
|
+
const result = discoverSessions([
|
|
596
|
+
{ pid: 1, command: 'claude', cwd: '', tty: '' },
|
|
597
|
+
]);
|
|
598
|
+
expect(result).toEqual([]);
|
|
270
599
|
});
|
|
271
600
|
});
|
|
272
601
|
|
|
273
602
|
describe('helper methods', () => {
|
|
274
603
|
describe('determineStatus', () => {
|
|
275
|
-
it('should return "unknown" for sessions with no last entry', () => {
|
|
276
|
-
const adapter = new ClaudeCodeAdapter();
|
|
604
|
+
it('should return "unknown" for sessions with no last entry type', () => {
|
|
277
605
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
278
606
|
|
|
279
607
|
const session = {
|
|
280
608
|
sessionId: 'test',
|
|
281
609
|
projectPath: '/test',
|
|
282
|
-
|
|
610
|
+
sessionStart: new Date(),
|
|
611
|
+
lastActive: new Date(),
|
|
612
|
+
isInterrupted: false,
|
|
283
613
|
};
|
|
284
614
|
|
|
285
|
-
|
|
286
|
-
expect(status).toBe(AgentStatus.UNKNOWN);
|
|
615
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
287
616
|
});
|
|
288
617
|
|
|
289
618
|
it('should return "waiting" for assistant entries', () => {
|
|
290
|
-
const adapter = new ClaudeCodeAdapter();
|
|
291
619
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
292
620
|
|
|
293
621
|
const session = {
|
|
294
622
|
sessionId: 'test',
|
|
295
623
|
projectPath: '/test',
|
|
296
|
-
|
|
297
|
-
lastEntry: { type: 'assistant' },
|
|
624
|
+
sessionStart: new Date(),
|
|
298
625
|
lastActive: new Date(),
|
|
626
|
+
lastEntryType: 'assistant',
|
|
627
|
+
isInterrupted: false,
|
|
299
628
|
};
|
|
300
629
|
|
|
301
|
-
|
|
302
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
630
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
303
631
|
});
|
|
304
632
|
|
|
305
633
|
it('should return "waiting" for user interruption', () => {
|
|
306
|
-
const adapter = new ClaudeCodeAdapter();
|
|
307
634
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
308
635
|
|
|
309
636
|
const session = {
|
|
310
637
|
sessionId: 'test',
|
|
311
638
|
projectPath: '/test',
|
|
312
|
-
|
|
313
|
-
lastEntry: {
|
|
314
|
-
type: 'user',
|
|
315
|
-
message: {
|
|
316
|
-
content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }],
|
|
317
|
-
},
|
|
318
|
-
},
|
|
639
|
+
sessionStart: new Date(),
|
|
319
640
|
lastActive: new Date(),
|
|
641
|
+
lastEntryType: 'user',
|
|
642
|
+
isInterrupted: true,
|
|
320
643
|
};
|
|
321
644
|
|
|
322
|
-
|
|
323
|
-
expect(status).toBe(AgentStatus.WAITING);
|
|
645
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
324
646
|
});
|
|
325
647
|
|
|
326
648
|
it('should return "running" for user/progress entries', () => {
|
|
327
|
-
const adapter = new ClaudeCodeAdapter();
|
|
328
649
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
329
650
|
|
|
330
651
|
const session = {
|
|
331
652
|
sessionId: 'test',
|
|
332
653
|
projectPath: '/test',
|
|
333
|
-
|
|
334
|
-
lastEntry: { type: 'user' },
|
|
654
|
+
sessionStart: new Date(),
|
|
335
655
|
lastActive: new Date(),
|
|
656
|
+
lastEntryType: 'user',
|
|
657
|
+
isInterrupted: false,
|
|
336
658
|
};
|
|
337
659
|
|
|
338
|
-
|
|
339
|
-
expect(status).toBe(AgentStatus.RUNNING);
|
|
660
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
340
661
|
});
|
|
341
662
|
|
|
342
|
-
it('should
|
|
343
|
-
const adapter = new ClaudeCodeAdapter();
|
|
663
|
+
it('should not override status based on age (process is running)', () => {
|
|
344
664
|
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
345
665
|
|
|
346
666
|
const oldDate = new Date(Date.now() - 10 * 60 * 1000);
|
|
347
|
-
|
|
348
667
|
const session = {
|
|
349
668
|
sessionId: 'test',
|
|
350
669
|
projectPath: '/test',
|
|
351
|
-
|
|
352
|
-
lastEntry: { type: 'assistant' },
|
|
670
|
+
sessionStart: oldDate,
|
|
353
671
|
lastActive: oldDate,
|
|
672
|
+
lastEntryType: 'assistant',
|
|
673
|
+
isInterrupted: false,
|
|
354
674
|
};
|
|
355
675
|
|
|
356
|
-
|
|
357
|
-
expect(status).toBe(AgentStatus.IDLE);
|
|
676
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
358
677
|
});
|
|
359
|
-
});
|
|
360
678
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
679
|
+
it('should return "idle" for system entries', () => {
|
|
680
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
681
|
+
|
|
682
|
+
const session = {
|
|
683
|
+
sessionId: 'test',
|
|
684
|
+
projectPath: '/test',
|
|
685
|
+
sessionStart: new Date(),
|
|
686
|
+
lastActive: new Date(),
|
|
687
|
+
lastEntryType: 'system',
|
|
688
|
+
isInterrupted: false,
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
expect(determineStatus(session)).toBe(AgentStatus.IDLE);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should return "running" for thinking entries', () => {
|
|
695
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
365
696
|
|
|
366
697
|
const session = {
|
|
367
|
-
sessionId: 'test
|
|
368
|
-
projectPath: '/
|
|
369
|
-
|
|
698
|
+
sessionId: 'test',
|
|
699
|
+
projectPath: '/test',
|
|
700
|
+
sessionStart: new Date(),
|
|
701
|
+
lastActive: new Date(),
|
|
702
|
+
lastEntryType: 'thinking',
|
|
703
|
+
isInterrupted: false,
|
|
370
704
|
};
|
|
371
705
|
|
|
372
|
-
|
|
373
|
-
expect(name).toBe('my-project');
|
|
706
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
374
707
|
});
|
|
375
708
|
|
|
376
|
-
it('should
|
|
377
|
-
const
|
|
378
|
-
const generateAgentName = (adapter as any).generateAgentName.bind(adapter);
|
|
709
|
+
it('should return "running" for progress entries', () => {
|
|
710
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
379
711
|
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
projectPath: '/
|
|
383
|
-
|
|
384
|
-
status: AgentStatus.RUNNING,
|
|
385
|
-
summary: 'Test',
|
|
386
|
-
pid: 123,
|
|
387
|
-
sessionId: 'existing-123',
|
|
388
|
-
slug: 'happy-cat',
|
|
712
|
+
const session = {
|
|
713
|
+
sessionId: 'test',
|
|
714
|
+
projectPath: '/test',
|
|
715
|
+
sessionStart: new Date(),
|
|
389
716
|
lastActive: new Date(),
|
|
717
|
+
lastEntryType: 'progress',
|
|
718
|
+
isInterrupted: false,
|
|
390
719
|
};
|
|
391
720
|
|
|
721
|
+
expect(determineStatus(session)).toBe(AgentStatus.RUNNING);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('should return "unknown" for unrecognized entry types', () => {
|
|
725
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
726
|
+
|
|
392
727
|
const session = {
|
|
393
|
-
sessionId: 'test
|
|
394
|
-
projectPath: '/
|
|
395
|
-
|
|
396
|
-
|
|
728
|
+
sessionId: 'test',
|
|
729
|
+
projectPath: '/test',
|
|
730
|
+
sessionStart: new Date(),
|
|
731
|
+
lastActive: new Date(),
|
|
732
|
+
lastEntryType: 'some_other_type',
|
|
733
|
+
isInterrupted: false,
|
|
397
734
|
};
|
|
398
735
|
|
|
399
|
-
|
|
400
|
-
|
|
736
|
+
expect(determineStatus(session)).toBe(AgentStatus.UNKNOWN);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
describe('extractUserMessageText', () => {
|
|
741
|
+
it('should extract plain string content', () => {
|
|
742
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
743
|
+
expect(extract('hello world')).toBe('hello world');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('should extract text from array content blocks', () => {
|
|
747
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
748
|
+
|
|
749
|
+
const content = [
|
|
750
|
+
{ type: 'tool_result', content: 'some result' },
|
|
751
|
+
{ type: 'text', text: 'user question' },
|
|
752
|
+
];
|
|
753
|
+
expect(extract(content)).toBe('user question');
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('should return undefined for empty/null content', () => {
|
|
757
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
758
|
+
|
|
759
|
+
expect(extract(undefined)).toBeUndefined();
|
|
760
|
+
expect(extract('')).toBeUndefined();
|
|
761
|
+
expect(extract([])).toBeUndefined();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should parse command-message tags', () => {
|
|
765
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
766
|
+
|
|
767
|
+
const msg = '<command-message><command-name>commit</command-name><command-args>fix bug</command-args></command-message>';
|
|
768
|
+
expect(extract(msg)).toBe('commit fix bug');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('should parse command-message without args', () => {
|
|
772
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
773
|
+
|
|
774
|
+
const msg = '<command-message><command-name>help</command-name></command-message>';
|
|
775
|
+
expect(extract(msg)).toBe('help');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should extract ARGUMENTS from skill expansion', () => {
|
|
779
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
780
|
+
|
|
781
|
+
const msg = 'Base directory for this skill: /some/path\n\nSome instructions\n\nARGUMENTS: implement the feature';
|
|
782
|
+
expect(extract(msg)).toBe('implement the feature');
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('should return undefined for skill expansion without ARGUMENTS', () => {
|
|
786
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
787
|
+
|
|
788
|
+
const msg = 'Base directory for this skill: /some/path\n\nSome instructions only';
|
|
789
|
+
expect(extract(msg)).toBeUndefined();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('should filter noise messages', () => {
|
|
793
|
+
const extract = (adapter as any).extractUserMessageText.bind(adapter);
|
|
794
|
+
|
|
795
|
+
expect(extract('[Request interrupted by user]')).toBeUndefined();
|
|
796
|
+
expect(extract('Tool loaded.')).toBeUndefined();
|
|
797
|
+
expect(extract('This session is being continued from a previous conversation')).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
describe('parseCommandMessage', () => {
|
|
802
|
+
it('should return undefined for malformed command-message', () => {
|
|
803
|
+
const parse = (adapter as any).parseCommandMessage.bind(adapter);
|
|
804
|
+
expect(parse('<command-message>no tags</command-message>')).toBeUndefined();
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
describe('file I/O methods', () => {
|
|
810
|
+
let tmpDir: string;
|
|
811
|
+
|
|
812
|
+
beforeEach(() => {
|
|
813
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-test-'));
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
afterEach(() => {
|
|
817
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('tryPidFileMatching', () => {
|
|
821
|
+
let sessionsDir: string;
|
|
822
|
+
let projectsDir: string;
|
|
823
|
+
|
|
824
|
+
beforeEach(() => {
|
|
825
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
826
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
827
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
828
|
+
(adapter as any).sessionsDir = sessionsDir;
|
|
829
|
+
(adapter as any).projectsDir = projectsDir;
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const makeProc = (pid: number, cwd = '/project/test', startTime?: Date): ProcessInfo => ({
|
|
833
|
+
pid, command: 'claude', cwd, tty: 'ttys001', startTime,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const writePidFile = (pid: number, sessionId: string, cwd: string, startedAt: number) => {
|
|
837
|
+
fs.writeFileSync(
|
|
838
|
+
path.join(sessionsDir, `${pid}.json`),
|
|
839
|
+
JSON.stringify({ pid, sessionId, cwd, startedAt, kind: 'interactive', entrypoint: 'cli' }),
|
|
840
|
+
);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const writeJsonl = (cwd: string, sessionId: string) => {
|
|
844
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
845
|
+
const projDir = path.join(projectsDir, encoded);
|
|
846
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
847
|
+
const filePath = path.join(projDir, `${sessionId}.jsonl`);
|
|
848
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }));
|
|
849
|
+
return filePath;
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
it('should return direct match when PID file and JSONL both exist within time tolerance', () => {
|
|
853
|
+
const startTime = new Date();
|
|
854
|
+
const proc = makeProc(1001, '/project/test', startTime);
|
|
855
|
+
writePidFile(1001, 'session-abc', '/project/test', startTime.getTime());
|
|
856
|
+
writeJsonl('/project/test', 'session-abc');
|
|
857
|
+
|
|
858
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
859
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
860
|
+
|
|
861
|
+
expect(direct).toHaveLength(1);
|
|
862
|
+
expect(fallback).toHaveLength(0);
|
|
863
|
+
expect(direct[0].sessionFile.sessionId).toBe('session-abc');
|
|
864
|
+
expect(direct[0].sessionFile.resolvedCwd).toBe('/project/test');
|
|
865
|
+
expect(direct[0].process.pid).toBe(1001);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('should fall back when PID file exists but JSONL is missing', () => {
|
|
869
|
+
const startTime = new Date();
|
|
870
|
+
const proc = makeProc(1002, '/project/test', startTime);
|
|
871
|
+
writePidFile(1002, 'nonexistent-session', '/project/test', startTime.getTime());
|
|
872
|
+
// No JSONL file written
|
|
873
|
+
|
|
874
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
875
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
876
|
+
|
|
877
|
+
expect(direct).toHaveLength(0);
|
|
878
|
+
expect(fallback).toHaveLength(1);
|
|
879
|
+
expect(fallback[0].pid).toBe(1002);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('should fall back when startedAt is stale (>60s from proc.startTime)', () => {
|
|
883
|
+
const startTime = new Date();
|
|
884
|
+
const staleTime = startTime.getTime() - 90_000; // 90 seconds earlier
|
|
885
|
+
const proc = makeProc(1003, '/project/test', startTime);
|
|
886
|
+
writePidFile(1003, 'stale-session', '/project/test', staleTime);
|
|
887
|
+
writeJsonl('/project/test', 'stale-session');
|
|
888
|
+
|
|
889
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
890
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
891
|
+
|
|
892
|
+
expect(direct).toHaveLength(0);
|
|
893
|
+
expect(fallback).toHaveLength(1);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should accept PID file when startedAt is within 60s tolerance', () => {
|
|
897
|
+
const startTime = new Date();
|
|
898
|
+
const closeTime = startTime.getTime() - 30_000; // 30 seconds earlier — within tolerance
|
|
899
|
+
const proc = makeProc(1004, '/project/test', startTime);
|
|
900
|
+
writePidFile(1004, 'close-session', '/project/test', closeTime);
|
|
901
|
+
writeJsonl('/project/test', 'close-session');
|
|
902
|
+
|
|
903
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
904
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
905
|
+
|
|
906
|
+
expect(direct).toHaveLength(1);
|
|
907
|
+
expect(fallback).toHaveLength(0);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('should fall back when PID file is absent', () => {
|
|
911
|
+
const proc = makeProc(1005, '/project/test', new Date());
|
|
912
|
+
// No PID file written
|
|
913
|
+
|
|
914
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
915
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
916
|
+
|
|
917
|
+
expect(direct).toHaveLength(0);
|
|
918
|
+
expect(fallback).toHaveLength(1);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('should fall back when PID file contains malformed JSON', () => {
|
|
922
|
+
const proc = makeProc(1006, '/project/test', new Date());
|
|
923
|
+
fs.writeFileSync(path.join(sessionsDir, '1006.json'), 'not valid json {{{');
|
|
924
|
+
|
|
925
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
926
|
+
expect(() => {
|
|
927
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
928
|
+
expect(direct).toHaveLength(0);
|
|
929
|
+
expect(fallback).toHaveLength(1);
|
|
930
|
+
}).not.toThrow();
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should fall back for all processes when sessions dir does not exist', () => {
|
|
934
|
+
(adapter as any).sessionsDir = path.join(tmpDir, 'nonexistent-sessions');
|
|
935
|
+
const processes = [makeProc(2001, '/a', new Date()), makeProc(2002, '/b', new Date())];
|
|
936
|
+
|
|
937
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
938
|
+
const { direct, fallback } = tryMatch(processes);
|
|
939
|
+
|
|
940
|
+
expect(direct).toHaveLength(0);
|
|
941
|
+
expect(fallback).toHaveLength(2);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('should correctly split mixed processes (some with PID files, some without)', () => {
|
|
945
|
+
const startTime = new Date();
|
|
946
|
+
const proc1 = makeProc(3001, '/project/one', startTime);
|
|
947
|
+
const proc2 = makeProc(3002, '/project/two', startTime);
|
|
948
|
+
const proc3 = makeProc(3003, '/project/three', startTime);
|
|
949
|
+
|
|
950
|
+
writePidFile(3001, 'session-one', '/project/one', startTime.getTime());
|
|
951
|
+
writeJsonl('/project/one', 'session-one');
|
|
952
|
+
writePidFile(3003, 'session-three', '/project/three', startTime.getTime());
|
|
953
|
+
writeJsonl('/project/three', 'session-three');
|
|
954
|
+
// proc2 has no PID file
|
|
955
|
+
|
|
956
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
957
|
+
const { direct, fallback } = tryMatch([proc1, proc2, proc3]);
|
|
958
|
+
|
|
959
|
+
expect(direct).toHaveLength(2);
|
|
960
|
+
expect(fallback).toHaveLength(1);
|
|
961
|
+
expect(direct.map((d: any) => d.process.pid).sort()).toEqual([3001, 3003]);
|
|
962
|
+
expect(fallback[0].pid).toBe(3002);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('should skip stale-file check when proc.startTime is undefined', () => {
|
|
966
|
+
const proc = makeProc(4001, '/project/test', undefined); // no startTime
|
|
967
|
+
writePidFile(4001, 'no-time-session', '/project/test', Date.now() - 999_999);
|
|
968
|
+
writeJsonl('/project/test', 'no-time-session');
|
|
969
|
+
|
|
970
|
+
const tryMatch = (adapter as any).tryPidFileMatching.bind(adapter);
|
|
971
|
+
const { direct, fallback } = tryMatch([proc]);
|
|
972
|
+
|
|
973
|
+
// startTime undefined → stale check skipped → direct match
|
|
974
|
+
expect(direct).toHaveLength(1);
|
|
975
|
+
expect(fallback).toHaveLength(0);
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe('readSession', () => {
|
|
980
|
+
it('should parse session file with timestamps, slug, cwd, and entry type', () => {
|
|
981
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
982
|
+
|
|
983
|
+
const filePath = path.join(tmpDir, 'test-session.jsonl');
|
|
984
|
+
const lines = [
|
|
985
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/my/project', slug: 'happy-dog' }),
|
|
986
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
987
|
+
];
|
|
988
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
989
|
+
|
|
990
|
+
const session = readSession(filePath, '/my/project');
|
|
991
|
+
expect(session).toMatchObject({
|
|
992
|
+
sessionId: 'test-session',
|
|
993
|
+
projectPath: '/my/project',
|
|
994
|
+
slug: 'happy-dog',
|
|
995
|
+
lastCwd: '/my/project',
|
|
996
|
+
lastEntryType: 'assistant',
|
|
997
|
+
isInterrupted: false,
|
|
998
|
+
});
|
|
999
|
+
expect(session.sessionStart.toISOString()).toBe('2026-03-10T10:00:00.000Z');
|
|
1000
|
+
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('should detect user interruption', () => {
|
|
1004
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1005
|
+
|
|
1006
|
+
const filePath = path.join(tmpDir, 'interrupted.jsonl');
|
|
1007
|
+
const lines = [
|
|
1008
|
+
JSON.stringify({
|
|
1009
|
+
type: 'user',
|
|
1010
|
+
timestamp: '2026-03-10T10:00:00Z',
|
|
1011
|
+
message: {
|
|
1012
|
+
content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }],
|
|
1013
|
+
},
|
|
1014
|
+
}),
|
|
1015
|
+
];
|
|
1016
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1017
|
+
|
|
1018
|
+
const session = readSession(filePath, '/test');
|
|
1019
|
+
expect(session.isInterrupted).toBe(true);
|
|
1020
|
+
expect(session.lastEntryType).toBe('user');
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it('should return session with defaults for empty file', () => {
|
|
1024
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1025
|
+
|
|
1026
|
+
const filePath = path.join(tmpDir, 'empty.jsonl');
|
|
1027
|
+
fs.writeFileSync(filePath, '');
|
|
1028
|
+
|
|
1029
|
+
const session = readSession(filePath, '/test');
|
|
1030
|
+
expect(session).not.toBeNull();
|
|
1031
|
+
expect(session.lastEntryType).toBeUndefined();
|
|
1032
|
+
expect(session.slug).toBeUndefined();
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('should return null for non-existent file', () => {
|
|
1036
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1037
|
+
expect(readSession(path.join(tmpDir, 'nonexistent.jsonl'), '/test')).toBeNull();
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it('should skip metadata entry types for lastEntryType', () => {
|
|
1041
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1042
|
+
|
|
1043
|
+
const filePath = path.join(tmpDir, 'metadata-test.jsonl');
|
|
1044
|
+
const lines = [
|
|
1045
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'hello' } }),
|
|
1046
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1047
|
+
JSON.stringify({ type: 'last-prompt', timestamp: '2026-03-10T10:02:00Z' }),
|
|
1048
|
+
JSON.stringify({ type: 'file-history-snapshot', timestamp: '2026-03-10T10:03:00Z' }),
|
|
1049
|
+
];
|
|
1050
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1051
|
+
|
|
1052
|
+
const session = readSession(filePath, '/test');
|
|
1053
|
+
expect(session.lastEntryType).toBe('assistant');
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('should parse snapshot.timestamp from file-history-snapshot first entry', () => {
|
|
1057
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1058
|
+
|
|
1059
|
+
const filePath = path.join(tmpDir, 'snapshot-ts.jsonl');
|
|
1060
|
+
const lines = [
|
|
1061
|
+
JSON.stringify({
|
|
1062
|
+
type: 'file-history-snapshot',
|
|
1063
|
+
snapshot: { timestamp: '2026-03-10T09:55:00Z', files: [] },
|
|
1064
|
+
}),
|
|
1065
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'test' } }),
|
|
1066
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1067
|
+
];
|
|
1068
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1069
|
+
|
|
1070
|
+
const session = readSession(filePath, '/test');
|
|
1071
|
+
expect(session.sessionStart.toISOString()).toBe('2026-03-10T09:55:00.000Z');
|
|
1072
|
+
expect(session.lastActive.toISOString()).toBe('2026-03-10T10:01:00.000Z');
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('should extract lastUserMessage from session entries', () => {
|
|
1076
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1077
|
+
|
|
1078
|
+
const filePath = path.join(tmpDir, 'user-msg.jsonl');
|
|
1079
|
+
const lines = [
|
|
1080
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', message: { content: 'first question' } }),
|
|
1081
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:01:00Z' }),
|
|
1082
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:02:00Z', message: { content: [{ type: 'text', text: 'second question' }] } }),
|
|
1083
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:03:00Z' }),
|
|
1084
|
+
];
|
|
1085
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1086
|
+
|
|
1087
|
+
const session = readSession(filePath, '/test');
|
|
1088
|
+
expect(session.lastUserMessage).toBe('second question');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('should use lastCwd as projectPath when projectPath is empty', () => {
|
|
1092
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1093
|
+
|
|
1094
|
+
const filePath = path.join(tmpDir, 'no-project.jsonl');
|
|
1095
|
+
const lines = [
|
|
1096
|
+
JSON.stringify({ type: 'user', timestamp: '2026-03-10T10:00:00Z', cwd: '/derived/path', message: { content: 'test' } }),
|
|
1097
|
+
];
|
|
1098
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1099
|
+
|
|
1100
|
+
const session = readSession(filePath, '');
|
|
1101
|
+
expect(session.projectPath).toBe('/derived/path');
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('should handle malformed JSON lines gracefully', () => {
|
|
1105
|
+
const readSession = (adapter as any).readSession.bind(adapter);
|
|
1106
|
+
|
|
1107
|
+
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
1108
|
+
const lines = [
|
|
1109
|
+
'not json',
|
|
1110
|
+
JSON.stringify({ type: 'assistant', timestamp: '2026-03-10T10:00:00Z' }),
|
|
1111
|
+
];
|
|
1112
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
1113
|
+
|
|
1114
|
+
const session = readSession(filePath, '/test');
|
|
1115
|
+
expect(session).not.toBeNull();
|
|
1116
|
+
expect(session.lastEntryType).toBe('assistant');
|
|
401
1117
|
});
|
|
402
1118
|
});
|
|
403
1119
|
});
|