pi-condense 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PRUNING.md +138 -23
  3. package/README.md +17 -1
  4. package/index.ts +305 -116
  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 +168 -5
  15. package/src/commands.ts +44 -11
  16. package/src/context-metrics.test.ts +335 -0
  17. package/src/context-metrics.ts +152 -0
  18. package/src/diagnostics.test.ts +114 -0
  19. package/src/diagnostics.ts +46 -0
  20. package/src/frontier.test.ts +1 -0
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +456 -25
  30. package/src/pruner.ts +84 -36
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +6 -1
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/reload-rearm.integration.test.ts +647 -0
  37. package/src/spill.test.ts +108 -1
  38. package/src/spill.ts +5 -3
  39. package/src/summarizer-wiring.test.ts +2 -0
  40. package/src/summary-refs.test.ts +51 -1
  41. package/src/summary-refs.ts +15 -4
  42. package/src/test-support.ts +54 -0
  43. package/src/tree-browser.ts +2 -1
  44. package/src/types.ts +89 -10
@@ -0,0 +1,152 @@
1
+ import { detectChains } from "./chain-detector.js";
2
+ import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
+ import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
4
+
5
+ function charsOf(msg: any): number {
6
+ return JSON.stringify(msg).length;
7
+ }
8
+
9
+ function tokensOf(msg: any): number {
10
+ return Math.round(charsOf(msg) / 4);
11
+ }
12
+
13
+ function isTextOnlyAssistant(msg: any): boolean {
14
+ if (msg.role !== "assistant") return false;
15
+ if (!Array.isArray(msg.content)) return true;
16
+ return !msg.content.some((b: any) => b.type === "toolCall");
17
+ }
18
+
19
+ function toolCallBlocksOf(msg: any): { id: string; input?: unknown; arguments?: unknown }[] {
20
+ if (!Array.isArray(msg.content)) return [];
21
+ return msg.content.filter((b: any) => b.type === "toolCall");
22
+ }
23
+
24
+ function findArgsForToolCallId(branch: any[], resultIdx: number, toolCallId: string): unknown {
25
+ for (let i = resultIdx - 1; i >= 0; i--) {
26
+ const m = branch[i];
27
+ if (m.role !== "assistant") continue;
28
+ const call = toolCallBlocksOf(m).find((c) => c.id === toolCallId);
29
+ if (call) return (call as any).input ?? (call as any).arguments;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * Pure snapshot of what the pruner cannot (yet) reclaim: thinking tokens
36
+ * trapped in the trailing open cycle, the largest single chain's share of
37
+ * the branch, and unsummarized toolResult tokens past the prune frontier.
38
+ */
39
+ export function computeContextMetrics(
40
+ branch: any[],
41
+ frontier: PruneFrontier | null,
42
+ isSummarized: (occurrenceKey: string) => boolean,
43
+ isProtected: (toolName: string, args: unknown) => boolean,
44
+ ): ContextMetricsSnapshot {
45
+ if (branch.length === 0) {
46
+ return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
47
+ }
48
+
49
+ // ── Open segment: strictly after the last text-only assistant ──────────
50
+ let lastTextOnlyIdx = -1;
51
+ for (let i = 0; i < branch.length; i++) {
52
+ if (isTextOnlyAssistant(branch[i])) lastTextOnlyIdx = i;
53
+ }
54
+ const openStart = lastTextOnlyIdx + 1;
55
+
56
+ let thinkingChars = 0;
57
+ for (let i = openStart; i < branch.length; i++) {
58
+ const m = branch[i];
59
+ if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
60
+ for (const block of m.content) {
61
+ if (block.type === "thinking") thinkingChars += JSON.stringify(block).length;
62
+ }
63
+ }
64
+ const openCycleThinkingTokens = Math.round(thinkingChars / 4);
65
+
66
+ // ── Largest chain share ─────────────────────────────────────────────────
67
+ const branchChars = branch.map(charsOf);
68
+ const sumChars = (start: number, end: number): number => {
69
+ let sum = 0;
70
+ for (let i = start; i <= end; i++) sum += branchChars[i];
71
+ return sum;
72
+ };
73
+ const totalChars = branchChars.reduce((a, b) => a + b, 0);
74
+
75
+ const chains = detectChains(branch, isProtected);
76
+ let largestClosedChainChars = 0;
77
+ for (const range of chains) {
78
+ const startIdx = branch.findIndex((m) => m.role === "user" && m.timestamp === range.startUserTimestamp);
79
+ if (startIdx === -1) continue;
80
+ let endIdx: number;
81
+ if (range.finalAssistantTimestamp !== null) {
82
+ endIdx = branch.findIndex(
83
+ (m, i) => i >= startIdx && m.role === "assistant" && m.timestamp === range.finalAssistantTimestamp,
84
+ );
85
+ if (endIdx === -1) continue;
86
+ } else {
87
+ let nextUserIdx = -1;
88
+ for (let i = startIdx + 1; i < branch.length; i++) {
89
+ if (branch[i].role === "user") {
90
+ nextUserIdx = i;
91
+ break;
92
+ }
93
+ }
94
+ endIdx = nextUserIdx === -1 ? branch.length - 1 : nextUserIdx - 1;
95
+ }
96
+ if (endIdx < startIdx) continue;
97
+ const chainChars = sumChars(startIdx, endIdx);
98
+ if (chainChars > largestClosedChainChars) largestClosedChainChars = chainChars;
99
+ }
100
+
101
+ const openSegmentChars = openStart < branch.length ? sumChars(openStart, branch.length - 1) : 0;
102
+ const numerator = Math.max(largestClosedChainChars, openSegmentChars);
103
+ const largestChainSharePct = totalChars === 0 ? 0 : Math.round((100 * numerator) / totalChars);
104
+
105
+ // ── Frontier gap ─────────────────────────────────────────────────────────
106
+ // Exclusion is positional: only toolResults belonging to the boundary turn's
107
+ // own calls (up to and including the last-attempted call) are excluded.
108
+ // Ids are only unique per turn (see occurrence-key.ts), so a later turn may
109
+ // legally reuse a bare id — its result must still count toward the gap.
110
+ let boundaryIdx = -1;
111
+ let boundaryTurnEndIdx = branch.length;
112
+ const boundaryExcludedIds = new Set<string>();
113
+ if (frontier) {
114
+ let counter = 0;
115
+ for (let i = 0; i < branch.length; i++) {
116
+ const m = branch[i];
117
+ if (m.role !== "assistant") continue;
118
+ const turnIdx = counter;
119
+ counter++;
120
+ if (turnIdx === frontier.lastAttemptedTurnIndex) {
121
+ const calls = toolCallBlocksOf(m);
122
+ const k = calls.findIndex((c) => c.id === frontier.lastAttemptedToolCallId);
123
+ if (k !== -1) {
124
+ boundaryIdx = i;
125
+ for (let j = 0; j <= k; j++) boundaryExcludedIds.add(calls[j].id);
126
+ for (let j = i + 1; j < branch.length; j++) {
127
+ if (branch[j].role === "assistant") {
128
+ boundaryTurnEndIdx = j;
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ break;
134
+ }
135
+ }
136
+ }
137
+
138
+ let frontierGapTokens = 0;
139
+ const scanStart = boundaryIdx === -1 ? 0 : boundaryIdx + 1;
140
+ for (let i = scanStart; i < branch.length; i++) {
141
+ const m = branch[i];
142
+ if (m.role !== "toolResult") continue;
143
+ if (i < boundaryTurnEndIdx && boundaryExcludedIds.has(m.toolCallId)) continue;
144
+ const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp));
145
+ if (isSummarized(key)) continue;
146
+ const args = findArgsForToolCallId(branch, i, m.toolCallId);
147
+ if (isProtected(m.toolName, args)) continue;
148
+ frontierGapTokens += tokensOf(m);
149
+ }
150
+
151
+ return { openCycleThinkingTokens, largestChainSharePct, frontierGapTokens };
152
+ }
@@ -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
+ });