pi-condense 2.5.0 → 2.7.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PRUNING.md +138 -23
  3. package/README.md +17 -1
  4. package/index.ts +305 -116
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +168 -5
  15. package/src/commands.ts +44 -11
  16. package/src/context-metrics.test.ts +335 -0
  17. package/src/context-metrics.ts +152 -0
  18. package/src/diagnostics.test.ts +114 -0
  19. package/src/diagnostics.ts +46 -0
  20. package/src/frontier.test.ts +1 -0
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +456 -25
  30. package/src/pruner.ts +84 -36
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +6 -1
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/reload-rearm.integration.test.ts +647 -0
  37. package/src/spill.test.ts +108 -1
  38. package/src/spill.ts +5 -3
  39. package/src/summarizer-wiring.test.ts +2 -0
  40. package/src/summary-refs.test.ts +51 -1
  41. package/src/summary-refs.ts +15 -4
  42. package/src/test-support.ts +54 -0
  43. package/src/tree-browser.ts +2 -1
  44. package/src/types.ts +89 -10
@@ -1,8 +1,20 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { pruneMessages, sizeMessages } from "./pruner.js";
3
+ import { ToolCallIndexer } from "./indexer.js";
4
+ import { CUSTOM_TYPE_INDEX } from "./types.js";
3
5
  import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
6
+ import { DiagnosticSink } from "./diagnostics.js";
7
+ import { pruneWithZeroSweepAssertion } from "./test-support.js";
4
8
 
5
9
  // Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
10
+ // `hasLegacyBareRecord` defaults to the bare `summarized` set: most of the fixture
11
+ // messages in this file carry no occurrence-keyed records, so the fail-closed lookup
12
+ // in pruneMessages falls through to this legacy-bare-id path for them, matching how
13
+ // pre-upgrade sessions behave (that path must stay covered - it is a supported
14
+ // shape). The "occurrence-keyed coverage" describe block below seeds `summarized` /
15
+ // `records` / `shortRefs` with `id@timestamp`-shaped keys instead, so those tests
16
+ // hit `isSummarized(key)` directly - the occurrence branch of the ladder - rather
17
+ // than falling through to `hasLegacyBareRecord`.
6
18
  function makeMockIndexer({
7
19
  summarized = new Set<string>(),
8
20
  shortRefs = new Map<string, string>(),
@@ -18,6 +30,7 @@ function makeMockIndexer({
18
30
  } = {}) {
19
31
  return {
20
32
  isSummarized: (id: string) => summarized.has(id),
33
+ hasLegacyBareRecord: (id: string) => summarized.has(id),
21
34
  getShortRefForToolCallId: (id: string) => shortRefs.get(id),
22
35
  getRecord: (id: string) => records.get(id),
23
36
  getChainEntries: () => chainEntries,
@@ -45,6 +58,7 @@ describe("pruneMessages", () => {
45
58
  shortRefs: new Map([["tc1", "t1"]]),
46
59
  });
47
60
  const messages = [
61
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
48
62
  {
49
63
  role: "toolResult",
50
64
  toolCallId: "tc1",
@@ -56,8 +70,8 @@ describe("pruneMessages", () => {
56
70
  ];
57
71
  const { messages: out, pruned } = pruneMessages(messages, indexer);
58
72
  expect(pruned).toBe(true);
59
- expect(out[0].content[0].text).toContain("`t1`");
60
- expect(out[0].content[0].text).toContain("context_tree_query");
73
+ expect(out[1].content[0].text).toContain("`t1`");
74
+ expect(out[1].content[0].text).toContain("context_tree_query");
61
75
  });
62
76
 
63
77
  it("returns original array reference when nothing is summarized or compressed", () => {
@@ -317,9 +331,12 @@ describe("pruneMessages", () => {
317
331
  spillPath: "/blobs/tc1.txt", isError: false, turnIndex: 0, timestamp: 1,
318
332
  }]]),
319
333
  });
320
- const messages = [{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1 }];
334
+ const messages = [
335
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
336
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1 },
337
+ ];
321
338
  const { messages: out } = pruneMessages(messages, indexer);
322
- const text = out[0].content[0].text as string;
339
+ const text = out[1].content[0].text as string;
323
340
  expect(text).toContain("/blobs/tc1.txt");
324
341
  expect(text).toContain("?");
325
342
  expect(text).not.toContain("Summarized in pruner summary");
@@ -334,13 +351,16 @@ describe("pruneMessages", () => {
334
351
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
335
352
  }]]),
336
353
  });
337
- const messages = [{
338
- role: "toolResult", toolCallId: "tc1", toolName: "fetch",
339
- content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1,
340
- }];
354
+ const messages = [
355
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 0 },
356
+ {
357
+ role: "toolResult", toolCallId: "tc1", toolName: "fetch",
358
+ content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1,
359
+ },
360
+ ];
341
361
  const { messages: out, pruned } = pruneMessages(messages, indexer);
342
362
  expect(pruned).toBe(true);
343
- const text = out[0].content[0].text as string;
363
+ const text = out[1].content[0].text as string;
344
364
  expect(text).toContain("/blobs/tc1.txt");
345
365
  expect(text).toContain("PREVIEW-HEAD");
346
366
  expect(text).toContain("1048576");
@@ -359,6 +379,7 @@ describe("pruneMessages", () => {
359
379
  const indexer = makeMockIndexer({ chainEntries: [chainEntry] });
360
380
  const messages = [
361
381
  { role: "user", content: "hi", timestamp: 100 },
382
+ { role: "assistant", content: [{ type: "toolCall", id: "tc-x", name: "bash", input: {} }], timestamp: 140 },
362
383
  {
363
384
  role: "toolResult",
364
385
  toolCallId: "tc-x",
@@ -408,6 +429,7 @@ describe("pruneMessages", () => {
408
429
  });
409
430
 
410
431
  describe("render-time protection re-check", () => {
432
+ const skillAsst = { role: "assistant", content: [{ type: "toolCall", id: "tc-skill", name: "read", input: {} }], timestamp: 5 };
411
433
  const skillMsg = {
412
434
  role: "toolResult",
413
435
  toolCallId: "tc-skill",
@@ -433,17 +455,17 @@ describe("render-time protection re-check", () => {
433
455
 
434
456
  it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
435
457
  const { messages, pruned } = pruneMessages(
436
- [skillMsg], indexer as any, undefined, undefined,
458
+ [skillAsst, skillMsg], indexer as any, undefined, undefined,
437
459
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
438
460
  );
439
461
  expect(pruned).toBe(false);
440
- expect(messages[0].content[0].text).toBe("FULL SKILL BODY");
462
+ expect(messages[1].content[0].text).toBe("FULL SKILL BODY");
441
463
  });
442
464
 
443
465
  it("still stubs when no protection config is passed", () => {
444
- const { messages, pruned } = pruneMessages([skillMsg], indexer as any);
466
+ const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any);
445
467
  expect(pruned).toBe(true);
446
- expect(messages[0].content[0].text).toContain("context_tree_query");
468
+ expect(messages[1].content[0].text).toContain("context_tree_query");
447
469
  });
448
470
  });
449
471
 
@@ -457,15 +479,20 @@ describe("pruneMessages recovery grace", () => {
457
479
  timestamp,
458
480
  });
459
481
  const mkUser = (timestamp: number) => ({ role: "user", content: [{ type: "text", text: "go" }], timestamp });
482
+ const mkAsst = (toolCallId: string, toolName: string, timestamp: number) => ({
483
+ role: "assistant",
484
+ content: [{ type: "toolCall", id: toolCallId, name: toolName, input: {} }],
485
+ timestamp,
486
+ });
460
487
 
461
488
  it("renders a context_tree_query recovery output verbatim at age 0 within grace", () => {
462
489
  const indexer = makeMockIndexer({
463
490
  summarized: new Set(["tc-recover"]),
464
491
  shortRefs: new Map([["tc-recover", "t1"]]),
465
492
  });
466
- const messages = [mkQueryResult("tc-recover", 1)];
493
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
467
494
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
468
- expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
495
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
469
496
  });
470
497
 
471
498
  it("stubs a context_tree_query recovery output aged past the grace window", () => {
@@ -473,7 +500,7 @@ describe("pruneMessages recovery grace", () => {
473
500
  summarized: new Set(["tc-recover"]),
474
501
  shortRefs: new Map([["tc-recover", "t1"]]),
475
502
  });
476
- const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
503
+ const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
477
504
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
478
505
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
479
506
  expect(tr.content[0].text).toContain("context_tree_query");
@@ -485,10 +512,10 @@ describe("pruneMessages recovery grace", () => {
485
512
  summarized: new Set(["tc-recover"]),
486
513
  shortRefs: new Map([["tc-recover", "t1"]]),
487
514
  });
488
- const messages = [mkQueryResult("tc-recover", 1)];
515
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
489
516
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
490
- expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
491
- expect(out[0].content[0].text).toContain("context_tree_query");
517
+ expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
518
+ expect(out[1].content[0].text).toContain("context_tree_query");
492
519
  });
493
520
 
494
521
  it("does not apply the grace window to non-context_tree_query outputs", () => {
@@ -497,6 +524,7 @@ describe("pruneMessages recovery grace", () => {
497
524
  shortRefs: new Map([["tc-bash", "t1"]]),
498
525
  });
499
526
  const messages = [
527
+ mkAsst("tc-bash", "bash", 0),
500
528
  {
501
529
  role: "toolResult",
502
530
  toolCallId: "tc-bash",
@@ -507,8 +535,8 @@ describe("pruneMessages recovery grace", () => {
507
535
  },
508
536
  ];
509
537
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
510
- expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
511
- expect(out[0].content[0].text).toContain("context_tree_query");
538
+ expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
539
+ expect(out[1].content[0].text).toContain("context_tree_query");
512
540
  });
513
541
 
514
542
  it("isProtected precedence: a protected context_tree_query output stays verbatim even with grace off", () => {
@@ -520,7 +548,7 @@ describe("pruneMessages recovery grace", () => {
520
548
  resultText: "", isError: false, turnIndex: 0, timestamp: 1,
521
549
  }]]),
522
550
  });
523
- const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
551
+ const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
524
552
  const { messages: out } = pruneMessages(
525
553
  messages, indexer, undefined, undefined,
526
554
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
@@ -540,9 +568,9 @@ describe("pruneMessages recovery grace", () => {
540
568
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
541
569
  }]]),
542
570
  });
543
- const messages = [mkQueryResult("tc-recover", 1)];
571
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
544
572
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
545
- expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
573
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
546
574
  });
547
575
 
548
576
  it("stubs a spilled context_tree_query recovery output aged past the grace window to the spill-pointer stub", () => {
@@ -555,7 +583,7 @@ describe("pruneMessages recovery grace", () => {
555
583
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
556
584
  }]]),
557
585
  });
558
- const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
586
+ const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
559
587
  const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
560
588
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
561
589
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
@@ -564,6 +592,289 @@ describe("pruneMessages recovery grace", () => {
564
592
  });
565
593
  });
566
594
 
595
+ describe("occurrence-keyed stub replacement", () => {
596
+ it("stubs the summarized occurrence and leaves the live one verbatim", () => {
597
+ const idx = new ToolCallIndexer();
598
+ idx.addBatch(
599
+ {
600
+ turnIndex: 0,
601
+ timestamp: 1000,
602
+ assistantText: "",
603
+ toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }],
604
+ } as any,
605
+ () => {},
606
+ );
607
+ const messages: any[] = [
608
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 },
609
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 1150 },
610
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 3100 },
611
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 3150 },
612
+ ];
613
+ const out = pruneMessages(messages, idx);
614
+ expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary");
615
+ expect(out.messages[3].content[0].text).toBe("LIVE");
616
+ });
617
+
618
+ it("fail-closed: a timestamped result with no occurrence record is never stubbed", () => {
619
+ const idx = new ToolCallIndexer();
620
+ idx.addBatch(
621
+ {
622
+ turnIndex: 0,
623
+ timestamp: 1000,
624
+ assistantText: "",
625
+ toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }],
626
+ } as any,
627
+ () => {},
628
+ );
629
+ const messages: any[] = [
630
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 9100 },
631
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 },
632
+ ];
633
+ const out = pruneMessages(messages, idx);
634
+ expect(out.pruned).toBe(false);
635
+ expect(out.messages).toBe(messages);
636
+ });
637
+
638
+ it("fail-closed: a mixed legacy+occurrence bare id does not stub a live later occurrence (F1 regression)", () => {
639
+ const idx = new ToolCallIndexer();
640
+ idx.reconstructFromSession({
641
+ sessionManager: {
642
+ getBranch: () => [
643
+ {
644
+ type: "custom",
645
+ customType: CUSTOM_TYPE_INDEX,
646
+ data: { toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD-LEGACY", isError: false, turnIndex: 0, timestamp: 500 }] },
647
+ },
648
+ ],
649
+ },
650
+ } as any);
651
+ idx.addBatch(
652
+ {
653
+ turnIndex: 1,
654
+ timestamp: 2000,
655
+ assistantText: "",
656
+ toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "MID", isError: false, resultTimestamp: 2150 }],
657
+ } as any,
658
+ () => {},
659
+ );
660
+ const messages: any[] = [
661
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 9100 },
662
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 },
663
+ ];
664
+ const out = pruneMessages(messages, idx);
665
+ expect(out.messages[1].content[0].text).toBe("LIVE");
666
+ expect(out.pruned).toBe(false);
667
+ });
668
+
669
+ it("legacy bare-id records still stub (pre-upgrade sessions keep working)", () => {
670
+ const idx = new ToolCallIndexer();
671
+ idx.reconstructFromSession({
672
+ sessionManager: {
673
+ getBranch: () => [
674
+ {
675
+ type: "custom",
676
+ customType: CUSTOM_TYPE_INDEX,
677
+ data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] },
678
+ },
679
+ ],
680
+ },
681
+ } as any);
682
+ const messages: any[] = [
683
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 500 },
684
+ { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 },
685
+ ];
686
+ const out = pruneMessages(messages, idx);
687
+ expect(out.pruned).toBe(true);
688
+ expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary");
689
+ });
690
+
691
+ it("accepted limitation: a pure-legacy summarized bash_7 stubs a LIVE colliding bash_7 result (pre-upgrade sessions only)", () => {
692
+ const idx = new ToolCallIndexer();
693
+ idx.reconstructFromSession({
694
+ sessionManager: {
695
+ getBranch: () => [
696
+ {
697
+ type: "custom",
698
+ customType: CUSTOM_TYPE_INDEX,
699
+ data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] },
700
+ },
701
+ ],
702
+ },
703
+ } as any);
704
+
705
+ // No migration: a bare-keyed legacy record has no occurrence-keyed
706
+ // siblings, so hasLegacyBareRecord stays true even though a later, live,
707
+ // unrelated occurrence of the same reused provider id now exists.
708
+ expect(idx.hasLegacyBareRecord("bash_7")).toBe(true);
709
+
710
+ const messages: any[] = [
711
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 9100 },
712
+ { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 },
713
+ ];
714
+ const out = pruneMessages(messages, idx);
715
+ // Accepted, documented exposure (PRUNING.md): a session spanning the
716
+ // upgrade keeps this pre-upgrade behavior for its legacy half - the live
717
+ // result is stub-replaced with the stale legacy record's content.
718
+ expect(out.pruned).toBe(true);
719
+ expect(out.messages[1].content[0].text).toContain("Summarized in pruner summary");
720
+ });
721
+ });
722
+
723
+ describe("orphan sweep in pruneMessages", () => {
724
+ it("a clean render returns the identical array reference with pruned false", () => {
725
+ const idx = new ToolCallIndexer();
726
+ const messages: any[] = [
727
+ { role: "assistant", content: [{ type: "toolCall", id: "a", name: "bash", input: {} }], timestamp: 1 },
728
+ { role: "toolResult", toolCallId: "a", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 },
729
+ ];
730
+ const out = pruneMessages(messages, idx);
731
+ expect(out.messages).toBe(messages);
732
+ expect(out.pruned).toBe(false);
733
+ expect(out.beforeChars).toBe(0);
734
+ });
735
+
736
+ it("sweeps an orphan and reports the diagnostic once across repeated renders of the same input", () => {
737
+ const idx = new ToolCallIndexer();
738
+ const appended: any[] = [];
739
+ const sink = new DiagnosticSink((_customType, data) => appended.push(data));
740
+ const messages: any[] = [
741
+ { role: "assistant", content: [{ type: "toolCall", id: "a", name: "bash", input: {} }], timestamp: 1 },
742
+ { role: "toolResult", toolCallId: "a", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 },
743
+ { role: "toolResult", toolCallId: "ghost", toolName: "bash", content: [{ type: "text", text: "y" }], isError: false, timestamp: 3 },
744
+ ];
745
+ const first = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any);
746
+ expect(first.pruned).toBe(true);
747
+ expect(first.messages).toHaveLength(2);
748
+ expect(first.messages.some((m: any) => m.toolCallId === "ghost")).toBe(false);
749
+ expect(first.messages.some((m: any) => m.toolCallId === "a")).toBe(true);
750
+
751
+ // Same orphan on a second render of the same (still-unsupplemented) input
752
+ // must not write a second diagnostic entry: DiagnosticSink dedups per
753
+ // (kind, dedupKey), and pruneMessages must compute the same dedupKey both
754
+ // times for the same swept id set.
755
+ const second = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any);
756
+ expect(second.pruned).toBe(true);
757
+
758
+ expect(appended).toHaveLength(1);
759
+ expect(appended[0].kind).toBe("orphan-sweep");
760
+ expect(appended[0].detail).toContain("ghost");
761
+ expect(appended[0].detail).toContain("swept 1 orphan");
762
+ });
763
+
764
+ it("bounds the sweep dedup key and truncation marker for a large orphan set", () => {
765
+ const idx = new ToolCallIndexer();
766
+ const reports: any[] = [];
767
+ const sink = { report: (kind: string, key: string, detail: string) => reports.push({ kind, key, detail }), counts: () => ({}) as any };
768
+ const messages: any[] = [
769
+ { role: "assistant", content: [{ type: "toolCall", id: "keep", name: "bash", input: {} }], timestamp: 1 },
770
+ { role: "toolResult", toolCallId: "keep", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 2 },
771
+ ];
772
+ for (let i = 0; i < 12; i++) {
773
+ messages.push({ role: "toolResult", toolCallId: `ghost-${i}`, toolName: "bash", content: [{ type: "text", text: "y" }], isError: false, timestamp: 3 + i });
774
+ }
775
+ const out = pruneMessages(messages, idx, undefined, undefined, undefined, 0, sink as any);
776
+ expect(out.pruned).toBe(true);
777
+ expect(reports).toHaveLength(1);
778
+ // A short, bounded hash key regardless of how many ids were swept.
779
+ expect(reports[0].key.length).toBe(16);
780
+ expect(reports[0].detail).toContain("swept 12 orphan");
781
+ expect(reports[0].detail).toContain("... +7 more");
782
+ });
783
+ });
784
+
785
+ describe("occurrence-keyed coverage via mock indexer (spill / protection / grace)", () => {
786
+ it("stub-replaces via a direct occurrence-key hit (not the legacy branch)", () => {
787
+ const indexer = makeMockIndexer({
788
+ summarized: new Set(["tc1@1500"]),
789
+ shortRefs: new Map([["tc1@1500", "t1"]]),
790
+ });
791
+ const messages = [
792
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 1400 },
793
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "big output" }], isError: false, timestamp: 1500 },
794
+ ];
795
+ const { messages: out, pruned } = pruneMessages(messages, indexer);
796
+ expect(pruned).toBe(true);
797
+ expect(out[1].content[0].text).toContain("`t1`");
798
+ });
799
+
800
+ it("emits the mechanical spill stub via a direct occurrence-key hit", () => {
801
+ const indexer = makeMockIndexer({
802
+ summarized: new Set(["tc1@1500"]),
803
+ records: new Map([["tc1@1500", {
804
+ toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" },
805
+ resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt",
806
+ spillBytes: 1048576, isError: false, turnIndex: 0, resultTimestamp: 1500, timestamp: 1400,
807
+ }]]),
808
+ });
809
+ const messages = [
810
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 1400 },
811
+ { role: "toolResult", toolCallId: "tc1", toolName: "fetch", content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1500 },
812
+ ];
813
+ const { messages: out, pruned } = pruneMessages(messages, indexer);
814
+ expect(pruned).toBe(true);
815
+ const text = out[1].content[0].text as string;
816
+ expect(text).toContain("/blobs/tc1.txt");
817
+ expect(text).toContain("PREVIEW-HEAD");
818
+ expect(text).not.toContain("Summarized in pruner summary");
819
+ });
820
+
821
+ it("render-time protection re-check applies to a direct occurrence-key hit", () => {
822
+ const indexer = makeMockIndexer({
823
+ summarized: new Set(["tc-skill@1500"]),
824
+ shortRefs: new Map([["tc-skill@1500", "t1"]]),
825
+ records: new Map([["tc-skill@1500", {
826
+ toolCallId: "tc-skill", toolName: "read", args: { path: "/h/skills/x/SKILL.md" },
827
+ resultText: "", isError: false, turnIndex: 0, resultTimestamp: 1500, timestamp: 1400,
828
+ }]]),
829
+ });
830
+ const messages = [
831
+ { role: "assistant", content: [{ type: "toolCall", id: "tc-skill", name: "read", input: {} }], timestamp: 1400 },
832
+ { role: "toolResult", toolCallId: "tc-skill", toolName: "read", content: [{ type: "text", text: "FULL SKILL BODY" }], isError: false, timestamp: 1500 },
833
+ ];
834
+ const { messages: out, pruned } = pruneMessages(
835
+ messages, indexer, undefined, undefined,
836
+ { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
837
+ );
838
+ expect(pruned).toBe(false);
839
+ expect(out[1].content[0].text).toBe("FULL SKILL BODY");
840
+ });
841
+
842
+ it("recovery grace protects a direct occurrence-key hit at age 0", () => {
843
+ const indexer = makeMockIndexer({
844
+ summarized: new Set(["tc-recover@1500"]),
845
+ shortRefs: new Map([["tc-recover@1500", "t1"]]),
846
+ });
847
+ const messages = [
848
+ { role: "assistant", content: [{ type: "toolCall", id: "tc-recover", name: "context_tree_query", input: {} }], timestamp: 1400 },
849
+ { role: "toolResult", toolCallId: "tc-recover", toolName: "context_tree_query", content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], isError: false, timestamp: 1500 },
850
+ ];
851
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
852
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
853
+ });
854
+
855
+ it("a graced occurrence does not protect a different occurrence of the same reused bare id", () => {
856
+ const indexer = makeMockIndexer({
857
+ summarized: new Set(["reused@1500", "reused@9500"]),
858
+ shortRefs: new Map([["reused@1500", "t1"], ["reused@9500", "t2"]]),
859
+ });
860
+ const messages = [
861
+ // Graced context_tree_query recovery at occurrence reused@1500 (age 0).
862
+ { role: "assistant", content: [{ type: "toolCall", id: "reused", name: "context_tree_query", input: {} }], timestamp: 1400 },
863
+ { role: "toolResult", toolCallId: "reused", toolName: "context_tree_query", content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }], isError: false, timestamp: 1500 },
864
+ // A LATER, unrelated summarized occurrence of the same reused provider id.
865
+ { role: "assistant", content: [{ type: "toolCall", id: "reused", name: "bash", input: {} }], timestamp: 9400 },
866
+ { role: "toolResult", toolCallId: "reused", toolName: "bash", content: [{ type: "text", text: "different output" }], isError: false, timestamp: 9500 },
867
+ ];
868
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
869
+ // The grace-protected recovery output stays verbatim...
870
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
871
+ // ...but the later, different occurrence of the same bare id is NOT
872
+ // shielded by that grace entry - it gets stubbed on its own merits.
873
+ expect(out[3].content[0].text).toContain("`t2`");
874
+ expect(out[3].content[0].text).not.toBe("different output");
875
+ });
876
+ });
877
+
567
878
  describe("sizeMessages", () => {
568
879
  it("counts hidden fields (thinking blocks), not just visible text", () => {
569
880
  // Two messages with identical visible .text but different hidden content.
@@ -606,6 +917,7 @@ describe("pruneMessages beforeChars/afterChars", () => {
606
917
  shortRefs: new Map([["tc1", "t1"]]),
607
918
  });
608
919
  const messages = [
920
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
609
921
  {
610
922
  role: "toolResult",
611
923
  toolCallId: "tc1",
@@ -620,5 +932,124 @@ describe("pruneMessages beforeChars/afterChars", () => {
620
932
  expect(result.beforeChars).toBe(sizeMessages(messages));
621
933
  expect(result.afterChars).toBe(sizeMessages(result.messages));
622
934
  expect(result.afterChars).toBeLessThan(result.beforeChars);
935
+ expect(result.messages).toHaveLength(2);
623
936
  });
624
937
  });
938
+
939
+ describe("G4/C3: orphan-sweep zero-fire proof across pruner fixtures", () => {
940
+ // Wraps a representative set of existing pruneMessages fixtures with a
941
+ // counting DiagnosticSink (pruneWithZeroSweepAssertion, src/test-support.ts)
942
+ // and fails if the orphan-sweep diagnostic ever fires. Deliberately excludes
943
+ // the two tests in "orphan sweep in pruneMessages" above that construct an
944
+ // orphan on purpose - those pin the OPPOSITE contract (the sweep firing when
945
+ // it should).
946
+ const fixtures: Array<[string, () => void]> = [
947
+ ["stub-replaces a summarized tool result", () => {
948
+ const indexer = makeMockIndexer({ summarized: new Set(["tc1"]), shortRefs: new Map([["tc1", "t1"]]) });
949
+ const messages = [
950
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
951
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "big output" }], isError: false, timestamp: 1 },
952
+ ];
953
+ pruneWithZeroSweepAssertion(messages, indexer);
954
+ }],
955
+ ["applies chain compression after stub-replace", () => {
956
+ const toolCallId = "tc-mid";
957
+ const chainEntry: ChainCompressionEntry = {
958
+ blockId: "b1", startUserTimestamp: 100, droppedToolCallIds: [toolCallId],
959
+ finalAssistantTimestamp: 300, toolRefs: ["t1"], compressedAt: 999,
960
+ };
961
+ const indexer = makeMockIndexer({
962
+ summarized: new Set([toolCallId]), shortRefs: new Map([[toolCallId, "t1"]]),
963
+ chainEntries: [chainEntry], summaryBodyMap: new Map([[toolCallId, "ran bash, got results"]]),
964
+ });
965
+ const messages: any[] = [
966
+ { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 },
967
+ { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
968
+ { role: "toolResult", toolCallId, toolName: "bash", content: [{ type: "text", text: "output" }], isError: false, timestamp: 210 },
969
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 300, usage: {}, stopReason: "end_turn" },
970
+ ];
971
+ pruneWithZeroSweepAssertion(messages, indexer, enabledCC);
972
+ }],
973
+ ["purges errored toolCall args through errorPurge wiring", () => {
974
+ const indexer = makeMockIndexer();
975
+ const largeArgs = { content: "x".repeat(200) };
976
+ const messages: any[] = [
977
+ { role: "assistant", content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }], timestamp: 100, usage: {}, stopReason: "tool_use" },
978
+ { role: "toolResult", toolCallId: "tc-err", toolName: "write", content: [{ type: "text", text: "Error: permission denied" }], isError: true, timestamp: 110 },
979
+ { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: { cmd: "ls" } }], timestamp: 200, usage: {}, stopReason: "tool_use" },
980
+ { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 210 },
981
+ ];
982
+ pruneWithZeroSweepAssertion(
983
+ messages, indexer,
984
+ { enabled: false, rollingWindow: 3, stripFinalAssistantThinking: true, fuseRangeSummary: false },
985
+ { enabled: true, cooldownTurns: 2, minArgChars: 100 },
986
+ );
987
+ }],
988
+ ["legacy bare-id records still stub (pre-upgrade sessions)", () => {
989
+ const idx = new ToolCallIndexer();
990
+ idx.reconstructFromSession({
991
+ sessionManager: {
992
+ getBranch: () => [
993
+ { type: "custom", customType: CUSTOM_TYPE_INDEX, data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] } },
994
+ { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 } },
995
+ ],
996
+ },
997
+ } as any);
998
+ const messages: any[] = [
999
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 500 },
1000
+ { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 },
1001
+ ];
1002
+ pruneWithZeroSweepAssertion(messages, idx);
1003
+ }],
1004
+ ["occurrence-keyed: stubs the summarized occurrence and leaves the live one verbatim", () => {
1005
+ const idx = new ToolCallIndexer();
1006
+ idx.addBatch(
1007
+ { turnIndex: 0, timestamp: 1000, assistantText: "", toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OLD", isError: false, resultTimestamp: 1150 }] } as any,
1008
+ () => {},
1009
+ );
1010
+ const messages: any[] = [
1011
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 },
1012
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 1150 },
1013
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 3100 },
1014
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 3150 },
1015
+ ];
1016
+ pruneWithZeroSweepAssertion(messages, idx);
1017
+ }],
1018
+ ["G1 conformance fixture: the accepted pre-upgrade legacy collision case still triggers no orphan sweep", () => {
1019
+ const idx = new ToolCallIndexer();
1020
+ idx.reconstructFromSession({
1021
+ sessionManager: {
1022
+ getBranch: () => [
1023
+ { type: "custom", customType: CUSTOM_TYPE_INDEX, data: { toolCalls: [{ toolCallId: "bash_7", toolName: "bash", args: {}, resultText: "OLD", isError: false, turnIndex: 0, timestamp: 500 }] } },
1024
+ { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "OLD" }], isError: false, timestamp: 550 } },
1025
+ { type: "message", message: { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 } },
1026
+ ],
1027
+ },
1028
+ } as any);
1029
+ const messages: any[] = [
1030
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_7", name: "bash", input: {} }], timestamp: 9100 },
1031
+ { role: "toolResult", toolCallId: "bash_7", toolName: "bash", content: [{ type: "text", text: "LIVE" }], isError: false, timestamp: 9150 },
1032
+ ];
1033
+ pruneWithZeroSweepAssertion(messages, idx);
1034
+ }],
1035
+ ["spill mechanical stub for a spilled record", () => {
1036
+ const indexer = makeMockIndexer({
1037
+ summarized: new Set(["tc1"]),
1038
+ records: new Map([["tc1", {
1039
+ toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" },
1040
+ resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt",
1041
+ spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
1042
+ }]]),
1043
+ });
1044
+ const messages = [
1045
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "fetch", input: {} }], timestamp: 0 },
1046
+ { role: "toolResult", toolCallId: "tc1", toolName: "fetch", content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1 },
1047
+ ];
1048
+ pruneWithZeroSweepAssertion(messages, indexer);
1049
+ }],
1050
+ ];
1051
+
1052
+ for (const [name, run] of fixtures) {
1053
+ it(`zero orphan sweeps: ${name}`, run);
1054
+ }
1055
+ });