@herdctl/core 5.6.0 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/config/__tests__/merge.test.js +1 -1
  2. package/dist/config/__tests__/merge.test.js.map +1 -1
  3. package/dist/config/schema.d.ts +10 -2
  4. package/dist/config/schema.d.ts.map +1 -1
  5. package/dist/config/schema.js +6 -2
  6. package/dist/config/schema.js.map +1 -1
  7. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  8. package/dist/scheduler/schedule-runner.js +6 -5
  9. package/dist/scheduler/schedule-runner.js.map +1 -1
  10. package/dist/state/__tests__/jsonl-parser.test.d.ts +5 -0
  11. package/dist/state/__tests__/jsonl-parser.test.d.ts.map +1 -0
  12. package/dist/state/__tests__/jsonl-parser.test.js +332 -0
  13. package/dist/state/__tests__/jsonl-parser.test.js.map +1 -0
  14. package/dist/state/__tests__/session-attribution.test.d.ts +2 -0
  15. package/dist/state/__tests__/session-attribution.test.d.ts.map +1 -0
  16. package/dist/state/__tests__/session-attribution.test.js +567 -0
  17. package/dist/state/__tests__/session-attribution.test.js.map +1 -0
  18. package/dist/state/__tests__/session-discovery.test.d.ts +2 -0
  19. package/dist/state/__tests__/session-discovery.test.d.ts.map +1 -0
  20. package/dist/state/__tests__/session-discovery.test.js +953 -0
  21. package/dist/state/__tests__/session-discovery.test.js.map +1 -0
  22. package/dist/state/__tests__/session-metadata.test.d.ts +2 -0
  23. package/dist/state/__tests__/session-metadata.test.d.ts.map +1 -0
  24. package/dist/state/__tests__/session-metadata.test.js +474 -0
  25. package/dist/state/__tests__/session-metadata.test.js.map +1 -0
  26. package/dist/state/__tests__/tool-parsing.test.d.ts +5 -0
  27. package/dist/state/__tests__/tool-parsing.test.d.ts.map +1 -0
  28. package/dist/state/__tests__/tool-parsing.test.js +315 -0
  29. package/dist/state/__tests__/tool-parsing.test.js.map +1 -0
  30. package/dist/state/index.d.ts +5 -0
  31. package/dist/state/index.d.ts.map +1 -1
  32. package/dist/state/index.js +10 -0
  33. package/dist/state/index.js.map +1 -1
  34. package/dist/state/jsonl-parser.d.ts +126 -0
  35. package/dist/state/jsonl-parser.d.ts.map +1 -0
  36. package/dist/state/jsonl-parser.js +482 -0
  37. package/dist/state/jsonl-parser.js.map +1 -0
  38. package/dist/state/session-attribution.d.ts +35 -0
  39. package/dist/state/session-attribution.d.ts.map +1 -0
  40. package/dist/state/session-attribution.js +179 -0
  41. package/dist/state/session-attribution.js.map +1 -0
  42. package/dist/state/session-discovery.d.ts +198 -0
  43. package/dist/state/session-discovery.d.ts.map +1 -0
  44. package/dist/state/session-discovery.js +555 -0
  45. package/dist/state/session-discovery.js.map +1 -0
  46. package/dist/state/session-metadata.d.ts +240 -0
  47. package/dist/state/session-metadata.d.ts.map +1 -0
  48. package/dist/state/session-metadata.js +377 -0
  49. package/dist/state/session-metadata.js.map +1 -0
  50. package/dist/state/tool-parsing.d.ts +88 -0
  51. package/dist/state/tool-parsing.d.ts.map +1 -0
  52. package/dist/state/tool-parsing.js +199 -0
  53. package/dist/state/tool-parsing.js.map +1 -0
  54. package/package.json +1 -1
@@ -0,0 +1,953 @@
1
+ import { mkdir, realpath, rm, utimes, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ // =============================================================================
6
+ // Mocks
7
+ // =============================================================================
8
+ // Mock session-attribution
9
+ vi.mock("../session-attribution.js", () => ({
10
+ buildAttributionIndex: vi.fn(),
11
+ }));
12
+ // Mock jsonl-parser
13
+ vi.mock("../jsonl-parser.js", () => ({
14
+ extractFirstMessagePreview: vi.fn().mockResolvedValue(undefined),
15
+ extractLastSummary: vi.fn(),
16
+ extractSessionMetadata: vi.fn(),
17
+ extractSessionUsage: vi.fn(),
18
+ isSidechainSession: vi.fn().mockResolvedValue(false),
19
+ parseSessionMessages: vi.fn(),
20
+ }));
21
+ // Mock session-metadata - use a class for proper constructor behavior
22
+ const mockGetCustomName = vi.fn().mockResolvedValue(undefined);
23
+ const mockGetAutoName = vi.fn().mockResolvedValue(undefined);
24
+ const mockBatchSetAutoNames = vi.fn().mockResolvedValue(undefined);
25
+ const mockGetPreview = vi.fn().mockResolvedValue(undefined);
26
+ const mockBatchSetPreviews = vi.fn().mockResolvedValue(undefined);
27
+ vi.mock("../session-metadata.js", () => {
28
+ return {
29
+ SessionMetadataStore: class MockSessionMetadataStore {
30
+ getCustomName = mockGetCustomName;
31
+ getAutoName = mockGetAutoName;
32
+ batchSetAutoNames = mockBatchSetAutoNames;
33
+ getPreview = mockGetPreview;
34
+ batchSetPreviews = mockBatchSetPreviews;
35
+ },
36
+ };
37
+ });
38
+ import { extractFirstMessagePreview, extractLastSummary, extractSessionMetadata, extractSessionUsage, isSidechainSession, parseSessionMessages, } from "../jsonl-parser.js";
39
+ // Import after mocks
40
+ import { buildAttributionIndex } from "../session-attribution.js";
41
+ import { SessionDiscoveryService } from "../session-discovery.js";
42
+ const mockBuildAttributionIndex = vi.mocked(buildAttributionIndex);
43
+ const mockExtractFirstMessagePreview = vi.mocked(extractFirstMessagePreview);
44
+ const mockExtractLastSummary = vi.mocked(extractLastSummary);
45
+ const mockExtractSessionMetadata = vi.mocked(extractSessionMetadata);
46
+ const mockExtractSessionUsage = vi.mocked(extractSessionUsage);
47
+ const mockIsSidechainSession = vi.mocked(isSidechainSession);
48
+ const mockParseSessionMessages = vi.mocked(parseSessionMessages);
49
+ // =============================================================================
50
+ // Test Helpers
51
+ // =============================================================================
52
+ /**
53
+ * Create a temporary directory with a unique name
54
+ */
55
+ async function createTempDir(prefix) {
56
+ const baseDir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
57
+ await mkdir(baseDir, { recursive: true });
58
+ // Resolve to real path to handle macOS /var -> /private/var symlink
59
+ return await realpath(baseDir);
60
+ }
61
+ /**
62
+ * Create an empty .jsonl session file
63
+ */
64
+ async function createSessionFile(dir, sessionId) {
65
+ await writeFile(join(dir, `${sessionId}.jsonl`), "");
66
+ }
67
+ /**
68
+ * Create a default mock attribution index
69
+ */
70
+ function createMockAttributionIndex(overrides) {
71
+ const agentName = overrides?.defaultAgentName ?? "my-agent";
72
+ const defaultGetAttribute = (sessionId) => ({
73
+ origin: "native",
74
+ agentName,
75
+ triggerType: undefined,
76
+ });
77
+ const getAttribute = overrides?.getAttribute ?? defaultGetAttribute;
78
+ return {
79
+ getAttribute,
80
+ getAttributes: (ids) => new Map(ids.map((id) => [id, getAttribute(id)])),
81
+ size: 0,
82
+ };
83
+ }
84
+ // =============================================================================
85
+ // Tests
86
+ // =============================================================================
87
+ describe("SessionDiscoveryService", () => {
88
+ let tempClaudeHome;
89
+ let tempStateDir;
90
+ beforeEach(async () => {
91
+ tempClaudeHome = await createTempDir("claude-home-test");
92
+ tempStateDir = await createTempDir("state-dir-test");
93
+ // Set up default attribution mock
94
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex());
95
+ // Reset metadata store mocks to default
96
+ mockGetCustomName.mockReset();
97
+ mockGetCustomName.mockResolvedValue(undefined);
98
+ mockGetAutoName.mockReset();
99
+ mockGetAutoName.mockResolvedValue(undefined);
100
+ mockBatchSetAutoNames.mockReset();
101
+ mockBatchSetAutoNames.mockResolvedValue(undefined);
102
+ mockGetPreview.mockReset();
103
+ mockGetPreview.mockResolvedValue(undefined);
104
+ mockBatchSetPreviews.mockReset();
105
+ mockBatchSetPreviews.mockResolvedValue(undefined);
106
+ // Reset JSONL parser mocks
107
+ mockExtractLastSummary.mockReset();
108
+ mockExtractLastSummary.mockResolvedValue(undefined);
109
+ mockExtractFirstMessagePreview.mockReset();
110
+ mockExtractFirstMessagePreview.mockResolvedValue(undefined);
111
+ });
112
+ afterEach(async () => {
113
+ await rm(tempClaudeHome, { recursive: true, force: true });
114
+ await rm(tempStateDir, { recursive: true, force: true });
115
+ vi.restoreAllMocks();
116
+ });
117
+ // ===========================================================================
118
+ // getAgentSessions
119
+ // ===========================================================================
120
+ describe("getAgentSessions", () => {
121
+ it("returns sessions from agent's working directory, sorted by mtime descending", async () => {
122
+ // Create projects directory structure
123
+ const workingDir = "/Users/ed/Code/myproject";
124
+ const encodedPath = "-Users-ed-Code-myproject";
125
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
126
+ await mkdir(projectDir, { recursive: true });
127
+ // Create session files with different mtimes
128
+ const now = Date.now();
129
+ await createSessionFile(projectDir, "session-older");
130
+ await createSessionFile(projectDir, "session-newer");
131
+ // Set mtimes explicitly
132
+ const olderTime = new Date(now - 10000);
133
+ const newerTime = new Date(now);
134
+ await utimes(join(projectDir, "session-older.jsonl"), olderTime, olderTime);
135
+ await utimes(join(projectDir, "session-newer.jsonl"), newerTime, newerTime);
136
+ const service = new SessionDiscoveryService({
137
+ claudeHomePath: tempClaudeHome,
138
+ stateDir: tempStateDir,
139
+ });
140
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
141
+ expect(sessions).toHaveLength(2);
142
+ // Newest first
143
+ expect(sessions[0].sessionId).toBe("session-newer");
144
+ expect(sessions[1].sessionId).toBe("session-older");
145
+ });
146
+ it("returns empty array when projects directory doesn't exist", async () => {
147
+ const service = new SessionDiscoveryService({
148
+ claudeHomePath: tempClaudeHome,
149
+ stateDir: tempStateDir,
150
+ });
151
+ // Directory doesn't exist at all
152
+ const sessions = await service.getAgentSessions("my-agent", "/nonexistent/path", false);
153
+ expect(sessions).toEqual([]);
154
+ });
155
+ it("returns empty array when no .jsonl files in directory", async () => {
156
+ const workingDir = "/Users/ed/Code/emptyproject";
157
+ const encodedPath = "-Users-ed-Code-emptyproject";
158
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
159
+ await mkdir(projectDir, { recursive: true });
160
+ // Create a non-.jsonl file
161
+ await writeFile(join(projectDir, "readme.txt"), "hello");
162
+ const service = new SessionDiscoveryService({
163
+ claudeHomePath: tempClaudeHome,
164
+ stateDir: tempStateDir,
165
+ });
166
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
167
+ expect(sessions).toEqual([]);
168
+ });
169
+ it("includes attribution data from the attribution index", async () => {
170
+ const workingDir = "/Users/ed/Code/myproject";
171
+ const encodedPath = "-Users-ed-Code-myproject";
172
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
173
+ await mkdir(projectDir, { recursive: true });
174
+ await createSessionFile(projectDir, "session-abc");
175
+ // Set up attribution mock to return discord origin attributed to the requested agent
176
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
177
+ getAttribute: (sessionId) => ({
178
+ origin: "discord",
179
+ agentName: "my-agent",
180
+ triggerType: "discord",
181
+ }),
182
+ }));
183
+ const service = new SessionDiscoveryService({
184
+ claudeHomePath: tempClaudeHome,
185
+ stateDir: tempStateDir,
186
+ });
187
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
188
+ expect(sessions[0].origin).toBe("discord");
189
+ expect(sessions[0].agentName).toBe("my-agent");
190
+ });
191
+ it("includes custom name from metadata store", async () => {
192
+ const workingDir = "/Users/ed/Code/myproject";
193
+ const encodedPath = "-Users-ed-Code-myproject";
194
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
195
+ await mkdir(projectDir, { recursive: true });
196
+ await createSessionFile(projectDir, "session-abc");
197
+ // Set up metadata store mock to return a custom name
198
+ mockGetCustomName.mockResolvedValue("My Custom Session");
199
+ const service = new SessionDiscoveryService({
200
+ claudeHomePath: tempClaudeHome,
201
+ stateDir: tempStateDir,
202
+ });
203
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
204
+ expect(sessions[0].customName).toBe("My Custom Session");
205
+ expect(mockGetCustomName).toHaveBeenCalledWith("my-agent", "session-abc");
206
+ });
207
+ it("sets resumable: true for non-Docker agents", async () => {
208
+ const workingDir = "/Users/ed/Code/myproject";
209
+ const encodedPath = "-Users-ed-Code-myproject";
210
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
211
+ await mkdir(projectDir, { recursive: true });
212
+ await createSessionFile(projectDir, "session-abc");
213
+ const service = new SessionDiscoveryService({
214
+ claudeHomePath: tempClaudeHome,
215
+ stateDir: tempStateDir,
216
+ });
217
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
218
+ expect(sessions[0].resumable).toBe(true);
219
+ });
220
+ it("sets resumable: false for Docker agents", async () => {
221
+ const workingDir = "/Users/ed/Code/myproject";
222
+ const encodedPath = "-Users-ed-Code-myproject";
223
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
224
+ await mkdir(projectDir, { recursive: true });
225
+ await createSessionFile(projectDir, "session-abc");
226
+ const service = new SessionDiscoveryService({
227
+ claudeHomePath: tempClaudeHome,
228
+ stateDir: tempStateDir,
229
+ });
230
+ const sessions = await service.getAgentSessions("my-agent", workingDir, true);
231
+ expect(sessions[0].resumable).toBe(false);
232
+ });
233
+ it("cache behavior: second call within TTL uses cache (doesn't re-readdir)", async () => {
234
+ const workingDir = "/Users/ed/Code/myproject";
235
+ const encodedPath = "-Users-ed-Code-myproject";
236
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
237
+ await mkdir(projectDir, { recursive: true });
238
+ await createSessionFile(projectDir, "session-abc");
239
+ const service = new SessionDiscoveryService({
240
+ claudeHomePath: tempClaudeHome,
241
+ stateDir: tempStateDir,
242
+ cacheTtlMs: 5000, // 5 second TTL
243
+ });
244
+ // First call
245
+ const sessions1 = await service.getAgentSessions("my-agent", workingDir, false);
246
+ expect(sessions1).toHaveLength(1);
247
+ // Add a new session file
248
+ await createSessionFile(projectDir, "session-def");
249
+ // Second call within TTL - should return cached result
250
+ const sessions2 = await service.getAgentSessions("my-agent", workingDir, false);
251
+ expect(sessions2).toHaveLength(1); // Still 1, from cache
252
+ });
253
+ it("cache behavior: after TTL expires, re-reads directory", async () => {
254
+ const workingDir = "/Users/ed/Code/myproject";
255
+ const encodedPath = "-Users-ed-Code-myproject";
256
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
257
+ await mkdir(projectDir, { recursive: true });
258
+ await createSessionFile(projectDir, "session-abc");
259
+ const service = new SessionDiscoveryService({
260
+ claudeHomePath: tempClaudeHome,
261
+ stateDir: tempStateDir,
262
+ cacheTtlMs: 50, // Very short TTL for testing
263
+ });
264
+ // First call
265
+ const sessions1 = await service.getAgentSessions("my-agent", workingDir, false);
266
+ expect(sessions1).toHaveLength(1);
267
+ // Add a new session file
268
+ await createSessionFile(projectDir, "session-def");
269
+ // Wait for TTL to expire
270
+ await new Promise((resolve) => setTimeout(resolve, 100));
271
+ // Third call after TTL - should read new file
272
+ const sessions3 = await service.getAgentSessions("my-agent", workingDir, false);
273
+ expect(sessions3).toHaveLength(2);
274
+ });
275
+ it("preview field is undefined when session has no user messages", async () => {
276
+ const workingDir = "/Users/ed/Code/myproject";
277
+ const encodedPath = "-Users-ed-Code-myproject";
278
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
279
+ await mkdir(projectDir, { recursive: true });
280
+ await createSessionFile(projectDir, "session-abc");
281
+ const service = new SessionDiscoveryService({
282
+ claudeHomePath: tempClaudeHome,
283
+ stateDir: tempStateDir,
284
+ });
285
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
286
+ expect(sessions[0].preview).toBeUndefined();
287
+ });
288
+ it("only returns sessions attributed to the requested agent", async () => {
289
+ const workingDir = "/Users/ed/Code/myproject";
290
+ const encodedPath = "-Users-ed-Code-myproject";
291
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
292
+ await mkdir(projectDir, { recursive: true });
293
+ await createSessionFile(projectDir, "session-abc");
294
+ // Attribution points to a different agent
295
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
296
+ getAttribute: () => ({
297
+ origin: "web",
298
+ agentName: "other-agent",
299
+ triggerType: "web",
300
+ }),
301
+ }));
302
+ const service = new SessionDiscoveryService({
303
+ claudeHomePath: tempClaudeHome,
304
+ stateDir: tempStateDir,
305
+ });
306
+ // Requesting sessions for "my-agent" should not include sessions attributed to "other-agent"
307
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
308
+ expect(sessions).toHaveLength(0);
309
+ });
310
+ it("excludes unattributed sessions from per-agent results", async () => {
311
+ const workingDir = "/Users/ed/Code/myproject";
312
+ const encodedPath = "-Users-ed-Code-myproject";
313
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
314
+ await mkdir(projectDir, { recursive: true });
315
+ await createSessionFile(projectDir, "session-abc");
316
+ // Attribution has no agentName (native CLI session)
317
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
318
+ getAttribute: () => ({
319
+ origin: "native",
320
+ agentName: undefined,
321
+ triggerType: undefined,
322
+ }),
323
+ }));
324
+ const service = new SessionDiscoveryService({
325
+ claudeHomePath: tempClaudeHome,
326
+ stateDir: tempStateDir,
327
+ });
328
+ // Unattributed sessions should not appear in per-agent results
329
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
330
+ expect(sessions).toHaveLength(0);
331
+ });
332
+ });
333
+ // ===========================================================================
334
+ // getAllSessions
335
+ // ===========================================================================
336
+ describe("getAllSessions", () => {
337
+ it("returns directory groups for all project directories", async () => {
338
+ // Create multiple project directories
339
+ const projectDir1 = join(tempClaudeHome, "projects", "-Users-ed-Code-project1");
340
+ const projectDir2 = join(tempClaudeHome, "projects", "-Users-ed-Code-project2");
341
+ await mkdir(projectDir1, { recursive: true });
342
+ await mkdir(projectDir2, { recursive: true });
343
+ await createSessionFile(projectDir1, "session-a");
344
+ await createSessionFile(projectDir2, "session-b");
345
+ const service = new SessionDiscoveryService({
346
+ claudeHomePath: tempClaudeHome,
347
+ stateDir: tempStateDir,
348
+ });
349
+ const groups = await service.getAllSessions([]);
350
+ expect(groups).toHaveLength(2);
351
+ expect(groups.map((g) => g.encodedPath).sort()).toEqual([
352
+ "-Users-ed-Code-project1",
353
+ "-Users-ed-Code-project2",
354
+ ]);
355
+ });
356
+ it("matches agent directories to fleet agents by encoded path", async () => {
357
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
358
+ await mkdir(projectDir, { recursive: true });
359
+ await createSessionFile(projectDir, "session-a");
360
+ const service = new SessionDiscoveryService({
361
+ claudeHomePath: tempClaudeHome,
362
+ stateDir: tempStateDir,
363
+ });
364
+ const groups = await service.getAllSessions([
365
+ {
366
+ name: "my-fleet/my-agent",
367
+ workingDirectory: "/Users/ed/Code/myproject",
368
+ dockerEnabled: false,
369
+ },
370
+ ]);
371
+ expect(groups).toHaveLength(1);
372
+ expect(groups[0].agentName).toBe("my-fleet/my-agent");
373
+ });
374
+ it("filters out temp directories (paths starting with /tmp/)", async () => {
375
+ // Create a temp-like directory
376
+ const tempProjectDir = join(tempClaudeHome, "projects", "-tmp-test-project");
377
+ await mkdir(tempProjectDir, { recursive: true });
378
+ await createSessionFile(tempProjectDir, "session-temp");
379
+ // Create a normal directory
380
+ const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
381
+ await mkdir(normalProjectDir, { recursive: true });
382
+ await createSessionFile(normalProjectDir, "session-normal");
383
+ const service = new SessionDiscoveryService({
384
+ claudeHomePath: tempClaudeHome,
385
+ stateDir: tempStateDir,
386
+ });
387
+ const groups = await service.getAllSessions([]);
388
+ // Only the normal directory should be returned
389
+ expect(groups).toHaveLength(1);
390
+ expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
391
+ });
392
+ it("filters out temp directories (paths containing /var/folders/)", async () => {
393
+ // Create a var/folders-like directory
394
+ const varFoldersDir = join(tempClaudeHome, "projects", "-var-folders-ab-cd-T-test");
395
+ await mkdir(varFoldersDir, { recursive: true });
396
+ await createSessionFile(varFoldersDir, "session-temp");
397
+ // Create a normal directory
398
+ const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
399
+ await mkdir(normalProjectDir, { recursive: true });
400
+ await createSessionFile(normalProjectDir, "session-normal");
401
+ const service = new SessionDiscoveryService({
402
+ claudeHomePath: tempClaudeHome,
403
+ stateDir: tempStateDir,
404
+ });
405
+ const groups = await service.getAllSessions([]);
406
+ // Only the normal directory should be returned
407
+ expect(groups).toHaveLength(1);
408
+ expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
409
+ });
410
+ it("returns empty array when projects directory doesn't exist", async () => {
411
+ // Don't create the projects directory
412
+ const service = new SessionDiscoveryService({
413
+ claudeHomePath: tempClaudeHome,
414
+ stateDir: tempStateDir,
415
+ });
416
+ const groups = await service.getAllSessions([]);
417
+ expect(groups).toEqual([]);
418
+ });
419
+ it("sorts groups by most recent session mtime", async () => {
420
+ const now = Date.now();
421
+ // Create project directories with sessions at different times
422
+ const olderProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-older");
423
+ const newerProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-newer");
424
+ await mkdir(olderProjectDir, { recursive: true });
425
+ await mkdir(newerProjectDir, { recursive: true });
426
+ await createSessionFile(olderProjectDir, "session-old");
427
+ await createSessionFile(newerProjectDir, "session-new");
428
+ // Set mtimes
429
+ const olderTime = new Date(now - 10000);
430
+ const newerTime = new Date(now);
431
+ await utimes(join(olderProjectDir, "session-old.jsonl"), olderTime, olderTime);
432
+ await utimes(join(newerProjectDir, "session-new.jsonl"), newerTime, newerTime);
433
+ const service = new SessionDiscoveryService({
434
+ claudeHomePath: tempClaudeHome,
435
+ stateDir: tempStateDir,
436
+ });
437
+ const groups = await service.getAllSessions([]);
438
+ expect(groups).toHaveLength(2);
439
+ // Newest first
440
+ expect(groups[0].encodedPath).toBe("-Users-ed-Code-newer");
441
+ expect(groups[1].encodedPath).toBe("-Users-ed-Code-older");
442
+ });
443
+ it("skips directories with no .jsonl files", async () => {
444
+ // Create a project directory with only non-jsonl files
445
+ const emptyProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-empty");
446
+ await mkdir(emptyProjectDir, { recursive: true });
447
+ await writeFile(join(emptyProjectDir, "readme.txt"), "hello");
448
+ // Create a normal project directory
449
+ const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
450
+ await mkdir(normalProjectDir, { recursive: true });
451
+ await createSessionFile(normalProjectDir, "session-a");
452
+ const service = new SessionDiscoveryService({
453
+ claudeHomePath: tempClaudeHome,
454
+ stateDir: tempStateDir,
455
+ });
456
+ const groups = await service.getAllSessions([]);
457
+ expect(groups).toHaveLength(1);
458
+ expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
459
+ });
460
+ it("skips non-directory entries in projects folder", async () => {
461
+ // Create a file in the projects directory
462
+ const projectsDir = join(tempClaudeHome, "projects");
463
+ await mkdir(projectsDir, { recursive: true });
464
+ await writeFile(join(projectsDir, "some-file.txt"), "hello");
465
+ // Create a normal project directory
466
+ const normalProjectDir = join(projectsDir, "-Users-ed-Code-normal");
467
+ await mkdir(normalProjectDir, { recursive: true });
468
+ await createSessionFile(normalProjectDir, "session-a");
469
+ const service = new SessionDiscoveryService({
470
+ claudeHomePath: tempClaudeHome,
471
+ stateDir: tempStateDir,
472
+ });
473
+ const groups = await service.getAllSessions([]);
474
+ expect(groups).toHaveLength(1);
475
+ expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
476
+ });
477
+ it("sets resumable based on agent dockerEnabled", async () => {
478
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
479
+ await mkdir(projectDir, { recursive: true });
480
+ await createSessionFile(projectDir, "session-a");
481
+ const service = new SessionDiscoveryService({
482
+ claudeHomePath: tempClaudeHome,
483
+ stateDir: tempStateDir,
484
+ });
485
+ const groups = await service.getAllSessions([
486
+ {
487
+ name: "docker-agent",
488
+ workingDirectory: "/Users/ed/Code/myproject",
489
+ dockerEnabled: true,
490
+ },
491
+ ]);
492
+ expect(groups[0].sessions[0].resumable).toBe(false);
493
+ });
494
+ it("defaults resumable to true for unmatched directories", async () => {
495
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
496
+ await mkdir(projectDir, { recursive: true });
497
+ await createSessionFile(projectDir, "session-a");
498
+ const service = new SessionDiscoveryService({
499
+ claudeHomePath: tempClaudeHome,
500
+ stateDir: tempStateDir,
501
+ });
502
+ // No matching agents
503
+ const groups = await service.getAllSessions([]);
504
+ expect(groups[0].sessions[0].resumable).toBe(true);
505
+ });
506
+ it("decodes workingDirectory from encoded path", async () => {
507
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
508
+ await mkdir(projectDir, { recursive: true });
509
+ await createSessionFile(projectDir, "session-a");
510
+ const service = new SessionDiscoveryService({
511
+ claudeHomePath: tempClaudeHome,
512
+ stateDir: tempStateDir,
513
+ });
514
+ const groups = await service.getAllSessions([]);
515
+ expect(groups[0].workingDirectory).toBe("/Users/ed/Code/myproject");
516
+ });
517
+ });
518
+ // ===========================================================================
519
+ // Delegation methods
520
+ // ===========================================================================
521
+ describe("getSessionMessages", () => {
522
+ it("delegates to parseSessionMessages", async () => {
523
+ const mockMessages = [
524
+ { role: "user", content: "Hello", timestamp: "2024-01-15T10:00:00Z" },
525
+ ];
526
+ mockParseSessionMessages.mockResolvedValue(mockMessages);
527
+ const service = new SessionDiscoveryService({
528
+ claudeHomePath: tempClaudeHome,
529
+ stateDir: tempStateDir,
530
+ });
531
+ const result = await service.getSessionMessages("/Users/ed/Code/myproject", "session-abc");
532
+ expect(result).toEqual(mockMessages);
533
+ expect(mockParseSessionMessages).toHaveBeenCalled();
534
+ });
535
+ });
536
+ describe("getSessionMetadata", () => {
537
+ it("delegates to extractSessionMetadata", async () => {
538
+ const mockMetadata = {
539
+ sessionId: "session-abc",
540
+ firstMessagePreview: "Hello world",
541
+ gitBranch: "main",
542
+ claudeCodeVersion: "1.0.0",
543
+ messageCount: 10,
544
+ firstMessageAt: "2024-01-15T10:00:00Z",
545
+ lastMessageAt: "2024-01-15T11:00:00Z",
546
+ summary: undefined,
547
+ isSidechain: false,
548
+ };
549
+ mockExtractSessionMetadata.mockResolvedValue(mockMetadata);
550
+ const service = new SessionDiscoveryService({
551
+ claudeHomePath: tempClaudeHome,
552
+ stateDir: tempStateDir,
553
+ });
554
+ const result = await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
555
+ expect(result).toEqual(mockMetadata);
556
+ expect(mockExtractSessionMetadata).toHaveBeenCalled();
557
+ });
558
+ it("caches metadata on subsequent calls", async () => {
559
+ // Clear the mock call count before this test
560
+ mockExtractSessionMetadata.mockClear();
561
+ const mockMetadata = {
562
+ sessionId: "session-abc",
563
+ firstMessagePreview: "Hello",
564
+ gitBranch: undefined,
565
+ claudeCodeVersion: undefined,
566
+ messageCount: 1,
567
+ firstMessageAt: "2024-01-15T10:00:00Z",
568
+ lastMessageAt: "2024-01-15T10:00:00Z",
569
+ summary: undefined,
570
+ isSidechain: false,
571
+ };
572
+ mockExtractSessionMetadata.mockResolvedValue(mockMetadata);
573
+ const service = new SessionDiscoveryService({
574
+ claudeHomePath: tempClaudeHome,
575
+ stateDir: tempStateDir,
576
+ });
577
+ // First call
578
+ await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
579
+ // Second call
580
+ await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
581
+ // Should only be called once due to caching
582
+ expect(mockExtractSessionMetadata).toHaveBeenCalledTimes(1);
583
+ });
584
+ });
585
+ describe("getSessionUsage", () => {
586
+ it("delegates to extractSessionUsage", async () => {
587
+ const mockUsage = {
588
+ inputTokens: 1000,
589
+ turnCount: 5,
590
+ hasData: true,
591
+ };
592
+ mockExtractSessionUsage.mockResolvedValue(mockUsage);
593
+ const service = new SessionDiscoveryService({
594
+ claudeHomePath: tempClaudeHome,
595
+ stateDir: tempStateDir,
596
+ });
597
+ const result = await service.getSessionUsage("/Users/ed/Code/myproject", "session-abc");
598
+ expect(result).toEqual(mockUsage);
599
+ expect(mockExtractSessionUsage).toHaveBeenCalled();
600
+ });
601
+ });
602
+ // ===========================================================================
603
+ // Cache invalidation
604
+ // ===========================================================================
605
+ describe("invalidateCache", () => {
606
+ it("invalidateCache() with no args clears all caches", async () => {
607
+ const workingDir = "/Users/ed/Code/myproject";
608
+ const encodedPath = "-Users-ed-Code-myproject";
609
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
610
+ await mkdir(projectDir, { recursive: true });
611
+ await createSessionFile(projectDir, "session-abc");
612
+ const service = new SessionDiscoveryService({
613
+ claudeHomePath: tempClaudeHome,
614
+ stateDir: tempStateDir,
615
+ cacheTtlMs: 60000, // Long TTL
616
+ });
617
+ // Populate caches
618
+ await service.getAgentSessions("my-agent", workingDir, false);
619
+ // Add a new session
620
+ await createSessionFile(projectDir, "session-def");
621
+ // Verify cache is still returning old data
622
+ const beforeInvalidate = await service.getAgentSessions("my-agent", workingDir, false);
623
+ expect(beforeInvalidate).toHaveLength(1);
624
+ // Invalidate all caches
625
+ service.invalidateCache();
626
+ // Should now see new session
627
+ const afterInvalidate = await service.getAgentSessions("my-agent", workingDir, false);
628
+ expect(afterInvalidate).toHaveLength(2);
629
+ });
630
+ it("invalidateCache(workingDirectory) clears only that directory's cache", async () => {
631
+ const workingDir1 = "/Users/ed/Code/project1";
632
+ const workingDir2 = "/Users/ed/Code/project2";
633
+ const encodedPath1 = "-Users-ed-Code-project1";
634
+ const encodedPath2 = "-Users-ed-Code-project2";
635
+ const projectDir1 = join(tempClaudeHome, "projects", encodedPath1);
636
+ const projectDir2 = join(tempClaudeHome, "projects", encodedPath2);
637
+ await mkdir(projectDir1, { recursive: true });
638
+ await mkdir(projectDir2, { recursive: true });
639
+ await createSessionFile(projectDir1, "session-a");
640
+ await createSessionFile(projectDir2, "session-b");
641
+ // Map sessions to their owning agents so the attribution filter passes
642
+ const sessionAgentMap = {
643
+ "session-a": "agent-1",
644
+ "session-a2": "agent-1",
645
+ "session-b": "agent-2",
646
+ "session-b2": "agent-2",
647
+ };
648
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
649
+ getAttribute: (sessionId) => ({
650
+ origin: "native",
651
+ agentName: sessionAgentMap[sessionId],
652
+ triggerType: undefined,
653
+ }),
654
+ }));
655
+ const service = new SessionDiscoveryService({
656
+ claudeHomePath: tempClaudeHome,
657
+ stateDir: tempStateDir,
658
+ cacheTtlMs: 60000,
659
+ });
660
+ // Populate caches for both directories
661
+ await service.getAgentSessions("agent-1", workingDir1, false);
662
+ await service.getAgentSessions("agent-2", workingDir2, false);
663
+ // Add new sessions to both
664
+ await createSessionFile(projectDir1, "session-a2");
665
+ await createSessionFile(projectDir2, "session-b2");
666
+ // Invalidate only project1's cache
667
+ service.invalidateCache(workingDir1);
668
+ // Project1 should see new session
669
+ const sessions1 = await service.getAgentSessions("agent-1", workingDir1, false);
670
+ expect(sessions1).toHaveLength(2);
671
+ // Project2 should still return cached (1 session)
672
+ const sessions2 = await service.getAgentSessions("agent-2", workingDir2, false);
673
+ expect(sessions2).toHaveLength(1);
674
+ });
675
+ });
676
+ // ===========================================================================
677
+ // Edge cases
678
+ // ===========================================================================
679
+ describe("edge cases", () => {
680
+ it("handles Windows-style encoded paths", async () => {
681
+ // Windows path: C:\Users\ed\Code\myproject encodes to C:-Users-ed-Code-myproject
682
+ const projectDir = join(tempClaudeHome, "projects", "C:-Users-ed-Code-myproject");
683
+ await mkdir(projectDir, { recursive: true });
684
+ await createSessionFile(projectDir, "session-a");
685
+ const service = new SessionDiscoveryService({
686
+ claudeHomePath: tempClaudeHome,
687
+ stateDir: tempStateDir,
688
+ });
689
+ const groups = await service.getAllSessions([]);
690
+ expect(groups).toHaveLength(1);
691
+ expect(groups[0].workingDirectory).toBe("C:/Users/ed/Code/myproject");
692
+ });
693
+ it("handles file being deleted between readdir and stat", async () => {
694
+ const workingDir = "/Users/ed/Code/myproject";
695
+ const encodedPath = "-Users-ed-Code-myproject";
696
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
697
+ await mkdir(projectDir, { recursive: true });
698
+ // Create two sessions
699
+ await createSessionFile(projectDir, "session-a");
700
+ await createSessionFile(projectDir, "session-b");
701
+ const service = new SessionDiscoveryService({
702
+ claudeHomePath: tempClaudeHome,
703
+ stateDir: tempStateDir,
704
+ });
705
+ // This should handle the race condition gracefully
706
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
707
+ expect(sessions.length).toBeGreaterThanOrEqual(0);
708
+ });
709
+ it("uses attribution index cache within TTL", async () => {
710
+ // Clear the mock call count before this test
711
+ mockBuildAttributionIndex.mockClear();
712
+ const workingDir = "/Users/ed/Code/myproject";
713
+ const encodedPath = "-Users-ed-Code-myproject";
714
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
715
+ await mkdir(projectDir, { recursive: true });
716
+ await createSessionFile(projectDir, "session-a");
717
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({ defaultAgentName: "agent" }));
718
+ const service = new SessionDiscoveryService({
719
+ claudeHomePath: tempClaudeHome,
720
+ stateDir: tempStateDir,
721
+ cacheTtlMs: 5000,
722
+ });
723
+ // First call
724
+ await service.getAgentSessions("agent", workingDir, false);
725
+ // Second call
726
+ await service.getAgentSessions("agent", workingDir, false);
727
+ // Attribution index should only be built once
728
+ expect(mockBuildAttributionIndex).toHaveBeenCalledTimes(1);
729
+ });
730
+ it("refreshes attribution index after TTL expires", async () => {
731
+ // Clear the mock call count before this test
732
+ mockBuildAttributionIndex.mockClear();
733
+ const workingDir = "/Users/ed/Code/myproject";
734
+ const encodedPath = "-Users-ed-Code-myproject";
735
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
736
+ await mkdir(projectDir, { recursive: true });
737
+ await createSessionFile(projectDir, "session-a");
738
+ mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({ defaultAgentName: "agent" }));
739
+ const service = new SessionDiscoveryService({
740
+ claudeHomePath: tempClaudeHome,
741
+ stateDir: tempStateDir,
742
+ cacheTtlMs: 50, // Very short TTL
743
+ });
744
+ // First call
745
+ await service.getAgentSessions("agent", workingDir, false);
746
+ // Wait for TTL to expire
747
+ await new Promise((resolve) => setTimeout(resolve, 100));
748
+ // Invalidate directory cache so it forces a re-read
749
+ service.invalidateCache(workingDir);
750
+ // Second call after TTL
751
+ await service.getAgentSessions("agent", workingDir, false);
752
+ // Attribution index should be rebuilt
753
+ expect(mockBuildAttributionIndex).toHaveBeenCalledTimes(2);
754
+ });
755
+ it("uses 'adhoc' key for metadata lookups on unattributed directories", async () => {
756
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-unmatched");
757
+ await mkdir(projectDir, { recursive: true });
758
+ await createSessionFile(projectDir, "session-a");
759
+ const service = new SessionDiscoveryService({
760
+ claudeHomePath: tempClaudeHome,
761
+ stateDir: tempStateDir,
762
+ });
763
+ // No matching agent
764
+ const groups = await service.getAllSessions([]);
765
+ // Custom name should still be looked up using "adhoc" key
766
+ expect(groups[0].sessions[0].customName).toBeUndefined();
767
+ // The metadata store should be called with "adhoc" key for unattributed sessions
768
+ expect(mockGetCustomName).toHaveBeenCalledWith("adhoc", "session-a");
769
+ });
770
+ it("gets custom name for directories with matching agent", async () => {
771
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-matched");
772
+ await mkdir(projectDir, { recursive: true });
773
+ await createSessionFile(projectDir, "session-a");
774
+ mockGetCustomName.mockResolvedValue("Custom Name");
775
+ const service = new SessionDiscoveryService({
776
+ claudeHomePath: tempClaudeHome,
777
+ stateDir: tempStateDir,
778
+ });
779
+ const groups = await service.getAllSessions([
780
+ {
781
+ name: "my-agent",
782
+ workingDirectory: "/Users/ed/Code/matched",
783
+ dockerEnabled: false,
784
+ },
785
+ ]);
786
+ expect(groups[0].sessions[0].customName).toBe("Custom Name");
787
+ expect(mockGetCustomName).toHaveBeenCalledWith("my-agent", "session-a");
788
+ });
789
+ it("returns correct sessionCount in directory groups", async () => {
790
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-multi");
791
+ await mkdir(projectDir, { recursive: true });
792
+ await createSessionFile(projectDir, "session-a");
793
+ await createSessionFile(projectDir, "session-b");
794
+ await createSessionFile(projectDir, "session-c");
795
+ const service = new SessionDiscoveryService({
796
+ claudeHomePath: tempClaudeHome,
797
+ stateDir: tempStateDir,
798
+ });
799
+ const groups = await service.getAllSessions([]);
800
+ expect(groups[0].sessionCount).toBe(3);
801
+ expect(groups[0].sessions).toHaveLength(3);
802
+ });
803
+ });
804
+ // ===========================================================================
805
+ // autoName caching
806
+ // ===========================================================================
807
+ describe("autoName caching", () => {
808
+ it("includes autoName field in discovered sessions from getAgentSessions", async () => {
809
+ const workingDir = "/Users/ed/Code/myproject";
810
+ const encodedPath = "-Users-ed-Code-myproject";
811
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
812
+ await mkdir(projectDir, { recursive: true });
813
+ await createSessionFile(projectDir, "session-abc");
814
+ // Mock cache miss then extraction returns a summary
815
+ mockGetAutoName.mockResolvedValue(undefined);
816
+ mockExtractLastSummary.mockResolvedValue("Auto-generated session name");
817
+ const service = new SessionDiscoveryService({
818
+ claudeHomePath: tempClaudeHome,
819
+ stateDir: tempStateDir,
820
+ });
821
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
822
+ expect(sessions[0].autoName).toBe("Auto-generated session name");
823
+ });
824
+ it("uses cached autoName when cache is valid", async () => {
825
+ const workingDir = "/Users/ed/Code/myproject";
826
+ const encodedPath = "-Users-ed-Code-myproject";
827
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
828
+ await mkdir(projectDir, { recursive: true });
829
+ await createSessionFile(projectDir, "session-abc");
830
+ // Mock cache hit - return cached value with mtime in the future to ensure validity
831
+ mockGetAutoName.mockResolvedValue({
832
+ autoName: "Cached Auto Name",
833
+ autoNameMtime: "2099-01-01T00:00:00.000Z",
834
+ });
835
+ const service = new SessionDiscoveryService({
836
+ claudeHomePath: tempClaudeHome,
837
+ stateDir: tempStateDir,
838
+ });
839
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
840
+ expect(sessions[0].autoName).toBe("Cached Auto Name");
841
+ // Should not have called extractLastSummary since cache was valid
842
+ expect(mockExtractLastSummary).not.toHaveBeenCalled();
843
+ });
844
+ it("re-extracts autoName when cache is stale", async () => {
845
+ const workingDir = "/Users/ed/Code/myproject";
846
+ const encodedPath = "-Users-ed-Code-myproject";
847
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
848
+ await mkdir(projectDir, { recursive: true });
849
+ await createSessionFile(projectDir, "session-abc");
850
+ // Mock cache miss (old mtime)
851
+ mockGetAutoName.mockResolvedValue({
852
+ autoName: "Old Cached Name",
853
+ autoNameMtime: "1990-01-01T00:00:00.000Z",
854
+ });
855
+ mockExtractLastSummary.mockResolvedValue("Fresh Extracted Name");
856
+ const service = new SessionDiscoveryService({
857
+ claudeHomePath: tempClaudeHome,
858
+ stateDir: tempStateDir,
859
+ });
860
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
861
+ expect(sessions[0].autoName).toBe("Fresh Extracted Name");
862
+ expect(mockExtractLastSummary).toHaveBeenCalled();
863
+ });
864
+ it("batch writes autoName updates for getAgentSessions", async () => {
865
+ const workingDir = "/Users/ed/Code/myproject";
866
+ const encodedPath = "-Users-ed-Code-myproject";
867
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
868
+ await mkdir(projectDir, { recursive: true });
869
+ await createSessionFile(projectDir, "session-1");
870
+ await createSessionFile(projectDir, "session-2");
871
+ // Mock cache miss for both — use implementation that returns based on path
872
+ mockGetAutoName.mockResolvedValue(undefined);
873
+ mockExtractLastSummary.mockImplementation(async (filePath) => {
874
+ if (filePath.includes("session-1"))
875
+ return "Session 1 Name";
876
+ if (filePath.includes("session-2"))
877
+ return "Session 2 Name";
878
+ return undefined;
879
+ });
880
+ const service = new SessionDiscoveryService({
881
+ claudeHomePath: tempClaudeHome,
882
+ stateDir: tempStateDir,
883
+ });
884
+ await service.getAgentSessions("my-agent", workingDir, false);
885
+ // Should have called batchSetAutoNames once with both sessions
886
+ expect(mockBatchSetAutoNames).toHaveBeenCalledTimes(1);
887
+ expect(mockBatchSetAutoNames).toHaveBeenCalledWith("my-agent", expect.arrayContaining([
888
+ expect.objectContaining({ sessionId: "session-1", autoName: "Session 1 Name" }),
889
+ expect.objectContaining({ sessionId: "session-2", autoName: "Session 2 Name" }),
890
+ ]));
891
+ });
892
+ it("does not batch write when all autoNames are from cache", async () => {
893
+ const workingDir = "/Users/ed/Code/myproject";
894
+ const encodedPath = "-Users-ed-Code-myproject";
895
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
896
+ await mkdir(projectDir, { recursive: true });
897
+ await createSessionFile(projectDir, "session-abc");
898
+ // Mock cache hit
899
+ mockGetAutoName.mockResolvedValue({
900
+ autoName: "Cached Name",
901
+ autoNameMtime: "2099-01-01T00:00:00.000Z",
902
+ });
903
+ const service = new SessionDiscoveryService({
904
+ claudeHomePath: tempClaudeHome,
905
+ stateDir: tempStateDir,
906
+ });
907
+ await service.getAgentSessions("my-agent", workingDir, false);
908
+ // Should not have called batchSetAutoNames
909
+ expect(mockBatchSetAutoNames).not.toHaveBeenCalled();
910
+ });
911
+ it("uses 'adhoc' key for autoName caching on unattributed sessions in getAllSessions", async () => {
912
+ const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-unattributed");
913
+ await mkdir(projectDir, { recursive: true });
914
+ await createSessionFile(projectDir, "session-abc");
915
+ // Mock cache miss
916
+ mockGetAutoName.mockResolvedValue(undefined);
917
+ mockExtractLastSummary.mockResolvedValue("Unattributed Session Name");
918
+ const service = new SessionDiscoveryService({
919
+ claudeHomePath: tempClaudeHome,
920
+ stateDir: tempStateDir,
921
+ });
922
+ const groups = await service.getAllSessions([]);
923
+ expect(groups[0].sessions[0].autoName).toBe("Unattributed Session Name");
924
+ // Should use "adhoc" key for unattributed sessions
925
+ expect(mockGetAutoName).toHaveBeenCalledWith("adhoc", "session-abc");
926
+ expect(mockBatchSetAutoNames).toHaveBeenCalledWith("adhoc", expect.arrayContaining([
927
+ expect.objectContaining({
928
+ sessionId: "session-abc",
929
+ autoName: "Unattributed Session Name",
930
+ }),
931
+ ]));
932
+ });
933
+ it("returns undefined autoName when session has no summary", async () => {
934
+ const workingDir = "/Users/ed/Code/myproject";
935
+ const encodedPath = "-Users-ed-Code-myproject";
936
+ const projectDir = join(tempClaudeHome, "projects", encodedPath);
937
+ await mkdir(projectDir, { recursive: true });
938
+ await createSessionFile(projectDir, "session-abc");
939
+ // Mock cache miss and no summary
940
+ mockGetAutoName.mockResolvedValue(undefined);
941
+ mockExtractLastSummary.mockResolvedValue(undefined);
942
+ const service = new SessionDiscoveryService({
943
+ claudeHomePath: tempClaudeHome,
944
+ stateDir: tempStateDir,
945
+ });
946
+ const sessions = await service.getAgentSessions("my-agent", workingDir, false);
947
+ expect(sessions[0].autoName).toBeUndefined();
948
+ // Should not batch write when there's nothing to write
949
+ expect(mockBatchSetAutoNames).not.toHaveBeenCalled();
950
+ });
951
+ });
952
+ });
953
+ //# sourceMappingURL=session-discovery.test.js.map