@herdctl/core 5.5.0 → 5.7.0

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