pi-condense 2.5.0 → 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 +10 -0
- package/PRUNING.md +111 -23
- package/README.md +6 -1
- package/index.ts +23 -13
- 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 -10
- 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/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 -10
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,
|
|
@@ -57,25 +58,38 @@ class SettingsOverlay extends Container {
|
|
|
57
58
|
|
|
58
59
|
// ── Status widget text ──────────────────────────────────────────────────────
|
|
59
60
|
|
|
60
|
-
export function pruneStatusText(
|
|
61
|
+
export function pruneStatusText(
|
|
62
|
+
config: ContextPruneConfig,
|
|
63
|
+
reclaim?: LiveReclaim,
|
|
64
|
+
diagnostics?: Record<DiagnosticKind, number>,
|
|
65
|
+
): string {
|
|
61
66
|
if (!config.enabled) return "prune: OFF";
|
|
62
|
-
|
|
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}`;
|
|
63
76
|
const beforeTok = Math.round(reclaim.beforeChars / 4);
|
|
64
77
|
const afterTok = Math.round(reclaim.afterChars / 4);
|
|
65
78
|
const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
|
|
66
|
-
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)`;
|
|
79
|
+
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
export function setPruneStatusWidget(
|
|
70
83
|
ctx: { ui: { setStatus: (id: string, text?: string) => void } },
|
|
71
84
|
config: ContextPruneConfig,
|
|
72
85
|
value?: LiveReclaim | string,
|
|
86
|
+
diagnostics?: Record<DiagnosticKind, number>,
|
|
73
87
|
): void {
|
|
74
88
|
if (!config.showPruneStatusLine) {
|
|
75
89
|
ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
|
|
76
90
|
return;
|
|
77
91
|
}
|
|
78
|
-
const text = typeof value === "string" ? value : pruneStatusText(config, value);
|
|
92
|
+
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
|
|
79
93
|
// Leading-only separator: the footer joins extension status segments with a
|
|
80
94
|
// single space, so a trailing divider collides with the next segment's leading
|
|
81
95
|
// one and renders doubled. One leading bar yields single dividers between
|
|
@@ -455,6 +469,7 @@ export function registerCommands(
|
|
|
455
469
|
getLiveReclaim: () => LiveReclaim | undefined,
|
|
456
470
|
indexer: ToolCallIndexer,
|
|
457
471
|
compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
|
|
472
|
+
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
458
473
|
): void {
|
|
459
474
|
// Register the /pruner command
|
|
460
475
|
pi.registerCommand("pruner", {
|
|
@@ -801,7 +816,7 @@ export function registerCommands(
|
|
|
801
816
|
}
|
|
802
817
|
currentConfig.value = newConfig;
|
|
803
818
|
saveConfig(newConfig);
|
|
804
|
-
setPruneStatusWidget(ctx, newConfig, getLiveReclaim());
|
|
819
|
+
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
805
820
|
settingsList?.invalidate();
|
|
806
821
|
};
|
|
807
822
|
|
|
@@ -836,7 +851,7 @@ export function registerCommands(
|
|
|
836
851
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
837
852
|
saveConfig(currentConfig.value);
|
|
838
853
|
ctx.ui.notify("Context pruning enabled.");
|
|
839
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
|
|
854
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
840
855
|
break;
|
|
841
856
|
}
|
|
842
857
|
|
|
@@ -845,7 +860,7 @@ export function registerCommands(
|
|
|
845
860
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
846
861
|
saveConfig(currentConfig.value);
|
|
847
862
|
ctx.ui.notify("Context pruning disabled.");
|
|
848
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
|
|
863
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
849
864
|
break;
|
|
850
865
|
}
|
|
851
866
|
|
|
@@ -964,7 +979,7 @@ export function registerCommands(
|
|
|
964
979
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
965
980
|
}
|
|
966
981
|
saveConfig(currentConfig.value);
|
|
967
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
|
|
982
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
968
983
|
break;
|
|
969
984
|
}
|
|
970
985
|
|
|
@@ -1011,7 +1026,7 @@ export function registerCommands(
|
|
|
1011
1026
|
// tool-result savings; but assistant-message savings (thinking + toolCall args + text)
|
|
1012
1027
|
// are not counted at all, so the two errors partly cancel. Treat as a rough proxy.
|
|
1013
1028
|
const droppedChars = compressedEntries.reduce((total, entry) => {
|
|
1014
|
-
const records = indexer.lookupToolCalls(entry.droppedToolCallIds);
|
|
1029
|
+
const records = indexer.lookupToolCalls(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
|
|
1015
1030
|
return total + records.reduce((s, r) => s + r.resultText.length, 0);
|
|
1016
1031
|
}, 0);
|
|
1017
1032
|
const reclaimedTokens = Math.ceil(droppedChars / 4);
|
|
@@ -1061,7 +1076,7 @@ export function registerCommands(
|
|
|
1061
1076
|
|
|
1062
1077
|
// Remove the widget and restore the normal footer status.
|
|
1063
1078
|
clearWidget();
|
|
1064
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim());
|
|
1079
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1065
1080
|
|
|
1066
1081
|
if (!result.ok) {
|
|
1067
1082
|
const suffix = "error" in result && result.error ? ` (${result.error})` : "";
|
|
@@ -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
|
@@ -85,6 +85,7 @@ describe("PruneFrontierTracker.reconstructFromSession", () => {
|
|
|
85
85
|
|
|
86
86
|
const indexer = {
|
|
87
87
|
isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
88
|
+
hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
88
89
|
getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
|
|
89
90
|
getRecord: () => undefined,
|
|
90
91
|
getChainEntries: () => [chainEntry],
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import { pruneMessages } from "./pruner.js";
|
|
4
|
+
import { detectChains } from "./chain-detector.js";
|
|
5
|
+
import { compressEligible } from "./chain-compressor.js";
|
|
6
|
+
import { captureUnindexedBatchesFromSession } from "./batch-capture.js";
|
|
7
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
8
|
+
import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX, CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
9
|
+
import type { CapturedBatch } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const user = (ts: number, text: string) => ({ role: "user", content: [{ type: "text", text }], timestamp: ts });
|
|
12
|
+
const callTurn = (ts: number, ids: string[]) => ({
|
|
13
|
+
role: "assistant",
|
|
14
|
+
content: ids.map((id) => ({ type: "toolCall", id, name: "bash", input: { cmd: id } })),
|
|
15
|
+
timestamp: ts,
|
|
16
|
+
});
|
|
17
|
+
const result = (ts: number, id: string, text: string) => ({
|
|
18
|
+
role: "toolResult",
|
|
19
|
+
toolCallId: id,
|
|
20
|
+
toolName: "bash",
|
|
21
|
+
content: [{ type: "text", text }],
|
|
22
|
+
isError: false,
|
|
23
|
+
timestamp: ts,
|
|
24
|
+
});
|
|
25
|
+
const finalTurn = (ts: number, text: string) => ({ role: "assistant", content: [{ type: "text", text }], timestamp: ts });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The incident shape: two closeable chains, then a live turn reusing bash_23.
|
|
29
|
+
* Pre-fix, the session-wide id-set drop deleted the live `bash_23` assistant
|
|
30
|
+
* turn and left `LIVE 24` (`gauntlet_setting_24`) orphaned, which the
|
|
31
|
+
* provider rejects with a 400.
|
|
32
|
+
*/
|
|
33
|
+
const buildSession = () => [
|
|
34
|
+
user(1000, "1"),
|
|
35
|
+
callTurn(1100, ["bash_18"]),
|
|
36
|
+
result(1150, "bash_18", "OUT 18"),
|
|
37
|
+
finalTurn(1200, "done 1"),
|
|
38
|
+
user(2000, "2"),
|
|
39
|
+
callTurn(2100, ["bash_23"]),
|
|
40
|
+
result(2150, "bash_23", "OUT 23 first"),
|
|
41
|
+
finalTurn(2200, "done 2"),
|
|
42
|
+
user(3000, "3"),
|
|
43
|
+
callTurn(3100, ["bash_23", "gauntlet_setting_24"]),
|
|
44
|
+
result(3150, "bash_23", "LIVE 23"),
|
|
45
|
+
result(3160, "gauntlet_setting_24", "LIVE 24"),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const batchFor = (turnIndex: number, ts: number, id: string, resultTs: number, text: string): CapturedBatch => ({
|
|
49
|
+
turnIndex,
|
|
50
|
+
timestamp: ts,
|
|
51
|
+
assistantText: "",
|
|
52
|
+
toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: id }, resultText: text, isError: false, resultTimestamp: resultTs }],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Indexes + summarizes the two older batches, then compresses their chains.
|
|
57
|
+
* Mirrors index.ts's live-flush ordering and keying exactly: allocate refs ->
|
|
58
|
+
* send/append the summary -> registerSummaryRefs -> addBatch ->
|
|
59
|
+
* registerSummaryBody(<keys>). `keyer` controls the shape of the ids passed
|
|
60
|
+
* to registerSummaryBody so both the production (bare-id) bug and the fixed
|
|
61
|
+
* (occurrence-key) contract can be exercised with the same helper.
|
|
62
|
+
*/
|
|
63
|
+
const primeIndexer = async (
|
|
64
|
+
messages: any[],
|
|
65
|
+
keyer: (r: { toolCallId: string; resultTimestamp?: number }) => string = (r) =>
|
|
66
|
+
`${r.toolCallId}@${r.resultTimestamp}`,
|
|
67
|
+
) => {
|
|
68
|
+
const appended: Array<{ type: string; data: any }> = [];
|
|
69
|
+
const indexer = new ToolCallIndexer();
|
|
70
|
+
const append = (type: string, data?: unknown) => appended.push({ type, data });
|
|
71
|
+
const refsByToolCallId = new Map<string, import("./types.js").SummaryToolCallRef>();
|
|
72
|
+
|
|
73
|
+
for (const [turnIndex, spec] of [
|
|
74
|
+
[0, { id: "bash_18", ts: 1000, resultTs: 1150, text: "OUT 18" }],
|
|
75
|
+
[1, { id: "bash_23", ts: 2000, resultTs: 2150, text: "OUT 23 first" }],
|
|
76
|
+
] as const) {
|
|
77
|
+
const batch = batchFor(turnIndex, spec.ts, spec.id, spec.resultTs, spec.text);
|
|
78
|
+
const refs = indexer.allocateSummaryRefs(batch);
|
|
79
|
+
// (send/append the summary message here in the real flush; no-op for this harness)
|
|
80
|
+
indexer.registerSummaryRefs(refs);
|
|
81
|
+
indexer.addBatch(batch, append);
|
|
82
|
+
indexer.registerSummaryBody(refs.map(keyer), `summary of ${spec.id}`);
|
|
83
|
+
for (const ref of refs) refsByToolCallId.set(ref.toolCallId, ref);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let nextBlock = 1;
|
|
87
|
+
await compressEligible(detectChains(messages), 0, {
|
|
88
|
+
indexer,
|
|
89
|
+
blockRefs: { issue: () => `b${nextBlock++}` } as any,
|
|
90
|
+
appendEntry: append,
|
|
91
|
+
now: () => 9000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { indexer, appended, refsByToolCallId };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const chainConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false } as any;
|
|
98
|
+
const syntheticsOf = (messages: any[]) =>
|
|
99
|
+
messages.filter((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith("<compressed-chain"));
|
|
100
|
+
|
|
101
|
+
describe("id collision, end to end", () => {
|
|
102
|
+
test("render keeps the live turn, drops both chain interiors, leaves no orphan", async () => {
|
|
103
|
+
const messages = buildSession();
|
|
104
|
+
const { indexer } = await primeIndexer(messages);
|
|
105
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
106
|
+
|
|
107
|
+
expect(out.pruned).toBe(true);
|
|
108
|
+
// live turn intact, both results verbatim. Synthetics also carry
|
|
109
|
+
// timestamp=compressedAt(9000) here, so they must be excluded from this
|
|
110
|
+
// filter or they'd inflate the count - see chain-range-prune.test.ts's
|
|
111
|
+
// "compressedAt kept below 3000" comment for the same caveat.
|
|
112
|
+
const liveReal = out.messages.filter(
|
|
113
|
+
(m: any) => m.timestamp >= 3000 && !(m.content?.[0]?.text ?? "").startsWith("<compressed-chain"),
|
|
114
|
+
);
|
|
115
|
+
expect(liveReal).toHaveLength(4);
|
|
116
|
+
expect(out.messages.find((m: any) => m.timestamp === 3150).content[0].text).toBe("LIVE 23");
|
|
117
|
+
expect(out.messages.find((m: any) => m.timestamp === 3160).content[0].text).toBe("LIVE 24");
|
|
118
|
+
// both chain interiors gone, one synthetic each, bodies non-empty
|
|
119
|
+
expect(out.messages.some((m: any) => m.timestamp === 1150 || m.timestamp === 2150)).toBe(false);
|
|
120
|
+
const synthetics = syntheticsOf(out.messages);
|
|
121
|
+
expect(synthetics).toHaveLength(2);
|
|
122
|
+
const bash18Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_18"));
|
|
123
|
+
const bash23Synthetic = synthetics.find((s: any) => s.content[0].text.includes("summary of bash_23"));
|
|
124
|
+
expect(bash18Synthetic).toBeDefined();
|
|
125
|
+
expect(bash23Synthetic).toBeDefined();
|
|
126
|
+
expect(bash18Synthetic).not.toBe(bash23Synthetic);
|
|
127
|
+
expect(bash18Synthetic.content[0].text).not.toContain("summary of bash_23");
|
|
128
|
+
expect(bash23Synthetic.content[0].text).not.toContain("summary of bash_18");
|
|
129
|
+
expectNoOrphanToolResults(out.messages);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Regression for the live-flush bug (ref #8, index.ts registerSummaryBody
|
|
133
|
+
// call): production must key registerSummaryBody with the occurrence key
|
|
134
|
+
// (`id@resultTimestamp`), because hasPerBatchSummaryCoveringAny /
|
|
135
|
+
// getPerBatchSummariesForToolCallIds are always queried with occurrence
|
|
136
|
+
// keys (src/chain-compressor.ts's `lookupKeys`). Bare ids (`tc.toolCallId`)
|
|
137
|
+
// silently mismatch and every chain is skipped as "no-summary" - it only
|
|
138
|
+
// appears to work after a restart because reconstructFromSession rebuilds
|
|
139
|
+
// bodies from summary refs, which DO carry resultTimestamp. The default
|
|
140
|
+
// `keyer` on primeIndexer above pins the correct (occurrence-key) shape;
|
|
141
|
+
// this test pins the failure mode of the bare-id shape as a contrast.
|
|
142
|
+
test("live-flush occurrence-key contract: chains compress with non-empty, per-chain-distinct bodies", async () => {
|
|
143
|
+
const messages = buildSession();
|
|
144
|
+
const { indexer } = await primeIndexer(messages); // default keyer = occurrence key, i.e. the fixed index.ts contract
|
|
145
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
146
|
+
|
|
147
|
+
const synthetics = syntheticsOf(out.messages);
|
|
148
|
+
expect(synthetics).toHaveLength(2);
|
|
149
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true);
|
|
150
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true);
|
|
151
|
+
expectNoOrphanToolResults(out.messages);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("live-flush bare-id keying bug: chains are skipped as no-summary, no synthetics emitted", async () => {
|
|
155
|
+
const messages = buildSession();
|
|
156
|
+
// Mirrors the production BUG exactly: `tc.toolCallId` with no resultTimestamp,
|
|
157
|
+
// matching index.ts's pre-fix `batch.toolCalls.map((tc) => tc.toolCallId)`.
|
|
158
|
+
const { indexer } = await primeIndexer(messages, (r) => r.toolCallId);
|
|
159
|
+
const out = pruneMessages(messages, indexer, chainConfig);
|
|
160
|
+
|
|
161
|
+
const synthetics = syntheticsOf(out.messages);
|
|
162
|
+
expect(synthetics).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("re-rendering the same session is deep-equal", async () => {
|
|
166
|
+
const messages = buildSession();
|
|
167
|
+
const { indexer } = await primeIndexer(messages);
|
|
168
|
+
const first = pruneMessages(messages, indexer, chainConfig);
|
|
169
|
+
const second = pruneMessages(first.messages, indexer, chainConfig);
|
|
170
|
+
expect(second.messages).toEqual(first.messages);
|
|
171
|
+
expectNoOrphanToolResults(second.messages);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("G4/C2: a live collision batch is captured (not filtered as summarized) and separately addressable after summarization", () => {
|
|
175
|
+
// Prime the indexer with an already-summarized bash_23 occurrence.
|
|
176
|
+
const indexer = new ToolCallIndexer();
|
|
177
|
+
indexer.addBatch(
|
|
178
|
+
{
|
|
179
|
+
turnIndex: 0,
|
|
180
|
+
timestamp: 2000,
|
|
181
|
+
assistantText: "",
|
|
182
|
+
toolCalls: [{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "OUT 23 first", isError: false, resultTimestamp: 2150 }],
|
|
183
|
+
},
|
|
184
|
+
() => {},
|
|
185
|
+
);
|
|
186
|
+
expect(indexer.isSummarized("bash_23@2150")).toBe(true);
|
|
187
|
+
|
|
188
|
+
// A NEW live occurrence of the same bare id, at a later resultTimestamp,
|
|
189
|
+
// not yet in the index.
|
|
190
|
+
const branch = [
|
|
191
|
+
{ type: "message", message: user(3000, "3") },
|
|
192
|
+
{ type: "message", message: callTurn(3100, ["bash_23"]) },
|
|
193
|
+
{ type: "message", message: result(3150, "bash_23", "LIVE 23") },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const batches = captureUnindexedBatchesFromSession(branch, indexer);
|
|
197
|
+
// The live occurrence must be captured, NOT skipped as already-summarized -
|
|
198
|
+
// isSummarized is asked with the occurrence key (bash_23@3150), which is
|
|
199
|
+
// distinct from the primed bash_23@2150.
|
|
200
|
+
expect(batches).toHaveLength(1);
|
|
201
|
+
expect(batches[0].toolCalls).toHaveLength(1);
|
|
202
|
+
expect(batches[0].toolCalls[0].toolCallId).toBe("bash_23");
|
|
203
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(3150);
|
|
204
|
+
expect(batches[0].toolCalls[0].resultText).toBe("LIVE 23");
|
|
205
|
+
|
|
206
|
+
// Capture it into the index (mirrors a successful summarization flush) and
|
|
207
|
+
// confirm both occurrences remain separately addressable.
|
|
208
|
+
indexer.addBatch(batches[0], () => {});
|
|
209
|
+
expect(indexer.getRecord("bash_23@2150")?.resultText).toBe("OUT 23 first");
|
|
210
|
+
expect(indexer.getRecord("bash_23@3150")?.resultText).toBe("LIVE 23");
|
|
211
|
+
expect(indexer.isSummarized("bash_23@3150")).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a restart-shaped rebuild reproduces both tN refs and non-empty synthetics", async () => {
|
|
215
|
+
const messages = buildSession();
|
|
216
|
+
const { appended, refsByToolCallId } = await primeIndexer(messages);
|
|
217
|
+
|
|
218
|
+
// Replay only what the session would hold: index, summary and chain entries.
|
|
219
|
+
const branch: any[] = [];
|
|
220
|
+
for (const { type, data } of appended) {
|
|
221
|
+
if (type === CUSTOM_TYPE_INDEX) branch.push({ type: "custom", customType: CUSTOM_TYPE_INDEX, data });
|
|
222
|
+
if (type === CUSTOM_TYPE_CHAIN) branch.push({ type: "custom", customType: CUSTOM_TYPE_CHAIN, data });
|
|
223
|
+
}
|
|
224
|
+
// Use the refs the flush actually allocated (via allocateSummaryRefs), not
|
|
225
|
+
// hand-picked shortIds - this proves the restart replay honors whatever
|
|
226
|
+
// numbering the flush produced instead of assuming t1/t2.
|
|
227
|
+
for (const id of ["bash_18", "bash_23"]) {
|
|
228
|
+
const ref = refsByToolCallId.get(id);
|
|
229
|
+
if (!ref) throw new Error(`primeIndexer did not allocate a ref for ${id}`);
|
|
230
|
+
branch.push({
|
|
231
|
+
type: "custom_message",
|
|
232
|
+
customType: CUSTOM_TYPE_SUMMARY,
|
|
233
|
+
content: `summary of ${id}`,
|
|
234
|
+
details: { toolCallRefs: [ref] },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const [ref18, ref23] = [refsByToolCallId.get("bash_18")!, refsByToolCallId.get("bash_23")!];
|
|
239
|
+
const rebuilt = new ToolCallIndexer();
|
|
240
|
+
rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any);
|
|
241
|
+
expect(rebuilt.getRecord(ref18.shortId)?.resultText).toBe("OUT 18");
|
|
242
|
+
expect(rebuilt.getRecord(ref23.shortId)?.resultText).toBe("OUT 23 first");
|
|
243
|
+
|
|
244
|
+
const out = pruneMessages(buildSession(), rebuilt, chainConfig);
|
|
245
|
+
const synthetics = syntheticsOf(out.messages);
|
|
246
|
+
expect(synthetics).toHaveLength(2);
|
|
247
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_18"))).toBe(true);
|
|
248
|
+
expect(synthetics.some((s: any) => s.content[0].text.includes("summary of bash_23"))).toBe(true);
|
|
249
|
+
expectNoOrphanToolResults(out.messages);
|
|
250
|
+
});
|
|
251
|
+
});
|