pi-condense 2.4.3 → 2.6.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 +14 -0
  2. package/PRUNING.md +96 -54
  3. package/README.md +6 -1
  4. package/index.ts +28 -42
  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 +31 -2
  15. package/src/commands.ts +25 -35
  16. package/src/config.test.ts +27 -1
  17. package/src/diagnostics.test.ts +114 -0
  18. package/src/diagnostics.ts +46 -0
  19. package/src/frontier.test.ts +138 -16
  20. package/src/frontier.ts +0 -1
  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 +471 -64
  30. package/src/pruner.ts +84 -54
  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 +7 -44
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/spill.test.ts +108 -1
  37. package/src/spill.ts +5 -3
  38. package/src/summary-refs.test.ts +51 -1
  39. package/src/summary-refs.ts +15 -4
  40. package/src/test-support.ts +54 -0
  41. package/src/tree-browser.ts +2 -1
  42. package/src/types.ts +56 -49
  43. package/src/thinking-strip.test.ts +0 -257
  44. package/src/thinking-strip.ts +0 -83
@@ -1,9 +1,20 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { pruneMessages, sizeMessages } from "./pruner.js";
3
- import type { ChainCompressionConfig, ChainCompressionEntry, ThinkingStripConfig } from "./types.js";
4
3
  import { ToolCallIndexer } from "./indexer.js";
4
+ import { CUSTOM_TYPE_INDEX } from "./types.js";
5
+ import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
6
+ import { DiagnosticSink } from "./diagnostics.js";
7
+ import { pruneWithZeroSweepAssertion } from "./test-support.js";
5
8
 
6
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`.
7
18
  function makeMockIndexer({
8
19
  summarized = new Set<string>(),
9
20
  shortRefs = new Map<string, string>(),
@@ -19,6 +30,7 @@ function makeMockIndexer({
19
30
  } = {}) {
20
31
  return {
21
32
  isSummarized: (id: string) => summarized.has(id),
33
+ hasLegacyBareRecord: (id: string) => summarized.has(id),
22
34
  getShortRefForToolCallId: (id: string) => shortRefs.get(id),
23
35
  getRecord: (id: string) => records.get(id),
24
36
  getChainEntries: () => chainEntries,
@@ -46,6 +58,7 @@ describe("pruneMessages", () => {
46
58
  shortRefs: new Map([["tc1", "t1"]]),
47
59
  });
48
60
  const messages = [
61
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
49
62
  {
50
63
  role: "toolResult",
51
64
  toolCallId: "tc1",
@@ -57,8 +70,8 @@ describe("pruneMessages", () => {
57
70
  ];
58
71
  const { messages: out, pruned } = pruneMessages(messages, indexer);
59
72
  expect(pruned).toBe(true);
60
- expect(out[0].content[0].text).toContain("`t1`");
61
- 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");
62
75
  });
63
76
 
64
77
  it("returns original array reference when nothing is summarized or compressed", () => {
@@ -318,9 +331,12 @@ describe("pruneMessages", () => {
318
331
  spillPath: "/blobs/tc1.txt", isError: false, turnIndex: 0, timestamp: 1,
319
332
  }]]),
320
333
  });
321
- 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
+ ];
322
338
  const { messages: out } = pruneMessages(messages, indexer);
323
- const text = out[0].content[0].text as string;
339
+ const text = out[1].content[0].text as string;
324
340
  expect(text).toContain("/blobs/tc1.txt");
325
341
  expect(text).toContain("?");
326
342
  expect(text).not.toContain("Summarized in pruner summary");
@@ -335,13 +351,16 @@ describe("pruneMessages", () => {
335
351
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
336
352
  }]]),
337
353
  });
338
- const messages = [{
339
- role: "toolResult", toolCallId: "tc1", toolName: "fetch",
340
- content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1,
341
- }];
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
+ ];
342
361
  const { messages: out, pruned } = pruneMessages(messages, indexer);
343
362
  expect(pruned).toBe(true);
344
- const text = out[0].content[0].text as string;
363
+ const text = out[1].content[0].text as string;
345
364
  expect(text).toContain("/blobs/tc1.txt");
346
365
  expect(text).toContain("PREVIEW-HEAD");
347
366
  expect(text).toContain("1048576");
@@ -360,6 +379,7 @@ describe("pruneMessages", () => {
360
379
  const indexer = makeMockIndexer({ chainEntries: [chainEntry] });
361
380
  const messages = [
362
381
  { role: "user", content: "hi", timestamp: 100 },
382
+ { role: "assistant", content: [{ type: "toolCall", id: "tc-x", name: "bash", input: {} }], timestamp: 140 },
363
383
  {
364
384
  role: "toolResult",
365
385
  toolCallId: "tc-x",
@@ -375,7 +395,7 @@ describe("pruneMessages", () => {
375
395
  expect(pruned).toBe(false);
376
396
  });
377
397
 
378
- it("composes stub-replace (Phase 1) with thinking-strip (Phase 4)", () => {
398
+ it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => {
379
399
  const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
380
400
  const mkAsst = (ts: number) => ({
381
401
  role: "assistant",
@@ -394,44 +414,22 @@ describe("pruneMessages", () => {
394
414
  messages.push(mkAsst(10 + i));
395
415
  messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
396
416
  }
397
- const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, {
398
- enabled: true,
399
- keepLastTurns: 2,
417
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
418
+ enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false,
400
419
  });
420
+ // Phase 1 still fires: c10's toolResult is stub-replaced.
401
421
  expect(pruned).toBe(true);
402
-
403
- // Phase 1: c10 toolResult stub-replaced
404
422
  const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
405
423
  expect(tr.content[0].text).toContain("`t1`");
406
-
407
- // Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
424
+ // No phase strips thinking any more — all five assistants keep theirs.
408
425
  const assistants = out.filter((m: any) => m.role === "assistant");
409
- const hasThinking = (m: any) => m.content.some((c: any) => c.type === "thinking");
410
- expect(assistants.slice(0, 3).every((a: any) => !hasThinking(a))).toBe(true);
411
- expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
412
- });
413
-
414
- it("threads thinkingBoundaryTimestamp into phase 4", () => {
415
- const indexer = new ToolCallIndexer();
416
- const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
417
- const messages: any[] = [
418
- { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
419
- { role: "assistant", content: [{ type: "thinking", thinking: "old", thinkingSignature: "s" }, { type: "text", text: "a" }], timestamp: 10, usage: {}, stopReason: "stop" },
420
- { role: "assistant", content: [{ type: "thinking", thinking: "new", thinkingSignature: "s" }, { type: "text", text: "b" }], timestamp: 30, usage: {}, stopReason: "stop" },
421
- ];
422
- // Boundary 20: ts=10 assistant older -> stripped; ts=30 kept.
423
- // Live-count would strip nothing (2 assistants < keepLastTurns=16), so a pass
424
- // proves the boundary arg reached phase 4.
425
- const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, strip, undefined, 0, 20);
426
- expect(pruned).toBe(true);
427
- const older = out.find((m: any) => m.timestamp === 10) as any;
428
- const newer = out.find((m: any) => m.timestamp === 30) as any;
429
- expect(older.content.some((c: any) => c.type === "thinking")).toBe(false);
430
- expect(newer.content.some((c: any) => c.type === "thinking")).toBe(true);
426
+ expect(assistants.length).toBe(5);
427
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
431
428
  });
432
429
  });
433
430
 
434
431
  describe("render-time protection re-check", () => {
432
+ const skillAsst = { role: "assistant", content: [{ type: "toolCall", id: "tc-skill", name: "read", input: {} }], timestamp: 5 };
435
433
  const skillMsg = {
436
434
  role: "toolResult",
437
435
  toolCallId: "tc-skill",
@@ -457,17 +455,17 @@ describe("render-time protection re-check", () => {
457
455
 
458
456
  it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
459
457
  const { messages, pruned } = pruneMessages(
460
- [skillMsg], indexer as any, undefined, undefined, undefined,
458
+ [skillAsst, skillMsg], indexer as any, undefined, undefined,
461
459
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
462
460
  );
463
461
  expect(pruned).toBe(false);
464
- expect(messages[0].content[0].text).toBe("FULL SKILL BODY");
462
+ expect(messages[1].content[0].text).toBe("FULL SKILL BODY");
465
463
  });
466
464
 
467
465
  it("still stubs when no protection config is passed", () => {
468
- const { messages, pruned } = pruneMessages([skillMsg], indexer as any);
466
+ const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any);
469
467
  expect(pruned).toBe(true);
470
- expect(messages[0].content[0].text).toContain("context_tree_query");
468
+ expect(messages[1].content[0].text).toContain("context_tree_query");
471
469
  });
472
470
  });
473
471
 
@@ -481,15 +479,20 @@ describe("pruneMessages recovery grace", () => {
481
479
  timestamp,
482
480
  });
483
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
+ });
484
487
 
485
488
  it("renders a context_tree_query recovery output verbatim at age 0 within grace", () => {
486
489
  const indexer = makeMockIndexer({
487
490
  summarized: new Set(["tc-recover"]),
488
491
  shortRefs: new Map([["tc-recover", "t1"]]),
489
492
  });
490
- const messages = [mkQueryResult("tc-recover", 1)];
491
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
492
- expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
493
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
494
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
495
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
493
496
  });
494
497
 
495
498
  it("stubs a context_tree_query recovery output aged past the grace window", () => {
@@ -497,8 +500,8 @@ describe("pruneMessages recovery grace", () => {
497
500
  summarized: new Set(["tc-recover"]),
498
501
  shortRefs: new Map([["tc-recover", "t1"]]),
499
502
  });
500
- const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
501
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
503
+ const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
504
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
502
505
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
503
506
  expect(tr.content[0].text).toContain("context_tree_query");
504
507
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
@@ -509,10 +512,10 @@ describe("pruneMessages recovery grace", () => {
509
512
  summarized: new Set(["tc-recover"]),
510
513
  shortRefs: new Map([["tc-recover", "t1"]]),
511
514
  });
512
- const messages = [mkQueryResult("tc-recover", 1)];
513
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 0);
514
- expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
515
- expect(out[0].content[0].text).toContain("context_tree_query");
515
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
516
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
517
+ expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
518
+ expect(out[1].content[0].text).toContain("context_tree_query");
516
519
  });
517
520
 
518
521
  it("does not apply the grace window to non-context_tree_query outputs", () => {
@@ -521,6 +524,7 @@ describe("pruneMessages recovery grace", () => {
521
524
  shortRefs: new Map([["tc-bash", "t1"]]),
522
525
  });
523
526
  const messages = [
527
+ mkAsst("tc-bash", "bash", 0),
524
528
  {
525
529
  role: "toolResult",
526
530
  toolCallId: "tc-bash",
@@ -530,9 +534,9 @@ describe("pruneMessages recovery grace", () => {
530
534
  timestamp: 1,
531
535
  },
532
536
  ];
533
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
534
- expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
535
- expect(out[0].content[0].text).toContain("context_tree_query");
537
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
538
+ expect(out[1].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
539
+ expect(out[1].content[0].text).toContain("context_tree_query");
536
540
  });
537
541
 
538
542
  it("isProtected precedence: a protected context_tree_query output stays verbatim even with grace off", () => {
@@ -544,9 +548,9 @@ describe("pruneMessages recovery grace", () => {
544
548
  resultText: "", isError: false, turnIndex: 0, timestamp: 1,
545
549
  }]]),
546
550
  });
547
- 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)];
548
552
  const { messages: out } = pruneMessages(
549
- messages, indexer, undefined, undefined, undefined,
553
+ messages, indexer, undefined, undefined,
550
554
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
551
555
  0,
552
556
  );
@@ -564,9 +568,9 @@ describe("pruneMessages recovery grace", () => {
564
568
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
565
569
  }]]),
566
570
  });
567
- const messages = [mkQueryResult("tc-recover", 1)];
568
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
569
- expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
571
+ const messages = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1)];
572
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
573
+ expect(out[1].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
570
574
  });
571
575
 
572
576
  it("stubs a spilled context_tree_query recovery output aged past the grace window to the spill-pointer stub", () => {
@@ -579,8 +583,8 @@ describe("pruneMessages recovery grace", () => {
579
583
  spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
580
584
  }]]),
581
585
  });
582
- const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
583
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
586
+ const messages: any[] = [mkAsst("tc-recover", "context_tree_query", 0), mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
587
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
584
588
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
585
589
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
586
590
  expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
@@ -588,11 +592,294 @@ describe("pruneMessages recovery grace", () => {
588
592
  });
589
593
  });
590
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
+
591
878
  describe("sizeMessages", () => {
592
879
  it("counts hidden fields (thinking blocks), not just visible text", () => {
593
880
  // Two messages with identical visible .text but different hidden content.
594
881
  // sizeMessages must count the full serialized weight so all reclaim
595
- // mechanisms (thinking-strip, error-purge, etc.) register correctly.
882
+ // mechanisms (stub-replace, error-purge, chain-range-prune) register correctly.
596
883
  const withThinking = [{
597
884
  role: "assistant",
598
885
  content: [
@@ -630,6 +917,7 @@ describe("pruneMessages beforeChars/afterChars", () => {
630
917
  shortRefs: new Map([["tc1", "t1"]]),
631
918
  });
632
919
  const messages = [
920
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
633
921
  {
634
922
  role: "toolResult",
635
923
  toolCallId: "tc1",
@@ -644,5 +932,124 @@ describe("pruneMessages beforeChars/afterChars", () => {
644
932
  expect(result.beforeChars).toBe(sizeMessages(messages));
645
933
  expect(result.afterChars).toBe(sizeMessages(result.messages));
646
934
  expect(result.afterChars).toBeLessThan(result.beforeChars);
935
+ expect(result.messages).toHaveLength(2);
647
936
  });
648
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
+ });