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.
- package/CHANGELOG.md +20 -0
- package/PRUNING.md +138 -23
- package/README.md +17 -1
- package/index.ts +305 -116
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +168 -5
- package/src/commands.ts +44 -11
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +89 -10
package/src/commands.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|