@ricky-stevens/context-guardian 2.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.
- package/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +63 -0
- package/.github/workflows/ci.yml +66 -0
- package/CLAUDE.md +132 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/biome.json +34 -0
- package/bun.lock +31 -0
- package/hooks/precompact.mjs +73 -0
- package/hooks/session-start.mjs +133 -0
- package/hooks/stop.mjs +172 -0
- package/hooks/submit.mjs +133 -0
- package/lib/checkpoint.mjs +258 -0
- package/lib/compact-cli.mjs +124 -0
- package/lib/compact-output.mjs +350 -0
- package/lib/config.mjs +40 -0
- package/lib/content.mjs +33 -0
- package/lib/diagnostics.mjs +221 -0
- package/lib/estimate.mjs +254 -0
- package/lib/extract-helpers.mjs +869 -0
- package/lib/handoff.mjs +329 -0
- package/lib/logger.mjs +34 -0
- package/lib/mcp-tools.mjs +200 -0
- package/lib/paths.mjs +90 -0
- package/lib/stats.mjs +81 -0
- package/lib/statusline.mjs +123 -0
- package/lib/synthetic-session.mjs +273 -0
- package/lib/tokens.mjs +170 -0
- package/lib/tool-summary.mjs +399 -0
- package/lib/transcript.mjs +939 -0
- package/lib/trim.mjs +158 -0
- package/package.json +22 -0
- package/skills/compact/SKILL.md +20 -0
- package/skills/config/SKILL.md +70 -0
- package/skills/handoff/SKILL.md +26 -0
- package/skills/prune/SKILL.md +20 -0
- package/skills/stats/SKILL.md +100 -0
- package/sonar-project.properties +12 -0
- package/test/checkpoint.test.mjs +171 -0
- package/test/compact-cli.test.mjs +230 -0
- package/test/compact-output.test.mjs +284 -0
- package/test/compaction-e2e.test.mjs +809 -0
- package/test/content.test.mjs +86 -0
- package/test/diagnostics.test.mjs +188 -0
- package/test/edge-cases.test.mjs +543 -0
- package/test/estimate.test.mjs +262 -0
- package/test/extract-helpers-coverage.test.mjs +333 -0
- package/test/extract-helpers.test.mjs +234 -0
- package/test/handoff.test.mjs +738 -0
- package/test/integration.test.mjs +582 -0
- package/test/logger.test.mjs +70 -0
- package/test/manual-compaction-test.md +426 -0
- package/test/mcp-tools.test.mjs +443 -0
- package/test/paths.test.mjs +250 -0
- package/test/quick-compaction-test.md +191 -0
- package/test/stats.test.mjs +88 -0
- package/test/statusline.test.mjs +222 -0
- package/test/submit.test.mjs +232 -0
- package/test/synthetic-session.test.mjs +600 -0
- package/test/tokens.test.mjs +293 -0
- package/test/tool-summary.test.mjs +771 -0
- package/test/transcript-coverage.test.mjs +369 -0
- package/test/transcript.test.mjs +596 -0
- package/test/trim.test.mjs +356 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
6
|
+
import { extractConversation, extractRecent } from "../lib/transcript.mjs";
|
|
7
|
+
|
|
8
|
+
let tmpDir;
|
|
9
|
+
let transcriptPath;
|
|
10
|
+
|
|
11
|
+
function writeLine(obj) {
|
|
12
|
+
fs.appendFileSync(transcriptPath, `${JSON.stringify(obj)}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function userMsg(text) {
|
|
16
|
+
return {
|
|
17
|
+
type: "user",
|
|
18
|
+
message: { role: "user", content: text },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assistantMsg(text) {
|
|
23
|
+
return {
|
|
24
|
+
type: "assistant",
|
|
25
|
+
message: {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: [{ type: "text", text }],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assistantToolOnly() {
|
|
33
|
+
return {
|
|
34
|
+
type: "assistant",
|
|
35
|
+
message: {
|
|
36
|
+
role: "assistant",
|
|
37
|
+
content: [
|
|
38
|
+
{ type: "tool_use", id: "t1", name: "Read", input: { path: "/foo" } },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-test-"));
|
|
46
|
+
transcriptPath = path.join(tmpDir, "transcript.jsonl");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// =========================================================================
|
|
54
|
+
// extractConversation
|
|
55
|
+
// =========================================================================
|
|
56
|
+
describe("extractConversation", () => {
|
|
57
|
+
it("returns placeholder for missing transcript", () => {
|
|
58
|
+
assert.equal(extractConversation(null), "(no transcript available)");
|
|
59
|
+
assert.equal(
|
|
60
|
+
extractConversation("/no/such/file"),
|
|
61
|
+
"(no transcript available)",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("extracts user and assistant text messages", () => {
|
|
66
|
+
writeLine(userMsg("Hello Claude"));
|
|
67
|
+
writeLine(assistantMsg("Hello! How can I help?"));
|
|
68
|
+
writeLine(userMsg("Fix the bug"));
|
|
69
|
+
writeLine(assistantMsg("Done, I fixed it."));
|
|
70
|
+
|
|
71
|
+
const result = extractConversation(transcriptPath);
|
|
72
|
+
assert.ok(result.includes("User: Hello Claude"));
|
|
73
|
+
assert.ok(result.includes("Asst: Hello! How can I help?"));
|
|
74
|
+
assert.ok(result.includes("User: Fix the bug"));
|
|
75
|
+
assert.ok(result.includes("Asst: Done, I fixed it."));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("replaces tool-only assistant messages with placeholder", () => {
|
|
79
|
+
writeLine(userMsg("show me the file"));
|
|
80
|
+
writeLine(assistantToolOnly());
|
|
81
|
+
writeLine(assistantMsg("Here is the file content."));
|
|
82
|
+
|
|
83
|
+
const result = extractConversation(transcriptPath);
|
|
84
|
+
// Tool-only assistant message gets a tool summary
|
|
85
|
+
assert.ok(result.includes("Read `/foo`"));
|
|
86
|
+
assert.ok(result.includes("Here is the file content."));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("skips empty user messages", () => {
|
|
90
|
+
writeLine(userMsg(""));
|
|
91
|
+
writeLine(userMsg("real message"));
|
|
92
|
+
|
|
93
|
+
const result = extractConversation(transcriptPath);
|
|
94
|
+
assert.ok(!result.includes("User: \n")); // no empty user entry
|
|
95
|
+
assert.ok(result.includes("User: real message"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- Compact marker detection ---
|
|
99
|
+
it("detects [SMART COMPACT marker and uses as boundary", () => {
|
|
100
|
+
writeLine(userMsg("old message"));
|
|
101
|
+
writeLine(assistantMsg("old response"));
|
|
102
|
+
writeLine({
|
|
103
|
+
type: "user",
|
|
104
|
+
message: {
|
|
105
|
+
role: "user",
|
|
106
|
+
content:
|
|
107
|
+
"[SMART COMPACT — restored checkpoint]\n\nUser: prior\n\nAsst: prior answer",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
writeLine(userMsg("new message"));
|
|
111
|
+
writeLine(assistantMsg("new response"));
|
|
112
|
+
|
|
113
|
+
const result = extractConversation(transcriptPath);
|
|
114
|
+
// Should NOT include "old message" — it's before the marker
|
|
115
|
+
assert.ok(!result.includes("User: old message"));
|
|
116
|
+
// Should include preamble (the marker content) and new messages
|
|
117
|
+
assert.ok(result.includes("new message"));
|
|
118
|
+
assert.ok(result.includes("new response"));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("detects [KEEP RECENT marker", () => {
|
|
122
|
+
writeLine(userMsg("old"));
|
|
123
|
+
writeLine({
|
|
124
|
+
type: "user",
|
|
125
|
+
message: {
|
|
126
|
+
role: "user",
|
|
127
|
+
content: "[KEEP RECENT — restored checkpoint]\n\nstuff",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
writeLine(userMsg("new"));
|
|
131
|
+
|
|
132
|
+
const result = extractConversation(transcriptPath);
|
|
133
|
+
assert.ok(!result.includes("User: old"));
|
|
134
|
+
assert.ok(result.includes("User: new"));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("detects # Context Checkpoint marker", () => {
|
|
138
|
+
writeLine(userMsg("old"));
|
|
139
|
+
writeLine({
|
|
140
|
+
type: "user",
|
|
141
|
+
message: {
|
|
142
|
+
role: "user",
|
|
143
|
+
content:
|
|
144
|
+
"# Context Checkpoint (Smart Compact)\n> Created: 2026-01-01\n\nUser: hi",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
writeLine(userMsg("new"));
|
|
148
|
+
|
|
149
|
+
const result = extractConversation(transcriptPath);
|
|
150
|
+
assert.ok(!result.includes("User: old"));
|
|
151
|
+
assert.ok(result.includes("User: new"));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("uses the LAST marker when multiple exist", () => {
|
|
155
|
+
writeLine({
|
|
156
|
+
type: "user",
|
|
157
|
+
message: {
|
|
158
|
+
role: "user",
|
|
159
|
+
content: "[SMART COMPACT — first]\n\nfirst checkpoint",
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
writeLine(userMsg("middle"));
|
|
163
|
+
writeLine({
|
|
164
|
+
type: "user",
|
|
165
|
+
message: {
|
|
166
|
+
role: "user",
|
|
167
|
+
content: "[KEEP RECENT — second]\n\nsecond checkpoint",
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
writeLine(userMsg("latest"));
|
|
171
|
+
|
|
172
|
+
const result = extractConversation(transcriptPath);
|
|
173
|
+
assert.ok(!result.includes("User: middle"));
|
|
174
|
+
assert.ok(result.includes("User: latest"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- CG menu reply filtering ---
|
|
178
|
+
it("filters menu replies after CG menu prompt", () => {
|
|
179
|
+
// Simulate: assistant shows CG menu, user replies with "2"
|
|
180
|
+
writeLine(userMsg("implement feature"));
|
|
181
|
+
writeLine(
|
|
182
|
+
assistantMsg(
|
|
183
|
+
"Context Guardian — ~35.1% used\n\nReply with 1, 2, 3, 4, or 0.",
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
writeLine(userMsg("2"));
|
|
187
|
+
writeLine(userMsg("next real message"));
|
|
188
|
+
|
|
189
|
+
const result = extractConversation(transcriptPath);
|
|
190
|
+
assert.ok(result.includes("User: implement feature"));
|
|
191
|
+
assert.ok(!result.includes("User: 2")); // menu reply filtered
|
|
192
|
+
assert.ok(result.includes("User: next real message"));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("filters cancel reply after CG menu", () => {
|
|
196
|
+
writeLine(
|
|
197
|
+
assistantMsg(
|
|
198
|
+
"Context Guardian — ~40.0% used\n\nReply with 1, 2, 3, 4, or 0.",
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
writeLine(userMsg("cancel"));
|
|
202
|
+
|
|
203
|
+
const result = extractConversation(transcriptPath);
|
|
204
|
+
assert.ok(!result.includes("User: cancel"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("does NOT filter digits when not preceded by CG menu", () => {
|
|
208
|
+
writeLine(userMsg("which option?"));
|
|
209
|
+
writeLine(assistantMsg("Pick 1, 2, or 3."));
|
|
210
|
+
writeLine(userMsg("2"));
|
|
211
|
+
|
|
212
|
+
const result = extractConversation(transcriptPath);
|
|
213
|
+
assert.ok(result.includes("User: 2")); // not filtered — assistant wasn't CG menu
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does NOT filter digit 5 even after CG menu", () => {
|
|
217
|
+
writeLine(
|
|
218
|
+
assistantMsg(
|
|
219
|
+
"Context Guardian — ~50% used\n\nReply with 1, 2, 3, 4, or 0.",
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
writeLine(userMsg("5"));
|
|
223
|
+
|
|
224
|
+
const result = extractConversation(transcriptPath);
|
|
225
|
+
assert.ok(result.includes("User: 5")); // only 0-4 are filtered
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- Skill injection filtering ---
|
|
229
|
+
it("keeps long structured messages that don't match injection patterns", () => {
|
|
230
|
+
const skillContent =
|
|
231
|
+
"# Some Skill Title\n\nInstructions here.\n\n## Step 1\n\nDo this.\n\n## Step 2\n\nDo that.\n\n" +
|
|
232
|
+
"x".repeat(800);
|
|
233
|
+
writeLine(userMsg(skillContent));
|
|
234
|
+
writeLine(userMsg("real message"));
|
|
235
|
+
|
|
236
|
+
const result = extractConversation(transcriptPath);
|
|
237
|
+
// Long structured messages are now kept (old heuristic removed)
|
|
238
|
+
assert.ok(result.includes("Some Skill Title"));
|
|
239
|
+
assert.ok(result.includes("User: real message"));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does NOT filter short messages starting with heading", () => {
|
|
243
|
+
writeLine(userMsg("# My Plan\n\nDo the thing."));
|
|
244
|
+
|
|
245
|
+
const result = extractConversation(transcriptPath);
|
|
246
|
+
assert.ok(result.includes("# My Plan"));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does NOT filter long messages without sub-headings", () => {
|
|
250
|
+
const longMsg = `# Title\n\n${"Some long content without sub headings. ".repeat(30)}`;
|
|
251
|
+
writeLine(userMsg(longMsg));
|
|
252
|
+
|
|
253
|
+
const result = extractConversation(transcriptPath);
|
|
254
|
+
assert.ok(result.includes("# Title"));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// --- Parse errors ---
|
|
258
|
+
it("counts and reports parse errors", () => {
|
|
259
|
+
writeLine(userMsg("good message"));
|
|
260
|
+
fs.appendFileSync(transcriptPath, "this is not valid json\n");
|
|
261
|
+
writeLine(userMsg("another good one"));
|
|
262
|
+
|
|
263
|
+
const result = extractConversation(transcriptPath);
|
|
264
|
+
assert.ok(result.includes("User: good message"));
|
|
265
|
+
assert.ok(result.includes("User: another good one"));
|
|
266
|
+
assert.ok(
|
|
267
|
+
result.includes("Warning: 1 transcript line(s) could not be parsed"),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --- System messages ignored ---
|
|
272
|
+
it("ignores system and progress message types", () => {
|
|
273
|
+
writeLine({ type: "system", message: { content: "system prompt" } });
|
|
274
|
+
writeLine({ type: "progress", message: { content: "working..." } });
|
|
275
|
+
writeLine(userMsg("hello"));
|
|
276
|
+
|
|
277
|
+
const result = extractConversation(transcriptPath);
|
|
278
|
+
assert.ok(!result.includes("system prompt"));
|
|
279
|
+
assert.ok(!result.includes("working"));
|
|
280
|
+
assert.ok(result.includes("User: hello"));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// --- Preamble preservation ---
|
|
284
|
+
it("includes compact preamble before new messages", () => {
|
|
285
|
+
writeLine({
|
|
286
|
+
type: "user",
|
|
287
|
+
message: {
|
|
288
|
+
role: "user",
|
|
289
|
+
content:
|
|
290
|
+
"[SMART COMPACT — restored checkpoint]\n\nUser: old stuff\n\nAsst: old answer",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
writeLine(userMsg("new question"));
|
|
294
|
+
|
|
295
|
+
const result = extractConversation(transcriptPath);
|
|
296
|
+
assert.ok(result.startsWith("## Session State"));
|
|
297
|
+
assert.ok(result.includes("[SMART COMPACT")); // preamble still present after header
|
|
298
|
+
assert.ok(result.includes("---")); // separator between preamble and new messages
|
|
299
|
+
assert.ok(result.includes("User: new question"));
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// =========================================================================
|
|
304
|
+
// extractRecent
|
|
305
|
+
// =========================================================================
|
|
306
|
+
describe("extractRecent", () => {
|
|
307
|
+
it("returns placeholder for missing transcript", () => {
|
|
308
|
+
assert.equal(extractRecent(null, 20), "(no transcript available)");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("extracts the last N user exchanges", () => {
|
|
312
|
+
for (let i = 0; i < 10; i++) {
|
|
313
|
+
writeLine(userMsg(`message ${i}`));
|
|
314
|
+
writeLine(assistantMsg(`response ${i}`));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// N=4 means last 4 USER messages + their grouped assistant responses
|
|
318
|
+
const result = extractRecent(transcriptPath, 4);
|
|
319
|
+
// Should have exchanges 6-9 (the last 4 user messages)
|
|
320
|
+
assert.ok(
|
|
321
|
+
!result.includes("message 5"),
|
|
322
|
+
"exchange 5 should be outside window",
|
|
323
|
+
);
|
|
324
|
+
assert.ok(result.includes("message 6"), "exchange 6 should be in window");
|
|
325
|
+
assert.ok(result.includes("response 6"));
|
|
326
|
+
assert.ok(result.includes("message 9"), "last exchange in window");
|
|
327
|
+
assert.ok(result.includes("response 9"));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("filters CG menu replies", () => {
|
|
331
|
+
writeLine(
|
|
332
|
+
assistantMsg(
|
|
333
|
+
"Context Guardian — ~35% used\n\nReply with 1, 2, 3, 4, or 0.",
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
writeLine(userMsg("2"));
|
|
337
|
+
writeLine(userMsg("real message"));
|
|
338
|
+
|
|
339
|
+
const result = extractRecent(transcriptPath, 20);
|
|
340
|
+
assert.ok(!result.includes("User: 2"));
|
|
341
|
+
assert.ok(result.includes("User: real message"));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("filters compact markers", () => {
|
|
345
|
+
writeLine({
|
|
346
|
+
type: "user",
|
|
347
|
+
message: { role: "user", content: "[SMART COMPACT — restored]\n\nstuff" },
|
|
348
|
+
});
|
|
349
|
+
writeLine(userMsg("real"));
|
|
350
|
+
|
|
351
|
+
const result = extractRecent(transcriptPath, 20);
|
|
352
|
+
assert.ok(!result.includes("SMART COMPACT"));
|
|
353
|
+
assert.ok(result.includes("User: real"));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("returns all messages when fewer than N exist", () => {
|
|
357
|
+
writeLine(userMsg("only"));
|
|
358
|
+
writeLine(assistantMsg("one exchange"));
|
|
359
|
+
|
|
360
|
+
const result = extractRecent(transcriptPath, 20);
|
|
361
|
+
assert.ok(result.includes("User: only"));
|
|
362
|
+
assert.ok(result.includes("Asst: one exchange"));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("handles empty transcript", () => {
|
|
366
|
+
fs.writeFileSync(transcriptPath, "");
|
|
367
|
+
const result = extractRecent(transcriptPath, 20);
|
|
368
|
+
// Empty transcript now returns a state header
|
|
369
|
+
assert.ok(result.startsWith("## Session State"));
|
|
370
|
+
assert.ok(result.includes("Messages preserved: 0"));
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// =========================================================================
|
|
375
|
+
// Preamble preservation — prior compacted data is kept verbatim
|
|
376
|
+
// =========================================================================
|
|
377
|
+
describe("extractConversation — preamble preservation", () => {
|
|
378
|
+
it("preserves full preamble from prior compaction without trimming", () => {
|
|
379
|
+
// Simulate a restored checkpoint followed by new messages
|
|
380
|
+
const bigPreamble = "Prior conversation content. ".repeat(2000); // ~54K chars
|
|
381
|
+
writeLine({
|
|
382
|
+
type: "user",
|
|
383
|
+
message: {
|
|
384
|
+
role: "user",
|
|
385
|
+
content: `[SMART COMPACT — restored checkpoint]\n\n${bigPreamble}`,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
writeLine(userMsg("new question after restore"));
|
|
389
|
+
writeLine(assistantMsg("new answer after restore"));
|
|
390
|
+
|
|
391
|
+
const result = extractConversation(transcriptPath);
|
|
392
|
+
// The preamble must NOT be trimmed — prior compacted data is preserved verbatim
|
|
393
|
+
assert.ok(
|
|
394
|
+
!result.includes("chars of prior history trimmed"),
|
|
395
|
+
"Preamble must not be trimmed",
|
|
396
|
+
);
|
|
397
|
+
assert.ok(
|
|
398
|
+
result.includes(bigPreamble.trim()),
|
|
399
|
+
"Full preamble content must survive",
|
|
400
|
+
);
|
|
401
|
+
// New messages should be present
|
|
402
|
+
assert.ok(result.includes("new question after restore"));
|
|
403
|
+
assert.ok(result.includes("new answer after restore"));
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("preserves checkpoint content across simulated multi-cycle compaction", () => {
|
|
407
|
+
// Cycle 1 checkpoint content (already compacted)
|
|
408
|
+
const cycle1Content =
|
|
409
|
+
"# Context Checkpoint (Smart Compact)\n> Created: 2026-04-01\n\nUser: implement auth\nAsst: Added JWT middleware to auth.mjs\n→ Edit `lib/auth.mjs`";
|
|
410
|
+
writeLine({
|
|
411
|
+
type: "user",
|
|
412
|
+
message: { role: "user", content: cycle1Content },
|
|
413
|
+
});
|
|
414
|
+
// Synthetic ack — should be filtered
|
|
415
|
+
writeLine(
|
|
416
|
+
assistantMsg(
|
|
417
|
+
"Context restored from checkpoint. I have the full session history above including all decisions.",
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
// New work in cycle 2
|
|
421
|
+
writeLine(userMsg("now add rate limiting"));
|
|
422
|
+
writeLine(assistantMsg("Added rate limiter to middleware stack"));
|
|
423
|
+
|
|
424
|
+
const result = extractConversation(transcriptPath);
|
|
425
|
+
// Cycle 1 checkpoint must survive verbatim as preamble
|
|
426
|
+
assert.ok(
|
|
427
|
+
result.includes("implement auth"),
|
|
428
|
+
"Cycle 1 user content preserved",
|
|
429
|
+
);
|
|
430
|
+
assert.ok(
|
|
431
|
+
result.includes("Added JWT middleware"),
|
|
432
|
+
"Cycle 1 assistant content preserved",
|
|
433
|
+
);
|
|
434
|
+
assert.ok(
|
|
435
|
+
result.includes("Edit `lib/auth.mjs`"),
|
|
436
|
+
"Cycle 1 tool summary preserved",
|
|
437
|
+
);
|
|
438
|
+
// Synthetic ack must be filtered
|
|
439
|
+
assert.ok(
|
|
440
|
+
!result.includes("Context restored from checkpoint"),
|
|
441
|
+
"Synthetic ack filtered",
|
|
442
|
+
);
|
|
443
|
+
// Cycle 2 new work must be present
|
|
444
|
+
assert.ok(
|
|
445
|
+
result.includes("now add rate limiting"),
|
|
446
|
+
"Cycle 2 user message present",
|
|
447
|
+
);
|
|
448
|
+
assert.ok(
|
|
449
|
+
result.includes("Added rate limiter"),
|
|
450
|
+
"Cycle 2 assistant message present",
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// =========================================================================
|
|
456
|
+
// Checkpoint footer — generated for sessions with >15 messages + tool ops
|
|
457
|
+
// =========================================================================
|
|
458
|
+
describe("extractConversation — checkpoint footer", () => {
|
|
459
|
+
it("generates footer when session has >15 messages with tool operations", () => {
|
|
460
|
+
// Write 18 exchanges, some with Edit tool patterns
|
|
461
|
+
for (let i = 1; i <= 18; i++) {
|
|
462
|
+
writeLine(userMsg(`task ${i}: fix the code`));
|
|
463
|
+
writeLine({
|
|
464
|
+
type: "assistant",
|
|
465
|
+
message: {
|
|
466
|
+
role: "assistant",
|
|
467
|
+
content: [
|
|
468
|
+
{ type: "text", text: `Working on task ${i}.` },
|
|
469
|
+
{
|
|
470
|
+
type: "tool_use",
|
|
471
|
+
id: `e${i}`,
|
|
472
|
+
name: "Edit",
|
|
473
|
+
input: {
|
|
474
|
+
file_path: `/app${i}.js`,
|
|
475
|
+
old_string: `old${i}`,
|
|
476
|
+
new_string: `new${i}`,
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const result = extractConversation(transcriptPath);
|
|
485
|
+
// Footer should reference edit exchanges
|
|
486
|
+
assert.ok(
|
|
487
|
+
result.includes("Edit") || result.includes("edit"),
|
|
488
|
+
"Should reference edits in footer or body",
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("does not generate footer for short sessions", () => {
|
|
493
|
+
for (let i = 1; i <= 5; i++) {
|
|
494
|
+
writeLine(userMsg(`task ${i}`));
|
|
495
|
+
writeLine(assistantMsg(`done ${i}`));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const result = extractConversation(transcriptPath);
|
|
499
|
+
// Short sessions (< 15 messages) should have no footer
|
|
500
|
+
assert.ok(
|
|
501
|
+
!result.includes("Quick reference"),
|
|
502
|
+
"Short sessions should not have footer",
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// =========================================================================
|
|
508
|
+
// Edit coalescing — overlapping edits to same file get merged
|
|
509
|
+
// =========================================================================
|
|
510
|
+
describe("extractConversation — edit coalescing", () => {
|
|
511
|
+
it("coalesces successive edits to same file region", () => {
|
|
512
|
+
writeLine(userMsg("refactor the function"));
|
|
513
|
+
// First edit
|
|
514
|
+
writeLine({
|
|
515
|
+
type: "assistant",
|
|
516
|
+
message: {
|
|
517
|
+
role: "assistant",
|
|
518
|
+
content: [
|
|
519
|
+
{ type: "text", text: "I'll refactor step by step." },
|
|
520
|
+
{
|
|
521
|
+
type: "tool_use",
|
|
522
|
+
id: "e1",
|
|
523
|
+
name: "Edit",
|
|
524
|
+
input: {
|
|
525
|
+
file_path: "/app.js",
|
|
526
|
+
old_string: "function old() { return 1; }",
|
|
527
|
+
new_string: "function mid() { return 2; }",
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
writeLine({
|
|
534
|
+
type: "user",
|
|
535
|
+
message: {
|
|
536
|
+
role: "user",
|
|
537
|
+
content: [
|
|
538
|
+
{
|
|
539
|
+
type: "tool_result",
|
|
540
|
+
tool_use_id: "e1",
|
|
541
|
+
content: "success",
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
// Second edit to same region (old_string matches previous new_string)
|
|
547
|
+
writeLine({
|
|
548
|
+
type: "assistant",
|
|
549
|
+
message: {
|
|
550
|
+
role: "assistant",
|
|
551
|
+
content: [
|
|
552
|
+
{ type: "text", text: "Now finishing the refactor." },
|
|
553
|
+
{
|
|
554
|
+
type: "tool_use",
|
|
555
|
+
id: "e2",
|
|
556
|
+
name: "Edit",
|
|
557
|
+
input: {
|
|
558
|
+
file_path: "/app.js",
|
|
559
|
+
old_string: "function mid() { return 2; }",
|
|
560
|
+
new_string: "function final() { return 3; }",
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
writeLine({
|
|
567
|
+
type: "user",
|
|
568
|
+
message: {
|
|
569
|
+
role: "user",
|
|
570
|
+
content: [
|
|
571
|
+
{
|
|
572
|
+
type: "tool_result",
|
|
573
|
+
tool_use_id: "e2",
|
|
574
|
+
content: "success",
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const result = extractConversation(transcriptPath);
|
|
581
|
+
// Should show coalesced edit with first old + last new
|
|
582
|
+
assert.ok(
|
|
583
|
+
result.includes("function old()"),
|
|
584
|
+
"Should have first old_string",
|
|
585
|
+
);
|
|
586
|
+
assert.ok(
|
|
587
|
+
result.includes("function final()"),
|
|
588
|
+
"Should have last new_string",
|
|
589
|
+
);
|
|
590
|
+
// Should mention coalescing
|
|
591
|
+
assert.ok(
|
|
592
|
+
result.includes("coalesced") || result.includes("edits"),
|
|
593
|
+
"Should indicate edits were coalesced or show edit summary",
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
});
|