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,17 +1,129 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { pruneStatusText, setPruneStatusWidget } from "./commands.js";
3
- import type { ContextPruneConfig } from "./types.js";
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
3
+ import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
4
+ import { DEFAULT_CONFIG } from "./types.js";
4
5
 
5
6
  const cfg = (enabled: boolean): ContextPruneConfig => ({ enabled } as ContextPruneConfig);
6
7
  const cfgVisible = (enabled: boolean): ContextPruneConfig =>
7
8
  ({ enabled, showPruneStatusLine: true } as ContextPruneConfig);
8
9
 
9
- function captureStatus(config: ContextPruneConfig, value?: Parameters<typeof setPruneStatusWidget>[2]): string | undefined {
10
+ function captureStatus(
11
+ config: ContextPruneConfig,
12
+ value?: Parameters<typeof setPruneStatusWidget>[2],
13
+ diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
14
+ metrics?: Parameters<typeof setPruneStatusWidget>[4],
15
+ ): string | undefined {
10
16
  let captured: string | undefined;
11
- setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value);
17
+ setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics, metrics);
12
18
  return captured;
13
19
  }
14
20
 
21
+ // ── /pruner command handler harness (registerCommands) ──────────────────────
22
+ // Drives the real switch-statement handler registered by registerCommands,
23
+ // with all injected collaborators stubbed. This is the seam for exercising
24
+ // /pruner subcommands without booting the full index.ts extension.
25
+ function setupPrunerCommand(overrides: {
26
+ capturePendingBatches?: () => any[];
27
+ flushPending?: (ctx: any, options?: any) => Promise<any>;
28
+ getRearmed?: () => boolean;
29
+ getContextMetrics?: (ctx: any) => ContextMetricsSnapshot;
30
+ } = {}) {
31
+ let handler: (args: string, ctx: any) => Promise<void>;
32
+ const notifications: { message: string; type?: string }[] = [];
33
+ const flushCalls: any[] = [];
34
+
35
+ const flushPending =
36
+ overrides.flushPending ??
37
+ (async (_ctx: any, options?: any) => {
38
+ flushCalls.push(options);
39
+ return { ok: false, reason: "empty" };
40
+ });
41
+
42
+ const pi: any = {
43
+ registerCommand(_name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
44
+ handler = spec.handler;
45
+ },
46
+ registerMessageRenderer() {},
47
+ };
48
+
49
+ const currentConfig = { value: { ...DEFAULT_CONFIG, enabled: true } };
50
+
51
+ registerCommands(
52
+ pi,
53
+ currentConfig,
54
+ flushPending,
55
+ overrides.capturePendingBatches ?? (() => []),
56
+ () => ({ callCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 } as SummarizerStats),
57
+ () => undefined,
58
+ {} as any,
59
+ async () => ({ compressedEntries: [], skipped: 0 }),
60
+ undefined,
61
+ overrides.getContextMetrics,
62
+ undefined,
63
+ overrides.getRearmed,
64
+ );
65
+
66
+ const ctx: any = {
67
+ ui: {
68
+ notify(message: string, type?: string) {
69
+ notifications.push({ message, type });
70
+ },
71
+ },
72
+ };
73
+
74
+ return {
75
+ run: (args: string) => handler(args, ctx),
76
+ notifications,
77
+ flushCalls,
78
+ };
79
+ }
80
+
81
+ describe("/pruner now (empty capture)", () => {
82
+ it("invokes flushPending even when nothing is pending, so a flush-metrics entry is recorded", async () => {
83
+ const harness = setupPrunerCommand({ capturePendingBatches: () => [] });
84
+ await harness.run("now");
85
+
86
+ expect(harness.flushCalls.length).toBe(1);
87
+ expect(harness.flushCalls[0]).toMatchObject({ previewedBatches: [], trigger: "manual" });
88
+ expect(harness.notifications[0]?.message).toBe("pruner: nothing pending — no batches to summarize");
89
+ });
90
+ });
91
+
92
+ describe("/pruner status context block", () => {
93
+ const metrics: ContextMetricsSnapshot = {
94
+ openCycleThinkingTokens: 12000,
95
+ largestChainSharePct: 62,
96
+ frontierGapTokens: 195000,
97
+ };
98
+
99
+ it("renders the --- context --- block with all three metrics", async () => {
100
+ const harness = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false });
101
+ await harness.run("status");
102
+
103
+ const text = harness.notifications[0]?.message ?? "";
104
+ expect(text).toContain("--- context ---");
105
+ expect(text).toContain("thinking: 12.0k tokens (open segment)");
106
+ expect(text).toContain("chain share: 62%");
107
+ expect(text).toContain("frontier gap: 195.0k tokens");
108
+ });
109
+
110
+ it("appends the rearmed: line only when getRearmed() is true", async () => {
111
+ const armed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => true });
112
+ await armed.run("status");
113
+ expect(armed.notifications[0]?.message).toContain("rearmed: yes");
114
+
115
+ const notArmed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false });
116
+ await notArmed.run("status");
117
+ expect(notArmed.notifications[0]?.message).not.toContain("rearmed:");
118
+ });
119
+
120
+ it("omits the context block entirely when getContextMetrics is unwired", async () => {
121
+ const harness = setupPrunerCommand();
122
+ await harness.run("status");
123
+ expect(harness.notifications[0]?.message).not.toContain("--- context ---");
124
+ });
125
+ });
126
+
15
127
  describe("pruneStatusText", () => {
16
128
  it("disabled config -> 'prune: OFF'", () => {
17
129
  expect(pruneStatusText(cfg(false))).toBe("prune: OFF");
@@ -65,3 +177,54 @@ describe("setPruneStatusWidget", () => {
65
177
  expect(captureStatus(cfg(true))).toBeUndefined();
66
178
  });
67
179
  });
180
+
181
+ describe("diagnostic counters on the status line", () => {
182
+ const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const;
183
+ const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
184
+
185
+ it("omits the diagnostic segment when all counters are zero", () => {
186
+ expect(pruneStatusText(cfg(true), undefined, zeroDiag)).toBe("prune: ON");
187
+ });
188
+
189
+ it("appends a compact segment when a counter fires, omitting zero kinds", () => {
190
+ const text = pruneStatusText(cfg(true), undefined, mixedDiag);
191
+ expect(text).toBe("prune: ON \u00b7 diag u2/o1");
192
+ });
193
+
194
+ it("appends the diagnostic segment to the reclaim form too", () => {
195
+ const text = pruneStatusText(cfg(true), { beforeChars: 368000, afterChars: 56000 }, mixedDiag);
196
+ expect(text).toBe("prune: ON \u00b7 92.0k->14.0k (-85%) \u00b7 diag u2/o1");
197
+ });
198
+
199
+ it("setPruneStatusWidget forwards the counters", () => {
200
+ expect(captureStatus(cfgVisible(true), undefined, { "unresolved-range": 1, "range-id-mismatch": 0, "orphan-sweep": 0 })).toBe(
201
+ "\u2502 prune: ON \u00b7 diag u1",
202
+ );
203
+ });
204
+ });
205
+
206
+ describe("context metrics suffix on the status line", () => {
207
+ const metrics: ContextMetricsSnapshot = {
208
+ openCycleThinkingTokens: 12000,
209
+ largestChainSharePct: 62,
210
+ frontierGapTokens: 195000,
211
+ };
212
+
213
+ it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
214
+ const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
215
+ expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
216
+ });
217
+
218
+ it("omits the suffix when frontierGapTokens is 0", () => {
219
+ const withZeroGap = { ...metrics, frontierGapTokens: 0 };
220
+ expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
221
+ pruneStatusText(cfg(true)),
222
+ );
223
+ });
224
+
225
+ it("composes after the diag suffix when both are present", () => {
226
+ const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
227
+ const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
228
+ expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
229
+ });
230
+ });
package/src/commands.ts CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  type CapturedBatch,
6
6
  type ChainCompressionEntry,
7
7
  type FlushOptions,
8
+ type DiagnosticKind,
9
+ type ContextMetricsSnapshot,
8
10
  PRUNE_ON_MODES,
9
11
  BATCHING_MODES,
10
12
  STATUS_WIDGET_ID,
@@ -57,25 +59,43 @@ class SettingsOverlay extends Container {
57
59
 
58
60
  // ── Status widget text ──────────────────────────────────────────────────────
59
61
 
60
- export function pruneStatusText(config: ContextPruneConfig, reclaim?: LiveReclaim): string {
62
+ export function pruneStatusText(
63
+ config: ContextPruneConfig,
64
+ reclaim?: LiveReclaim,
65
+ diagnostics?: Record<DiagnosticKind, number>,
66
+ metrics?: ContextMetricsSnapshot,
67
+ ): string {
61
68
  if (!config.enabled) return "prune: OFF";
62
- if (!reclaim || reclaim.beforeChars <= 0) return "prune: ON";
69
+ const diag = diagnostics
70
+ ? [
71
+ diagnostics["unresolved-range"] ? `u${diagnostics["unresolved-range"]}` : "",
72
+ diagnostics["range-id-mismatch"] ? `m${diagnostics["range-id-mismatch"]}` : "",
73
+ diagnostics["orphan-sweep"] ? `o${diagnostics["orphan-sweep"]}` : "",
74
+ ].filter(Boolean)
75
+ : [];
76
+ const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
77
+ const metricsSuffix = metrics && metrics.frontierGapTokens > 0
78
+ ? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
79
+ : "";
80
+ if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
63
81
  const beforeTok = Math.round(reclaim.beforeChars / 4);
64
82
  const afterTok = Math.round(reclaim.afterChars / 4);
65
83
  const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
66
- return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)`;
84
+ return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}${metricsSuffix}`;
67
85
  }
68
86
 
69
87
  export function setPruneStatusWidget(
70
88
  ctx: { ui: { setStatus: (id: string, text?: string) => void } },
71
89
  config: ContextPruneConfig,
72
90
  value?: LiveReclaim | string,
91
+ diagnostics?: Record<DiagnosticKind, number>,
92
+ metrics?: ContextMetricsSnapshot,
73
93
  ): void {
74
94
  if (!config.showPruneStatusLine) {
75
95
  ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
76
96
  return;
77
97
  }
78
- const text = typeof value === "string" ? value : pruneStatusText(config, value);
98
+ const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics, metrics);
79
99
  // Leading-only separator: the footer joins extension status segments with a
80
100
  // single space, so a trailing divider collides with the next segment's leading
81
101
  // one and renders doubled. One leading bar yields single dividers between
@@ -455,6 +475,10 @@ export function registerCommands(
455
475
  getLiveReclaim: () => LiveReclaim | undefined,
456
476
  indexer: ToolCallIndexer,
457
477
  compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
478
+ getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
479
+ getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
480
+ getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
481
+ getRearmed?: () => boolean,
458
482
  ): void {
459
483
  // Register the /pruner command
460
484
  pi.registerCommand("pruner", {
@@ -801,7 +825,7 @@ export function registerCommands(
801
825
  }
802
826
  currentConfig.value = newConfig;
803
827
  saveConfig(newConfig);
804
- setPruneStatusWidget(ctx, newConfig, getLiveReclaim());
828
+ setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
805
829
  settingsList?.invalidate();
806
830
  };
807
831
 
@@ -836,7 +860,7 @@ export function registerCommands(
836
860
  currentConfig.value = { ...currentConfig.value, enabled: true };
837
861
  saveConfig(currentConfig.value);
838
862
  ctx.ui.notify("Context pruning enabled.");
839
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
863
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
840
864
  break;
841
865
  }
842
866
 
@@ -845,7 +869,7 @@ export function registerCommands(
845
869
  currentConfig.value = { ...currentConfig.value, enabled: false };
846
870
  saveConfig(currentConfig.value);
847
871
  ctx.ui.notify("Context pruning disabled.");
848
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
872
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
849
873
  break;
850
874
  }
851
875
 
@@ -858,8 +882,12 @@ export function registerCommands(
858
882
  ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}`
859
883
  : "\n (no summarizer calls yet)";
860
884
  const fmtTimeout = (ms: number) => (ms === 0 ? "disabled" : `${Math.round(ms / 1000)}s`);
885
+ const m = getContextMetrics?.(ctx);
886
+ const contextLine = m
887
+ ? `\n --- context ---\n thinking: ${formatTokens(m.openCycleThinkingTokens)} tokens (open segment)\n chain share: ${m.largestChainSharePct}%\n frontier gap: ${formatTokens(m.frontierGapTokens)} tokens${getRearmed?.() ? "\n rearmed: yes" : ""}`
888
+ : "";
861
889
  ctx.ui.notify(
862
- `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
890
+ `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}${contextLine}`,
863
891
  );
864
892
  break;
865
893
  }
@@ -964,7 +992,7 @@ export function registerCommands(
964
992
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
965
993
  }
966
994
  saveConfig(currentConfig.value);
967
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
995
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
968
996
  break;
969
997
  }
970
998
 
@@ -1011,7 +1039,7 @@ export function registerCommands(
1011
1039
  // tool-result savings; but assistant-message savings (thinking + toolCall args + text)
1012
1040
  // are not counted at all, so the two errors partly cancel. Treat as a rough proxy.
1013
1041
  const droppedChars = compressedEntries.reduce((total, entry) => {
1014
- const records = indexer.lookupToolCalls(entry.droppedToolCallIds);
1042
+ const records = indexer.lookupToolCalls(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
1015
1043
  return total + records.reduce((s, r) => s + r.resultText.length, 0);
1016
1044
  }, 0);
1017
1045
  const reclaimedTokens = Math.ceil(droppedChars / 4);
@@ -1037,6 +1065,11 @@ export function registerCommands(
1037
1065
  const batches = capturePendingBatches(ctx);
1038
1066
  if (batches.length === 0) {
1039
1067
  ctx.ui.notify("pruner: nothing pending — no batches to summarize", "info");
1068
+ // Still invoke flushPending so its finally-emitted flush-metrics entry
1069
+ // records this attempt (outcome "empty") — the incident's exact
1070
+ // undiagnosable "nothing pending" report is precisely what this log
1071
+ // exists to make diagnosable on recurrence.
1072
+ await flushPending(ctx, { previewedBatches: batches, trigger: "manual" });
1040
1073
  break;
1041
1074
  }
1042
1075
 
@@ -1061,7 +1094,7 @@ export function registerCommands(
1061
1094
 
1062
1095
  // Remove the widget and restore the normal footer status.
1063
1096
  clearWidget();
1064
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
1097
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
1065
1098
 
1066
1099
  if (!result.ok) {
1067
1100
  const suffix = "error" in result && result.error ? ` (${result.error})` : "";
@@ -0,0 +1,335 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computeContextMetrics } from "./context-metrics.js";
3
+ import type { PruneFrontier } from "./types.js";
4
+
5
+ // ── Minimal message factories (mirrors src/chain-detector.test.ts style) ───
6
+
7
+ function userMsg(timestamp: number, text = "do the thing"): any {
8
+ return { role: "user", content: [{ type: "text", text }], timestamp };
9
+ }
10
+
11
+ function assistantWithTools(timestamp: number, toolCallIds: string[]): any {
12
+ return {
13
+ role: "assistant",
14
+ content: [
15
+ { type: "text", text: "working..." },
16
+ ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
17
+ ],
18
+ timestamp,
19
+ usage: {},
20
+ stopReason: "toolUse",
21
+ };
22
+ }
23
+
24
+ function assistantWithToolsAndThinking(timestamp: number, toolCallIds: string[], thinking = "hmm"): any {
25
+ return {
26
+ role: "assistant",
27
+ content: [
28
+ { type: "thinking", thinking, thinkingSignature: "sig" },
29
+ { type: "text", text: "working..." },
30
+ ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
31
+ ],
32
+ timestamp,
33
+ usage: {},
34
+ stopReason: "toolUse",
35
+ };
36
+ }
37
+
38
+ function toolResult(timestamp: number, toolCallId: string, toolName = "bash", text = "output"): any {
39
+ return {
40
+ role: "toolResult",
41
+ toolCallId,
42
+ toolName,
43
+ content: [{ type: "text", text }],
44
+ isError: false,
45
+ timestamp,
46
+ };
47
+ }
48
+
49
+ function assistantText(timestamp: number, text = "done"): any {
50
+ return { role: "assistant", content: [{ type: "text", text }], timestamp, usage: {}, stopReason: "stop" };
51
+ }
52
+
53
+ function assistantTextWithThinking(timestamp: number, thinking = "closing thought", text = "done"): any {
54
+ return {
55
+ role: "assistant",
56
+ content: [
57
+ { type: "thinking", thinking, thinkingSignature: "sig" },
58
+ { type: "text", text },
59
+ ],
60
+ timestamp,
61
+ usage: {},
62
+ stopReason: "stop",
63
+ };
64
+ }
65
+
66
+ const noSummarized = () => false;
67
+ const noProtected = () => false;
68
+
69
+ function fullFrontier(overrides: Partial<PruneFrontier>): PruneFrontier {
70
+ return {
71
+ lastAttemptedToolCallId: "tc1",
72
+ lastAttemptedToolName: "bash",
73
+ lastAttemptedTurnIndex: 0,
74
+ lastAttemptedTimestamp: 0,
75
+ attemptedBatchCount: 1,
76
+ attemptedToolCallCount: 1,
77
+ rawCharCount: 0,
78
+ summaryCharCount: 0,
79
+ outcome: "summarized",
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ describe("computeContextMetrics", () => {
85
+ test("empty branch -> all zeros", () => {
86
+ const result = computeContextMetrics([], null, noSummarized, noProtected);
87
+ expect(result).toEqual({ openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 });
88
+ });
89
+
90
+ test("open segment thinking: only counts thinking blocks strictly after the last text-only assistant", () => {
91
+ const openThinkingBlock = { type: "thinking", thinking: "open thought that survives", thinkingSignature: "sig" };
92
+ const msgs = [
93
+ userMsg(100),
94
+ assistantTextWithThinking(200, "excluded thought A"), // text-only -> excluded (at/before boundary)
95
+ userMsg(300),
96
+ assistantWithTools(400, ["tc1"]),
97
+ toolResult(500, "tc1"),
98
+ assistantTextWithThinking(600, "excluded thought B"), // new last text-only assistant
99
+ userMsg(700),
100
+ {
101
+ role: "assistant",
102
+ content: [openThinkingBlock, { type: "text", text: "working" }, { type: "toolCall", id: "tc2", name: "bash", arguments: {} }],
103
+ timestamp: 800,
104
+ usage: {},
105
+ stopReason: "toolUse",
106
+ },
107
+ toolResult(900, "tc2"),
108
+ ];
109
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
110
+ const expected = Math.round(JSON.stringify(openThinkingBlock).length / 4);
111
+ expect(result.openCycleThinkingTokens).toBe(expected);
112
+ });
113
+
114
+ test("zero text-only assistants -> whole branch is the open segment", () => {
115
+ const thinkingBlock = { type: "thinking", thinking: "the only thought", thinkingSignature: "sig" };
116
+ const msgs = [
117
+ userMsg(100),
118
+ { role: "assistant", content: [thinkingBlock, { type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "toolUse" },
119
+ toolResult(300, "tc1"),
120
+ ];
121
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
122
+ expect(result.openCycleThinkingTokens).toBe(Math.round(JSON.stringify(thinkingBlock).length / 4));
123
+ });
124
+
125
+ test("no thinking blocks anywhere -> openCycleThinkingTokens is 0", () => {
126
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
127
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
128
+ expect(result.openCycleThinkingTokens).toBe(0);
129
+ });
130
+
131
+ test("largestChainSharePct: closed chain larger than open segment -> chain dominates", () => {
132
+ const msgs = [
133
+ userMsg(100),
134
+ assistantWithTools(200, ["tc1"]),
135
+ toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain
136
+ assistantText(400),
137
+ userMsg(500),
138
+ assistantText(600), // tiny open segment (single text-only assistant, itself excluded from open... )
139
+ ];
140
+ // Recompute manually to avoid relying on the implementation under test.
141
+ const chars = msgs.map((m) => JSON.stringify(m).length);
142
+ const totalChars = chars.reduce((a, b) => a + b, 0);
143
+ const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // userMsg..assistantText(400)
144
+ const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty (after index 5)
145
+ const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
146
+
147
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
148
+ expect(result.largestChainSharePct).toBe(expectedPct);
149
+ expect(chainChars).toBeGreaterThan(openSegmentChars);
150
+ });
151
+
152
+ test("largestChainSharePct: open segment larger than any closed chain -> open segment dominates", () => {
153
+ const msgs = [
154
+ userMsg(100),
155
+ assistantWithTools(200, ["tc1"]),
156
+ toolResult(300, "tc1"), // small closed chain
157
+ assistantText(400), // closes chain 1
158
+ userMsg(500),
159
+ assistantWithTools(600, ["tc2"]),
160
+ toolResult(700, "tc2", "bash", "y".repeat(3000)), // large open segment (never closes)
161
+ ];
162
+ const chars = msgs.map((m) => JSON.stringify(m).length);
163
+ const totalChars = chars.reduce((a, b) => a + b, 0);
164
+ const chainChars = chars[0] + chars[1] + chars[2] + chars[3];
165
+ const openSegmentChars = chars[4] + chars[5] + chars[6];
166
+ const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
167
+
168
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
169
+ expect(result.largestChainSharePct).toBe(expectedPct);
170
+ expect(openSegmentChars).toBeGreaterThan(chainChars);
171
+ });
172
+
173
+ test("largestChainSharePct: a projected custom_message entry (role \"custom\") counts toward the denominator only, never the chain numerator", () => {
174
+ // Mirrors index.ts's branch projection for persisted summary custom_message
175
+ // entries: role "custom" never matches the user/assistant/toolResult roles
176
+ // detectChains and isTextOnlyAssistant key off, so it cannot join a chain
177
+ // or the open segment -- it only inflates totalChars (the denominator).
178
+ // The customEntry sits between two final text-only assistant messages, so
179
+ // it lands outside both the chain range and the open-cycle segment --
180
+ // isolating the denominator effect from any open-segment interaction.
181
+ const chainMsgs = [
182
+ userMsg(100),
183
+ assistantWithTools(200, ["tc1"]),
184
+ toolResult(300, "tc1", "bash", "x".repeat(2000)),
185
+ assistantText(400),
186
+ ];
187
+ const customEntry = { role: "custom", customType: "pi-condense:summary", content: "s".repeat(3000), display: true, timestamp: 450 };
188
+ const closer = assistantText(500, "ok");
189
+
190
+ const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected);
191
+ const withCustom = computeContextMetrics([...chainMsgs, customEntry, closer], null, noSummarized, noProtected);
192
+
193
+ const chainChars = chainMsgs.map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0);
194
+ const totalWithCustom = [...chainMsgs, customEntry, closer].map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0);
195
+ const expectedPctWithCustom = Math.round((100 * chainChars) / totalWithCustom);
196
+
197
+ expect(withoutCustom.largestChainSharePct).toBe(100);
198
+ expect(withCustom.largestChainSharePct).toBe(expectedPctWithCustom);
199
+ expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct);
200
+ });
201
+
202
+ test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => {
203
+ const msgs = [
204
+ userMsg(100),
205
+ assistantWithTools(200, ["tc1"]),
206
+ toolResult(300, "tc1", "bash", "z".repeat(5000)), // huge interrupted chain
207
+ userMsg(400), // interrupts before text-only close
208
+ assistantText(500), // closes the second (tiny) chain
209
+ ];
210
+ const chars = msgs.map((m) => JSON.stringify(m).length);
211
+ const totalChars = chars.reduce((a, b) => a + b, 0);
212
+ const interruptedChainChars = chars[0] + chars[1] + chars[2]; // startIdx..(nextUserIdx - 1)
213
+ const openSegmentChars = 0; // last text-only assistant is msgs[4] itself
214
+ const expectedPct = Math.round((100 * Math.max(interruptedChainChars, openSegmentChars)) / totalChars);
215
+
216
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
217
+ expect(result.largestChainSharePct).toBe(expectedPct);
218
+ expect(expectedPct).toBeGreaterThan(0);
219
+ });
220
+
221
+ test("largestChainSharePct: empty branch denominator is 0 -> 0 (not NaN)", () => {
222
+ const result = computeContextMetrics([], null, noSummarized, noProtected);
223
+ expect(result.largestChainSharePct).toBe(0);
224
+ });
225
+
226
+ test("frontierGapTokens: null frontier -> whole branch counted", () => {
227
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
228
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
229
+ const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4);
230
+ expect(result.frontierGapTokens).toBe(expected);
231
+ });
232
+
233
+ test("frontierGapTokens: boundary mid-turn split excludes at-or-before calls, includes later calls in same turn", () => {
234
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
235
+ const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 });
236
+ const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
237
+ const expected = Math.round(JSON.stringify(msgs[3]).length / 4); // only tc2's result
238
+ expect(result.frontierGapTokens).toBe(expected);
239
+ });
240
+
241
+ test("frontierGapTokens: bare-id miss (id not present in the matched turn) -> whole branch counted", () => {
242
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
243
+ const frontier = fullFrontier({ lastAttemptedToolCallId: "tc-does-not-exist", lastAttemptedTurnIndex: 0 });
244
+ const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
245
+ const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4);
246
+ expect(result.frontierGapTokens).toBe(expected);
247
+ });
248
+
249
+ test("frontierGapTokens: turn index not found in branch -> whole branch counted", () => {
250
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
251
+ const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 99 });
252
+ const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
253
+ const expected = Math.round(JSON.stringify(msgs[2]).length / 4);
254
+ expect(result.frontierGapTokens).toBe(expected);
255
+ });
256
+
257
+ test("frontierGapTokens: excludes a toolResult whose occurrence key is already summarized", () => {
258
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
259
+ const isSummarized = (key: string) => key === "tc1@300";
260
+ const result = computeContextMetrics(msgs, null, isSummarized, noProtected);
261
+ expect(result.frontierGapTokens).toBe(0);
262
+ });
263
+
264
+ test("frontierGapTokens: excludes a toolResult from a protected tool (args looked up from the pairing toolCall)", () => {
265
+ const msgs = [
266
+ userMsg(100),
267
+ {
268
+ role: "assistant",
269
+ content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }],
270
+ timestamp: 200,
271
+ usage: {},
272
+ stopReason: "toolUse",
273
+ },
274
+ toolResult(300, "tc1", "read"),
275
+ ];
276
+ const isProtected = (toolName: string, args: unknown) =>
277
+ toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
278
+ const result = computeContextMetrics(msgs, null, noSummarized, isProtected);
279
+ expect(result.frontierGapTokens).toBe(0);
280
+ });
281
+
282
+ test("exact-value pin: frontierGapTokens equals Math.round(JSON.stringify(msg).length / 4) for a single toolResult", () => {
283
+ const result_msg = toolResult(300, "tc1", "bash", "a fixed output payload");
284
+ const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), result_msg];
285
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
286
+ expect(result.frontierGapTokens).toBe(Math.round(JSON.stringify(result_msg).length / 4));
287
+ });
288
+
289
+ test("frontierGapTokens: a toolCall id reused in a LATER turn (past the boundary) still counts its result — exclusion is positional, not id-global", () => {
290
+ const laterResult = toolResult(700, "tc1", "bash", "y".repeat(196)); // ~49 tokens
291
+ const msgs = [
292
+ userMsg(100),
293
+ assistantWithTools(200, ["tc1"]), // turn 0 — this is the boundary turn
294
+ toolResult(300, "tc1"), // boundary result — correctly excluded
295
+ assistantText(400), // closes turn 0's chain
296
+ userMsg(500),
297
+ assistantWithTools(600, ["tc1"]), // turn 1 — reuses bare id "tc1" (legal: ids are only unique per turn)
298
+ laterResult, // must be counted: it is positionally after the boundary
299
+ ];
300
+ const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 });
301
+ const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
302
+ const expected = Math.round(JSON.stringify(laterResult).length / 4);
303
+ expect(result.frontierGapTokens).toBe(expected);
304
+ });
305
+
306
+ test("frontierGapTokens: args pairing for a reused id uses the nearest preceding assistant's toolCall, not a global id map", () => {
307
+ const msgs = [
308
+ userMsg(100),
309
+ {
310
+ role: "assistant",
311
+ content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/normal.md" } }],
312
+ timestamp: 200,
313
+ usage: {},
314
+ stopReason: "toolUse",
315
+ }, // turn 0 — unprotected args
316
+ toolResult(300, "tc1", "read"),
317
+ assistantText(400),
318
+ userMsg(500),
319
+ {
320
+ role: "assistant",
321
+ content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }],
322
+ timestamp: 600,
323
+ usage: {},
324
+ stopReason: "toolUse",
325
+ }, // turn 1 — reuses bare id "tc1" with protected args
326
+ toolResult(700, "tc1", "read"),
327
+ ];
328
+ const isProtected = (toolName: string, args: unknown) =>
329
+ toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
330
+ const result = computeContextMetrics(msgs, null, noSummarized, isProtected);
331
+ // turn 0's result (unprotected args) counts; turn 1's result (protected args) is excluded.
332
+ const expected = Math.round(JSON.stringify(msgs[2]).length / 4);
333
+ expect(result.frontierGapTokens).toBe(expected);
334
+ });
335
+ });