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.
@@ -5,9 +5,14 @@ import { compressEligible } from "./chain-compressor.js";
5
5
  import { pruneMessages } from "./pruner.js";
6
6
  import { detectChains } from "./chain-detector.js";
7
7
  import { isProtected } from "./protected.js";
8
- import { bareToolCallId } from "./occurrence-key.js";
8
+ import { bareToolCallId, occKey } from "./occurrence-key.js";
9
+ import { expectNoOrphanToolResults } from "./test-support.js";
10
+ import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX } from "./types.js";
9
11
  import type { ChainRange, ChainCompressionConfig } from "./types.js";
10
12
 
13
+ const noopDiagnostics = { report: () => {} };
14
+ const testBackfill = { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" };
15
+
11
16
  // End-to-end of the in-memory B path (everything except the LLM call, which is
12
17
  // the shared runSummarization already exercised live): a span's per-batch
13
18
  // summaries are fused by compressEligible, the entry lands in the real indexer's
@@ -41,6 +46,9 @@ describe("range compression integration", () => {
41
46
  fuseInputs.push(text);
42
47
  return "FUSED COHESIVE SUMMARY";
43
48
  },
49
+ messages: [],
50
+ diagnostics: noopDiagnostics,
51
+ backfill: testBackfill,
44
52
  });
45
53
 
46
54
  // Fusion received the concatenated per-batch summaries and stored its result.
@@ -101,6 +109,9 @@ describe("range compression integration", () => {
101
109
  blockRefs,
102
110
  appendEntry: () => {},
103
111
  now: () => 999,
112
+ messages: [],
113
+ diagnostics: noopDiagnostics,
114
+ backfill: testBackfill,
104
115
  });
105
116
 
106
117
  expect(compressedEntries).toHaveLength(1);
@@ -198,6 +209,9 @@ describe("range compression integration", () => {
198
209
  blockRefs,
199
210
  appendEntry: () => {},
200
211
  now: () => 999,
212
+ messages,
213
+ diagnostics: noopDiagnostics,
214
+ backfill: testBackfill,
201
215
  });
202
216
 
203
217
  expect(compressedEntries).toHaveLength(1);
@@ -238,7 +252,15 @@ describe("range compression integration", () => {
238
252
  indexer.registerSummaryBody(["tc2"], "batch two body");
239
253
 
240
254
  const chain: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tc1", "tc2"], finalAssistantTimestamp: 400 };
241
- await compressEligible([chain], 0, { indexer, blockRefs, appendEntry: () => {}, now: () => 1 });
255
+ await compressEligible([chain], 0, {
256
+ indexer,
257
+ blockRefs,
258
+ appendEntry: () => {},
259
+ now: () => 1,
260
+ messages: [],
261
+ diagnostics: noopDiagnostics,
262
+ backfill: testBackfill,
263
+ });
242
264
 
243
265
  const messages: any[] = [
244
266
  { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
@@ -254,4 +276,375 @@ describe("range compression integration", () => {
254
276
  expect(synthetic.content[0].text).toContain("batch one body");
255
277
  expect(synthetic.content[0].text).toContain("batch two body");
256
278
  });
279
+
280
+ test("mixed flush: one covered + one uncovered chain compress in a single compressEligible call", async () => {
281
+ const indexer = new ToolCallIndexer();
282
+ const blockRefs = new BlockRefIssuer();
283
+
284
+ // Chain A (covered): per-batch summary exists for tcA.
285
+ indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]);
286
+ indexer.registerSummaryBody(["tcA"], "summary of tcA");
287
+
288
+ const messages: any[] = [
289
+ { role: "user", content: [{ type: "text", text: "go A" }], timestamp: 100 },
290
+ { role: "assistant", content: [{ type: "toolCall", id: "tcA", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
291
+ { role: "toolResult", toolCallId: "tcA", toolName: "bash", content: [{ type: "text", text: "outA" }], isError: false, timestamp: 210 },
292
+ { role: "assistant", content: [{ type: "text", text: "done A" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
293
+ // Chain B (uncovered): no per-batch summary for tcB1/tcB2.
294
+ { role: "user", content: [{ type: "text", text: "go B" }], timestamp: 1000 },
295
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
296
+ { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 },
297
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "bash", arguments: { cmd: "two" } }], timestamp: 1200, usage: {}, stopReason: "tool_use" },
298
+ { role: "toolResult", toolCallId: "tcB2", toolName: "bash", content: [{ type: "text", text: "outB2" }], isError: false, timestamp: 1210 },
299
+ { role: "assistant", content: [{ type: "text", text: "done B" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
300
+ ];
301
+
302
+ const chainA: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tcA"], finalAssistantTimestamp: 400 };
303
+ const chainB: ChainRange = {
304
+ startUserTimestamp: 1000,
305
+ middleToolCallIds: ["tcB1", "tcB2"],
306
+ middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)],
307
+ finalAssistantTimestamp: 1400,
308
+ };
309
+
310
+ const { compressedEntries } = await compressEligible([chainA, chainB], 0, {
311
+ indexer,
312
+ blockRefs,
313
+ appendEntry: () => {},
314
+ now: () => 999,
315
+ messages,
316
+ diagnostics: noopDiagnostics,
317
+ backfill: testBackfill,
318
+ });
319
+
320
+ expect(compressedEntries).toHaveLength(2);
321
+ const entryA = compressedEntries.find((e) => e.startUserTimestamp === 100)!;
322
+ const entryB = compressedEntries.find((e) => e.startUserTimestamp === 1000)!;
323
+ expect(entryA.bodySource).toBeUndefined();
324
+ expect(entryB.bodySource).toBe("deterministic");
325
+
326
+ const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
327
+ const { messages: out } = pruneMessages(messages, indexer, cc);
328
+
329
+ const synthetics = out.filter((m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
330
+ expect(synthetics).toHaveLength(2);
331
+ const uncoveredSynthetic = synthetics.find((s: any) => s.content[0].text.includes("Deterministic chain compression"));
332
+ expect(uncoveredSynthetic).toBeDefined();
333
+ expectNoOrphanToolResults(out);
334
+
335
+ // Covered-output identity: the presence of chain B's deterministic branch
336
+ // must not perturb chain A's rendering at all. Rebuild chain A alone (chain
337
+ // B never existed) and require byte-identical entry + rendered text.
338
+ const referenceIndexer = new ToolCallIndexer();
339
+ const referenceBlockRefs = new BlockRefIssuer();
340
+ referenceIndexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]);
341
+ referenceIndexer.registerSummaryBody(["tcA"], "summary of tcA");
342
+ const referenceMessages = messages.slice(0, 4); // chain A's own span only
343
+ const { compressedEntries: refEntries } = await compressEligible([chainA], 0, {
344
+ indexer: referenceIndexer,
345
+ blockRefs: referenceBlockRefs,
346
+ appendEntry: () => {},
347
+ now: () => 999,
348
+ messages: referenceMessages,
349
+ diagnostics: noopDiagnostics,
350
+ backfill: testBackfill,
351
+ });
352
+ expect(refEntries).toHaveLength(1);
353
+ expect(entryA).toEqual(refEntries[0]);
354
+
355
+ const { messages: refOut } = pruneMessages(referenceMessages, referenceIndexer, cc);
356
+ const refSynthetic = refOut.find(
357
+ (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
358
+ );
359
+ expect(refSynthetic).toBeDefined();
360
+ const coveredSynthetic = synthetics.find((s: any) => !s.content[0].text.includes("Deterministic chain compression"));
361
+ expect(coveredSynthetic).toEqual(refSynthetic);
362
+ });
363
+
364
+ test("recovery through backfill: an uncovered chain's raw output is recoverable via its t<N> ref", async () => {
365
+ const indexer = new ToolCallIndexer();
366
+ const blockRefs = new BlockRefIssuer();
367
+
368
+ const messages: any[] = [
369
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
370
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
371
+ { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 },
372
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
373
+ ];
374
+ const chainB: ChainRange = {
375
+ startUserTimestamp: 1000,
376
+ middleToolCallIds: ["tcB1"],
377
+ middleOccurrenceKeys: [occKey("tcB1", 1110)],
378
+ finalAssistantTimestamp: 1400,
379
+ };
380
+
381
+ const { compressedEntries } = await compressEligible([chainB], 0, {
382
+ indexer,
383
+ blockRefs,
384
+ appendEntry: () => {},
385
+ now: () => 999,
386
+ messages,
387
+ diagnostics: noopDiagnostics,
388
+ backfill: testBackfill,
389
+ });
390
+
391
+ expect(compressedEntries).toHaveLength(1);
392
+ const ref = compressedEntries[0].toolRefs[0];
393
+ const resolved = indexer.resolveToolCallId(ref);
394
+ expect(resolved).toBeDefined();
395
+ expect(indexer.getRecord(resolved!)?.resultText).toBe("outB1 raw");
396
+ });
397
+
398
+ test("restart mid-failure-window: index entry persists even when the chain-entry append fails, and converges on retry", async () => {
399
+ const indexer = new ToolCallIndexer();
400
+ const blockRefs = new BlockRefIssuer();
401
+
402
+ const messages: any[] = [
403
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
404
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
405
+ { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 },
406
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
407
+ ];
408
+ const chainB: ChainRange = {
409
+ startUserTimestamp: 1000,
410
+ middleToolCallIds: ["tcB1"],
411
+ middleOccurrenceKeys: [occKey("tcB1", 1110)],
412
+ finalAssistantTimestamp: 1400,
413
+ };
414
+
415
+ const captured: Array<{ type: string; data: unknown }> = [];
416
+ const flakyAppendEntry = (type: string, data?: unknown) => {
417
+ if (type === CUSTOM_TYPE_CHAIN) throw new Error("session write failed");
418
+ captured.push({ type, data });
419
+ };
420
+
421
+ // compressEligible does not catch chain-entry append failures itself
422
+ // (index.ts's caller wraps the whole call in try/catch); the throw
423
+ // propagates, but the index entry (with its refs) is already durable in
424
+ // `captured` by the time it does, per the append-before-commit ordering.
425
+ await expect(
426
+ compressEligible([chainB], 0, {
427
+ indexer,
428
+ blockRefs,
429
+ appendEntry: flakyAppendEntry,
430
+ now: () => 999,
431
+ messages,
432
+ diagnostics: noopDiagnostics,
433
+ backfill: testBackfill,
434
+ }),
435
+ ).rejects.toThrow("session write failed");
436
+
437
+ expect(captured.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(1);
438
+ expect(captured.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(0);
439
+
440
+ // Rebuild a fresh indexer from exactly what got captured (simulating a restart).
441
+ const branch = captured.map(({ type, data }) => ({ type: "custom", customType: type, data }));
442
+ const rebuilt = new ToolCallIndexer();
443
+ rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any);
444
+
445
+ // Re-run with an honest appendEntry: records are already indexed, so this
446
+ // composes-and-persists the chain entry without re-backfilling.
447
+ const captured2: Array<{ type: string; data: unknown }> = [];
448
+ const round2 = await compressEligible([chainB], 0, {
449
+ indexer: rebuilt,
450
+ blockRefs,
451
+ appendEntry: (type, data) => captured2.push({ type, data }),
452
+ now: () => 1000,
453
+ messages,
454
+ diagnostics: noopDiagnostics,
455
+ backfill: testBackfill,
456
+ });
457
+
458
+ expect(round2.compressedEntries).toHaveLength(1);
459
+ expect(captured2.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(0);
460
+ expect(captured2.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(1);
461
+
462
+ // Refs are identical to round 1's - the index entry (with its refs) never
463
+ // got re-persisted, so round 2 reused the durable ref from round 1.
464
+ const round1IndexEntry = captured.find((c) => c.type === CUSTOM_TYPE_INDEX)!.data as any;
465
+ const round1Refs = round1IndexEntry.refs.map((r: { shortId: string }) => r.shortId);
466
+ expect(round2.compressedEntries[0].toolRefs).toEqual(round1Refs);
467
+ });
468
+
469
+ test("multi-chain compact: three uncovered chains compress in one compressEligible call", async () => {
470
+ const indexer = new ToolCallIndexer();
471
+ const blockRefs = new BlockRefIssuer();
472
+
473
+ const messages: any[] = [];
474
+ const chains: ChainRange[] = [];
475
+ for (let i = 0; i < 3; i++) {
476
+ const base = 1000 + i * 1000;
477
+ const startTs = base;
478
+ const callTs = base + 100;
479
+ const resultTs = base + 110;
480
+ const finalTs = base + 400;
481
+ const id = `tc${i}`;
482
+ messages.push(
483
+ { role: "user", content: [{ type: "text", text: `go ${i}` }], timestamp: startTs },
484
+ { role: "assistant", content: [{ type: "toolCall", id, name: "bash", arguments: { cmd: id } }], timestamp: callTs, usage: {}, stopReason: "tool_use" },
485
+ { role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: `out ${i}` }], isError: false, timestamp: resultTs },
486
+ { role: "assistant", content: [{ type: "text", text: `done ${i}` }], timestamp: finalTs, usage: {}, stopReason: "end_turn" },
487
+ );
488
+ chains.push({
489
+ startUserTimestamp: startTs,
490
+ middleToolCallIds: [id],
491
+ middleOccurrenceKeys: [occKey(id, resultTs)],
492
+ finalAssistantTimestamp: finalTs,
493
+ });
494
+ }
495
+
496
+ const { compressedEntries } = await compressEligible(chains, 0, {
497
+ indexer,
498
+ blockRefs,
499
+ appendEntry: () => {},
500
+ now: () => 9999,
501
+ messages,
502
+ diagnostics: noopDiagnostics,
503
+ backfill: testBackfill,
504
+ });
505
+
506
+ expect(compressedEntries).toHaveLength(3);
507
+ expect(compressedEntries.every((e) => e.bodySource === "deterministic")).toBe(true);
508
+ });
509
+
510
+ test("fully-deduped chain: backfilled via index-membership filter (not isSummarized), compressed, pre-existing alias entries still resolve", async () => {
511
+ const indexer = new ToolCallIndexer();
512
+ const blockRefs = new BlockRefIssuer();
513
+
514
+ // Originals indexed in an earlier flush (as if summarized then).
515
+ indexer.addBatch(
516
+ {
517
+ turnIndex: 0,
518
+ timestamp: 10,
519
+ assistantText: "",
520
+ toolCalls: [
521
+ { toolCallId: "origA", toolName: "bash", args: {}, resultText: "SAME_A", isError: false, resultTimestamp: 20 },
522
+ { toolCallId: "origB", toolName: "bash", args: {}, resultText: "SAME_B", isError: false, resultTimestamp: 21 },
523
+ ],
524
+ },
525
+ () => {},
526
+ );
527
+ indexer.registerSummaryBody([occKey("origA", 20), occKey("origB", 21)], "summary of originals");
528
+ indexer.registerSummaryRefs([
529
+ { shortId: "t1", toolCallId: "origA", resultTimestamp: 20 },
530
+ { shortId: "t2", toolCallId: "origB", resultTimestamp: 21 },
531
+ ]);
532
+
533
+ // Pre-existing, unrelated duplicate of origA (registered before the chain
534
+ // under test even ran) - the control used below to prove the chain's own
535
+ // backfill does not disturb unrelated alias entries.
536
+ indexer.registerDuplicate(occKey("dup3", 30), occKey("origA", 20), () => {});
537
+
538
+ // The chain's own middle calls (tc1, tc2) were FULLY deduped by the
539
+ // pre-flush content-hash pass: both matched already-indexed originals,
540
+ // so neither was ever sent to the summarizer and neither has a direct
541
+ // index entry - only a dedup-alias entry pointing at origA/origB.
542
+ indexer.registerDuplicate(occKey("tc1", 110), occKey("origA", 20), () => {});
543
+ indexer.registerDuplicate(occKey("tc2", 120), occKey("origB", 21), () => {});
544
+
545
+ // isSummarized is true (dedup-alias hit) but the record is NOT in the
546
+ // index map directly - this is exactly the distinction the backfill
547
+ // filter (index membership) must honor instead of isSummarized().
548
+ expect(indexer.isSummarized(occKey("tc1", 110))).toBe(true);
549
+ expect(indexer.getIndex().has(occKey("tc1", 110))).toBe(false);
550
+ expect(indexer.isSummarized(occKey("tc2", 120))).toBe(true);
551
+ expect(indexer.getIndex().has(occKey("tc2", 120))).toBe(false);
552
+
553
+ const messages: any[] = [
554
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
555
+ { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 105, usage: {}, stopReason: "tool_use" },
556
+ { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "SAME_A" }], isError: false, timestamp: 110 },
557
+ { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 115, usage: {}, stopReason: "tool_use" },
558
+ { role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "SAME_B" }], isError: false, timestamp: 120 },
559
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 200, usage: {}, stopReason: "end_turn" },
560
+ ];
561
+ const chain: ChainRange = {
562
+ startUserTimestamp: 100,
563
+ middleToolCallIds: ["tc1", "tc2"],
564
+ middleOccurrenceKeys: [occKey("tc1", 110), occKey("tc2", 120)],
565
+ finalAssistantTimestamp: 200,
566
+ };
567
+
568
+ // Zero coverage: no summary body was ever registered for tc1/tc2's own keys.
569
+ expect(indexer.hasPerBatchSummaryCoveringAny([occKey("tc1", 110), occKey("tc2", 120)])).toBe(false);
570
+
571
+ const { compressedEntries } = await compressEligible([chain], 0, {
572
+ indexer,
573
+ blockRefs,
574
+ appendEntry: () => {},
575
+ now: () => 1,
576
+ messages,
577
+ diagnostics: noopDiagnostics,
578
+ backfill: testBackfill,
579
+ });
580
+
581
+ expect(compressedEntries).toHaveLength(1);
582
+ expect(compressedEntries[0].bodySource).toBe("deterministic");
583
+
584
+ // tc1/tc2 are now directly backfilled (index-membership filter let them through).
585
+ expect(indexer.getRecord(occKey("tc1", 110))?.resultText).toBe("SAME_A");
586
+ expect(indexer.getRecord(occKey("tc2", 120))?.resultText).toBe("SAME_B");
587
+
588
+ // Pre-existing, unrelated alias entry (dup3 -> origA) still resolves unchanged.
589
+ expect(indexer.resolveToolCallId(occKey("dup3", 30))).toBe(occKey("origA", 20));
590
+ expect(indexer.getRecord(occKey("dup3", 30))?.resultText).toBe("SAME_A");
591
+
592
+ // Backfilled records never seed contentHashToOriginal - the canonical for
593
+ // "SAME_A"/"SAME_B" content stays origA/origB, unpoisoned by tc1/tc2.
594
+ expect(indexer.lookupByContent("bash", "SAME_A")).toBe(occKey("origA", 20));
595
+ expect(indexer.lookupByContent("bash", "SAME_B")).toBe(occKey("origB", 21));
596
+ });
597
+
598
+ test("protected member in an uncovered chain: excluded from backfill index+refs, relocated verbatim at render, unprotected middle dropped", async () => {
599
+ const indexer = new ToolCallIndexer();
600
+ const blockRefs = new BlockRefIssuer();
601
+
602
+ const messages: any[] = [
603
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
604
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
605
+ { role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 },
606
+ { role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "todowrite", arguments: {} }], timestamp: 1200, usage: {}, stopReason: "tool_use" },
607
+ { role: "toolResult", toolCallId: "tcB2", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE-XYZ" }], isError: false, timestamp: 1210 },
608
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
609
+ ];
610
+ const chainB: ChainRange = {
611
+ startUserTimestamp: 1000,
612
+ middleToolCallIds: ["tcB1", "tcB2"],
613
+ middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)],
614
+ finalAssistantTimestamp: 1400,
615
+ protectedToolCallIds: ["tcB2"],
616
+ };
617
+
618
+ const { compressedEntries } = await compressEligible([chainB], 0, {
619
+ indexer,
620
+ blockRefs,
621
+ appendEntry: () => {},
622
+ now: () => 999,
623
+ messages,
624
+ diagnostics: noopDiagnostics,
625
+ backfill: testBackfill,
626
+ });
627
+
628
+ expect(compressedEntries).toHaveLength(1);
629
+ const entry = compressedEntries[0];
630
+ expect(entry.bodySource).toBe("deterministic");
631
+
632
+ // (a) protected id absent from the backfilled index entry's records and from toolRefs.
633
+ expect(indexer.getRecord(occKey("tcB2", 1210))).toBeUndefined();
634
+ expect(indexer.getRecord(occKey("tcB1", 1110))).toBeDefined();
635
+ expect(entry.toolRefs).toHaveLength(1);
636
+ expect(entry.toolRefs.map((r) => indexer.resolveToolCallId(r))).not.toContain(occKey("tcB2", 1210));
637
+
638
+ // (b) + (c) render: protected relocated verbatim inside the compressed-chain block; unprotected middle dropped.
639
+ const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
640
+ const { messages: out } = pruneMessages(messages, indexer, cc);
641
+ const synthetic = out.find(
642
+ (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
643
+ );
644
+ expect(synthetic).toBeDefined();
645
+ expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
646
+ expect(synthetic.content[0].text).toContain("PLAN-STATE-XYZ");
647
+ expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
648
+ expectNoOrphanToolResults(out);
649
+ });
257
650
  });
package/src/spill.ts CHANGED
@@ -36,6 +36,24 @@ interface SpillConfig {
36
36
  dedupByContentHash: boolean;
37
37
  }
38
38
 
39
+ interface SpillableRecord {
40
+ toolName: string;
41
+ resultText: string;
42
+ spillBytes?: number;
43
+ resultPreview?: string;
44
+ spillPath?: string;
45
+ contentHash?: string;
46
+ }
47
+
48
+ /** Mutates `record` in place: spillBytes/resultPreview/spillPath/contentHash set, resultText emptied. */
49
+ export function applySpill(record: SpillableRecord, spillPath: string, previewBytes: number): void {
50
+ record.spillBytes = Buffer.byteLength(record.resultText, "utf8");
51
+ record.resultPreview = headPreview(record.resultText, previewBytes);
52
+ record.spillPath = spillPath;
53
+ record.contentHash = hashToolResult(record.toolName, record.resultText);
54
+ record.resultText = "";
55
+ }
56
+
39
57
  export async function spillOversizedBatch(args: {
40
58
  batch: CapturedBatch;
41
59
  indexer: ToolCallIndexer;
@@ -52,7 +70,6 @@ export async function spillOversizedBatch(args: {
52
70
  if (tc.resultText.length < config.spillThreshold) continue;
53
71
 
54
72
  const key = occKey(tc.toolCallId, tc.resultTimestamp);
55
- const hash = hashToolResult(tc.toolName, tc.resultText);
56
73
 
57
74
  if (config.dedupByContentHash) {
58
75
  const original = indexer.lookupByContent(tc.toolName, tc.resultText);
@@ -72,11 +89,7 @@ export async function spillOversizedBatch(args: {
72
89
  continue;
73
90
  }
74
91
 
75
- tc.spillBytes = Buffer.byteLength(tc.resultText, "utf8");
76
- tc.resultPreview = headPreview(tc.resultText, config.spillPreviewBytes);
77
- tc.spillPath = path;
78
- tc.contentHash = hash;
79
- tc.resultText = "";
92
+ applySpill(tc, path, config.spillPreviewBytes);
80
93
  toIndex.push(tc);
81
94
  handled.add(tc.toolCallId);
82
95
  }
package/src/types.ts CHANGED
@@ -94,7 +94,7 @@ export const CUSTOM_TYPE_DIAGNOSTIC = "context-prune-diagnostic";
94
94
  */
95
95
  export const CUSTOM_TYPE_FLUSH_METRICS = "context-prune-flush-metrics";
96
96
 
97
- export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep";
97
+ export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep" | "backfill-empty";
98
98
 
99
99
  export interface DiagnosticEntryData {
100
100
  kind: DiagnosticKind;
@@ -239,7 +239,8 @@ export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[]
239
239
  /**
240
240
  * Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
241
241
  * the settings UI cycles string values). "0" is the disabled sentinel → null.
242
- * Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at 80%).
242
+ * Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at
243
+ * 80% of the window, or at MAX_BUDGET_WINDOW tokens, whichever comes first).
243
244
  */
244
245
  export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [
245
246
  { value: "0", label: "Off (default)" },
@@ -387,10 +388,14 @@ export interface ContextPruneConfig {
387
388
  /**
388
389
  * Token-budget auto-flush trigger. A fraction in (0, 1] (a 0–1 share of the
389
390
  * context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the
390
- * window). When set, a flush of all pending batches is forced at the end of
391
- * any tool-using turn once context usage (tokens / contextWindow) reaches the
392
- * threshold regardless of `pruneOn`. An ADDITIONAL trigger on top of
393
- * `pruneOn`, not a replacement.
391
+ * window, capped at 300k tokens - see below). When set, a flush of all
392
+ * pending batches is forced at the end of
393
+ * any tool-using turn once context usage reaches `threshold * contextWindow`
394
+ * tokens OR 300,000 tokens (MAX_BUDGET_WINDOW in src/budget.ts), whichever
395
+ * comes first — regardless of `pruneOn`. The ceiling keeps the setting
396
+ * reachable on huge-window models, where 0.9 of 1M would mean 900k tokens; it
397
+ * never binds on a model advertising 300k or less. An ADDITIONAL trigger on
398
+ * top of `pruneOn`, not a replacement.
394
399
  *
395
400
  * null (default) = disabled, preserving pre-feature behavior. Out-of-range
396
401
  * values (<= 0 or > 1) normalize to null.
@@ -402,7 +407,11 @@ export interface ContextPruneConfig {
402
407
  spillPreviewBytes: number;
403
408
  /**
404
409
  * Per-turn usage-fraction increase (0–1) that forces a flush, independent of
405
- * autoBudgetThreshold. null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
410
+ * autoBudgetThreshold. The fraction is measured against the effective window
411
+ * `min(contextWindow, MAX_BUDGET_WINDOW)` (300_000), so the required growth is
412
+ * `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens - e.g. 0.1 means +30k
413
+ * tokens in one turn on any model at or above 300k, and +20k on a 200k model.
414
+ * null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
406
415
  */
407
416
  budgetTurnDelta: number | null;
408
417
  }
@@ -496,6 +505,12 @@ export interface ChainCompressionEntry {
496
505
  * Absent on fusion failure / single-batch spans → renderer falls back to concat.
497
506
  */
498
507
  rangeSummaryText?: string;
508
+ /**
509
+ * "deterministic" = zero-LLM synthetic body built by the uncovered-chain
510
+ * backfill path (rangeSummaryText holds the stub). Absent = LLM-fused or
511
+ * per-batch semantics, unchanged.
512
+ */
513
+ bodySource?: "deterministic";
499
514
  }
500
515
 
501
516
  export interface ChainCompressionConfig {
@@ -631,6 +646,10 @@ export interface ToolCallRecord {
631
646
  */
632
647
  export interface IndexEntryData {
633
648
  toolCalls: ToolCallRecord[];
649
+ /** Entry written by backfillChainRecords: records must NOT seed contentHashToOriginal. */
650
+ backfilled?: true;
651
+ /** Refs allocated at backfill time; durable carrier for alias reconstruction. */
652
+ refs?: SummaryToolCallRef[];
634
653
  }
635
654
 
636
655
  /**