@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.
@@ -6,8 +6,37 @@ import {
6
6
  escapeRegex,
7
7
  truncateText,
8
8
  projectNameFromDir,
9
+ resolveModelAuth,
10
+ buildReflectAnalysisTool,
9
11
  } from "../extensions/reflect.js";
10
12
 
13
+ describe("resolveModelAuth", () => {
14
+ const model = { provider: "test", id: "model" };
15
+
16
+ it("prefers current getApiKeyAndHeaders and preserves header-only auth", async () => {
17
+ let legacyCalled = false;
18
+ const auth = await resolveModelAuth({
19
+ getApiKeyAndHeaders: async () => ({ ok: true, headers: { "X-Test": "yes" } }),
20
+ getApiKey: async () => { legacyCalled = true; return "legacy"; },
21
+ }, model);
22
+ assert.deepEqual(auth, { ok: true, apiKey: undefined, headers: { "X-Test": "yes" } });
23
+ assert.equal(legacyCalled, false);
24
+ });
25
+
26
+ it("falls back to the legacy getApiKey API", async () => {
27
+ assert.deepEqual(await resolveModelAuth({ getApiKey: async () => "legacy" }, model), { ok: true, apiKey: "legacy" });
28
+ });
29
+ });
30
+
31
+ describe("reflect analysis tool schema", () => {
32
+ it("uses scalar JSON Schema types for protobuf-compatible optional fields", () => {
33
+ const properties = (buildReflectAnalysisTool().parameters.properties.edits.items as any).properties;
34
+ assert.equal(properties.old_text.type, "string");
35
+ assert.equal(properties.after_text.type, "string");
36
+ assert.equal(properties.merge_sources.type, "array");
37
+ });
38
+ });
39
+
11
40
  describe("resolvePath", () => {
12
41
  it("expands ~ to HOME", () => {
13
42
  const home = process.env.HOME!;
@@ -27,25 +27,18 @@ describe("buildReflectionPrompt", () => {
27
27
  it("instructs JSON-only output", () => {
28
28
  const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
29
29
  assert.ok(prompt.includes("single JSON object"));
30
- assert.ok(prompt.includes("No markdown, no explanation, no preamble"));
30
+ assert.ok(prompt.includes("No markdown, no preamble"));
31
31
  });
32
32
 
33
- it("describes the strengthen edit type", () => {
33
+ it("describes all edit types", () => {
34
34
  const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
35
35
  assert.ok(prompt.includes('"strengthen"'));
36
- assert.ok(prompt.includes("old_text"));
37
- assert.ok(prompt.includes("character-for-character"));
38
- });
39
-
40
- it("describes the add edit type", () => {
41
- const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
42
36
  assert.ok(prompt.includes('"add"'));
37
+ assert.ok(prompt.includes('"remove"'));
38
+ assert.ok(prompt.includes('"merge"'));
39
+ assert.ok(prompt.includes("old_text"));
43
40
  assert.ok(prompt.includes("after_text"));
44
- });
45
-
46
- it("mentions duplication prevention", () => {
47
- const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
48
- assert.ok(prompt.includes("Never duplicate content"));
41
+ assert.ok(prompt.includes("merge_sources"));
49
42
  });
50
43
 
51
44
  it("requires 2+ occurrences for new rules", () => {
@@ -55,15 +48,14 @@ describe("buildReflectionPrompt", () => {
55
48
 
56
49
  it("lists correction signals to look for", () => {
57
50
  const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
58
- assert.ok(prompt.includes("bro"));
59
- assert.ok(prompt.includes("wtf"));
60
- assert.ok(prompt.includes("actually"));
51
+ assert.ok(prompt.includes("frustration"));
52
+ assert.ok(prompt.includes("correcting"));
61
53
  });
62
54
 
63
55
  it("warns about false positives", () => {
64
56
  const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
65
57
  assert.ok(prompt.includes("no worries"));
66
- assert.ok(prompt.includes("NOT corrections"));
58
+ assert.ok(prompt.includes("Ignore normal flow"));
67
59
  });
68
60
 
69
61
  it("uses basename, not full path in prompt", () => {
@@ -0,0 +1,539 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+
7
+ import {
8
+ computeFileMetrics,
9
+ applyEdits,
10
+ buildTranscriptBatches,
11
+ buildReflectionPrompt,
12
+ buildPromptForTarget,
13
+ escapeRegex,
14
+ truncateText,
15
+ projectNameFromDir,
16
+ extractTranscript,
17
+ formatSessionTranscript,
18
+ loadHistory,
19
+ saveHistory,
20
+ loadConfig,
21
+ saveConfig,
22
+ resolvePath,
23
+ type AnalysisEdit,
24
+ type ReflectTarget,
25
+ type ReflectRun,
26
+ type SessionData,
27
+ type SessionExchange,
28
+ DEFAULT_TARGET,
29
+ HISTORY_FILE,
30
+ CONFIG_FILE,
31
+ } from "../extensions/reflect.js";
32
+
33
+ function makeTmpDir(): string {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-reflect-test-"));
35
+ }
36
+
37
+ function rmrf(dir: string): void {
38
+ fs.rmSync(dir, { recursive: true, force: true });
39
+ }
40
+
41
+ // --- computeFileMetrics ---
42
+
43
+ describe("computeFileMetrics", () => {
44
+ it("counts chars, words, lines, estTokens", () => {
45
+ const m = computeFileMetrics("hello world\nsecond line\n");
46
+ assert.equal(m.chars, 24);
47
+ assert.equal(m.words, 4);
48
+ assert.equal(m.lines, 3); // trailing newline = extra empty line
49
+ assert.equal(m.estTokens, Math.ceil(24 / 4));
50
+ });
51
+
52
+ it("handles empty string", () => {
53
+ const m = computeFileMetrics("");
54
+ assert.equal(m.chars, 0);
55
+ assert.equal(m.words, 0);
56
+ assert.equal(m.lines, 1);
57
+ assert.equal(m.estTokens, 0);
58
+ });
59
+ });
60
+
61
+ // --- escapeRegex ---
62
+
63
+ describe("escapeRegex", () => {
64
+ it("escapes regex special chars", () => {
65
+ assert.equal(escapeRegex("a.b*c?d"), "a\\.b\\*c\\?d");
66
+ assert.equal(escapeRegex("foo[bar]"), "foo\\[bar\\]");
67
+ assert.equal(escapeRegex("$100 (USD)"), "\\$100 \\(USD\\)");
68
+ });
69
+ });
70
+
71
+ // --- truncateText ---
72
+
73
+ describe("truncateText", () => {
74
+ it("returns null for null input", () => {
75
+ assert.equal(truncateText(null, 10), null);
76
+ });
77
+
78
+ it("truncates long text", () => {
79
+ const result = truncateText("hello world this is long", 10);
80
+ assert.ok(result !== null);
81
+ // truncateText uses "\n[...truncated, N chars omitted]" format
82
+ assert.ok(result!.includes("truncated") || result!.length <= 24);
83
+ // Original 24 chars, limit 10 — should be truncated
84
+ assert.ok(result!.length < 24 || result!.includes("truncated"));
85
+ });
86
+
87
+ it("returns text unchanged if within limit", () => {
88
+ assert.equal(truncateText("short", 100), "short");
89
+ });
90
+ });
91
+
92
+ // --- projectNameFromDir ---
93
+
94
+ describe("projectNameFromDir", () => {
95
+ it("extracts project name from worktree path", () => {
96
+ const name = projectNameFromDir("my-project-feature-branch");
97
+ assert.equal(typeof name, "string");
98
+ });
99
+ });
100
+
101
+ // --- applyEdits ---
102
+
103
+ describe("applyEdits", () => {
104
+ const baseContent = `# Rules
105
+
106
+ ## Debugging
107
+ - **Always check logs first** before theorizing.
108
+ - **Don't guess** — verify assumptions with data.
109
+
110
+ ## Execution
111
+ - **Execute first, explain minimally**: Do the work, say what you did.
112
+ - **Use the oracle subagent early**: For complex problems, use the oracle BEFORE spending 10+ minutes.
113
+ `;
114
+
115
+ it("strengthen: replaces matching text", () => {
116
+ const edits: AnalysisEdit[] = [{
117
+ type: "strengthen",
118
+ old_text: "- **Always check logs first** before theorizing.",
119
+ new_text: "- **Check logs first** — no theorizing without evidence.",
120
+ }];
121
+ const { result, applied, skipped } = applyEdits(baseContent, edits);
122
+ assert.equal(applied, 1);
123
+ assert.equal(skipped.length, 0);
124
+ assert.ok(result.includes("Check logs first"));
125
+ assert.ok(!result.includes("before theorizing."));
126
+ });
127
+
128
+ it("strengthen: skips when old_text not found", () => {
129
+ const edits: AnalysisEdit[] = [{
130
+ type: "strengthen",
131
+ old_text: "- This text does not exist in the file.",
132
+ new_text: "- Replacement text.",
133
+ }];
134
+ const { applied, skipped } = applyEdits(baseContent, edits);
135
+ assert.equal(applied, 0);
136
+ assert.equal(skipped.length, 1);
137
+ assert.ok(skipped[0].includes("Could not find"));
138
+ });
139
+
140
+ it("strengthen: skips ambiguous matches", () => {
141
+ const content = "rule A\nrule A\n";
142
+ const edits: AnalysisEdit[] = [{
143
+ type: "strengthen",
144
+ old_text: "rule A",
145
+ new_text: "rule B",
146
+ }];
147
+ const { applied, skipped } = applyEdits(content, edits);
148
+ assert.equal(applied, 0);
149
+ assert.equal(skipped.length, 1);
150
+ assert.ok(skipped[0].includes("Ambiguous"));
151
+ });
152
+
153
+ it("strengthen: detects duplication in replacement text", () => {
154
+ // old_text is >50 chars so duplication detection kicks in
155
+ const longRule = "- **When the user pushes back or says you're wrong, pivot immediately**: After 1 pushback, stop.";
156
+ const content = `# Rules\n${longRule}\n`;
157
+ const edits: AnalysisEdit[] = [{
158
+ type: "strengthen",
159
+ old_text: longRule,
160
+ // new_text contains the old text twice (LLM duplication bug)
161
+ new_text: `${longRule}\n${longRule}`,
162
+ }];
163
+ const { applied, skipped } = applyEdits(content, edits);
164
+ assert.equal(applied, 0);
165
+ assert.equal(skipped.length, 1);
166
+ assert.ok(skipped[0].includes("Duplication"));
167
+ });
168
+
169
+ it("add: inserts after matching text", () => {
170
+ const edits: AnalysisEdit[] = [{
171
+ type: "add",
172
+ new_text: "- **New rule**: Always verify the fix works.",
173
+ after_text: "- **Don't guess** — verify assumptions with data.",
174
+ }];
175
+ const { result, applied } = applyEdits(baseContent, edits);
176
+ assert.equal(applied, 1);
177
+ assert.ok(result.includes("New rule"));
178
+ // Verify insertion is after the anchor
179
+ const anchorIdx = result.indexOf("verify assumptions with data.");
180
+ const newIdx = result.indexOf("New rule");
181
+ assert.ok(newIdx > anchorIdx);
182
+ });
183
+
184
+ it("add: skips if text already exists", () => {
185
+ const edits: AnalysisEdit[] = [{
186
+ type: "add",
187
+ new_text: "- **Don't guess** — verify assumptions with data.",
188
+ after_text: "- **Always check logs first** before theorizing.",
189
+ }];
190
+ const { applied, skipped } = applyEdits(baseContent, edits);
191
+ assert.equal(applied, 0);
192
+ assert.equal(skipped.length, 1);
193
+ assert.ok(skipped[0].includes("already exists"));
194
+ });
195
+
196
+ it("add: skips ambiguous insertion point", () => {
197
+ const content = "anchor\nstuff\nanchor\n";
198
+ const edits: AnalysisEdit[] = [{
199
+ type: "add",
200
+ new_text: "new line",
201
+ after_text: "anchor",
202
+ }];
203
+ const { applied, skipped } = applyEdits(content, edits);
204
+ assert.equal(applied, 0);
205
+ assert.ok(skipped[0].includes("Ambiguous"));
206
+ });
207
+
208
+ it("remove: deletes matching text", () => {
209
+ const edits: AnalysisEdit[] = [{
210
+ type: "remove",
211
+ old_text: "- **Don't guess** — verify assumptions with data.",
212
+ new_text: "",
213
+ }];
214
+ const { result, applied } = applyEdits(baseContent, edits);
215
+ assert.equal(applied, 1);
216
+ assert.ok(!result.includes("Don't guess"));
217
+ });
218
+
219
+ it("remove: skips ambiguous matches", () => {
220
+ const content = "dup line\nstuff\ndup line\n";
221
+ const edits: AnalysisEdit[] = [{
222
+ type: "remove",
223
+ old_text: "dup line",
224
+ new_text: "",
225
+ }];
226
+ const { applied, skipped } = applyEdits(content, edits);
227
+ assert.equal(applied, 0);
228
+ assert.ok(skipped[0].includes("Ambiguous"));
229
+ });
230
+
231
+ it("merge: consolidates multiple sources into one", () => {
232
+ const edits: AnalysisEdit[] = [{
233
+ type: "merge",
234
+ merge_sources: [
235
+ "- **Always check logs first** before theorizing.",
236
+ "- **Don't guess** — verify assumptions with data.",
237
+ ],
238
+ new_text: "- **Check logs and verify with data** — no guessing or theorizing.",
239
+ }];
240
+ const { result, applied } = applyEdits(baseContent, edits);
241
+ assert.equal(applied, 1);
242
+ assert.ok(result.includes("Check logs and verify with data"));
243
+ assert.ok(!result.includes("Always check logs first"));
244
+ assert.ok(!result.includes("Don't guess"));
245
+ });
246
+
247
+ it("merge: skips when a source is missing", () => {
248
+ const edits: AnalysisEdit[] = [{
249
+ type: "merge",
250
+ merge_sources: [
251
+ "- **Always check logs first** before theorizing.",
252
+ "- This source does not exist.",
253
+ ],
254
+ new_text: "- Merged rule.",
255
+ }];
256
+ const { applied, skipped } = applyEdits(baseContent, edits);
257
+ assert.equal(applied, 0);
258
+ assert.ok(skipped[0].includes("Merge source not found"));
259
+ });
260
+
261
+ it("applies multiple edits in sequence", () => {
262
+ const edits: AnalysisEdit[] = [
263
+ {
264
+ type: "strengthen",
265
+ old_text: "- **Always check logs first** before theorizing.",
266
+ new_text: "- **Logs first** — no theorizing.",
267
+ },
268
+ {
269
+ type: "add",
270
+ new_text: "- **3-attempt rule**: Stop after 3 failures.",
271
+ after_text: "- **Use the oracle subagent early**: For complex problems, use the oracle BEFORE spending 10+ minutes.",
272
+ },
273
+ ];
274
+ const { result, applied } = applyEdits(baseContent, edits);
275
+ assert.equal(applied, 2);
276
+ assert.ok(result.includes("Logs first"));
277
+ assert.ok(result.includes("3-attempt rule"));
278
+ });
279
+
280
+ it("handles invalid edit gracefully", () => {
281
+ const edits: AnalysisEdit[] = [{
282
+ type: "strengthen" as any,
283
+ new_text: "replacement",
284
+ // missing old_text
285
+ }];
286
+ const { applied, skipped } = applyEdits(baseContent, edits);
287
+ assert.equal(applied, 0);
288
+ assert.equal(skipped.length, 1);
289
+ assert.ok(skipped[0].includes("Invalid edit"));
290
+ });
291
+ });
292
+
293
+ // --- buildTranscriptBatches ---
294
+
295
+ describe("buildTranscriptBatches", () => {
296
+ function makeSessions(sizes: number[]): SessionData[] {
297
+ return sizes.map((size, i) => ({
298
+ userCount: 1,
299
+ exchangeCount: 2,
300
+ transcript: "x".repeat(size),
301
+ size,
302
+ project: "test",
303
+ time: `session-${i}`,
304
+ }));
305
+ }
306
+
307
+ it("puts all sessions in one batch when they fit", () => {
308
+ const sessions = makeSessions([100, 200, 300]);
309
+ const batches = buildTranscriptBatches(sessions, 10000);
310
+ assert.equal(batches.length, 1);
311
+ assert.equal(batches[0].length, 3);
312
+ });
313
+
314
+ it("splits sessions into multiple batches by size", () => {
315
+ const sessions = makeSessions([500, 500, 500]);
316
+ // Each entry adds "\n---\n\n" (6 chars) to the transcript
317
+ const batches = buildTranscriptBatches(sessions, 600);
318
+ assert.ok(batches.length >= 2);
319
+ });
320
+
321
+ it("handles a single oversized session", () => {
322
+ const sessions = makeSessions([10000]);
323
+ const batches = buildTranscriptBatches(sessions, 100);
324
+ // Even oversized, it should be in its own batch (not dropped)
325
+ assert.equal(batches.length, 1);
326
+ assert.equal(batches[0].length, 1);
327
+ });
328
+
329
+ it("returns empty for no sessions", () => {
330
+ const batches = buildTranscriptBatches([], 10000);
331
+ assert.equal(batches.length, 0);
332
+ });
333
+ });
334
+
335
+ // --- buildReflectionPrompt ---
336
+
337
+ describe("buildReflectionPrompt", () => {
338
+ it("includes file name, content, and transcripts", () => {
339
+ const prompt = buildReflectionPrompt(
340
+ "/path/to/AGENTS.md",
341
+ "# Rules\n- Rule 1\n",
342
+ "## Session 1\nUser: fix this\nAssistant: done",
343
+ );
344
+ assert.ok(prompt.includes("AGENTS.md"));
345
+ assert.ok(prompt.includes("# Rules"));
346
+ assert.ok(prompt.includes("Rule 1"));
347
+ assert.ok(prompt.includes("Session 1"));
348
+ assert.ok(prompt.includes("fix this"));
349
+ });
350
+
351
+ it("includes conciseness instructions", () => {
352
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
353
+ assert.ok(prompt.includes("Conciseness"));
354
+ assert.ok(prompt.includes("SHORTER"));
355
+ });
356
+
357
+ it("includes all edit types in instructions", () => {
358
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
359
+ assert.ok(prompt.includes("strengthen"));
360
+ assert.ok(prompt.includes("add"));
361
+ assert.ok(prompt.includes("remove"));
362
+ assert.ok(prompt.includes("merge"));
363
+ });
364
+ });
365
+
366
+ // --- buildPromptForTarget ---
367
+
368
+ describe("buildPromptForTarget", () => {
369
+ it("uses default prompt when no custom prompt", () => {
370
+ const target: ReflectTarget = { ...DEFAULT_TARGET, path: "/AGENTS.md" };
371
+ const prompt = buildPromptForTarget(target, "/AGENTS.md", "content", "transcripts");
372
+ assert.ok(prompt.includes("reviewing recent agent session"));
373
+ });
374
+
375
+ it("interpolates custom prompt template", () => {
376
+ const target: ReflectTarget = {
377
+ ...DEFAULT_TARGET,
378
+ path: "/AGENTS.md",
379
+ prompt: "File: {fileName}\nContent: {targetContent}\nTranscripts: {transcripts}\nContext: {context}",
380
+ };
381
+ const prompt = buildPromptForTarget(target, "/path/to/AGENTS.md", "my content", "my transcripts", "my context");
382
+ assert.equal(prompt, "File: AGENTS.md\nContent: my content\nTranscripts: my transcripts\nContext: my context");
383
+ });
384
+ });
385
+
386
+ // --- Config and History I/O ---
387
+
388
+ describe("config and history I/O", () => {
389
+ let origConfigFile: string;
390
+ let origHistoryFile: string;
391
+ let tmp: string;
392
+
393
+ before(() => {
394
+ tmp = makeTmpDir();
395
+ // Monkey-patch the module-level file paths
396
+ // These are exported as const, so we need to use Object.defineProperty
397
+ origConfigFile = CONFIG_FILE;
398
+ origHistoryFile = HISTORY_FILE;
399
+ });
400
+
401
+ after(() => {
402
+ rmrf(tmp);
403
+ });
404
+
405
+ it("loadHistory returns empty array for missing file", () => {
406
+ // loadHistory reads from HISTORY_FILE which we can't easily redirect
407
+ // So just test the function exists and handles errors
408
+ const history = loadHistory();
409
+ assert.ok(Array.isArray(history));
410
+ });
411
+
412
+ it("loadConfig returns empty targets for missing file", () => {
413
+ const config = loadConfig();
414
+ assert.ok(Array.isArray(config.targets));
415
+ });
416
+ });
417
+
418
+ // --- Dedup in runReflection ---
419
+
420
+ describe("dedup logic", () => {
421
+ it("sourceDate is computed from lookbackDays", () => {
422
+ // Verify the date computation matches what runReflection uses
423
+ const lookbackDays = 1;
424
+ const sourceDate = new Date();
425
+ sourceDate.setDate(sourceDate.getDate() - lookbackDays);
426
+ const sourceDateStr = sourceDate.toISOString().slice(0, 10);
427
+
428
+ // Should be yesterday's date
429
+ const yesterday = new Date();
430
+ yesterday.setDate(yesterday.getDate() - 1);
431
+ assert.equal(sourceDateStr, yesterday.toISOString().slice(0, 10));
432
+ });
433
+
434
+ it("history entry sourceDate matching works", () => {
435
+ const targetPath = "/path/to/AGENTS.md";
436
+ const sourceDateStr = "2026-05-02";
437
+
438
+ const history: ReflectRun[] = [
439
+ {
440
+ timestamp: "2026-05-03T05:00:00Z",
441
+ targetPath,
442
+ sessionsAnalyzed: 10,
443
+ correctionsFound: 3,
444
+ editsApplied: 2,
445
+ summary: "test",
446
+ diffLines: 5,
447
+ correctionRate: 0.3,
448
+ sourceDate: sourceDateStr,
449
+ },
450
+ ];
451
+
452
+ const alreadyRan = history.some(
453
+ (r) => r.targetPath === targetPath && r.sourceDate === sourceDateStr,
454
+ );
455
+ assert.ok(alreadyRan, "should detect already-processed date");
456
+
457
+ const notRan = history.some(
458
+ (r) => r.targetPath === targetPath && r.sourceDate === "2026-05-03",
459
+ );
460
+ assert.ok(!notRan, "should not match different date");
461
+
462
+ const diffTarget = history.some(
463
+ (r) => r.targetPath === "/other/file.md" && r.sourceDate === sourceDateStr,
464
+ );
465
+ assert.ok(!diffTarget, "should not match different target");
466
+ });
467
+ });
468
+
469
+ // --- extractTranscript ---
470
+
471
+ describe("extractTranscript", () => {
472
+ let tmp: string;
473
+ before(() => { tmp = makeTmpDir(); });
474
+ after(() => { rmrf(tmp); });
475
+
476
+ it("extracts exchanges from a session file", async () => {
477
+ // Create a minimal session file (JSONL format)
478
+ // extractTranscript expects {type: "message", message: {role, content}} wrapper
479
+ const sessionFile = path.join(tmp, "test-session.jsonl");
480
+ const entries = [
481
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "fix the bug" }] } },
482
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "I'll look into it." }] } },
483
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "thanks" }] } },
484
+ ];
485
+ fs.writeFileSync(sessionFile, entries.map(e => JSON.stringify(e)).join("\n"), "utf-8");
486
+
487
+ const exchanges = await extractTranscript(sessionFile);
488
+ assert.ok(exchanges.length >= 2, `expected >= 2 exchanges, got ${exchanges.length}`);
489
+ assert.equal(exchanges[0].role, "user");
490
+ assert.equal(exchanges[0].text, "fix the bug");
491
+ assert.equal(exchanges[1].role, "assistant");
492
+ assert.equal(exchanges[1].text, "I'll look into it.");
493
+ });
494
+
495
+ it("handles missing file gracefully", async () => {
496
+ const exchanges = await extractTranscript("/nonexistent/file.jsonl");
497
+ assert.deepEqual(exchanges, []);
498
+ });
499
+ });
500
+
501
+ // --- formatSessionTranscript ---
502
+
503
+ describe("formatSessionTranscript", () => {
504
+ it("formats exchanges into readable text", () => {
505
+ const exchanges: SessionExchange[] = [
506
+ { role: "user", text: "fix it", thinking: null },
507
+ { role: "assistant", text: "done", thinking: "I should check the logs" },
508
+ ];
509
+ const result = formatSessionTranscript(exchanges, "session-1", "my-project");
510
+ assert.ok(result.includes("fix it"));
511
+ assert.ok(result.includes("done"));
512
+ assert.ok(result.includes("session-1") || result.includes("my-project"));
513
+ });
514
+
515
+ it("handles empty exchanges", () => {
516
+ const result = formatSessionTranscript([], "empty-session", "proj");
517
+ assert.equal(typeof result, "string");
518
+ });
519
+ });
520
+
521
+ // --- resolvePath ---
522
+
523
+ describe("resolvePath", () => {
524
+ it("resolves tilde paths", () => {
525
+ const home = process.env.HOME ?? os.homedir();
526
+ const resolved = resolvePath("~/test.md");
527
+ assert.equal(resolved, path.join(home, "test.md"));
528
+ });
529
+
530
+ it("resolves relative paths", () => {
531
+ const resolved = resolvePath("./test.md");
532
+ assert.ok(path.isAbsolute(resolved));
533
+ });
534
+
535
+ it("keeps absolute paths unchanged", () => {
536
+ const resolved = resolvePath("/absolute/path.md");
537
+ assert.equal(resolved, "/absolute/path.md");
538
+ });
539
+ });
@@ -54,7 +54,7 @@ function makeDeps(llmResponseJson: string, transcripts = "### Session: test\n\n*
54
54
  function makeModelRegistry() {
55
55
  return {
56
56
  find: () => ({ provider: "test", id: "test-model" }),
57
- getApiKey: async () => "test-key",
57
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key", headers: undefined }),
58
58
  };
59
59
  }
60
60
 
@@ -96,7 +96,7 @@ describe("runReflection", () => {
96
96
  ...makeDeps("{}"),
97
97
  getModel: () => null,
98
98
  };
99
- const registry = { find: () => null, getApiKey: async () => null };
99
+ const registry = { find: () => null, getApiKeyAndHeaders: async () => ({ ok: false, error: "no key" }) };
100
100
  const result = await runReflection(target, registry, notify, deps);
101
101
  assert.equal(result, null);
102
102
  assert.ok(notifications.some(n => n.level === "error" && n.msg.includes("Model not found")));
@@ -108,13 +108,42 @@ describe("runReflection", () => {
108
108
  const target = makeTarget({ path: fp });
109
109
  const registry = {
110
110
  find: () => ({ provider: "test", id: "test" }),
111
- getApiKey: async () => null,
111
+ getApiKeyAndHeaders: async () => ({ ok: false, error: "No API key" }),
112
112
  };
113
113
  const result = await runReflection(target, registry, notify, makeDeps("{}"));
114
114
  assert.equal(result, null);
115
115
  assert.ok(notifications.some(n => n.level === "error" && n.msg.includes("No API key")));
116
116
  });
117
117
 
118
+ it("supports header-only auth from the current model registry", async () => {
119
+ const fp = path.join(tmpDir, "AGENTS.md");
120
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
121
+ let receivedOptions: any;
122
+ const deps = makeDeps(makeLlmResponse([], 0));
123
+ deps.completeSimple = async (_model, _request, options) => {
124
+ receivedOptions = options;
125
+ return { content: [{ type: "text", text: makeLlmResponse([], 0) }] };
126
+ };
127
+ const registry = {
128
+ find: () => ({ provider: "test", id: "test" }),
129
+ getApiKeyAndHeaders: async () => ({ ok: true, headers: { "X-Custom": "value" } }),
130
+ };
131
+ const result = await runReflection(makeTarget({ path: fp }), registry, notify, deps);
132
+ assert.notEqual(result, null);
133
+ assert.deepEqual(receivedOptions.headers, { "X-Custom": "value" });
134
+ });
135
+
136
+ it("supports the legacy modelRegistry.getApiKey API", async () => {
137
+ const fp = path.join(tmpDir, "AGENTS.md");
138
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
139
+ const registry = {
140
+ find: () => ({ provider: "test", id: "test" }),
141
+ getApiKey: async () => "legacy-key",
142
+ };
143
+ const result = await runReflection(makeTarget({ path: fp }), registry, notify, makeDeps(makeLlmResponse([], 0)));
144
+ assert.notEqual(result, null);
145
+ });
146
+
118
147
  it("returns run with 0 edits when LLM says no edits needed", async () => {
119
148
  const fp = path.join(tmpDir, "AGENTS.md");
120
149
  fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
@@ -312,7 +341,7 @@ describe("runReflection", () => {
312
341
  let registryFindCalled = false;
313
342
  const registry = {
314
343
  find: () => { registryFindCalled = true; return { provider: "test", id: "test" }; },
315
- getApiKey: async () => "test-key",
344
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key", headers: undefined }),
316
345
  };
317
346
  const deps: RunReflectionDeps = {
318
347
  ...makeDeps(llmResponse),