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