@askjo/pi-reflect 1.0.0 → 1.2.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,642 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import {
6
+ buildTranscriptBatches,
7
+ runReflection,
8
+ type ReflectTarget,
9
+ type RunReflectionDeps,
10
+ type NotifyFn,
11
+ type SessionData,
12
+ type TranscriptResult,
13
+ DEFAULT_TARGET,
14
+ } from "../extensions/reflect.js";
15
+ import { makeTempDir, cleanup, SAMPLE_AGENTS_MD } from "./helpers.js";
16
+
17
+ // --- buildTranscriptBatches tests ---
18
+
19
+ function makeSession(id: string, sizeBytes: number): SessionData {
20
+ const transcript = "x".repeat(sizeBytes);
21
+ return {
22
+ transcript,
23
+ project: "test-project",
24
+ sessionId: id,
25
+ date: "2026-02-20",
26
+ size: sizeBytes,
27
+ };
28
+ }
29
+
30
+ describe("buildTranscriptBatches", () => {
31
+ it("puts all sessions in one batch when they fit", () => {
32
+ const sessions = [makeSession("a", 100), makeSession("b", 100), makeSession("c", 100)];
33
+ const batches = buildTranscriptBatches(sessions, 1000);
34
+ assert.equal(batches.length, 1);
35
+ assert.equal(batches[0].length, 3);
36
+ });
37
+
38
+ it("splits sessions across batches when they exceed maxBytes", () => {
39
+ const sessions = [makeSession("a", 500), makeSession("b", 500), makeSession("c", 500)];
40
+ // Each entry is transcript + "\n---\n\n" = 506 bytes
41
+ const batches = buildTranscriptBatches(sessions, 600);
42
+ assert.equal(batches.length, 3);
43
+ assert.equal(batches[0].length, 1);
44
+ assert.equal(batches[1].length, 1);
45
+ assert.equal(batches[2].length, 1);
46
+ });
47
+
48
+ it("groups multiple small sessions into one batch", () => {
49
+ const sessions = [
50
+ makeSession("a", 100),
51
+ makeSession("b", 100),
52
+ makeSession("c", 100),
53
+ makeSession("d", 100),
54
+ ];
55
+ // Each entry ~106 bytes, 4 × 106 = 424 bytes
56
+ const batches = buildTranscriptBatches(sessions, 250);
57
+ assert.equal(batches.length, 2);
58
+ assert.equal(batches[0].length, 2);
59
+ assert.equal(batches[1].length, 2);
60
+ });
61
+
62
+ it("returns empty array for empty sessions", () => {
63
+ const batches = buildTranscriptBatches([], 1000);
64
+ assert.equal(batches.length, 0);
65
+ });
66
+
67
+ it("handles a single session larger than maxBytes", () => {
68
+ const sessions = [makeSession("a", 2000)];
69
+ const batches = buildTranscriptBatches(sessions, 500);
70
+ // Single session still gets its own batch even if it exceeds budget
71
+ assert.equal(batches.length, 1);
72
+ assert.equal(batches[0].length, 1);
73
+ });
74
+
75
+ it("preserves session order across batches", () => {
76
+ const sessions = [
77
+ { ...makeSession("1st", 300), transcript: "FIRST_SESSION_CONTENT" + "x".repeat(279) },
78
+ { ...makeSession("2nd", 300), transcript: "SECOND_SESSION_CONTENT" + "x".repeat(278) },
79
+ { ...makeSession("3rd", 300), transcript: "THIRD_SESSION_CONTENT" + "x".repeat(279) },
80
+ ];
81
+ const batches = buildTranscriptBatches(sessions, 400);
82
+ assert.equal(batches.length, 3);
83
+ assert.ok(batches[0][0].includes("FIRST_SESSION"));
84
+ assert.ok(batches[1][0].includes("SECOND_SESSION"));
85
+ assert.ok(batches[2][0].includes("THIRD_SESSION"));
86
+ });
87
+
88
+ it("each entry includes separator suffix", () => {
89
+ const sessions = [makeSession("a", 50)];
90
+ const batches = buildTranscriptBatches(sessions, 1000);
91
+ assert.ok(batches[0][0].endsWith("\n---\n\n"));
92
+ });
93
+ });
94
+
95
+ // --- Tool call parsing + batched runReflection tests ---
96
+
97
+ let tmpDir: string;
98
+ let notifications: Array<{ msg: string; level: string }>;
99
+ let notify: NotifyFn;
100
+
101
+ beforeEach(() => {
102
+ tmpDir = makeTempDir();
103
+ notifications = [];
104
+ notify = (msg, level) => notifications.push({ msg, level });
105
+ });
106
+
107
+ afterEach(() => {
108
+ cleanup(tmpDir);
109
+ });
110
+
111
+ function makeTarget(overrides: Partial<ReflectTarget> = {}): ReflectTarget {
112
+ return {
113
+ ...DEFAULT_TARGET,
114
+ backupDir: path.join(tmpDir, "backups"),
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function makeModelRegistry() {
120
+ return {
121
+ find: () => ({ provider: "test", id: "test-model" }),
122
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key", headers: undefined }),
123
+ };
124
+ }
125
+
126
+ function makeToolCallResponse(analysis: any) {
127
+ return {
128
+ content: [{
129
+ type: "toolCall",
130
+ name: "submit_analysis",
131
+ arguments: analysis,
132
+ }],
133
+ };
134
+ }
135
+
136
+ function makeTextResponse(text: string) {
137
+ return {
138
+ content: [{ type: "text", text }],
139
+ };
140
+ }
141
+
142
+ // batchBudget = Math.max(maxSessionBytes - overhead, 100_000)
143
+ // overhead = targetContent.length + context.length + 20_000
144
+ // SAMPLE_AGENTS_MD is ~900 bytes, so overhead ≈ 20_900
145
+ // To force batching: totalBytes must exceed batchBudget
146
+ // With maxSessionBytes=120_000, batchBudget = max(120_000 - 20_900, 100_000) = 100_000
147
+ // So sessions totaling >100KB will trigger batching.
148
+ const LARGE_SESSION_SIZE = 40_000; // 40KB each — 3 sessions = 120KB > 100KB budget
149
+
150
+ function makeLargeSessions(count: number): SessionData[] {
151
+ return Array.from({ length: count }, (_, i) =>
152
+ makeSession(`s${i + 1}`, LARGE_SESSION_SIZE)
153
+ );
154
+ }
155
+
156
+ function makeBatchDeps(
157
+ responsePerCall: any[],
158
+ sessions: SessionData[],
159
+ ): RunReflectionDeps {
160
+ let callIndex = 0;
161
+ return {
162
+ completeSimple: async () => {
163
+ const resp = responsePerCall[Math.min(callIndex, responsePerCall.length - 1)];
164
+ callIndex++;
165
+ return resp;
166
+ },
167
+ getModel: () => ({ provider: "test", id: "test-model" }),
168
+ collectTranscriptsFn: async () => ({
169
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
170
+ sessionCount: sessions.length,
171
+ includedCount: sessions.length,
172
+ sessions,
173
+ }),
174
+ };
175
+ }
176
+
177
+ describe("analyzeTranscriptBatch — tool call parsing", () => {
178
+ it("parses structured tool call response", async () => {
179
+ const fp = path.join(tmpDir, "AGENTS.md");
180
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
181
+ const target = makeTarget({ path: fp });
182
+
183
+ const deps: RunReflectionDeps = {
184
+ completeSimple: async () => makeToolCallResponse({
185
+ corrections_found: 1,
186
+ sessions_with_corrections: 1,
187
+ edits: [{
188
+ type: "strengthen",
189
+ section: "Rules",
190
+ old_text: "- **Keep code DRY**: NEVER duplicate logic.",
191
+ new_text: "- **Keep code DRY**: NEVER duplicate logic. Always use shared helpers.",
192
+ reason: "Agent duplicated code in 2 sessions",
193
+ }],
194
+ summary: "Added DRY emphasis.",
195
+ }),
196
+ getModel: () => ({ provider: "test", id: "test-model" }),
197
+ collectTranscriptsFn: async () => ({
198
+ transcripts: "### Session\n\n**USER:** test\n",
199
+ sessionCount: 1,
200
+ includedCount: 1,
201
+ }),
202
+ };
203
+
204
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
205
+ assert.notEqual(result, null);
206
+ assert.equal(result!.editsApplied, 1);
207
+ const updated = fs.readFileSync(fp, "utf-8");
208
+ assert.ok(updated.includes("shared helpers"));
209
+ });
210
+
211
+ it("falls back to JSON text when no tool call in response", async () => {
212
+ const fp = path.join(tmpDir, "AGENTS.md");
213
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
214
+ const target = makeTarget({ path: fp });
215
+
216
+ const jsonResponse = JSON.stringify({
217
+ corrections_found: 1,
218
+ sessions_with_corrections: 1,
219
+ edits: [{
220
+ type: "strengthen",
221
+ section: "Rules",
222
+ old_text: "- **Keep code DRY**: NEVER duplicate logic.",
223
+ new_text: "- **Keep code DRY**: NEVER duplicate logic. Use parameterized functions.",
224
+ reason: "test",
225
+ }],
226
+ summary: "Fallback test.",
227
+ });
228
+
229
+ const deps: RunReflectionDeps = {
230
+ completeSimple: async () => makeTextResponse(jsonResponse),
231
+ getModel: () => ({ provider: "test", id: "test-model" }),
232
+ collectTranscriptsFn: async () => ({
233
+ transcripts: "### Session\n\n**USER:** test\n",
234
+ sessionCount: 1,
235
+ includedCount: 1,
236
+ }),
237
+ };
238
+
239
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
240
+ assert.notEqual(result, null);
241
+ assert.equal(result!.editsApplied, 1);
242
+ });
243
+
244
+ it("handles LLM error response gracefully", async () => {
245
+ const fp = path.join(tmpDir, "AGENTS.md");
246
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
247
+ const target = makeTarget({ path: fp });
248
+
249
+ const deps: RunReflectionDeps = {
250
+ completeSimple: async () => ({
251
+ stopReason: "error",
252
+ errorMessage: "Rate limit exceeded",
253
+ content: [],
254
+ }),
255
+ getModel: () => ({ provider: "test", id: "test-model" }),
256
+ collectTranscriptsFn: async () => ({
257
+ transcripts: "### Session\n\n**USER:** test\n",
258
+ sessionCount: 1,
259
+ includedCount: 1,
260
+ }),
261
+ };
262
+
263
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
264
+ assert.equal(result, null);
265
+ assert.ok(notifications.some(n => n.level === "error" && n.msg.includes("Rate limit")));
266
+ });
267
+ });
268
+
269
+ describe("batched runReflection", () => {
270
+ it("triggers batching when sessions exceed context budget", async () => {
271
+ const fp = path.join(tmpDir, "AGENTS.md");
272
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
273
+
274
+ const target = makeTarget({
275
+ path: fp,
276
+ maxSessionBytes: 120_000, // batchBudget ≈ 100KB, 3 × 40KB = 120KB > 100KB
277
+ });
278
+
279
+ const sessions = makeLargeSessions(3);
280
+
281
+ // Each batch gets its own LLM call
282
+ let callCount = 0;
283
+ const deps: RunReflectionDeps = {
284
+ completeSimple: async () => {
285
+ callCount++;
286
+ if (callCount === 1) {
287
+ return makeToolCallResponse({
288
+ corrections_found: 1,
289
+ sessions_with_corrections: 1,
290
+ edits: [{
291
+ type: "add",
292
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
293
+ new_text: "- **Batch 1 rule**: From first batch.",
294
+ reason: "batch 1",
295
+ section: "Rules",
296
+ }],
297
+ summary: "Batch 1 done.",
298
+ });
299
+ }
300
+ // Later batches: add after batch 1's rule
301
+ return makeToolCallResponse({
302
+ corrections_found: 1,
303
+ sessions_with_corrections: 1,
304
+ edits: [{
305
+ type: "add",
306
+ after_text: "- **Batch 1 rule**: From first batch.",
307
+ new_text: `- **Batch ${callCount} rule**: From batch ${callCount}.`,
308
+ reason: `batch ${callCount}`,
309
+ section: "Rules",
310
+ }],
311
+ summary: `Batch ${callCount} done.`,
312
+ });
313
+ },
314
+ getModel: () => ({ provider: "test", id: "test-model" }),
315
+ collectTranscriptsFn: async () => ({
316
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
317
+ sessionCount: sessions.length,
318
+ includedCount: sessions.length,
319
+ sessions,
320
+ }),
321
+ };
322
+
323
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
324
+
325
+ assert.notEqual(result, null);
326
+ assert.ok(callCount >= 2, `Expected multiple LLM calls, got ${callCount}`);
327
+ assert.ok(notifications.some(n => n.msg.includes("splitting into")));
328
+ assert.ok(notifications.some(n => n.msg.includes("Batch 1")));
329
+
330
+ const updated = fs.readFileSync(fp, "utf-8");
331
+ assert.ok(updated.includes("Batch 1 rule"));
332
+ });
333
+
334
+ it("creates backup only once for multi-batch edits", async () => {
335
+ const fp = path.join(tmpDir, "AGENTS.md");
336
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
337
+ const backupDir = path.join(tmpDir, "backups");
338
+ const target = makeTarget({
339
+ path: fp,
340
+ backupDir,
341
+ maxSessionBytes: 120_000,
342
+ });
343
+
344
+ const sessions = makeLargeSessions(3);
345
+ let callCount = 0;
346
+ const deps: RunReflectionDeps = {
347
+ completeSimple: async () => {
348
+ callCount++;
349
+ if (callCount === 1) {
350
+ return makeToolCallResponse({
351
+ corrections_found: 1,
352
+ sessions_with_corrections: 1,
353
+ edits: [{
354
+ type: "add",
355
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
356
+ new_text: "- **Rule from batch 1**: Added.",
357
+ reason: "test",
358
+ section: "Rules",
359
+ }],
360
+ summary: "Batch 1.",
361
+ });
362
+ }
363
+ return makeToolCallResponse({
364
+ corrections_found: 1,
365
+ sessions_with_corrections: 1,
366
+ edits: [{
367
+ type: "add",
368
+ after_text: "- **Rule from batch 1**: Added.",
369
+ new_text: `- **Rule from batch ${callCount}**: Also added.`,
370
+ reason: "test",
371
+ section: "Rules",
372
+ }],
373
+ summary: `Batch ${callCount}.`,
374
+ });
375
+ },
376
+ getModel: () => ({ provider: "test", id: "test-model" }),
377
+ collectTranscriptsFn: async () => ({
378
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
379
+ sessionCount: sessions.length,
380
+ includedCount: sessions.length,
381
+ sessions,
382
+ }),
383
+ };
384
+
385
+ await runReflection(target, makeModelRegistry(), notify, deps);
386
+
387
+ // Only one backup file should exist
388
+ const backups = fs.readdirSync(backupDir);
389
+ assert.equal(backups.length, 1);
390
+
391
+ // Backup should have original content (before any batch edits)
392
+ const backupContent = fs.readFileSync(path.join(backupDir, backups[0]), "utf-8");
393
+ assert.equal(backupContent, SAMPLE_AGENTS_MD);
394
+ });
395
+
396
+ it("later batches see edits from earlier batches", async () => {
397
+ const fp = path.join(tmpDir, "AGENTS.md");
398
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
399
+ const target = makeTarget({
400
+ path: fp,
401
+ maxSessionBytes: 120_000,
402
+ });
403
+
404
+ const sessions = makeLargeSessions(3);
405
+
406
+ // Later batches should see edits from batch 1 on disk
407
+ let callIndex = 0;
408
+ let laterBatchSawEdit = false;
409
+ const deps: RunReflectionDeps = {
410
+ completeSimple: async () => {
411
+ callIndex++;
412
+ if (callIndex === 1) {
413
+ return makeToolCallResponse({
414
+ corrections_found: 1,
415
+ sessions_with_corrections: 1,
416
+ edits: [{
417
+ type: "add",
418
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
419
+ new_text: "- **Intermediate rule**: Inserted by batch 1.",
420
+ reason: "test",
421
+ section: "Rules",
422
+ }],
423
+ summary: "Batch 1.",
424
+ });
425
+ }
426
+ // Later batch — verify it sees batch 1's edit by reading the file
427
+ const currentContent = fs.readFileSync(fp, "utf-8");
428
+ if (currentContent.includes("Intermediate rule")) {
429
+ laterBatchSawEdit = true;
430
+ }
431
+ return makeToolCallResponse({
432
+ corrections_found: 0,
433
+ sessions_with_corrections: 0,
434
+ edits: [],
435
+ summary: `Batch ${callIndex} clean.`,
436
+ });
437
+ },
438
+ getModel: () => ({ provider: "test", id: "test-model" }),
439
+ collectTranscriptsFn: async () => ({
440
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
441
+ sessionCount: sessions.length,
442
+ includedCount: sessions.length,
443
+ sessions,
444
+ }),
445
+ };
446
+
447
+ await runReflection(target, makeModelRegistry(), notify, deps);
448
+ assert.ok(callIndex >= 2, "Should have multiple batch calls");
449
+ assert.ok(laterBatchSawEdit, "Later batch should see batch 1's edits on disk");
450
+ });
451
+
452
+ it("combines summaries from multiple batches", async () => {
453
+ const fp = path.join(tmpDir, "AGENTS.md");
454
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
455
+ const target = makeTarget({
456
+ path: fp,
457
+ maxSessionBytes: 120_000,
458
+ });
459
+
460
+ const sessions = makeLargeSessions(3);
461
+ let callCount = 0;
462
+ const deps: RunReflectionDeps = {
463
+ completeSimple: async () => {
464
+ callCount++;
465
+ return makeToolCallResponse({
466
+ corrections_found: 0,
467
+ sessions_with_corrections: 0,
468
+ edits: [],
469
+ summary: `Batch ${callCount} summary.`,
470
+ });
471
+ },
472
+ getModel: () => ({ provider: "test", id: "test-model" }),
473
+ collectTranscriptsFn: async () => ({
474
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
475
+ sessionCount: sessions.length,
476
+ includedCount: sessions.length,
477
+ sessions,
478
+ }),
479
+ };
480
+
481
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
482
+
483
+ assert.notEqual(result, null);
484
+ assert.ok(callCount >= 2, `Expected multiple LLM calls, got ${callCount}`);
485
+ assert.ok(result!.summary.includes("Batch 1 summary."));
486
+ assert.ok(result!.summary.includes("Batch 2 summary."));
487
+ });
488
+
489
+ it("continues processing when one batch fails", async () => {
490
+ const fp = path.join(tmpDir, "AGENTS.md");
491
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
492
+ const target = makeTarget({
493
+ path: fp,
494
+ maxSessionBytes: 120_000,
495
+ });
496
+
497
+ const sessions = makeLargeSessions(3);
498
+
499
+ let callIndex = 0;
500
+ const deps: RunReflectionDeps = {
501
+ completeSimple: async () => {
502
+ callIndex++;
503
+ if (callIndex === 1) {
504
+ // First batch fails
505
+ return { stopReason: "error", errorMessage: "Timeout", content: [] };
506
+ }
507
+ // Later batches succeed
508
+ return makeToolCallResponse({
509
+ corrections_found: 1,
510
+ sessions_with_corrections: 1,
511
+ edits: [{
512
+ type: "add",
513
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
514
+ new_text: "- **Surviving rule**: From later batch.",
515
+ reason: "test",
516
+ section: "Rules",
517
+ }],
518
+ summary: "Later batch succeeded.",
519
+ });
520
+ },
521
+ getModel: () => ({ provider: "test", id: "test-model" }),
522
+ collectTranscriptsFn: async () => ({
523
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
524
+ sessionCount: sessions.length,
525
+ includedCount: sessions.length,
526
+ sessions,
527
+ }),
528
+ };
529
+
530
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
531
+ assert.ok(callIndex >= 2, "Should have made multiple LLM calls");
532
+ // Later batch's edit should still apply even though batch 1 failed
533
+ const updated = fs.readFileSync(fp, "utf-8");
534
+ assert.ok(updated.includes("Surviving rule"));
535
+ assert.ok(notifications.some(n => n.msg.includes("Timeout")));
536
+ });
537
+
538
+ it("does not batch when sessions fit within budget", async () => {
539
+ const fp = path.join(tmpDir, "AGENTS.md");
540
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
541
+ const target = makeTarget({
542
+ path: fp,
543
+ maxSessionBytes: 500_000, // large budget
544
+ });
545
+
546
+ const sessions = [makeSession("s1", 100), makeSession("s2", 100)];
547
+ const deps: RunReflectionDeps = {
548
+ completeSimple: async () => makeToolCallResponse({
549
+ corrections_found: 0,
550
+ sessions_with_corrections: 0,
551
+ edits: [],
552
+ summary: "All clean.",
553
+ }),
554
+ getModel: () => ({ provider: "test", id: "test-model" }),
555
+ collectTranscriptsFn: async () => ({
556
+ transcripts: "### Session\n\n**USER:** test\n",
557
+ sessionCount: 2,
558
+ includedCount: 2,
559
+ sessions,
560
+ }),
561
+ };
562
+
563
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
564
+ assert.notEqual(result, null);
565
+ // Should NOT see batching notification
566
+ assert.ok(!notifications.some(n => n.msg.includes("splitting into")));
567
+ assert.ok(notifications.some(n => n.msg.includes("Analyzing with")));
568
+ });
569
+
570
+ it("accumulates correctionsFound across batches", async () => {
571
+ const fp = path.join(tmpDir, "AGENTS.md");
572
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
573
+ const target = makeTarget({
574
+ path: fp,
575
+ maxSessionBytes: 120_000,
576
+ });
577
+
578
+ const sessions = makeLargeSessions(3);
579
+ let callCount = 0;
580
+ const correctionsPerBatch = [3, 5, 2]; // one per batch
581
+ const deps: RunReflectionDeps = {
582
+ completeSimple: async () => {
583
+ const idx = callCount;
584
+ callCount++;
585
+ return makeToolCallResponse({
586
+ corrections_found: correctionsPerBatch[idx] ?? 0,
587
+ sessions_with_corrections: 1,
588
+ edits: [],
589
+ summary: `Batch ${idx + 1}.`,
590
+ });
591
+ },
592
+ getModel: () => ({ provider: "test", id: "test-model" }),
593
+ collectTranscriptsFn: async () => ({
594
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
595
+ sessionCount: sessions.length,
596
+ includedCount: sessions.length,
597
+ sessions,
598
+ }),
599
+ };
600
+
601
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
602
+
603
+ assert.notEqual(result, null);
604
+ // Sum all corrections across however many batches were created
605
+ const expectedTotal = correctionsPerBatch.slice(0, callCount).reduce((a, b) => a + b, 0);
606
+ assert.equal(result!.correctionsFound, expectedTotal);
607
+ });
608
+
609
+ it("uses transcriptsOverride sessions for batching", async () => {
610
+ const fp = path.join(tmpDir, "AGENTS.md");
611
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
612
+ const target = makeTarget({
613
+ path: fp,
614
+ maxSessionBytes: 120_000,
615
+ });
616
+
617
+ const sessions = makeLargeSessions(3);
618
+
619
+ const deps: RunReflectionDeps = {
620
+ completeSimple: async () => makeToolCallResponse({
621
+ corrections_found: 0,
622
+ sessions_with_corrections: 0,
623
+ edits: [],
624
+ summary: "Clean.",
625
+ }),
626
+ getModel: () => ({ provider: "test", id: "test-model" }),
627
+ };
628
+
629
+ const result = await runReflection(target, makeModelRegistry(), notify, deps, {
630
+ transcriptsOverride: {
631
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
632
+ sessionCount: 2,
633
+ includedCount: 2,
634
+ sessions,
635
+ },
636
+ });
637
+
638
+ assert.notEqual(result, null);
639
+ // Should batch since totalBytes (800) > batchBudget with tiny maxSessionBytes
640
+ assert.ok(notifications.some(n => n.msg.includes("splitting into")));
641
+ });
642
+ });
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { collectContext } from "../extensions/reflect.js";
6
+ import { cleanup, makeTempDir } from "./helpers.js";
7
+
8
+ describe("collectContext file sources", () => {
9
+ let tmpDir: string;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = makeTempDir();
13
+ });
14
+
15
+ afterEach(() => {
16
+ cleanup(tmpDir);
17
+ });
18
+
19
+ it("finds matching files in nested subdirectories for a filetype glob", async () => {
20
+ fs.mkdirSync(path.join(tmpDir, "nested", "deeper"), { recursive: true });
21
+ fs.writeFileSync(path.join(tmpDir, "top.md"), "top-level note", "utf-8");
22
+ fs.writeFileSync(path.join(tmpDir, "nested", "deeper", "child.md"), "nested note", "utf-8");
23
+ fs.writeFileSync(path.join(tmpDir, "nested", "deeper", "ignore.txt"), "ignore me", "utf-8");
24
+
25
+ const context = await collectContext([
26
+ { type: "files", label: "notes", paths: [path.join(tmpDir, "*.md")] },
27
+ ], 1);
28
+
29
+ assert.match(context, /### top\.md\ntop-level note/);
30
+ assert.match(context, /### nested\/deeper\/child\.md\nnested note/);
31
+ assert.doesNotMatch(context, /ignore me/);
32
+ });
33
+
34
+ it("supports explicit recursive glob patterns", async () => {
35
+ fs.mkdirSync(path.join(tmpDir, "a", "b"), { recursive: true });
36
+ fs.writeFileSync(path.join(tmpDir, "root.md"), "root file", "utf-8");
37
+ fs.writeFileSync(path.join(tmpDir, "a", "b", "deep.md"), "deep file", "utf-8");
38
+
39
+ const context = await collectContext([
40
+ { type: "files", label: "notes", paths: [path.join(tmpDir, "**", "*.md")] },
41
+ ], 1);
42
+
43
+ assert.match(context, /### root\.md\nroot file/);
44
+ assert.match(context, /### a\/b\/deep\.md\ndeep file/);
45
+ });
46
+
47
+ it("prunes nested dated files outside the lookback window", async () => {
48
+ fs.mkdirSync(path.join(tmpDir, "archive"), { recursive: true });
49
+ fs.writeFileSync(path.join(tmpDir, "archive", "2000-01-01-old.md"), "old note", "utf-8");
50
+ fs.writeFileSync(path.join(tmpDir, "archive", "2099-01-01-new.md"), "new note", "utf-8");
51
+
52
+ const context = await collectContext([
53
+ { type: "files", label: "dated", paths: [path.join(tmpDir, "*.md")] },
54
+ ], 1);
55
+
56
+ assert.match(context, /2099-01-01-new\.md\nnew note/);
57
+ assert.doesNotMatch(context, /2000-01-01-old\.md\nold note/);
58
+ });
59
+ });