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
@@ -6,9 +6,13 @@ const cfg = (enabled: boolean): ContextPruneConfig => ({ enabled } as ContextPru
6
6
  const cfgVisible = (enabled: boolean): ContextPruneConfig =>
7
7
  ({ enabled, showPruneStatusLine: true } as ContextPruneConfig);
8
8
 
9
- function captureStatus(config: ContextPruneConfig, value?: Parameters<typeof setPruneStatusWidget>[2]): string | undefined {
9
+ function captureStatus(
10
+ config: ContextPruneConfig,
11
+ value?: Parameters<typeof setPruneStatusWidget>[2],
12
+ diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
13
+ ): string | undefined {
10
14
  let captured: string | undefined;
11
- setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value);
15
+ setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
12
16
  return captured;
13
17
  }
14
18
 
@@ -65,3 +69,28 @@ describe("setPruneStatusWidget", () => {
65
69
  expect(captureStatus(cfg(true))).toBeUndefined();
66
70
  });
67
71
  });
72
+
73
+ describe("diagnostic counters on the status line", () => {
74
+ const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const;
75
+ const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
76
+
77
+ it("omits the diagnostic segment when all counters are zero", () => {
78
+ expect(pruneStatusText(cfg(true), undefined, zeroDiag)).toBe("prune: ON");
79
+ });
80
+
81
+ it("appends a compact segment when a counter fires, omitting zero kinds", () => {
82
+ const text = pruneStatusText(cfg(true), undefined, mixedDiag);
83
+ expect(text).toBe("prune: ON \u00b7 diag u2/o1");
84
+ });
85
+
86
+ it("appends the diagnostic segment to the reclaim form too", () => {
87
+ const text = pruneStatusText(cfg(true), { beforeChars: 368000, afterChars: 56000 }, mixedDiag);
88
+ expect(text).toBe("prune: ON \u00b7 92.0k->14.0k (-85%) \u00b7 diag u2/o1");
89
+ });
90
+
91
+ it("setPruneStatusWidget forwards the counters", () => {
92
+ expect(captureStatus(cfgVisible(true), undefined, { "unresolved-range": 1, "range-id-mismatch": 0, "orphan-sweep": 0 })).toBe(
93
+ "\u2502 prune: ON \u00b7 diag u1",
94
+ );
95
+ });
96
+ });
package/src/commands.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type CapturedBatch,
6
6
  type ChainCompressionEntry,
7
7
  type FlushOptions,
8
+ type DiagnosticKind,
8
9
  PRUNE_ON_MODES,
9
10
  BATCHING_MODES,
10
11
  STATUS_WIDGET_ID,
@@ -16,7 +17,6 @@ import {
16
17
  SUMMARIZER_MAX_TIMEOUT_PRESETS,
17
18
  AUTO_BUDGET_PRESETS,
18
19
  ROLLING_WINDOW_PRESETS,
19
- KEEP_LAST_TURNS_PRESETS,
20
20
  PURGE_COOLDOWN_PRESETS,
21
21
  PURGE_MIN_ARG_PRESETS,
22
22
  DEFAULT_CONFIG,
@@ -58,25 +58,38 @@ class SettingsOverlay extends Container {
58
58
 
59
59
  // ── Status widget text ──────────────────────────────────────────────────────
60
60
 
61
- export function pruneStatusText(config: ContextPruneConfig, reclaim?: LiveReclaim): string {
61
+ export function pruneStatusText(
62
+ config: ContextPruneConfig,
63
+ reclaim?: LiveReclaim,
64
+ diagnostics?: Record<DiagnosticKind, number>,
65
+ ): string {
62
66
  if (!config.enabled) return "prune: OFF";
63
- if (!reclaim || reclaim.beforeChars <= 0) return "prune: ON";
67
+ const diag = diagnostics
68
+ ? [
69
+ diagnostics["unresolved-range"] ? `u${diagnostics["unresolved-range"]}` : "",
70
+ diagnostics["range-id-mismatch"] ? `m${diagnostics["range-id-mismatch"]}` : "",
71
+ diagnostics["orphan-sweep"] ? `o${diagnostics["orphan-sweep"]}` : "",
72
+ ].filter(Boolean)
73
+ : [];
74
+ const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
75
+ if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
64
76
  const beforeTok = Math.round(reclaim.beforeChars / 4);
65
77
  const afterTok = Math.round(reclaim.afterChars / 4);
66
78
  const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
67
- return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)`;
79
+ return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
68
80
  }
69
81
 
70
82
  export function setPruneStatusWidget(
71
83
  ctx: { ui: { setStatus: (id: string, text?: string) => void } },
72
84
  config: ContextPruneConfig,
73
85
  value?: LiveReclaim | string,
86
+ diagnostics?: Record<DiagnosticKind, number>,
74
87
  ): void {
75
88
  if (!config.showPruneStatusLine) {
76
89
  ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
77
90
  return;
78
91
  }
79
- const text = typeof value === "string" ? value : pruneStatusText(config, value);
92
+ const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
80
93
  // Leading-only separator: the footer joins extension status segments with a
81
94
  // single space, so a trailing divider collides with the next segment's leading
82
95
  // one and renders doubled. One leading bar yields single dividers between
@@ -456,6 +469,7 @@ export function registerCommands(
456
469
  getLiveReclaim: () => LiveReclaim | undefined,
457
470
  indexer: ToolCallIndexer,
458
471
  compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
472
+ getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
459
473
  ): void {
460
474
  // Register the /pruner command
461
475
  pi.registerCommand("pruner", {
@@ -648,22 +662,6 @@ export function registerCommands(
648
662
  currentValue: String(config.chainCompression.fuseRangeSummary),
649
663
  description: `Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span). Off keeps the per-batch concatenation. Currently ${config.chainCompression.fuseRangeSummary ? "ON" : "OFF"}.`,
650
664
  },
651
- {
652
- id: "thinkingStripEnabled",
653
- label: "Thinking strip",
654
- values: ["true", "false"],
655
- currentValue: String(config.thinkingStrip.enabled),
656
- description: `Strip thinking blocks from assistant turns older than the last ${config.thinkingStrip.keepLastTurns}. Reclaims main-loop thinking accumulation; no-op under ${config.thinkingStrip.keepLastTurns} turns. Currently ${config.thinkingStrip.enabled ? "ON" : "OFF"}.`,
657
- },
658
- {
659
- id: "thinkingStripKeepLastTurns",
660
- label: "Thinking keep (last N turns)",
661
- values: KEEP_LAST_TURNS_PRESETS.map((p) => p.value),
662
- currentValue: KEEP_LAST_TURNS_PRESETS.some((p) => p.value === String(config.thinkingStrip.keepLastTurns))
663
- ? String(config.thinkingStrip.keepLastTurns)
664
- : KEEP_LAST_TURNS_PRESETS[2].value,
665
- description: `Keep thinking on the last N assistant turns; strip older. Counts assistant turns, not chains. Currently ${config.thinkingStrip.keepLastTurns}.`,
666
- },
667
665
  {
668
666
  id: "purgeErrorsEnabled",
669
667
  label: "Error purge",
@@ -801,14 +799,6 @@ export function registerCommands(
801
799
  newConfig.chainCompression = { ...newConfig.chainCompression, stripFinalAssistantThinking: newValue === "true" };
802
800
  } else if (id === "chainCompressionFuseRange") {
803
801
  newConfig.chainCompression = { ...newConfig.chainCompression, fuseRangeSummary: newValue === "true" };
804
- } else if (id === "thinkingStripEnabled") {
805
- newConfig.thinkingStrip = { ...newConfig.thinkingStrip, enabled: newValue === "true" };
806
- } else if (id === "thinkingStripKeepLastTurns") {
807
- const parsed = Number.parseInt(newValue, 10);
808
- newConfig.thinkingStrip = {
809
- ...newConfig.thinkingStrip,
810
- keepLastTurns: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.thinkingStrip.keepLastTurns,
811
- };
812
802
  } else if (id === "purgeErrorsEnabled") {
813
803
  newConfig.purgeErrors = { ...newConfig.purgeErrors, enabled: newValue === "true" };
814
804
  } else if (id === "purgeErrorsCooldown") {
@@ -826,7 +816,7 @@ export function registerCommands(
826
816
  }
827
817
  currentConfig.value = newConfig;
828
818
  saveConfig(newConfig);
829
- setPruneStatusWidget(ctx, newConfig, getLiveReclaim());
819
+ setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
830
820
  settingsList?.invalidate();
831
821
  };
832
822
 
@@ -861,7 +851,7 @@ export function registerCommands(
861
851
  currentConfig.value = { ...currentConfig.value, enabled: true };
862
852
  saveConfig(currentConfig.value);
863
853
  ctx.ui.notify("Context pruning enabled.");
864
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
854
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
865
855
  break;
866
856
  }
867
857
 
@@ -870,7 +860,7 @@ export function registerCommands(
870
860
  currentConfig.value = { ...currentConfig.value, enabled: false };
871
861
  saveConfig(currentConfig.value);
872
862
  ctx.ui.notify("Context pruning disabled.");
873
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
863
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
874
864
  break;
875
865
  }
876
866
 
@@ -989,7 +979,7 @@ export function registerCommands(
989
979
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
990
980
  }
991
981
  saveConfig(currentConfig.value);
992
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
982
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
993
983
  break;
994
984
  }
995
985
 
@@ -1036,7 +1026,7 @@ export function registerCommands(
1036
1026
  // tool-result savings; but assistant-message savings (thinking + toolCall args + text)
1037
1027
  // are not counted at all, so the two errors partly cancel. Treat as a rough proxy.
1038
1028
  const droppedChars = compressedEntries.reduce((total, entry) => {
1039
- const records = indexer.lookupToolCalls(entry.droppedToolCallIds);
1029
+ const records = indexer.lookupToolCalls(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
1040
1030
  return total + records.reduce((s, r) => s + r.resultText.length, 0);
1041
1031
  }, 0);
1042
1032
  const reclaimedTokens = Math.ceil(droppedChars / 4);
@@ -1086,7 +1076,7 @@ export function registerCommands(
1086
1076
 
1087
1077
  // Remove the widget and restore the normal footer status.
1088
1078
  clearWidget();
1089
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
1079
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
1090
1080
 
1091
1081
  if (!result.ok) {
1092
1082
  const suffix = "error" in result && result.error ? ` (${result.error})` : "";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeAll, afterAll } from "bun:test";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DEFAULT_CONFIG } from "./types.js";
@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "./types.js";
14
14
  */
15
15
  let tmpDir: string;
16
16
  let loadConfig: typeof import("./config.js").loadConfig;
17
+ let saveConfig: typeof import("./config.js").saveConfig;
17
18
  let settingsPath: typeof import("./config.js").settingsPath;
18
19
 
19
20
  beforeAll(async () => {
@@ -21,6 +22,7 @@ beforeAll(async () => {
21
22
  process.env.PI_CODING_AGENT_DIR = tmpDir;
22
23
  const mod = await import("./config.js");
23
24
  loadConfig = mod.loadConfig;
25
+ saveConfig = mod.saveConfig;
24
26
  settingsPath = mod.settingsPath;
25
27
  });
26
28
 
@@ -99,3 +101,27 @@ describe("loadConfig summarizer timeout normalization", () => {
99
101
  expect(config.summarizerIdleTimeoutMs).toBe(1234);
100
102
  });
101
103
  });
104
+
105
+ describe("loadConfig backward compatibility with removed thinkingStrip key", () => {
106
+ it("loads without error and round-trips a stale contextPrune.thinkingStrip block unchanged", async () => {
107
+ const stale = { enabled: true, keepLastTurns: 16 };
108
+ await writeContextPrune({ thinkingStrip: stale });
109
+
110
+ const config = await loadConfig();
111
+
112
+ // thinkingStrip is no longer a recognized key: DEFAULT_CONFIG carries no
113
+ // such field, so nothing reads or acts on it.
114
+ expect((DEFAULT_CONFIG as unknown as Record<string, unknown>).thinkingStrip).toBeUndefined();
115
+ // normalize() spreads { ...DEFAULT_CONFIG, ...existing } and re-spreads
116
+ // the merge, so the unrecognized key survives verbatim on the loaded value.
117
+ expect((config as unknown as Record<string, unknown>).thinkingStrip).toEqual(stale);
118
+
119
+ // saveConfig() re-serializes the same config object it's given, so the
120
+ // stale block written above must still be present, byte-equivalent, after
121
+ // a full load -> save round trip through the real settingsPath() file.
122
+ await saveConfig(config);
123
+ const raw = await readFile(settingsPath(), "utf-8");
124
+ const written = JSON.parse(raw);
125
+ expect(written.contextPrune.thinkingStrip).toEqual(stale);
126
+ });
127
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { DiagnosticSink } from "./diagnostics.js";
3
+ import { CUSTOM_TYPE_DIAGNOSTIC } from "./types.js";
4
+
5
+ const sinkWithLog = () => {
6
+ const appended: Array<{ type: string; data: any }> = [];
7
+ const sink = new DiagnosticSink((type, data) => appended.push({ type, data }));
8
+ return { sink, appended };
9
+ };
10
+
11
+ describe("DiagnosticSink", () => {
12
+ test("writes one session entry per distinct (kind, dedupKey)", () => {
13
+ const { sink, appended } = sinkWithLog();
14
+ sink.report("unresolved-range", "b5", "blockId=b5 start=1000 final=null");
15
+ expect(appended).toHaveLength(1);
16
+ expect(appended[0].type).toBe(CUSTOM_TYPE_DIAGNOSTIC);
17
+ expect(appended[0].data).toEqual({ kind: "unresolved-range", detail: "blockId=b5 start=1000 final=null" });
18
+ });
19
+
20
+ test("dedupes a repeated (kind, dedupKey) within the session", () => {
21
+ const { sink, appended } = sinkWithLog();
22
+ sink.report("unresolved-range", "b5", "first");
23
+ sink.report("unresolved-range", "b5", "second");
24
+ expect(appended).toHaveLength(1);
25
+ expect(sink.counts()["unresolved-range"]).toBe(1);
26
+ });
27
+
28
+ test("a different dedupKey of the same kind still reports", () => {
29
+ const { sink, appended } = sinkWithLog();
30
+ sink.report("unresolved-range", "b5", "x");
31
+ sink.report("unresolved-range", "b7", "y");
32
+ expect(appended).toHaveLength(2);
33
+ expect(sink.counts()["unresolved-range"]).toBe(2);
34
+ });
35
+
36
+ test("the same dedupKey under two different kinds both report", () => {
37
+ const { sink, appended } = sinkWithLog();
38
+ sink.report("unresolved-range", "b5", "x");
39
+ sink.report("range-id-mismatch", "b5", "y");
40
+ expect(appended).toHaveLength(2);
41
+ expect(appended[0].type).toBe(CUSTOM_TYPE_DIAGNOSTIC);
42
+ expect(appended[1].type).toBe(CUSTOM_TYPE_DIAGNOSTIC);
43
+ expect(sink.counts()["unresolved-range"]).toBe(1);
44
+ expect(sink.counts()["range-id-mismatch"]).toBe(1);
45
+ });
46
+
47
+ test("counts are per kind and start at zero", () => {
48
+ const { sink } = sinkWithLog();
49
+ expect(sink.counts()).toEqual({ "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 });
50
+ sink.report("orphan-sweep", "a,b", "swept 2");
51
+ expect(sink.counts()["orphan-sweep"]).toBe(1);
52
+ });
53
+
54
+ test("an appendEntry failure never throws into the render path, and does not mark the key as seen", () => {
55
+ const spy = spyOn(console, "error").mockImplementation(() => {});
56
+ const sink = new DiagnosticSink(() => {
57
+ throw new Error("session closed");
58
+ });
59
+ expect(() => sink.report("orphan-sweep", "a", "detail")).not.toThrow();
60
+ expect(spy).toHaveBeenCalled();
61
+ expect(sink.counts()["orphan-sweep"]).toBe(0);
62
+ spy.mockRestore();
63
+ });
64
+
65
+ test("a retry of the same (kind, dedupKey) after appendEntry starts working persists and counts", () => {
66
+ const spy = spyOn(console, "error").mockImplementation(() => {});
67
+ let working = false;
68
+ const appended: Array<{ type: string; data: any }> = [];
69
+ const sink = new DiagnosticSink((type, data) => {
70
+ if (!working) throw new Error("session closed");
71
+ appended.push({ type, data });
72
+ });
73
+
74
+ expect(() => sink.report("orphan-sweep", "a", "detail")).not.toThrow();
75
+ expect(sink.counts()["orphan-sweep"]).toBe(0);
76
+ expect(appended).toHaveLength(0);
77
+
78
+ working = true;
79
+ sink.report("orphan-sweep", "a", "detail");
80
+ expect(sink.counts()["orphan-sweep"]).toBe(1);
81
+ expect(appended).toHaveLength(1);
82
+
83
+ spy.mockRestore();
84
+ });
85
+
86
+ test("counts() returns a snapshot; mutating it does not affect internal counters", () => {
87
+ const { sink } = sinkWithLog();
88
+ sink.report("orphan-sweep", "a", "detail");
89
+ const snapshot = sink.counts();
90
+ snapshot["orphan-sweep"] = 999;
91
+ expect(sink.counts()["orphan-sweep"]).toBe(1);
92
+ });
93
+
94
+ test("reset() zeroes all counters", () => {
95
+ const { sink } = sinkWithLog();
96
+ sink.report("unresolved-range", "b5", "x");
97
+ sink.report("orphan-sweep", "a", "y");
98
+ sink.reset();
99
+ expect(sink.counts()).toEqual({ "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 });
100
+ });
101
+
102
+ test("reset() allows a previously-seen (kind, dedupKey) to report again", () => {
103
+ const { sink, appended } = sinkWithLog();
104
+ sink.report("unresolved-range", "b5", "first");
105
+ expect(appended).toHaveLength(1);
106
+
107
+ sink.reset();
108
+
109
+ sink.report("unresolved-range", "b5", "second");
110
+ expect(appended).toHaveLength(2);
111
+ expect(appended[1].data).toEqual({ kind: "unresolved-range", detail: "second" });
112
+ expect(sink.counts()["unresolved-range"]).toBe(1);
113
+ });
114
+ });
@@ -0,0 +1,46 @@
1
+ import { CUSTOM_TYPE_DIAGNOSTIC } from "./types.js";
2
+ import type { DiagnosticEntryData, DiagnosticKind } from "./types.js";
3
+
4
+ /**
5
+ * Out-of-band diagnostic channel for prune-time degradations. Session entries
6
+ * only - never LLM context, so zero tokens and zero cache-prefix change.
7
+ * Deduped per (kind, dedupKey) so a permanently degraded condition writes one
8
+ * entry, not one per render.
9
+ */
10
+ export class DiagnosticSink {
11
+ private readonly seen = new Set<string>();
12
+ private readonly counters: Record<DiagnosticKind, number> = {
13
+ "unresolved-range": 0,
14
+ "range-id-mismatch": 0,
15
+ "orphan-sweep": 0,
16
+ };
17
+
18
+ constructor(private readonly appendEntry: (customType: string, data?: unknown) => void) {}
19
+
20
+ report(kind: DiagnosticKind, dedupKey: string, detail: string): void {
21
+ const key = `${kind}:${dedupKey}`;
22
+ if (this.seen.has(key)) return;
23
+ const payload: DiagnosticEntryData = { kind, detail };
24
+ try {
25
+ this.appendEntry(CUSTOM_TYPE_DIAGNOSTIC, payload);
26
+ } catch (err) {
27
+ // The render path must never fail because bookkeeping failed.
28
+ console.error(`pruner: failed to persist ${kind} diagnostic:`, err);
29
+ return;
30
+ }
31
+ this.seen.add(key);
32
+ this.counters[kind]++;
33
+ }
34
+
35
+ counts(): Record<DiagnosticKind, number> {
36
+ return { ...this.counters };
37
+ }
38
+
39
+ /** Clears session-scoped state; call on session_start/session_tree since this sink is process-scoped, not session-scoped. */
40
+ reset(): void {
41
+ this.seen.clear();
42
+ for (const kind of Object.keys(this.counters) as DiagnosticKind[]) {
43
+ this.counters[kind] = 0;
44
+ }
45
+ }
46
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { PruneFrontierTracker } from "./frontier.js";
3
+ import { pruneMessages } from "./pruner.js";
3
4
  import type { PruneFrontier } from "./types.js";
4
5
 
5
6
  const base: PruneFrontier = {
@@ -14,39 +15,160 @@ const base: PruneFrontier = {
14
15
  outcome: "summarized",
15
16
  };
16
17
 
17
- describe("PruneFrontierTracker.fromJSON - thinkingStripBoundaryTimestamp", () => {
18
- test("round-trips the boundary field", () => {
18
+ describe("PruneFrontierTracker.fromJSON", () => {
19
+ test("round-trips a full frontier", () => {
19
20
  const t = new PruneFrontierTracker();
20
- t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 });
21
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(777);
21
+ t.fromJSON({ ...base });
22
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
23
+ expect(t.get()?.outcome).toBe("summarized");
22
24
  });
23
25
 
24
- test("absent boundary stays undefined (live-count fallback)", () => {
26
+ test("ignores an entry with no lastAttemptedToolCallId", () => {
25
27
  const t = new PruneFrontierTracker();
26
- t.fromJSON({ ...base });
27
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
28
+ t.fromJSON({} as PruneFrontier);
29
+ expect(t.get()).toBeNull();
30
+ });
31
+
32
+ test("tolerates a legacy entry carrying the removed thinkingStripBoundaryTimestamp", () => {
33
+ const t = new PruneFrontierTracker();
34
+ t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 } as PruneFrontier);
35
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
36
+ expect((t.get() as any).thinkingStripBoundaryTimestamp).toBeUndefined();
28
37
  });
29
38
  });
30
39
 
31
- describe("PruneFrontierTracker.reconstructFromSession - boundary survives reload", () => {
32
- test("reconstructs thinkingStripBoundaryTimestamp from a persisted frontier entry", () => {
40
+ describe("PruneFrontierTracker.reconstructFromSession", () => {
41
+ test("reconstructs from a persisted frontier entry", () => {
33
42
  const t = new PruneFrontierTracker();
34
43
  const entries = [
35
- { type: "custom", customType: "context-prune-frontier", data: { ...base, thinkingStripBoundaryTimestamp: 555 } },
44
+ { type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } },
36
45
  ];
37
46
  const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
38
47
  t.reconstructFromSession(fakeCtx);
39
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBe(555);
48
+ expect(t.get()?.lastAttemptedTimestamp).toBe(2000);
40
49
  });
41
50
 
42
- test("a persisted entry without the field reconstructs as undefined (live-count fallback)", () => {
51
+ // Spans frontier.ts + pruner.ts on purpose: proves a resumed legacy frontier carries
52
+ // no boundary into an *actively-pruning* pipeline, not just an inert one. Phase 1
53
+ // (stub-replace) and Phase 3 (chain-range-prune) are both wired live here -- a
54
+ // summarized toolResult gets stubbed and a chain entry produces a synthetic
55
+ // <compressed-chain> message -- and every surviving assistant turn, both older and
56
+ // newer than the legacy boundary, still carries its thinking block. This does not
57
+ // (and cannot) prove the deleted thinking-strip phase stays deleted; it proves the
58
+ // phases that remain do not touch thinking regardless of the legacy field's presence.
59
+ test("a legacy frontier entry with thinkingStripBoundaryTimestamp resumes without error and strips nothing", () => {
60
+ const legacyBoundary = 555;
43
61
  const t = new PruneFrontierTracker();
44
62
  const entries = [
45
- { type: "custom", customType: "context-prune-frontier", data: { ...base } },
63
+ {
64
+ type: "custom",
65
+ customType: "context-prune-frontier",
66
+ data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary },
67
+ },
46
68
  ];
47
69
  const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
48
- t.reconstructFromSession(fakeCtx);
49
- expect(t.get()).not.toBeNull();
50
- expect(t.get()?.thinkingStripBoundaryTimestamp).toBeUndefined();
70
+
71
+ expect(() => t.reconstructFromSession(fakeCtx)).not.toThrow();
72
+ const frontier = t.get();
73
+ expect(frontier).not.toBeNull();
74
+ expect(frontier?.lastAttemptedToolCallId).toBe("tc1");
75
+ expect((frontier as any).thinkingStripBoundaryTimestamp).toBeUndefined();
76
+
77
+ const chainEntry = {
78
+ blockId: "b1",
79
+ startUserTimestamp: 560,
80
+ droppedToolCallIds: ["tc-old"],
81
+ finalAssistantTimestamp: 600,
82
+ toolRefs: ["told"],
83
+ compressedAt: 9999,
84
+ };
85
+
86
+ const indexer = {
87
+ isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
88
+ hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub",
89
+ getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
90
+ getRecord: () => undefined,
91
+ getChainEntries: () => [chainEntry],
92
+ getPerBatchSummaryTextForToolCallIds: () => "chain summary text",
93
+ findChainEntryByBlockId: () => undefined,
94
+ } as any;
95
+
96
+ const mkAsst = (ts: number) => ({
97
+ role: "assistant",
98
+ content: [
99
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
100
+ { type: "text", text: "x" },
101
+ ],
102
+ timestamp: ts,
103
+ usage: {},
104
+ stopReason: "end_turn",
105
+ });
106
+ const mkAsstWithCall = (ts: number, toolCallId: string) => ({
107
+ role: "assistant",
108
+ content: [
109
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
110
+ { type: "toolCall", id: toolCallId, name: "bash", arguments: {} },
111
+ ],
112
+ timestamp: ts,
113
+ usage: {},
114
+ stopReason: "tool_use",
115
+ });
116
+
117
+ // Timestamps straddle the legacy boundary: old code would have stripped the ones below it.
118
+ // tc-stub is a plain summarized tool result (phase 1 target, outside the chain).
119
+ // tc-old is dropped by the chain entry (phase 3 target).
120
+ const messages: any[] = [
121
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
122
+ mkAsst(legacyBoundary - 100),
123
+ mkAsstWithCall(legacyBoundary - 55, "tc-stub"),
124
+ {
125
+ role: "toolResult",
126
+ toolCallId: "tc-stub",
127
+ toolName: "bash",
128
+ content: [{ type: "text", text: "raw stub-target output" }],
129
+ isError: false,
130
+ timestamp: legacyBoundary - 50,
131
+ },
132
+ mkAsst(legacyBoundary - 1),
133
+ { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 560 },
134
+ mkAsstWithCall(570, "tc-old"),
135
+ {
136
+ role: "toolResult",
137
+ toolCallId: "tc-old",
138
+ toolName: "bash",
139
+ content: [{ type: "text", text: "raw chain output" }],
140
+ isError: false,
141
+ timestamp: 575,
142
+ },
143
+ mkAsst(600),
144
+ mkAsst(legacyBoundary + 100),
145
+ ];
146
+
147
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
148
+ enabled: true,
149
+ rollingWindow: 0,
150
+ stripFinalAssistantThinking: false,
151
+ fuseRangeSummary: false,
152
+ });
153
+
154
+ // Non-vacuity: the pipeline actually did something.
155
+ expect(pruned).toBe(true);
156
+
157
+ // Phase 1 fired: the summarized-but-not-chained toolResult was stub-replaced.
158
+ const stubResult = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-stub") as any;
159
+ expect(stubResult).toBeDefined();
160
+ expect(stubResult.content[0].text).toContain("`t1`");
161
+ expect(stubResult.content[0].text).not.toContain("raw stub-target output");
162
+
163
+ // Phase 3 fired: the chain entry produced a synthetic compressed-chain message.
164
+ const synthetic = out.find(
165
+ (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
166
+ );
167
+ expect(synthetic).toBeDefined();
168
+
169
+ // Every surviving assistant turn, older and newer than the legacy boundary, keeps thinking.
170
+ const assistants = out.filter((m: any) => m.role === "assistant");
171
+ expect(assistants.length).toBe(5);
172
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
51
173
  });
52
174
  });
package/src/frontier.ts CHANGED
@@ -32,7 +32,6 @@ export class PruneFrontierTracker {
32
32
  rawCharCount: data.rawCharCount ?? 0,
33
33
  summaryCharCount: data.summaryCharCount ?? 0,
34
34
  outcome: data.outcome ?? "summarized",
35
- thinkingStripBoundaryTimestamp: data.thinkingStripBoundaryTimestamp,
36
35
  };
37
36
  }
38
37