claude-session-skill 1.1.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.
@@ -0,0 +1,644 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
6
+
7
+ const FIXTURES = join(import.meta.dir, "fixtures");
8
+
9
+ const HISTORY_CONTENT = readFileSync(join(FIXTURES, "history.jsonl"), "utf-8");
10
+ const HISTORY_MALFORMED = readFileSync(join(FIXTURES, "history-malformed.jsonl"), "utf-8");
11
+ const SESSION_FILE_CONTENT = readFileSync(join(FIXTURES, "session-file.jsonl"), "utf-8");
12
+ const SUMMARIES_CONTENT = readFileSync(join(FIXTURES, "summaries.json"), "utf-8");
13
+ const NAMES_CONTENT = readFileSync(join(FIXTURES, "names.json"), "utf-8");
14
+
15
+ // ── Shared mock state (mutable so individual tests can override) ──────────────
16
+
17
+ const readFileMock = mock(async (_path: string): Promise<string> => {
18
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
19
+ });
20
+
21
+ const writeFileMock = mock(async (): Promise<void> => {});
22
+ const renameMock = mock(async (): Promise<void> => {});
23
+ const unlinkMock = mock(async (): Promise<void> => {});
24
+ const mkdirMock = mock(async (): Promise<void> => {});
25
+
26
+ const statMock = mock(async (_path: string): Promise<{ mtimeMs: number; size: number }> => {
27
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
28
+ });
29
+
30
+ const readdirMock = mock(async (_path: string): Promise<string[]> => []);
31
+
32
+ const openMock = mock(async () => ({
33
+ write: mock(async () => ({ bytesWritten: 0 })),
34
+ close: mock(async () => {}),
35
+ }));
36
+
37
+ // ── Register the module mock BEFORE any import of indexer ────────────────────
38
+
39
+ mock.module("fs/promises", () => ({
40
+ readFile: readFileMock,
41
+ writeFile: writeFileMock,
42
+ rename: renameMock,
43
+ unlink: unlinkMock,
44
+ mkdir: mkdirMock,
45
+ stat: statMock,
46
+ readdir: readdirMock,
47
+ open: openMock,
48
+ }));
49
+
50
+ // ── Now import the module (picks up the mock) ─────────────────────────────────
51
+
52
+ const {
53
+ buildIndex,
54
+ nameSession,
55
+ clearSessionName,
56
+ resolveSession,
57
+ } = await import("../indexer");
58
+
59
+ // ── Helpers ───────────────────────────────────────────────────────────────────
60
+
61
+ function makeValidCache(sessions: object[] = []) {
62
+ return JSON.stringify({
63
+ meta: { historyMtime: 1000, sessionFileCount: 0, maxSessionMtime: 0, builtAt: Date.now() },
64
+ sessions,
65
+ });
66
+ }
67
+
68
+ function makeCacheSession(overrides: object = {}) {
69
+ return {
70
+ id: "abc123def456abc1",
71
+ name: "",
72
+ project: "app",
73
+ projectDir: "",
74
+ topic: "",
75
+ firstMessage: "Fix the login bug",
76
+ lastMessage: "Done with fix",
77
+ allMessages: "Fix the login bug Done with fix",
78
+ messageCount: 2,
79
+ firstTimestamp: 1709000000000,
80
+ lastTimestamp: 1709001000000,
81
+ cwd: "/Users/tim/projects/app",
82
+ gitBranch: "main",
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ /** Reset all mocks to ENOENT/empty defaults */
88
+ function resetMocks() {
89
+ readFileMock.mockImplementation(async (_path: string): Promise<string> => {
90
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
91
+ });
92
+ writeFileMock.mockImplementation(async (): Promise<void> => {});
93
+ renameMock.mockImplementation(async (): Promise<void> => {});
94
+ unlinkMock.mockImplementation(async (): Promise<void> => {});
95
+ mkdirMock.mockImplementation(async (): Promise<void> => {});
96
+ statMock.mockImplementation(async (_path: string): Promise<{ mtimeMs: number; size: number }> => {
97
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
98
+ });
99
+ readdirMock.mockImplementation(async (): Promise<string[]> => []);
100
+ openMock.mockImplementation(async () => ({
101
+ write: mock(async () => ({ bytesWritten: 0 })),
102
+ close: mock(async () => {}),
103
+ }));
104
+ }
105
+
106
+ // ── resolveSession (pure function, no I/O) ────────────────────────────────────
107
+
108
+ describe("resolveSession", () => {
109
+ const s1 = makeCacheSession({ id: "abc123def456abc1" }) as Parameters<typeof resolveSession>[0][0];
110
+ const s2 = makeCacheSession({ id: "xyz789abc012xyz7" }) as Parameters<typeof resolveSession>[0][0];
111
+
112
+ test("exact ID match", () => {
113
+ const r = resolveSession([s1, s2], "abc123def456abc1");
114
+ expect(r.ok).toBe(true);
115
+ if (r.ok) expect(r.match.id).toBe("abc123def456abc1");
116
+ });
117
+
118
+ test("prefix match (unique)", () => {
119
+ const r = resolveSession([s1, s2], "abc123");
120
+ expect(r.ok).toBe(true);
121
+ if (r.ok) expect(r.match.id).toBe("abc123def456abc1");
122
+ });
123
+
124
+ test("prefix match (xyz prefix)", () => {
125
+ const r = resolveSession([s1, s2], "xyz789");
126
+ expect(r.ok).toBe(true);
127
+ if (r.ok) expect(r.match.id).toBe("xyz789abc012xyz7");
128
+ });
129
+
130
+ test("ambiguous prefix returns error with count", () => {
131
+ const s3 = makeCacheSession({ id: "abc999xyz000abc9" }) as typeof s1;
132
+ const r = resolveSession([s1, s3], "abc");
133
+ expect(r.ok).toBe(false);
134
+ if (!r.ok) {
135
+ expect(r.error).toContain("Ambiguous");
136
+ expect(r.error).toContain("2");
137
+ }
138
+ });
139
+
140
+ test("exact full ID wins over ambiguous prefix matches", () => {
141
+ // s1 starts with "abc123def456abc1" — exact match
142
+ // s2 id starts with xyz so no conflict. But let's add a 3rd that shares prefix.
143
+ const s3 = makeCacheSession({ id: "abc123def456abc1extra" }) as typeof s1;
144
+ const r = resolveSession([s1, s3], "abc123def456abc1");
145
+ // Exact match should win
146
+ expect(r.ok).toBe(true);
147
+ if (r.ok) expect(r.match.id).toBe("abc123def456abc1");
148
+ });
149
+
150
+ test("no match returns error", () => {
151
+ const r = resolveSession([s1, s2], "zzz999");
152
+ expect(r.ok).toBe(false);
153
+ if (!r.ok) {
154
+ expect(r.error).toContain("No session found");
155
+ expect(r.error).toContain("zzz999");
156
+ }
157
+ });
158
+
159
+ test("empty sessions list returns error", () => {
160
+ const r = resolveSession([], "abc123");
161
+ expect(r.ok).toBe(false);
162
+ });
163
+ });
164
+
165
+ // ── buildIndex ────────────────────────────────────────────────────────────────
166
+
167
+ describe("buildIndex", () => {
168
+ beforeEach(resetMocks);
169
+
170
+ test("returns [] when history.jsonl missing and no session files", async () => {
171
+ // All mocks default to ENOENT — no history, no cache, no session dirs
172
+ const result = await buildIndex(true);
173
+ expect(result).toEqual([]);
174
+ });
175
+
176
+ test("parses valid history.jsonl", async () => {
177
+ statMock.mockImplementation(async (path: string) => {
178
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 9999, size: 500 };
179
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
180
+ });
181
+ readFileMock.mockImplementation(async (path: string) => {
182
+ if (String(path).endsWith("history.jsonl")) return HISTORY_CONTENT;
183
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
184
+ });
185
+
186
+ const result = await buildIndex(true);
187
+ expect(result.length).toBeGreaterThan(0);
188
+ // The fixture has 2 messages for abc123... and 1 for xyz789...
189
+ const ids = result.map((s) => s.id);
190
+ expect(ids).toContain("abc123def456abc1");
191
+ expect(ids).toContain("xyz789abc012xyz7");
192
+ });
193
+
194
+ test("skips malformed JSONL lines", async () => {
195
+ statMock.mockImplementation(async (path: string) => {
196
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 9999, size: 500 };
197
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
198
+ });
199
+ readFileMock.mockImplementation(async (path: string) => {
200
+ if (String(path).endsWith("history.jsonl")) return HISTORY_MALFORMED;
201
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
202
+ });
203
+
204
+ const result = await buildIndex(true);
205
+ // Should only have the 2 valid sessions, not crash
206
+ expect(result.length).toBe(2);
207
+ const ids = result.map((s) => s.id);
208
+ expect(ids).toContain("valid001validid1");
209
+ expect(ids).toContain("valid002validid2");
210
+ });
211
+
212
+ test("returns cached sessions when fingerprint unchanged", async () => {
213
+ const cachedSession = makeCacheSession();
214
+ const cacheContent = makeValidCache([cachedSession]);
215
+
216
+ statMock.mockImplementation(async (path: string) => {
217
+ // history.jsonl mtime matches cache meta
218
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 1000, size: 500 };
219
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
220
+ });
221
+ readFileMock.mockImplementation(async (path: string) => {
222
+ if (String(path).endsWith("index.json")) return cacheContent;
223
+ if (String(path).endsWith("names.json")) return "{}";
224
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
225
+ });
226
+
227
+ const result = await buildIndex(false); // not forced
228
+ expect(result.length).toBe(1);
229
+ expect(result[0].id).toBe("abc123def456abc1");
230
+ });
231
+
232
+ test("bypasses cache when force=true", async () => {
233
+ const cachedSession = makeCacheSession();
234
+ const cacheContent = makeValidCache([cachedSession]);
235
+
236
+ statMock.mockImplementation(async (path: string) => {
237
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 1000, size: 500 };
238
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
239
+ });
240
+ readFileMock.mockImplementation(async (path: string) => {
241
+ if (String(path).endsWith("index.json")) return cacheContent;
242
+ // history.jsonl is empty — so rebuild yields 0 sessions
243
+ if (String(path).endsWith("history.jsonl")) return "";
244
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
245
+ });
246
+
247
+ const result = await buildIndex(true); // forced
248
+ // Force rebuilds from scratch — empty history means 0 sessions
249
+ expect(result.length).toBe(0);
250
+ });
251
+
252
+ test("applies names from names.json to cached sessions", async () => {
253
+ const cachedSession = makeCacheSession();
254
+ const cacheContent = makeValidCache([cachedSession]);
255
+
256
+ statMock.mockImplementation(async (path: string) => {
257
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 1000, size: 500 };
258
+ if (String(path).endsWith("names.json")) return { mtimeMs: 2000, size: 50 };
259
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
260
+ });
261
+ readFileMock.mockImplementation(async (path: string) => {
262
+ if (String(path).endsWith("index.json")) return cacheContent;
263
+ if (String(path).endsWith("names.json")) return NAMES_CONTENT;
264
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
265
+ });
266
+
267
+ const result = await buildIndex(false);
268
+ expect(result[0].name).toBe("Login Fix Session");
269
+ });
270
+
271
+ test("purges garbage summaries before regenerating", async () => {
272
+ // Summaries file has a garbage entry — buildIndex should purge it
273
+ statMock.mockImplementation(async (path: string) => {
274
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 9999, size: 500 };
275
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
276
+ });
277
+ readFileMock.mockImplementation(async (path: string) => {
278
+ if (String(path).endsWith("history.jsonl")) return HISTORY_CONTENT;
279
+ if (String(path).endsWith("summaries.json")) return SUMMARIES_CONTENT;
280
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
281
+ });
282
+
283
+ const result = await buildIndex(true);
284
+ // The good summary should be preserved on the matching session
285
+ const session = result.find((s) => s.id === "abc123def456abc1");
286
+ expect(session?.topic).toContain("Fixed authentication bug");
287
+ // Garbage summary session should not appear in results (it was only in summaries, not history/sessions)
288
+ const garbagedSession = result.find((s) => s.id === "garbage001garbag1");
289
+ expect(garbagedSession).toBeUndefined();
290
+ });
291
+ });
292
+
293
+ // ── nameSession ───────────────────────────────────────────────────────────────
294
+
295
+ describe("nameSession", () => {
296
+ beforeEach(resetMocks);
297
+
298
+ function setupCacheWithSession(session: object = makeCacheSession()) {
299
+ const namesMtime = Date.now();
300
+ readFileMock.mockImplementation(async (path: string) => {
301
+ if (String(path).endsWith("index.json")) return makeValidCache([session]);
302
+ if (String(path).endsWith("names.json")) return "{}";
303
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
304
+ });
305
+ statMock.mockImplementation(async (path: string) => {
306
+ if (String(path).endsWith("names.json")) return { mtimeMs: namesMtime, size: 2 };
307
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
308
+ });
309
+ }
310
+
311
+ test("rejects empty name", async () => {
312
+ setupCacheWithSession();
313
+ const r = await nameSession("abc123def456abc1", " ");
314
+ expect(r.ok).toBe(false);
315
+ expect(r.error).toContain("empty");
316
+ });
317
+
318
+ test("rejects name longer than 50 chars", async () => {
319
+ setupCacheWithSession();
320
+ const r = await nameSession("abc123def456abc1", "x".repeat(51));
321
+ expect(r.ok).toBe(false);
322
+ expect(r.error).toContain("too long");
323
+ });
324
+
325
+ test("errors when no cache exists", async () => {
326
+ // readFileMock already throws ENOENT for everything
327
+ const r = await nameSession("abc123def456abc1", "My Session");
328
+ expect(r.ok).toBe(false);
329
+ expect(r.error).toContain("No index found");
330
+ });
331
+
332
+ test("errors when session ID not found", async () => {
333
+ setupCacheWithSession();
334
+ const r = await nameSession("zzz999notfound00", "My Session");
335
+ expect(r.ok).toBe(false);
336
+ expect(r.error).toContain("No session found");
337
+ });
338
+
339
+ test("errors on ambiguous partial ID", async () => {
340
+ const s1 = makeCacheSession({ id: "abc123def456abc1" });
341
+ const s2 = makeCacheSession({ id: "abc123xyz789abc9" });
342
+ readFileMock.mockImplementation(async (path: string) => {
343
+ if (String(path).endsWith("index.json")) return makeValidCache([s1, s2]);
344
+ if (String(path).endsWith("names.json")) return "{}";
345
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
346
+ });
347
+ statMock.mockImplementation(async (path: string) => {
348
+ if (String(path).endsWith("names.json")) return { mtimeMs: 1, size: 2 };
349
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
350
+ });
351
+
352
+ const r = await nameSession("abc123", "My Session");
353
+ expect(r.ok).toBe(false);
354
+ expect(r.error).toContain("Ambiguous");
355
+ });
356
+
357
+ test("resolves partial ID to exact match and writes name", async () => {
358
+ setupCacheWithSession();
359
+ const r = await nameSession("abc123", "Auth Fix");
360
+ expect(r.ok).toBe(true);
361
+ expect(r.fullId).toBe("abc123def456abc1");
362
+ // writeFile was called (for the tmp names file)
363
+ expect(writeFileMock).toHaveBeenCalled();
364
+ });
365
+
366
+ test("acquires lock, writes, releases", async () => {
367
+ setupCacheWithSession();
368
+ const closespy = mock(async () => {});
369
+ openMock.mockImplementation(async () => ({
370
+ write: mock(async () => ({ bytesWritten: 0 })),
371
+ close: closespy,
372
+ }));
373
+
374
+ const r = await nameSession("abc123def456abc1", "Test Name");
375
+ expect(r.ok).toBe(true);
376
+ // Lock was acquired (open called) and released (unlink called)
377
+ expect(openMock).toHaveBeenCalled();
378
+ expect(unlinkMock).toHaveBeenCalled();
379
+ expect(closespy).toHaveBeenCalled();
380
+ });
381
+
382
+ test("returns error when lock cannot be acquired", async () => {
383
+ setupCacheWithSession();
384
+ // Lock file exists and is fresh — open throws EEXIST each time
385
+ openMock.mockImplementation(async () => {
386
+ throw Object.assign(new Error("EEXIST"), { code: "EEXIST" });
387
+ });
388
+ statMock.mockImplementation(async (path: string) => {
389
+ // Return fresh mtime for lock file (not stale)
390
+ if (String(path).endsWith(".lock")) return { mtimeMs: Date.now(), size: 1 };
391
+ if (String(path).endsWith("names.json")) return { mtimeMs: 1, size: 2 };
392
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
393
+ });
394
+
395
+ const r = await nameSession("abc123def456abc1", "Blocked");
396
+ expect(r.ok).toBe(false);
397
+ expect(r.error).toContain("lock");
398
+ }, 10000); // LOCK_TIMEOUT is 5s
399
+
400
+ test("treats stale lock (>10s) as expired and succeeds", async () => {
401
+ setupCacheWithSession();
402
+ let lockExists = false;
403
+ openMock.mockImplementation(async () => {
404
+ if (lockExists) throw Object.assign(new Error("EEXIST"), { code: "EEXIST" });
405
+ lockExists = true;
406
+ return {
407
+ write: mock(async () => ({ bytesWritten: 0 })),
408
+ close: mock(async () => {}),
409
+ };
410
+ });
411
+ statMock.mockImplementation(async (path: string) => {
412
+ if (String(path).endsWith(".lock")) return { mtimeMs: Date.now() - 15000, size: 1 }; // 15s old = stale
413
+ if (String(path).endsWith("names.json")) return { mtimeMs: 1, size: 2 };
414
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
415
+ });
416
+ // unlink removes the stale lock, then re-try of open sets lockExists=false indirectly
417
+ // (openMock tracks lockExists state — first call succeeds, second call sees lockExists=true)
418
+ // Actually let's simplify: just ensure stale lock path doesn't block
419
+ // Reset openMock to succeed unconditionally (stale lock was deleted by unlink)
420
+ unlinkMock.mockImplementation(async () => {
421
+ // After stale lock is unlinked, reset open to succeed
422
+ openMock.mockImplementation(async () => ({
423
+ write: mock(async () => ({ bytesWritten: 0 })),
424
+ close: mock(async () => {}),
425
+ }));
426
+ });
427
+
428
+ const r = await nameSession("abc123def456abc1", "Stale Lock Test");
429
+ expect(r.ok).toBe(true);
430
+ });
431
+ });
432
+
433
+ // ── clearSessionName ──────────────────────────────────────────────────────────
434
+
435
+ describe("clearSessionName", () => {
436
+ beforeEach(resetMocks);
437
+
438
+ function setupCacheWithNamedSession() {
439
+ // Use Date.now() as mtime to guarantee cache bust across tests
440
+ // (module-level _namesCache uses mtime equality check)
441
+ const namesMtime = Date.now();
442
+ readFileMock.mockImplementation(async (path: string) => {
443
+ if (String(path).endsWith("index.json"))
444
+ return makeValidCache([makeCacheSession({ name: "Login Fix Session" })]);
445
+ if (String(path).endsWith("names.json")) return NAMES_CONTENT;
446
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
447
+ });
448
+ statMock.mockImplementation(async (path: string) => {
449
+ if (String(path).endsWith("names.json")) return { mtimeMs: namesMtime, size: 50 };
450
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
451
+ });
452
+ }
453
+
454
+ test("errors when no cache exists", async () => {
455
+ const r = await clearSessionName("abc123def456abc1");
456
+ expect(r.ok).toBe(false);
457
+ expect(r.error).toContain("No index found");
458
+ });
459
+
460
+ test("errors when session ID not found", async () => {
461
+ setupCacheWithNamedSession();
462
+ const r = await clearSessionName("zzz999notfound00");
463
+ expect(r.ok).toBe(false);
464
+ expect(r.error).toContain("No session found");
465
+ });
466
+
467
+ test("errors when session has no name to clear", async () => {
468
+ const namesMtime = Date.now() + 1; // distinct from any prior test
469
+ readFileMock.mockImplementation(async (path: string) => {
470
+ if (String(path).endsWith("index.json")) return makeValidCache([makeCacheSession()]);
471
+ if (String(path).endsWith("names.json")) return "{}"; // no names
472
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
473
+ });
474
+ statMock.mockImplementation(async (path: string) => {
475
+ if (String(path).endsWith("names.json")) return { mtimeMs: namesMtime, size: 2 };
476
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
477
+ });
478
+
479
+ const r = await clearSessionName("abc123def456abc1");
480
+ expect(r.ok).toBe(false);
481
+ expect(r.error).toContain("no name to clear");
482
+ });
483
+
484
+ test("clears name successfully", async () => {
485
+ setupCacheWithNamedSession();
486
+ const r = await clearSessionName("abc123def456abc1");
487
+ expect(r.ok).toBe(true);
488
+ expect(r.fullId).toBe("abc123def456abc1");
489
+ expect(writeFileMock).toHaveBeenCalled();
490
+ });
491
+
492
+ test("resolves partial ID", async () => {
493
+ setupCacheWithNamedSession();
494
+ const r = await clearSessionName("abc123");
495
+ expect(r.ok).toBe(true);
496
+ });
497
+ });
498
+
499
+ // ── summarizeSession (via fetch mock) ─────────────────────────────────────────
500
+
501
+ describe("summarizeSession (via buildIndex with fetch mock)", () => {
502
+ const savedKey = process.env.ANTHROPIC_API_KEY;
503
+
504
+ beforeEach(() => {
505
+ resetMocks();
506
+ process.env.ANTHROPIC_API_KEY = "test-key-12345";
507
+ });
508
+
509
+ afterEach(() => {
510
+ process.env.ANTHROPIC_API_KEY = savedKey;
511
+ (globalThis as any).fetch = fetch; // restore original fetch
512
+ });
513
+
514
+ function setupHistoryWithConversation() {
515
+ statMock.mockImplementation(async (path: string) => {
516
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 9999, size: 500 };
517
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
518
+ });
519
+ readFileMock.mockImplementation(async (path: string) => {
520
+ if (String(path).endsWith("history.jsonl")) return HISTORY_CONTENT;
521
+ if (String(path).endsWith("summaries.json")) return "{}";
522
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
523
+ });
524
+ readdirMock.mockImplementation(async (path: string) => {
525
+ if (String(path).endsWith("projects")) return ["proj-dir"];
526
+ if (String(path).endsWith("proj-dir")) return ["abc123def456abc1.jsonl"];
527
+ return [];
528
+ });
529
+ // session file stat + content
530
+ statMock.mockImplementation(async (path: string) => {
531
+ if (String(path).endsWith("history.jsonl")) return { mtimeMs: 9999, size: 500 };
532
+ if (String(path).endsWith(".jsonl")) return { mtimeMs: 999, size: SESSION_FILE_CONTENT.length };
533
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
534
+ });
535
+ readFileMock.mockImplementation(async (path: string) => {
536
+ if (String(path).endsWith("history.jsonl")) return HISTORY_CONTENT;
537
+ if (String(path).endsWith("abc123def456abc1.jsonl")) return SESSION_FILE_CONTENT;
538
+ if (String(path).endsWith("summaries.json")) return "{}";
539
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
540
+ });
541
+ }
542
+
543
+ test("skips summarization when ANTHROPIC_API_KEY unset", async () => {
544
+ delete process.env.ANTHROPIC_API_KEY;
545
+ const fetchSpy = mock(() => Promise.resolve(new Response("{}", { status: 200 })));
546
+ (globalThis as any).fetch = fetchSpy;
547
+ setupHistoryWithConversation();
548
+
549
+ await buildIndex(true);
550
+ expect(fetchSpy).not.toHaveBeenCalled();
551
+ });
552
+
553
+ test("returns empty summary on 429 rate limit", async () => {
554
+ const fetchSpy = mock(() =>
555
+ Promise.resolve(new Response("Rate limited", { status: 429 }))
556
+ );
557
+ (globalThis as any).fetch = fetchSpy;
558
+ setupHistoryWithConversation();
559
+
560
+ const result = await buildIndex(true);
561
+ // Topic should be empty (no summary on 429)
562
+ const session = result.find((s) => s.id === "abc123def456abc1");
563
+ expect(session?.topic).toBe("");
564
+ });
565
+
566
+ test("returns empty summary on 401 auth error", async () => {
567
+ const fetchSpy = mock(() =>
568
+ Promise.resolve(new Response("Unauthorized", { status: 401 }))
569
+ );
570
+ (globalThis as any).fetch = fetchSpy;
571
+ setupHistoryWithConversation();
572
+
573
+ const result = await buildIndex(true);
574
+ const session = result.find((s) => s.id === "abc123def456abc1");
575
+ expect(session?.topic).toBe("");
576
+ });
577
+
578
+ test("returns empty summary on network failure", async () => {
579
+ const fetchSpy = mock(() => Promise.reject(new Error("network error")));
580
+ (globalThis as any).fetch = fetchSpy;
581
+ setupHistoryWithConversation();
582
+
583
+ const result = await buildIndex(true);
584
+ const session = result.find((s) => s.id === "abc123def456abc1");
585
+ expect(session?.topic).toBe("");
586
+ });
587
+
588
+ test("parses bullet format with • prefix", async () => {
589
+ const fetchSpy = mock(() =>
590
+ Promise.resolve(
591
+ new Response(
592
+ JSON.stringify({
593
+ content: [{ type: "text", text: "• Fixed login bug in auth module\n• Added unit tests" }],
594
+ }),
595
+ { status: 200, headers: { "content-type": "application/json" } }
596
+ )
597
+ )
598
+ );
599
+ (globalThis as any).fetch = fetchSpy;
600
+ setupHistoryWithConversation();
601
+
602
+ const result = await buildIndex(true);
603
+ const session = result.find((s) => s.id === "abc123def456abc1");
604
+ expect(session?.topic).toContain("- Fixed login bug");
605
+ });
606
+
607
+ test("parses bullet format with numbered list", async () => {
608
+ const fetchSpy = mock(() =>
609
+ Promise.resolve(
610
+ new Response(
611
+ JSON.stringify({
612
+ content: [{ type: "text", text: "1. Fixed login bug\n2. Added tests" }],
613
+ }),
614
+ { status: 200, headers: { "content-type": "application/json" } }
615
+ )
616
+ )
617
+ );
618
+ (globalThis as any).fetch = fetchSpy;
619
+ setupHistoryWithConversation();
620
+
621
+ const result = await buildIndex(true);
622
+ const session = result.find((s) => s.id === "abc123def456abc1");
623
+ expect(session?.topic).toContain("- Fixed login bug");
624
+ });
625
+
626
+ test("parses standard - bullet format", async () => {
627
+ const fetchSpy = mock(() =>
628
+ Promise.resolve(
629
+ new Response(
630
+ JSON.stringify({
631
+ content: [{ type: "text", text: "- Fixed login bug\n- Added unit tests" }],
632
+ }),
633
+ { status: 200, headers: { "content-type": "application/json" } }
634
+ )
635
+ )
636
+ );
637
+ (globalThis as any).fetch = fetchSpy;
638
+ setupHistoryWithConversation();
639
+
640
+ const result = await buildIndex(true);
641
+ const session = result.find((s) => s.id === "abc123def456abc1");
642
+ expect(session?.topic).toContain("- Fixed login bug");
643
+ });
644
+ });