@askjo/pi-reflect 1.1.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.
- package/.github/FUNDING.yml +1 -0
- package/.github/workflows/publish.yml +46 -0
- package/README.md +14 -0
- package/extensions/index.ts +100 -33
- package/extensions/reflect.ts +243 -70
- package/package.json +1 -1
- package/release.sh +39 -0
- package/tests/apply-edits.test.ts +51 -0
- package/tests/batching.test.ts +1 -1
- package/tests/context-files.test.ts +59 -0
- package/tests/helpers-and-utils.test.ts +29 -0
- package/tests/prompt-building.test.ts +9 -17
- package/tests/reflect.test.ts +539 -0
- package/tests/run-reflection.test.ts +33 -4
|
@@ -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
|
|
30
|
+
assert.ok(prompt.includes("No markdown, no preamble"));
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it("describes
|
|
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("
|
|
59
|
-
assert.ok(prompt.includes("
|
|
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("
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key", headers: undefined }),
|
|
316
345
|
};
|
|
317
346
|
const deps: RunReflectionDeps = {
|
|
318
347
|
...makeDeps(llmResponse),
|