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.
- package/CHANGELOG.md +10 -0
- package/PRUNING.md +27 -0
- package/README.md +11 -0
- package/index.ts +288 -109
- package/package.json +1 -1
- package/src/commands.test.ts +138 -4
- package/src/commands.ts +27 -9
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/types.ts +33 -0
package/src/commands.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|