pi-condense 2.7.0 → 2.9.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.
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, it, test } from "bun:test";
2
- import { selectEligible, compressEligible } from "./chain-compressor.js";
2
+ import { selectEligible, compressEligible, extractChainRecords, buildDeterministicBody } from "./chain-compressor.js";
3
3
  import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
4
- import type { ChainRange, ChainCompressionEntry } from "./types.js";
4
+ import type { ChainRange, ChainCompressionEntry, ToolCallRecord } from "./types.js";
5
5
  import { CUSTOM_TYPE_CHAIN } from "./types.js";
6
6
  import { detectChains } from "./chain-detector.js";
7
7
  import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
8
+ import { occKey } from "./occurrence-key.js";
8
9
 
9
10
  function closed(startUserTimestamp: number, toolCallIds: string[] = [`tc-${startUserTimestamp}`]): ChainRange {
10
11
  return { startUserTimestamp, middleToolCallIds: toolCallIds, finalAssistantTimestamp: startUserTimestamp + 100 };
@@ -102,6 +103,8 @@ describe("compressEligible", () => {
102
103
  getPerBatchSummariesForToolCallIds: (_ids: string[]) => opts.perBatchSummaries ?? [],
103
104
  getToolRefsForToolCallIds: (_ids: string[]) => opts.toolRefs ?? [],
104
105
  registerChain: (_entry: ChainCompressionEntry) => {},
106
+ getIndex: () => new Map(),
107
+ backfillChainRecords: async () => [{ shortId: "t1", toolCallId: "c1", resultTimestamp: 1050 }],
105
108
  } satisfies ChainCompressorIndexerDeps;
106
109
  }
107
110
 
@@ -110,6 +113,15 @@ describe("compressEligible", () => {
110
113
  return { issue: () => ids[i++] ?? `b${i}` } satisfies Pick<import("./block-refs.js").BlockRefIssuer, "issue">;
111
114
  }
112
115
 
116
+ // Covered-path fixtures never take the deterministic branch (hasSummary
117
+ // defaults true), so these three fields are unused at runtime - but
118
+ // CompressEligibleDeps requires them, so the fixture supplies inert defaults.
119
+ const NOOP_BACKFILL_DEPS = {
120
+ messages: [] as any[],
121
+ diagnostics: { report: () => {} },
122
+ backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" },
123
+ };
124
+
113
125
  test("compresses eligible chains and returns entries", async () => {
114
126
  const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
115
127
  const appended: unknown[] = [];
@@ -118,6 +130,7 @@ describe("compressEligible", () => {
118
130
  blockRefs: makeBlockRefs(["b1"]),
119
131
  appendEntry: (_type, data) => appended.push(data),
120
132
  now: () => 9999,
133
+ ...NOOP_BACKFILL_DEPS,
121
134
  });
122
135
  expect(result.compressedEntries).toHaveLength(1);
123
136
  expect(result.compressedEntries[0].blockId).toBe("b1");
@@ -126,17 +139,59 @@ describe("compressEligible", () => {
126
139
  expect(appended).toHaveLength(1);
127
140
  });
128
141
 
129
- test("skips chain with no summary and records reason", async () => {
142
+ test("covered-path chain entry pinned shape (identity pin, pre-deterministic-branch)", async () => {
143
+ // Pins the FULL entry shape produced by the existing covered path before
144
+ // the deterministic zero-LLM branch is introduced. Must stay byte-identical.
145
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
146
+ const appended: unknown[] = [];
147
+ const result = await compressEligible(chains, 3, {
148
+ indexer: makeIndexer({ hasSummary: true, toolRefs: ["t1"] }),
149
+ blockRefs: makeBlockRefs(["b1"]),
150
+ appendEntry: (_type, data) => appended.push(data),
151
+ now: () => 42,
152
+ });
153
+ expect(result.compressedEntries).toHaveLength(1);
154
+ const entry = result.compressedEntries[0];
155
+ expect(entry).toEqual({
156
+ blockId: "b1",
157
+ startUserTimestamp: 100,
158
+ droppedToolCallIds: ["tc1"],
159
+ finalAssistantTimestamp: 200,
160
+ toolRefs: ["t1"],
161
+ compressedAt: 42,
162
+ });
163
+ expect(entry.bodySource).toBeUndefined();
164
+ expect(appended[0]).toEqual(entry);
165
+ });
166
+
167
+ test("no coverage -> deterministic compression, not a permanent skip", async () => {
168
+ // Was: "skips chain with no summary and records reason". Per spec
169
+ // 2026-08-14, zero coverage now routes to the deterministic zero-LLM
170
+ // branch instead of a terminal skip.
171
+ const chainMessages = [
172
+ { role: "user", timestamp: 100, content: [{ type: "text", text: "u" }] },
173
+ { role: "assistant", timestamp: 101, content: [{ type: "toolCall", id: "tc1", name: "bash", input: { cmd: "a" } }] },
174
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", timestamp: 150, isError: false, content: [{ type: "text", text: "out" }] },
175
+ { role: "assistant", timestamp: 200, content: [{ type: "text", text: "done" }] },
176
+ ];
130
177
  const chains = [closed(100, ["tc1"]), closed(300, ["tc2"]), closed(500), closed(700)];
131
178
  const result = await compressEligible(chains, 3, {
132
- indexer: makeIndexer({ hasSummary: false }),
179
+ indexer: {
180
+ ...makeIndexer({ hasSummary: false }),
181
+ getIndex: () => new Map(),
182
+ backfillChainRecords: async () => [{ shortId: "t1", toolCallId: "tc1", resultTimestamp: 1050 }],
183
+ },
133
184
  blockRefs: makeBlockRefs(),
134
185
  appendEntry: () => {},
135
186
  now: () => 1,
187
+ messages: chainMessages,
188
+ diagnostics: { report: () => {} },
189
+ backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" },
136
190
  });
137
- expect(result.compressedEntries).toHaveLength(0);
138
- expect(result.skipped).toHaveLength(1);
139
- expect(result.skipped[0]).toEqual({ startUserTimestamp: 100, reason: "no-summary" });
191
+ expect(result.skipped).toHaveLength(0);
192
+ expect(result.compressedEntries).toHaveLength(1);
193
+ expect(result.compressedEntries[0].bodySource).toBe("deterministic");
194
+ expect(result.compressedEntries[0].startUserTimestamp).toBe(100);
140
195
  });
141
196
 
142
197
  test("reports already-compressed chains in skipped list", async () => {
@@ -155,6 +210,7 @@ describe("compressEligible", () => {
155
210
  blockRefs: makeBlockRefs(),
156
211
  appendEntry: () => {},
157
212
  now: () => 1,
213
+ ...NOOP_BACKFILL_DEPS,
158
214
  });
159
215
  // Primary contract: already-compressed chains must never be double-compressed.
160
216
  expect(result.compressedEntries).toHaveLength(0);
@@ -170,6 +226,7 @@ describe("compressEligible", () => {
170
226
  blockRefs: makeBlockRefs(["b1"]),
171
227
  appendEntry: (type, data) => calls.push({ type, data }),
172
228
  now: () => 0,
229
+ ...NOOP_BACKFILL_DEPS,
173
230
  });
174
231
  expect(calls).toHaveLength(1);
175
232
  expect(calls[0].type).toBe(CUSTOM_TYPE_CHAIN);
@@ -183,6 +240,7 @@ describe("compressEligible", () => {
183
240
  blockRefs: makeBlockRefs(["b1"]),
184
241
  appendEntry: () => {},
185
242
  now: () => 1,
243
+ ...NOOP_BACKFILL_DEPS,
186
244
  fuseRange: async (text) => {
187
245
  fuseCalls.push(text);
188
246
  return "FUSED";
@@ -200,6 +258,7 @@ describe("compressEligible", () => {
200
258
  blockRefs: makeBlockRefs(["b1"]),
201
259
  appendEntry: () => {},
202
260
  now: () => 1,
261
+ ...NOOP_BACKFILL_DEPS,
203
262
  fuseRange: async () => {
204
263
  fuseCalled = true;
205
264
  return "FUSED";
@@ -216,6 +275,7 @@ describe("compressEligible", () => {
216
275
  blockRefs: makeBlockRefs(["b1"]),
217
276
  appendEntry: () => {},
218
277
  now: () => 1,
278
+ ...NOOP_BACKFILL_DEPS,
219
279
  fuseRange: async () => null,
220
280
  });
221
281
  expect(result.compressedEntries).toHaveLength(1);
@@ -229,6 +289,7 @@ describe("compressEligible", () => {
229
289
  blockRefs: makeBlockRefs(["b1"]),
230
290
  appendEntry: () => {},
231
291
  now: () => 1,
292
+ ...NOOP_BACKFILL_DEPS,
232
293
  fuseRange: async () => {
233
294
  throw new Error("boom");
234
295
  },
@@ -253,6 +314,7 @@ describe("compressEligible", () => {
253
314
  blockRefs: makeBlockRefs(["b1"]),
254
315
  appendEntry: (_type, data) => appended.push(data),
255
316
  now: () => 1,
317
+ ...NOOP_BACKFILL_DEPS,
256
318
  },
257
319
  );
258
320
  expect(result.compressedEntries).toHaveLength(1);
@@ -267,6 +329,7 @@ describe("compressEligible", () => {
267
329
  blockRefs: makeBlockRefs(["b1"]),
268
330
  appendEntry: () => {},
269
331
  now: () => 1,
332
+ ...NOOP_BACKFILL_DEPS,
270
333
  });
271
334
  expect(result.compressedEntries).toHaveLength(1);
272
335
  expect("protectedToolCallIds" in result.compressedEntries[0]).toBe(false);
@@ -279,6 +342,7 @@ describe("compressEligible", () => {
279
342
  blockRefs: makeBlockRefs(["b1"]),
280
343
  appendEntry: () => {},
281
344
  now: () => 1,
345
+ ...NOOP_BACKFILL_DEPS,
282
346
  });
283
347
  expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
284
348
  });
@@ -427,3 +491,351 @@ describe("selectEligible - recovery grace wiring (production boundary)", () => {
427
491
  expect(eligible.map((c) => c.startUserTimestamp)).toEqual([3]);
428
492
  });
429
493
  });
494
+
495
+ describe("extractChainRecords", () => {
496
+ function messages() {
497
+ return [
498
+ { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] },
499
+ { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] },
500
+ { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] },
501
+ { role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] },
502
+ { role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] },
503
+ { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
504
+ ];
505
+ }
506
+ const chain = { startUserTimestamp: 1000, finalAssistantTimestamp: 1200, protectedToolCallIds: [] as string[] };
507
+
508
+ test("happy path: 2 middle calls -> 2 records with resultTimestamp/turnIndex/resultText", () => {
509
+ const records = extractChainRecords(messages(), chain, () => false);
510
+ expect(records).toHaveLength(2);
511
+ expect(records[0]).toEqual({
512
+ toolCallId: "c1",
513
+ toolName: "bash",
514
+ args: { cmd: "a" },
515
+ resultText: "out1",
516
+ isError: false,
517
+ turnIndex: -1,
518
+ timestamp: 1050,
519
+ resultTimestamp: 1050,
520
+ });
521
+ expect(records[1]).toEqual({
522
+ toolCallId: "c2",
523
+ toolName: "read",
524
+ args: { path: "x" },
525
+ resultText: "out2",
526
+ isError: false,
527
+ turnIndex: -1,
528
+ timestamp: 1150,
529
+ resultTimestamp: 1150,
530
+ });
531
+ });
532
+
533
+ test("excludes protected and already-indexed occurrence keys", () => {
534
+ const protectedChain = { ...chain, protectedToolCallIds: ["c1"] };
535
+ const indexedKey = occKey("c2", 1150);
536
+ const records = extractChainRecords(messages(), protectedChain, (k) => k === indexedKey);
537
+ expect(records).toHaveLength(0);
538
+ });
539
+
540
+ test("falls back to block.args when block.input is absent (batch-capture parity)", () => {
541
+ const msgs = [
542
+ { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] },
543
+ { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", args: { cmd: "a" } }] },
544
+ { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] },
545
+ { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
546
+ ];
547
+ const records = extractChainRecords(msgs, { ...chain, finalAssistantTimestamp: 1200 }, () => false);
548
+ expect(records).toHaveLength(1);
549
+ expect(records[0].args).toEqual({ cmd: "a" });
550
+ });
551
+ });
552
+
553
+ describe("buildDeterministicBody", () => {
554
+ function record(overrides: Partial<ToolCallRecord>): ToolCallRecord {
555
+ return {
556
+ toolCallId: "x",
557
+ toolName: "bash",
558
+ args: {},
559
+ resultText: "",
560
+ isError: false,
561
+ turnIndex: -1,
562
+ timestamp: 0,
563
+ ...overrides,
564
+ };
565
+ }
566
+
567
+ test("full grammar pin", () => {
568
+ const records = [
569
+ record({ toolCallId: "a1", toolName: "bash", args: { cmd: "ls" }, resultText: "r1", timestamp: 1000, resultTimestamp: 1000 }),
570
+ record({ toolCallId: "a2", toolName: "bash", args: { cmd: "pwd" }, resultText: "r2", timestamp: 2000, resultTimestamp: 2000 }),
571
+ record({ toolCallId: "a3", toolName: "read", args: { path: "f" }, resultText: "r3", timestamp: 3000, resultTimestamp: 3000 }),
572
+ ];
573
+ const body = buildDeterministicBody(records, ["t1", "t2", "t3"]);
574
+ expect(body).toBe(
575
+ [
576
+ "Deterministic chain compression (no per-batch summary existed for this span; raw outputs recoverable via context_tree_query).",
577
+ "Calls: 3",
578
+ "Tools: bash x2, read x1",
579
+ `Span: ${new Date(1000).toISOString()} -> ${new Date(3000).toISOString()} (2s)`,
580
+ 'First: bash {"cmd":"ls"}',
581
+ 'Last: read {"path":"f"}',
582
+ "Refs: t1, t2, t3",
583
+ ].join("\n"),
584
+ );
585
+ });
586
+
587
+ test("sorts/spans by resultTimestamp when it differs from timestamp", () => {
588
+ // timestamp order is reversed vs resultTimestamp order; resultTimestamp must win.
589
+ const records = [
590
+ record({ toolCallId: "a1", toolName: "bash", args: { cmd: "ls" }, timestamp: 9000, resultTimestamp: 1000 }),
591
+ record({ toolCallId: "a2", toolName: "read", args: { path: "f" }, timestamp: 1000, resultTimestamp: 9000 }),
592
+ ];
593
+ const body = buildDeterministicBody(records, ["t1", "t2"]);
594
+ expect(body).toContain("First: bash ");
595
+ expect(body).toContain("Last: read ");
596
+ expect(body).toContain(`Span: ${new Date(1000).toISOString()} -> ${new Date(9000).toISOString()} (8s)`);
597
+ });
598
+
599
+ test("empty toolRefs falls back to bare toolCallIds for the Refs line", () => {
600
+ const records = [
601
+ record({ toolCallId: "a1", toolName: "bash", timestamp: 1000 }),
602
+ record({ toolCallId: "a2", toolName: "read", timestamp: 2000 }),
603
+ ];
604
+ const body = buildDeterministicBody(records, []);
605
+ expect(body).toContain("Refs: a1, a2");
606
+ });
607
+
608
+ test("300-char args excerpt is capped at 200 chars + '...'", () => {
609
+ const longArgs = { s: "a".repeat(300) };
610
+ const records = [record({ toolName: "bash", args: longArgs, timestamp: 0 })];
611
+ const body = buildDeterministicBody(records, ["t1"]);
612
+ const firstLine = body.split("\n").find((l) => l.startsWith("First: "))!;
613
+ const excerptText = firstLine.slice("First: bash ".length);
614
+ expect(excerptText).toHaveLength(203);
615
+ expect(excerptText.endsWith("...")).toBe(true);
616
+ });
617
+
618
+ test("empty-args chain still produces a non-empty body with Calls/Tools/Span lines", () => {
619
+ const records = [record({ toolName: "bash", args: {}, resultText: "", timestamp: 500 })];
620
+ const body = buildDeterministicBody(records, ["t1"]);
621
+ expect(body.length).toBeGreaterThan(0);
622
+ expect(body).toContain("Calls: 1");
623
+ expect(body).toContain("Tools: bash x1");
624
+ expect(body).toContain("Span:");
625
+ });
626
+ });
627
+
628
+ describe("compressEligible - deterministic zero-LLM branch", () => {
629
+ function chainMessages() {
630
+ return [
631
+ { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] },
632
+ { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] },
633
+ { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] },
634
+ { role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] },
635
+ { role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] },
636
+ { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
637
+ ];
638
+ }
639
+
640
+ function uncoveredChain(): ChainRange {
641
+ return {
642
+ startUserTimestamp: 1000,
643
+ middleToolCallIds: ["c1", "c2"],
644
+ finalAssistantTimestamp: 1200,
645
+ protectedToolCallIds: [],
646
+ };
647
+ }
648
+
649
+ function makeDeterministicDeps(opts: {
650
+ messages?: any[];
651
+ indexRecords?: Map<string, ToolCallRecord>;
652
+ backfillImpl?: (records: ToolCallRecord[], opts: unknown) => Promise<import("./types.js").SummaryToolCallRef[]>;
653
+ toolRefs?: string[];
654
+ diagnosticsReport?: (kind: string, dedupKey: string, detail: string) => void;
655
+ fuseRange?: (text: string) => Promise<string | null>;
656
+ } = {}) {
657
+ const backfillCalls: Array<{ records: ToolCallRecord[]; opts: unknown }> = [];
658
+ const registerChainCalls: ChainCompressionEntry[] = [];
659
+ const appended: unknown[] = [];
660
+ const deps = {
661
+ indexer: {
662
+ getChainEntries: () => [],
663
+ hasPerBatchSummaryCoveringAny: () => false,
664
+ getPerBatchSummariesForToolCallIds: () => [],
665
+ getToolRefsForToolCallIds: () => opts.toolRefs ?? ["t1", "t2"],
666
+ registerChain: (entry: ChainCompressionEntry) => registerChainCalls.push(entry),
667
+ getIndex: () => opts.indexRecords ?? new Map<string, ToolCallRecord>(),
668
+ backfillChainRecords: async (records: ToolCallRecord[], backfillOpts: unknown) => {
669
+ backfillCalls.push({ records, opts: backfillOpts });
670
+ if (opts.backfillImpl) return opts.backfillImpl(records, backfillOpts);
671
+ return [
672
+ { shortId: "t1", toolCallId: "c1", resultTimestamp: 1050 },
673
+ { shortId: "t2", toolCallId: "c2", resultTimestamp: 1150 },
674
+ ];
675
+ },
676
+ } satisfies ChainCompressorIndexerDeps,
677
+ blockRefs: { issue: () => "b1" } satisfies Pick<import("./block-refs.js").BlockRefIssuer, "issue">,
678
+ appendEntry: (_type: string, data: unknown) => appended.push(data),
679
+ now: () => 1,
680
+ fuseRange: opts.fuseRange,
681
+ messages: opts.messages ?? chainMessages(),
682
+ diagnostics: { report: opts.diagnosticsReport ?? (() => {}) },
683
+ backfill: { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" },
684
+ };
685
+ return { deps, backfillCalls, registerChainCalls, appended };
686
+ }
687
+
688
+ test("compresses an uncovered chain with a deterministic body", async () => {
689
+ let fuseCalled = false;
690
+ const { deps, backfillCalls, registerChainCalls } = makeDeterministicDeps({
691
+ fuseRange: async () => {
692
+ fuseCalled = true;
693
+ return "FUSED";
694
+ },
695
+ });
696
+ const result = await compressEligible(
697
+ [{ ...uncoveredChain(), protectedToolCallIds: ["c2"] }],
698
+ 0,
699
+ deps as any,
700
+ );
701
+ expect(result.compressedEntries).toHaveLength(1);
702
+ const entry = result.compressedEntries[0];
703
+ expect(entry.bodySource).toBe("deterministic");
704
+ expect(entry.rangeSummaryText).toBeTruthy();
705
+ expect(entry.toolRefs).toEqual(["t1", "t2"]);
706
+ expect(fuseCalled).toBe(false);
707
+ expect(backfillCalls).toHaveLength(1);
708
+ expect(backfillCalls[0].records).toHaveLength(1);
709
+ expect(backfillCalls[0].records.map((r) => r.toolCallId)).not.toContain("c2");
710
+ expect(registerChainCalls).toHaveLength(1);
711
+ });
712
+
713
+ test("covered path is untouched: backfill never invoked, entry matches identity pin", async () => {
714
+ const { deps, backfillCalls } = makeDeterministicDeps();
715
+ // Override to simulate coverage so the covered branch (not the deterministic one) runs.
716
+ (deps.indexer as any).hasPerBatchSummaryCoveringAny = () => true;
717
+ (deps.indexer as any).getToolRefsForToolCallIds = () => ["t1"];
718
+ const chain: ChainRange = {
719
+ startUserTimestamp: 100,
720
+ middleToolCallIds: ["tc1"],
721
+ finalAssistantTimestamp: 200,
722
+ protectedToolCallIds: [],
723
+ };
724
+ const result = await compressEligible([chain], 0, deps as any);
725
+ expect(result.compressedEntries).toHaveLength(1);
726
+ const entry = result.compressedEntries[0];
727
+ expect(entry).toEqual({
728
+ blockId: "b1",
729
+ startUserTimestamp: 100,
730
+ droppedToolCallIds: ["tc1"],
731
+ finalAssistantTimestamp: 200,
732
+ toolRefs: ["t1"],
733
+ compressedAt: 1,
734
+ });
735
+ expect(backfillCalls).toHaveLength(0);
736
+ });
737
+
738
+ test("fail-closed: backfillChainRecords rejecting keeps the no-summary skip", async () => {
739
+ const { deps, registerChainCalls, appended } = makeDeterministicDeps({
740
+ backfillImpl: async () => {
741
+ throw new Error("spill failed");
742
+ },
743
+ });
744
+ const result = await compressEligible([uncoveredChain()], 0, deps as any);
745
+ expect(result.compressedEntries).toHaveLength(0);
746
+ expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]);
747
+ expect(registerChainCalls).toHaveLength(0);
748
+ expect(appended).toHaveLength(0);
749
+ });
750
+
751
+ test("retry discriminator: members already indexed, nothing new extracted -> composes from index", async () => {
752
+ const indexed: ToolCallRecord = {
753
+ toolCallId: "c1",
754
+ toolName: "bash",
755
+ args: { cmd: "a" },
756
+ resultText: "out1",
757
+ isError: false,
758
+ turnIndex: -1,
759
+ timestamp: 1050,
760
+ resultTimestamp: 1050,
761
+ };
762
+ const indexed2: ToolCallRecord = {
763
+ toolCallId: "c2",
764
+ toolName: "read",
765
+ args: { path: "x" },
766
+ resultText: "out2",
767
+ isError: false,
768
+ turnIndex: -1,
769
+ timestamp: 1150,
770
+ resultTimestamp: 1150,
771
+ };
772
+ const index = new Map<string, ToolCallRecord>([
773
+ [occKey("c1", 1050), indexed],
774
+ [occKey("c2", 1150), indexed2],
775
+ ]);
776
+ const chainWithOccKeys: ChainRange = {
777
+ ...uncoveredChain(),
778
+ middleOccurrenceKeys: [occKey("c1", 1050), occKey("c2", 1150)],
779
+ };
780
+ const { deps, backfillCalls } = makeDeterministicDeps({ indexRecords: index });
781
+ const result = await compressEligible([chainWithOccKeys], 0, deps as any);
782
+ expect(result.compressedEntries).toHaveLength(1);
783
+ expect(result.compressedEntries[0].bodySource).toBe("deterministic");
784
+ expect(backfillCalls).toHaveLength(0);
785
+ });
786
+
787
+ test("retry discriminator: genuine span mismatch -> skip + backfill-empty diagnostic", async () => {
788
+ const reports: Array<[string, string, string]> = [];
789
+ const { deps } = makeDeterministicDeps({
790
+ messages: [], // span cannot resolve -> extraction empty, index empty
791
+ diagnosticsReport: (kind, dedupKey, detail) => reports.push([kind, dedupKey, detail]),
792
+ });
793
+ const result = await compressEligible([uncoveredChain()], 0, deps as any);
794
+ expect(result.compressedEntries).toHaveLength(0);
795
+ expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]);
796
+ expect(reports).toHaveLength(1);
797
+ expect(reports[0][0]).toBe("backfill-empty");
798
+ expect(reports[0][1]).toBe("1000");
799
+ });
800
+
801
+ test("fully-protected uncovered chain: plain no-summary skip, no backfill-empty diagnostic", async () => {
802
+ // Every middle id is protected -> extraction excludes all of them by design,
803
+ // not by span mismatch. Reporting backfill-empty here would be a false alarm.
804
+ const reports: Array<[string, string, string]> = [];
805
+ const { deps } = makeDeterministicDeps({
806
+ diagnosticsReport: (kind, dedupKey, detail) => reports.push([kind, dedupKey, detail]),
807
+ });
808
+ const chain: ChainRange = {
809
+ startUserTimestamp: 1000,
810
+ middleToolCallIds: ["c1", "c2"],
811
+ finalAssistantTimestamp: 1200,
812
+ protectedToolCallIds: ["c1", "c2"],
813
+ };
814
+ const result = await compressEligible([chain], 0, deps as any);
815
+ expect(result.compressedEntries).toHaveLength(0);
816
+ expect(result.skipped).toEqual([{ startUserTimestamp: 1000, reason: "no-summary" }]);
817
+ expect(reports).toHaveLength(0);
818
+ });
819
+
820
+ test("empty-args chain compresses with a non-empty deterministic body", async () => {
821
+ const messages = [
822
+ { role: "user", timestamp: 1000, content: [{ type: "text", text: "u" }] },
823
+ { role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: {} }] },
824
+ { role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "" }] },
825
+ { role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
826
+ ];
827
+ const chain: ChainRange = {
828
+ startUserTimestamp: 1000,
829
+ middleToolCallIds: ["c1"],
830
+ finalAssistantTimestamp: 1200,
831
+ protectedToolCallIds: [],
832
+ };
833
+ const { deps } = makeDeterministicDeps({ messages });
834
+ const result = await compressEligible([chain], 0, deps as any);
835
+ expect(result.compressedEntries).toHaveLength(1);
836
+ const body = result.compressedEntries[0].rangeSummaryText!;
837
+ expect(body).toContain("Calls: 1");
838
+ expect(body).toContain("Tools: bash x1");
839
+ expect(body.length).toBeGreaterThan(0);
840
+ });
841
+ });