pi-condense 2.9.2 → 2.10.1

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.
@@ -559,6 +559,35 @@ describe("applyChainCompressions", () => {
559
559
  expect(synthetic.content[0].text).not.toContain("<protected-output");
560
560
  });
561
561
 
562
+ test("custom-anchored range: drops interior and relocates protected output, same as the user-anchored equivalent", () => {
563
+ const e = {
564
+ blockId: "b1",
565
+ startUserTimestamp: 1,
566
+ droppedToolCallIds: ["tc-read", "tc-todo"],
567
+ protectedToolCallIds: ["tc-todo"],
568
+ finalAssistantTimestamp: 9,
569
+ toolRefs: ["t1", "t2"],
570
+ compressedAt: 100,
571
+ };
572
+ const messages = [
573
+ { role: "custom", customType: "pi-gauntlet-transition-recovery", timestamp: 1 },
574
+ { role: "assistant", timestamp: 2, content: [
575
+ { type: "toolCall", id: "tc-read", name: "read" },
576
+ { type: "toolCall", id: "tc-todo", name: "todowrite" },
577
+ ] },
578
+ { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] },
579
+ { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] },
580
+ { role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
581
+ ];
582
+ const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
583
+ expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-todo")).toBeUndefined();
584
+ expect(out.find((m: any) => m.role === "assistant" && m.timestamp === 2)).toBeUndefined();
585
+ const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
586
+ expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
587
+ expect(synthetic.content[0].text).toContain("PLAN-STATE");
588
+ expect(synthetic.content[0].text).not.toContain("FILE");
589
+ });
590
+
562
591
  test("blockSummaryLookup: missing lookup leaves placeholder literal", () => {
563
592
  const msgs = [
564
593
  userMsg(100),
@@ -636,6 +665,60 @@ describe("resolveRange", () => {
636
665
  const range = resolveRange({ startUserTimestamp: 100, finalAssistantTimestamp: 200 }, messages);
637
666
  expect(range).toEqual({ startIndex: 0, endIndex: 1 });
638
667
  });
668
+
669
+ function customAnchor(timestamp: number, customType = "pi-gauntlet-transition-recovery"): any {
670
+ return { role: "custom", customType, timestamp };
671
+ }
672
+
673
+ test("resolves when the start anchor is an eligible non-pruner custom message", () => {
674
+ const messages = [
675
+ customAnchor(100),
676
+ assistantWithTools(200, ["tc1"]),
677
+ toolResult(300, "tc1"),
678
+ assistantText(400),
679
+ ];
680
+ const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
681
+ expect(range).toEqual({ startIndex: 0, endIndex: 3 });
682
+ });
683
+
684
+ test("returns null when a user message and an eligible custom message collide at the same start timestamp", () => {
685
+ const messages = [
686
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
687
+ customAnchor(100),
688
+ assistantWithTools(200, ["tc1"]),
689
+ toolResult(300, "tc1"),
690
+ assistantText(400),
691
+ ];
692
+ const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
693
+ expect(range).toBeNull();
694
+ });
695
+
696
+ test("returns null when two eligible custom messages share the start timestamp", () => {
697
+ const messages = [
698
+ customAnchor(100),
699
+ customAnchor(100, "other-extension"),
700
+ assistantWithTools(200, ["tc1"]),
701
+ toolResult(300, "tc1"),
702
+ assistantText(400),
703
+ ];
704
+ const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
705
+ expect(range).toBeNull();
706
+ });
707
+
708
+ test("user-anchored resolution is unaffected by the custom-anchor widening (regression pin)", () => {
709
+ expect(resolveRange(entry(), base())).toEqual({ startIndex: 0, endIndex: 3 });
710
+ });
711
+
712
+ test("a context-prune-summary custom at the start timestamp does not count as a start match", () => {
713
+ const messages = [
714
+ summaryMsg(100, ["tc0"]),
715
+ assistantWithTools(200, ["tc1"]),
716
+ toolResult(300, "tc1"),
717
+ assistantText(400),
718
+ ];
719
+ const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
720
+ expect(range).toBeNull();
721
+ });
639
722
  });
640
723
 
641
724
  describe("applyChainCompressions - positional", () => {
@@ -3,6 +3,7 @@ import { CUSTOM_TYPE_SUMMARY } from "./types.js";
3
3
  import type { ChainCompressionEntry } from "./types.js";
4
4
  import { substituteBlockRefs } from "./nested-placeholders.js";
5
5
  import { extractToolResultText } from "./batch-capture.js";
6
+ import { isChainAnchorCustom } from "./chain-detector.js";
6
7
  import { bareToolCallId, occKey, resultTimestampOf } from "./occurrence-key.js";
7
8
  import type { DiagnosticSink } from "./diagnostics.js";
8
9
 
@@ -68,11 +69,12 @@ export function buildSyntheticChainMessage(
68
69
  /**
69
70
  * Resolves a persisted chain entry to a positional index range.
70
71
  *
71
- * Role-gated and unique-match-or-nothing: exactly one user message at
72
+ * Role-gated and unique-match-or-nothing: exactly one start anchor (user
73
+ * message or eligible non-pruner custom, isChainAnchorCustom) at
72
74
  * startUserTimestamp, exactly one assistant at finalAssistantTimestamp, and
73
- * start < end. Otherwise null - the entry drops nothing. Fail-closed is the
74
- * whole point: an id-set or timestamp-window fallback is what deleted live
75
- * turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
75
+ * startIndex < endIndex. Otherwise null - the entry drops nothing. Fail-closed
76
+ * is the whole point: an id-set or timestamp-window fallback is what deleted
77
+ * live turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
76
78
  */
77
79
  export function resolveRange(
78
80
  entry: Pick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">,
@@ -85,7 +87,7 @@ export function resolveRange(
85
87
  let endMatches = 0;
86
88
  for (let i = 0; i < messages.length; i++) {
87
89
  const msg = messages[i];
88
- if (msg.role === "user" && msg.timestamp === entry.startUserTimestamp) {
90
+ if ((msg.role === "user" || isChainAnchorCustom(msg)) && msg.timestamp === entry.startUserTimestamp) {
89
91
  startMatches++;
90
92
  if (startIndex < 0) startIndex = i;
91
93
  } else if (msg.role === "assistant" && msg.timestamp === entry.finalAssistantTimestamp) {
@@ -125,3 +125,25 @@ describe("loadConfig backward compatibility with removed thinkingStrip key", ()
125
125
  expect(written.contextPrune.thinkingStrip).toEqual(stale);
126
126
  });
127
127
  });
128
+
129
+ describe("loadConfig frontierGapThresholdTokens normalization", () => {
130
+ it("defaults to null when unset", async () => {
131
+ await writeContextPrune({});
132
+ const config = await loadConfig();
133
+ expect(config.frontierGapThresholdTokens).toBeNull();
134
+ });
135
+
136
+ it("floors a fractional value", async () => {
137
+ await writeContextPrune({ frontierGapThresholdTokens: 80000.7 });
138
+ const config = await loadConfig();
139
+ expect(config.frontierGapThresholdTokens).toBe(80000);
140
+ });
141
+
142
+ it("falls back to null for 0, negative, Infinity, or a string", async () => {
143
+ for (const value of [0, -5, Infinity, "80000"]) {
144
+ await writeContextPrune({ frontierGapThresholdTokens: value });
145
+ const config = await loadConfig();
146
+ expect(config.frontierGapThresholdTokens).toBeNull();
147
+ }
148
+ });
149
+ });
package/src/config.ts CHANGED
@@ -106,6 +106,12 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
106
106
  merged.budgetTurnDelta <= 1
107
107
  ? merged.budgetTurnDelta
108
108
  : DEFAULT_CONFIG.budgetTurnDelta,
109
+ frontierGapThresholdTokens:
110
+ typeof merged.frontierGapThresholdTokens === "number" &&
111
+ Number.isFinite(merged.frontierGapThresholdTokens) &&
112
+ merged.frontierGapThresholdTokens > 0
113
+ ? Math.floor(merged.frontierGapThresholdTokens)
114
+ : DEFAULT_CONFIG.frontierGapThresholdTokens,
109
115
  };
110
116
  }
111
117
 
@@ -170,11 +170,11 @@ describe("computeContextMetrics", () => {
170
170
  expect(openSegmentChars).toBeGreaterThan(chainChars);
171
171
  });
172
172
 
173
- test("largestChainSharePct: a projected custom_message entry (role \"custom\") counts toward the denominator only, never the chain numerator", () => {
173
+ test("largestChainSharePct: a pruner custom message (customType starting with context-prune-) counts toward the denominator only, never the chain numerator", () => {
174
174
  // Mirrors index.ts's branch projection for persisted summary custom_message
175
- // entries: role "custom" never matches the user/assistant/toolResult roles
176
- // detectChains and isTextOnlyAssistant key off, so it cannot join a chain
177
- // or the open segment -- it only inflates totalChars (the denominator).
175
+ // entries: a pruner customType (context-prune-summary) is excluded from
176
+ // chain anchoring by isChainAnchorCustom, so it cannot join a chain or the
177
+ // open segment -- it only inflates totalChars (the denominator).
178
178
  // The customEntry sits between two final text-only assistant messages, so
179
179
  // it lands outside both the chain range and the open-cycle segment --
180
180
  // isolating the denominator effect from any open-segment interaction.
@@ -184,7 +184,7 @@ describe("computeContextMetrics", () => {
184
184
  toolResult(300, "tc1", "bash", "x".repeat(2000)),
185
185
  assistantText(400),
186
186
  ];
187
- const customEntry = { role: "custom", customType: "pi-condense:summary", content: "s".repeat(3000), display: true, timestamp: 450 };
187
+ const customEntry = { role: "custom", customType: "context-prune-summary", content: "s".repeat(3000), display: true, timestamp: 450 };
188
188
  const closer = assistantText(500, "ok");
189
189
 
190
190
  const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected);
@@ -199,6 +199,36 @@ describe("computeContextMetrics", () => {
199
199
  expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct);
200
200
  });
201
201
 
202
+ test("largestChainSharePct: a custom-anchored chain (eligible non-pruner custom message opens a chain) is counted in the numerator", () => {
203
+ // Mirrors the user-anchored share test's arithmetic, but the chain start
204
+ // is an eligible custom message (isChainAnchorCustom) instead of a user
205
+ // message -- Wave 1's chain-detector already opens chains on these; this
206
+ // pins that computeContextMetrics' chain-start lookup finds them too.
207
+ const customAnchor = {
208
+ role: "custom",
209
+ customType: "pi-gauntlet-transition-recovery",
210
+ content: "resuming",
211
+ timestamp: 100,
212
+ };
213
+ const msgs = [
214
+ customAnchor,
215
+ assistantWithTools(200, ["tc1"]),
216
+ toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain
217
+ assistantText(400),
218
+ userMsg(500),
219
+ assistantText(600), // tiny open segment (empty, since it's the last text-only assistant)
220
+ ];
221
+ const chars = msgs.map((m) => JSON.stringify(m).length);
222
+ const totalChars = chars.reduce((a, b) => a + b, 0);
223
+ const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // customAnchor..assistantText(400)
224
+ const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty
225
+ const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
226
+
227
+ const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
228
+ expect(result.largestChainSharePct).toBe(expectedPct);
229
+ expect(chainChars).toBeGreaterThan(openSegmentChars);
230
+ });
231
+
202
232
  test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => {
203
233
  const msgs = [
204
234
  userMsg(100),
@@ -1,4 +1,4 @@
1
- import { detectChains } from "./chain-detector.js";
1
+ import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
2
2
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
3
  import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
4
4
 
@@ -75,7 +75,9 @@ export function computeContextMetrics(
75
75
  const chains = detectChains(branch, isProtected);
76
76
  let largestClosedChainChars = 0;
77
77
  for (const range of chains) {
78
- const startIdx = branch.findIndex((m) => m.role === "user" && m.timestamp === range.startUserTimestamp);
78
+ const startIdx = branch.findIndex(
79
+ (m) => (m.role === "user" || isChainAnchorCustom(m)) && m.timestamp === range.startUserTimestamp,
80
+ );
79
81
  if (startIdx === -1) continue;
80
82
  let endIdx: number;
81
83
  if (range.finalAssistantTimestamp !== null) {
@@ -85,6 +87,7 @@ export function computeContextMetrics(
85
87
  if (endIdx === -1) continue;
86
88
  } else {
87
89
  let nextUserIdx = -1;
90
+ // User-only: under idle-only semantics only a user message interrupts an open chain.
88
91
  for (let i = startIdx + 1; i < branch.length; i++) {
89
92
  if (branch[i].role === "user") {
90
93
  nextUserIdx = i;
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, basename } from "node:path";
5
5
  import { ToolCallIndexer } from "./indexer.js";
6
+ import { occKey } from "./occurrence-key.js";
6
7
  import { spillOversizedBatch, blobPathFor } from "./spill.js";
7
8
  import { pruneMessages } from "./pruner.js";
8
9
  import type { CapturedBatch } from "./types.js";
@@ -75,4 +76,51 @@ describe("oversized spill end-to-end", () => {
75
76
  await rm(dir, { recursive: true, force: true });
76
77
  }
77
78
  });
79
+
80
+ it("long-id record survives the backfill -> restart round trip (AC4)", async () => {
81
+ const dir = await mkdtemp(join(tmpdir(), "spill-e2e-"));
82
+ try {
83
+ const indexer = new ToolCallIndexer();
84
+ const entries: any[] = [];
85
+ const appendEntry = (customType: string, data?: unknown) => {
86
+ entries.push({ type: "custom", customType, data });
87
+ };
88
+ const longId = "toolu_" + "q".repeat(494); // 500 chars
89
+ const body = "BACKFILL BODY\n".repeat(200);
90
+ const rec: any = {
91
+ toolCallId: longId, toolName: "bash", args: { command: "ls" },
92
+ resultText: body, isError: false, turnIndex: -1, timestamp: 1000, resultTimestamp: 1000,
93
+ };
94
+
95
+ // 1. backfill write: must not throw (fail-closed path), basename capped
96
+ await indexer.backfillChainRecords([rec], {
97
+ spillThreshold: 10, spillPreviewBytes: 16, sessionDir: dir, sessionId: "sid", appendEntry,
98
+ });
99
+ expect(rec.spillPath).toBeTruthy();
100
+ expect(Buffer.byteLength(basename(rec.spillPath), "utf8")).toBeLessThanOrEqual(255);
101
+
102
+ // 2. persisted index entry exists (backfilled shape)
103
+ expect(entries.some((e) => e.customType === CUSTOM_TYPE_INDEX && e.data.backfilled)).toBe(true);
104
+
105
+ // 3. restart: fresh indexer reconstructs from persisted entries only
106
+ const rebuilt = new ToolCallIndexer();
107
+ rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => entries } } as any);
108
+ const restored = rebuilt.getRecord(occKey(longId, 1000))!;
109
+ expect(restored.spillPath).toBe(rec.spillPath);
110
+
111
+ // 4. read-back of the restored persisted path equals the original body
112
+ expect(await readFile(restored.spillPath!, "utf-8")).toBe(body);
113
+ } finally {
114
+ await rm(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ it("capped names are namespace-disjoint from short-key names", () => {
119
+ const cappedPath = blobPathFor("/s", "sid", "a".repeat(300));
120
+ const stem = basename(cappedPath).slice(0, -".txt".length);
121
+ // sanitizeId can never emit ".", so no short key maps onto a capped name -
122
+ // even the short key spelled exactly like the capped stem.
123
+ expect(stem).toContain(".");
124
+ expect(blobPathFor("/s", "sid", stem)).not.toBe(cappedPath);
125
+ });
78
126
  });