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.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/PRUNING.md +111 -23
  3. package/README.md +6 -1
  4. package/index.ts +23 -13
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +31 -2
  15. package/src/commands.ts +25 -10
  16. package/src/diagnostics.test.ts +114 -0
  17. package/src/diagnostics.ts +46 -0
  18. package/src/frontier.test.ts +1 -0
  19. package/src/id-collision.integration.test.ts +251 -0
  20. package/src/indexer.test.ts +336 -0
  21. package/src/indexer.ts +168 -55
  22. package/src/occurrence-key.test.ts +57 -0
  23. package/src/occurrence-key.ts +36 -0
  24. package/src/orphan-sweep.test.ts +67 -0
  25. package/src/orphan-sweep.ts +40 -0
  26. package/src/oversized-spill.integration.test.ts +7 -2
  27. package/src/pruner.test.ts +456 -25
  28. package/src/pruner.ts +84 -36
  29. package/src/query-tool.test.ts +117 -0
  30. package/src/query-tool.ts +47 -31
  31. package/src/range-compression.integration.test.ts +6 -1
  32. package/src/recovery-grace.test.ts +13 -0
  33. package/src/recovery-grace.ts +12 -3
  34. package/src/spill.test.ts +108 -1
  35. package/src/spill.ts +5 -3
  36. package/src/summary-refs.test.ts +51 -1
  37. package/src/summary-refs.ts +15 -4
  38. package/src/test-support.ts +54 -0
  39. package/src/tree-browser.ts +2 -1
  40. package/src/types.ts +56 -10
@@ -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(config: ContextPruneConfig, value?: Parameters<typeof setPruneStatusWidget>[2]): string | undefined {
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(config: ContextPruneConfig, reclaim?: LiveReclaim): string {
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
- if (!reclaim || reclaim.beforeChars <= 0) return "prune: ON";
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
+ }
@@ -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
+ });