@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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +63 -0
  3. package/.github/workflows/ci.yml +66 -0
  4. package/CLAUDE.md +132 -0
  5. package/LICENSE +21 -0
  6. package/README.md +362 -0
  7. package/biome.json +34 -0
  8. package/bun.lock +31 -0
  9. package/hooks/precompact.mjs +73 -0
  10. package/hooks/session-start.mjs +133 -0
  11. package/hooks/stop.mjs +172 -0
  12. package/hooks/submit.mjs +133 -0
  13. package/lib/checkpoint.mjs +258 -0
  14. package/lib/compact-cli.mjs +124 -0
  15. package/lib/compact-output.mjs +350 -0
  16. package/lib/config.mjs +40 -0
  17. package/lib/content.mjs +33 -0
  18. package/lib/diagnostics.mjs +221 -0
  19. package/lib/estimate.mjs +254 -0
  20. package/lib/extract-helpers.mjs +869 -0
  21. package/lib/handoff.mjs +329 -0
  22. package/lib/logger.mjs +34 -0
  23. package/lib/mcp-tools.mjs +200 -0
  24. package/lib/paths.mjs +90 -0
  25. package/lib/stats.mjs +81 -0
  26. package/lib/statusline.mjs +123 -0
  27. package/lib/synthetic-session.mjs +273 -0
  28. package/lib/tokens.mjs +170 -0
  29. package/lib/tool-summary.mjs +399 -0
  30. package/lib/transcript.mjs +939 -0
  31. package/lib/trim.mjs +158 -0
  32. package/package.json +22 -0
  33. package/skills/compact/SKILL.md +20 -0
  34. package/skills/config/SKILL.md +70 -0
  35. package/skills/handoff/SKILL.md +26 -0
  36. package/skills/prune/SKILL.md +20 -0
  37. package/skills/stats/SKILL.md +100 -0
  38. package/sonar-project.properties +12 -0
  39. package/test/checkpoint.test.mjs +171 -0
  40. package/test/compact-cli.test.mjs +230 -0
  41. package/test/compact-output.test.mjs +284 -0
  42. package/test/compaction-e2e.test.mjs +809 -0
  43. package/test/content.test.mjs +86 -0
  44. package/test/diagnostics.test.mjs +188 -0
  45. package/test/edge-cases.test.mjs +543 -0
  46. package/test/estimate.test.mjs +262 -0
  47. package/test/extract-helpers-coverage.test.mjs +333 -0
  48. package/test/extract-helpers.test.mjs +234 -0
  49. package/test/handoff.test.mjs +738 -0
  50. package/test/integration.test.mjs +582 -0
  51. package/test/logger.test.mjs +70 -0
  52. package/test/manual-compaction-test.md +426 -0
  53. package/test/mcp-tools.test.mjs +443 -0
  54. package/test/paths.test.mjs +250 -0
  55. package/test/quick-compaction-test.md +191 -0
  56. package/test/stats.test.mjs +88 -0
  57. package/test/statusline.test.mjs +222 -0
  58. package/test/submit.test.mjs +232 -0
  59. package/test/synthetic-session.test.mjs +600 -0
  60. package/test/tokens.test.mjs +293 -0
  61. package/test/tool-summary.test.mjs +771 -0
  62. package/test/transcript-coverage.test.mjs +369 -0
  63. package/test/transcript.test.mjs +596 -0
  64. 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
+ });