pi-condense 2.6.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.
@@ -1,6 +1,7 @@
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 =>
@@ -10,12 +11,119 @@ function captureStatus(
10
11
  config: ContextPruneConfig,
11
12
  value?: Parameters<typeof setPruneStatusWidget>[2],
12
13
  diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
14
+ metrics?: Parameters<typeof setPruneStatusWidget>[4],
13
15
  ): string | undefined {
14
16
  let captured: string | undefined;
15
- setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
17
+ setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics, metrics);
16
18
  return captured;
17
19
  }
18
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
+
19
127
  describe("pruneStatusText", () => {
20
128
  it("disabled config -> 'prune: OFF'", () => {
21
129
  expect(pruneStatusText(cfg(false))).toBe("prune: OFF");
@@ -94,3 +202,29 @@ describe("diagnostic counters on the status line", () => {
94
202
  );
95
203
  });
96
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
@@ -6,6 +6,7 @@ import {
6
6
  type ChainCompressionEntry,
7
7
  type FlushOptions,
8
8
  type DiagnosticKind,
9
+ type ContextMetricsSnapshot,
9
10
  PRUNE_ON_MODES,
10
11
  BATCHING_MODES,
11
12
  STATUS_WIDGET_ID,
@@ -62,6 +63,7 @@ export function pruneStatusText(
62
63
  config: ContextPruneConfig,
63
64
  reclaim?: LiveReclaim,
64
65
  diagnostics?: Record<DiagnosticKind, number>,
66
+ metrics?: ContextMetricsSnapshot,
65
67
  ): string {
66
68
  if (!config.enabled) return "prune: OFF";
67
69
  const diag = diagnostics
@@ -72,11 +74,14 @@ export function pruneStatusText(
72
74
  ].filter(Boolean)
73
75
  : [];
74
76
  const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
75
- if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
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}`;
76
81
  const beforeTok = Math.round(reclaim.beforeChars / 4);
77
82
  const afterTok = Math.round(reclaim.afterChars / 4);
78
83
  const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
79
- return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
84
+ return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}${metricsSuffix}`;
80
85
  }
81
86
 
82
87
  export function setPruneStatusWidget(
@@ -84,12 +89,13 @@ export function setPruneStatusWidget(
84
89
  config: ContextPruneConfig,
85
90
  value?: LiveReclaim | string,
86
91
  diagnostics?: Record<DiagnosticKind, number>,
92
+ metrics?: ContextMetricsSnapshot,
87
93
  ): void {
88
94
  if (!config.showPruneStatusLine) {
89
95
  ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
90
96
  return;
91
97
  }
92
- const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
98
+ const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics, metrics);
93
99
  // Leading-only separator: the footer joins extension status segments with a
94
100
  // single space, so a trailing divider collides with the next segment's leading
95
101
  // one and renders doubled. One leading bar yields single dividers between
@@ -470,6 +476,9 @@ export function registerCommands(
470
476
  indexer: ToolCallIndexer,
471
477
  compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
472
478
  getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
479
+ getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
480
+ getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
481
+ getRearmed?: () => boolean,
473
482
  ): void {
474
483
  // Register the /pruner command
475
484
  pi.registerCommand("pruner", {
@@ -816,7 +825,7 @@ export function registerCommands(
816
825
  }
817
826
  currentConfig.value = newConfig;
818
827
  saveConfig(newConfig);
819
- setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
828
+ setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
820
829
  settingsList?.invalidate();
821
830
  };
822
831
 
@@ -851,7 +860,7 @@ export function registerCommands(
851
860
  currentConfig.value = { ...currentConfig.value, enabled: true };
852
861
  saveConfig(currentConfig.value);
853
862
  ctx.ui.notify("Context pruning enabled.");
854
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
863
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
855
864
  break;
856
865
  }
857
866
 
@@ -860,7 +869,7 @@ export function registerCommands(
860
869
  currentConfig.value = { ...currentConfig.value, enabled: false };
861
870
  saveConfig(currentConfig.value);
862
871
  ctx.ui.notify("Context pruning disabled.");
863
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
872
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
864
873
  break;
865
874
  }
866
875
 
@@ -873,8 +882,12 @@ export function registerCommands(
873
882
  ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}`
874
883
  : "\n (no summarizer calls yet)";
875
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
+ : "";
876
889
  ctx.ui.notify(
877
- `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}`,
878
891
  );
879
892
  break;
880
893
  }
@@ -979,7 +992,7 @@ export function registerCommands(
979
992
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
980
993
  }
981
994
  saveConfig(currentConfig.value);
982
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
995
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
983
996
  break;
984
997
  }
985
998
 
@@ -1052,6 +1065,11 @@ export function registerCommands(
1052
1065
  const batches = capturePendingBatches(ctx);
1053
1066
  if (batches.length === 0) {
1054
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" });
1055
1073
  break;
1056
1074
  }
1057
1075
 
@@ -1076,7 +1094,7 @@ export function registerCommands(
1076
1094
 
1077
1095
  // Remove the widget and restore the normal footer status.
1078
1096
  clearWidget();
1079
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
1097
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
1080
1098
 
1081
1099
  if (!result.ok) {
1082
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
+ });