pi-condense 2.9.1 → 2.10.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.
@@ -49,6 +49,18 @@ function okStream() {
49
49
  };
50
50
  }
51
51
 
52
+ // Classified "transient" by runOnce (src/summarizer.ts) — with
53
+ // summarizerModel: "default" (no distinct fallback model) this yields a
54
+ // null SummarizeResult after exactly one stream() call, no retries.
55
+ function errStream(message: string) {
56
+ return {
57
+ async *[Symbol.asyncIterator]() {},
58
+ async result() {
59
+ return { stopReason: "error", errorMessage: message, content: [], usage: USAGE };
60
+ },
61
+ };
62
+ }
63
+
52
64
  let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
53
65
  summarizerCalls++;
54
66
  return okStream();
@@ -117,6 +129,26 @@ function closedChainBranch(count: number): any[] {
117
129
  return msgs;
118
130
  }
119
131
 
132
+ // Builds one independent pending batch: user -> assistant toolCall -> toolResult,
133
+ // with no closing text-only assistant (chain stays open, matching defaultBranch's
134
+ // shape). Used by the frontier-gap tests below to grow/shrink the branch's
135
+ // un-pruned tail across turns by direct array mutation (bootExtension returns
136
+ // the live `branch` array reference, so pushing onto it after boot is visible
137
+ // to every later getBranch() call).
138
+ function pendingBatchEntries(toolCallId: string, text: string, timestamp: number): any[] {
139
+ return [
140
+ { type: "message", message: { role: "user", content: [{ type: "text", text: `do ${toolCallId}` }], timestamp } },
141
+ {
142
+ type: "message",
143
+ message: { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "read", arguments: {} }], timestamp: timestamp + 500 },
144
+ },
145
+ {
146
+ type: "message",
147
+ message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }], timestamp: timestamp + 1000 },
148
+ },
149
+ ];
150
+ }
151
+
120
152
  // Boots a fresh index.ts extension instance against an isolated agent dir +
121
153
  // session, mirroring the fixtures shared across the three scenarios below.
122
154
  //
@@ -137,35 +169,45 @@ function closedChainBranch(count: number): any[] {
137
169
  function bootExtension(
138
170
  options: {
139
171
  chainCompressionEnabled?: boolean;
172
+ rollingWindow?: number;
140
173
  separatePiAppended?: boolean;
141
174
  piAppendEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => void;
142
175
  sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string;
143
176
  branch?: any[];
144
177
  protectedTools?: string[];
178
+ autoBudgetThreshold?: number | null;
179
+ budgetTurnDelta?: number | null;
180
+ frontierGapThresholdTokens?: number | null;
145
181
  } = {},
146
182
  ) {
147
183
  const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
148
184
  process.env.PI_CODING_AGENT_DIR = agentDir;
185
+ const contextPruneSettings: any = {
186
+ enabled: true,
187
+ pruneOn: "agent-message",
188
+ batchingMode: "agent-message",
189
+ autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
190
+ summarizerModel: "default",
191
+ minBatchChars: 1,
192
+ showPruneStatusLine: true,
193
+ protectedTools: options.protectedTools ?? [],
194
+ chainCompression: {
195
+ enabled: options.chainCompressionEnabled ?? false,
196
+ rollingWindow: options.rollingWindow ?? 3,
197
+ stripFinalAssistantThinking: true,
198
+ fuseRangeSummary: true,
199
+ },
200
+ };
201
+ // Omitted unless the test explicitly passes them, so the "default-null
202
+ // inert" scenario can assert behavior with no key present at all (not an
203
+ // explicit null), matching config.ts's own default.
204
+ if (options.budgetTurnDelta !== undefined) contextPruneSettings.budgetTurnDelta = options.budgetTurnDelta;
205
+ if (options.frontierGapThresholdTokens !== undefined) {
206
+ contextPruneSettings.frontierGapThresholdTokens = options.frontierGapThresholdTokens;
207
+ }
149
208
  writeFileSync(
150
209
  join(agentDir, "settings.json"),
151
- JSON.stringify({
152
- contextPrune: {
153
- enabled: true,
154
- pruneOn: "agent-message",
155
- batchingMode: "agent-message",
156
- autoBudgetThreshold: 0.5,
157
- summarizerModel: "default",
158
- minBatchChars: 1,
159
- showPruneStatusLine: true,
160
- protectedTools: options.protectedTools ?? [],
161
- chainCompression: {
162
- enabled: options.chainCompressionEnabled ?? false,
163
- rollingWindow: 3,
164
- stripFinalAssistantThinking: true,
165
- fuseRangeSummary: true,
166
- },
167
- },
168
- }),
210
+ JSON.stringify({ contextPrune: contextPruneSettings }),
169
211
  );
170
212
 
171
213
  const sessionDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-session-"));
@@ -173,6 +215,8 @@ function bootExtension(
173
215
  const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
174
216
  const sessionAppended: AppendedEntry[] = appended;
175
217
  const handlers = new Map<string, (event: any, ctx: any) => any>();
218
+ const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
219
+ const notifications: string[] = [];
176
220
 
177
221
  const pushPi = (type: string, data?: unknown) => {
178
222
  piAppended.push({ type, data });
@@ -187,7 +231,9 @@ function bootExtension(
187
231
  },
188
232
  appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
189
233
  sendMessage() {},
190
- registerCommand() {},
234
+ registerCommand(name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
235
+ commands.set(name, spec.handler);
236
+ },
191
237
  registerTool() {},
192
238
  registerMessageRenderer() {},
193
239
  events: { emit() {} },
@@ -221,12 +267,14 @@ function bootExtension(
221
267
  ui: {
222
268
  setStatus() {},
223
269
  setWidget() {},
224
- notify() {},
270
+ notify(message: string) {
271
+ notifications.push(message);
272
+ },
225
273
  select: async () => undefined,
226
274
  },
227
275
  };
228
276
 
229
- return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
277
+ return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
230
278
  }
231
279
 
232
280
  async function boot(options?: Parameters<typeof bootExtension>[0]) {
@@ -526,75 +574,6 @@ describe("reload rearm (issue #6)", () => {
526
574
  expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
527
575
  });
528
576
 
529
- it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => {
530
- // Component 4 (spec): the snapshot cache recomputes at every enabled
531
- // turn_end carrying toolResults, unconditional on whether trim yields a
532
- // batch to push. Observed via the footer widget suffix (commands.ts's
533
- // pruneStatusText), which is rendered from the cache, not recomputed
534
- // itself — the honest seam here since the harness's pi.registerCommand
535
- // is a no-op stub and the registerCommands getCachedMetrics callback is
536
- // therefore unreachable from a test.
537
- const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] });
538
-
539
- // Neutralize the budget/delta gate so this test only observes the
540
- // recompute, not a side-effect flush (harness default usage is 0.6,
541
- // above the fixture's 0.5 autoBudgetThreshold).
542
- ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
543
-
544
- await handlers.get("session_start")!({}, ctx);
545
-
546
- const statusCalls: unknown[] = [];
547
- ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
548
-
549
- // Force a render of the current (session_start-computed) cache.
550
- const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
551
- await handlers.get("context")!({ messages: rawBefore }, ctx);
552
- const textBefore = statusCalls[statusCalls.length - 1];
553
-
554
- // Grow the branch as Pi would before firing turn_end: a new assistant
555
- // turn with a large thinking block and a protected tool call, plus its
556
- // toolResult. Protected content is excluded from frontierGapTokens by
557
- // design, but NOT from openCycleThinkingTokens or largestChainSharePct —
558
- // so this turn still moves the cache if recomputed.
559
- const newAssistant = {
560
- type: "message",
561
- message: {
562
- role: "assistant",
563
- content: [
564
- { type: "thinking", text: "t".repeat(4000) },
565
- { type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} },
566
- ],
567
- },
568
- };
569
- const newToolResult = {
570
- type: "message",
571
- message: {
572
- role: "toolResult",
573
- toolCallId: "tc2",
574
- toolName: "secret_tool",
575
- content: [{ type: "text", text: "s".repeat(400) }],
576
- timestamp: Date.now(),
577
- },
578
- };
579
- branch.push(newAssistant, newToolResult);
580
-
581
- // This turn's toolResults are entirely protected, so trimBatchToPendingRange
582
- // returns null and no batch is pushed — the case this fix targets.
583
- await handlers.get("turn_end")!(
584
- { message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 },
585
- ctx,
586
- );
587
-
588
- const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
589
- await handlers.get("context")!({ messages: rawAfter }, ctx);
590
- const textAfter = statusCalls[statusCalls.length - 1];
591
-
592
- // Pre-fix, the cache is stale (computed once at session_start, on the
593
- // pre-growth branch) — the widget text does not move. Post-fix, the
594
- // turn_end recompute picks up the larger open segment/thinking.
595
- expect(textAfter).not.toBe(textBefore);
596
- });
597
-
598
577
  it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
599
578
  // Component 1 (spec): denominator = per-message chars over the entire
600
579
  // branch projection, INCLUDING retained custom_message summary entries.
@@ -620,17 +599,13 @@ describe("reload rearm (issue #6)", () => {
620
599
  };
621
600
  const branch = [...closedChain, summaryEntry, closer];
622
601
 
623
- const { handlers, ctx } = await boot({ branch });
602
+ const { handlers, commands, notifications, ctx } = await boot({ branch });
624
603
  ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
625
604
 
626
- const statusCalls: unknown[] = [];
627
- ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
628
-
629
605
  await handlers.get("session_start")!({}, ctx);
630
606
 
631
- const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
632
- await handlers.get("context")!({ messages: rawMessages }, ctx);
633
- const text = statusCalls[statusCalls.length - 1] as string;
607
+ await commands.get("pruner")!("status", ctx);
608
+ const text = notifications[notifications.length - 1] as string;
634
609
 
635
610
  const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
636
611
  const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
@@ -641,7 +616,303 @@ describe("reload rearm (issue #6)", () => {
641
616
  (100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
642
617
  );
643
618
 
644
- expect(text).toContain(`chain ${expectedPct}%`);
619
+ expect(text).toContain(`chain share: ${expectedPct}%`);
645
620
  expect(expectedPct).toBeLessThan(inflatedPct);
646
621
  });
622
+
623
+ it("feeds persisted custom_message steers into chain detection via the shared projection (#13)", async () => {
624
+ // A production-feed regression: chain detection/compaction/metrics must
625
+ // all see custom_message entries projected as role "custom" (src/batch-
626
+ // capture.ts projectBranchMessages), not just plain "message" entries.
627
+ // A non-pruner customType (isChainAnchorCustom) opens a chain while idle
628
+ // and anchors resolveRange the same way a user message does; a pruner-
629
+ // namespaced customType (context-prune-*) must NOT anchor one.
630
+ const t0 = new Date().toISOString();
631
+ let t = new Date(t0).getTime();
632
+
633
+ const customAnchoredChain: any[] = [
634
+ {
635
+ type: "custom_message",
636
+ customType: "pi-gauntlet-transition-recovery",
637
+ content: [{ type: "text", text: "continue" }],
638
+ timestamp: t0,
639
+ },
640
+ {
641
+ type: "message",
642
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-custom", name: "read", arguments: {} }] },
643
+ },
644
+ {
645
+ type: "message",
646
+ message: {
647
+ role: "toolResult",
648
+ toolCallId: "tc-custom",
649
+ toolName: "read",
650
+ content: [{ type: "text", text: "x".repeat(400) }],
651
+ timestamp: (t += 1000),
652
+ },
653
+ },
654
+ {
655
+ type: "message",
656
+ message: { role: "assistant", content: [{ type: "text", text: "done custom" }], timestamp: (t += 1000) },
657
+ },
658
+ ];
659
+
660
+ // A second closed, user-anchored chain so the custom-anchored chain above
661
+ // is not the newest/frontier chain (rollingWindow: 0 makes every closed
662
+ // chain not already compressed eligible regardless, but this mirrors a
663
+ // realistic multi-turn session and rules out any "only chain" special case).
664
+ const trailingChain: any[] = [
665
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "do more" }], timestamp: (t += 1000) } },
666
+ {
667
+ type: "message",
668
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-trail", name: "read", arguments: {} }] },
669
+ },
670
+ {
671
+ type: "message",
672
+ message: {
673
+ role: "toolResult",
674
+ toolCallId: "tc-trail",
675
+ toolName: "read",
676
+ content: [{ type: "text", text: "y".repeat(400) }],
677
+ timestamp: (t += 1000),
678
+ },
679
+ },
680
+ {
681
+ type: "message",
682
+ message: { role: "assistant", content: [{ type: "text", text: "done trailing" }], timestamp: (t += 1000) },
683
+ },
684
+ ];
685
+
686
+ const branch = [...customAnchoredChain, ...trailingChain];
687
+
688
+ const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch });
689
+
690
+ await handlers.get("session_start")!({}, ctx);
691
+ await handlers.get("turn_end")!(
692
+ { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
693
+ ctx,
694
+ );
695
+ await handlers.get("message_end")!(
696
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
697
+ ctx,
698
+ );
699
+
700
+ const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
701
+ const anchoredAtCustom = chainEntries.find(
702
+ (e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
703
+ );
704
+ expect(anchoredAtCustom).toBeDefined();
705
+ });
706
+
707
+ it("does not let a pruner-namespaced custom_message (context-prune-*) anchor a chain (#13)", async () => {
708
+ const t0 = new Date().toISOString();
709
+ let t = new Date(t0).getTime();
710
+
711
+ const pruneSummaryEntry: any[] = [
712
+ {
713
+ type: "custom_message",
714
+ customType: "context-prune-summary",
715
+ content: [{ type: "text", text: "prior summary" }],
716
+ timestamp: t0,
717
+ },
718
+ {
719
+ type: "message",
720
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-p", name: "read", arguments: {} }] },
721
+ },
722
+ {
723
+ type: "message",
724
+ message: {
725
+ role: "toolResult",
726
+ toolCallId: "tc-p",
727
+ toolName: "read",
728
+ content: [{ type: "text", text: "x".repeat(400) }],
729
+ timestamp: (t += 1000),
730
+ },
731
+ },
732
+ {
733
+ type: "message",
734
+ message: { role: "assistant", content: [{ type: "text", text: "done p" }], timestamp: (t += 1000) },
735
+ },
736
+ ];
737
+
738
+ const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch: pruneSummaryEntry });
739
+
740
+ await handlers.get("session_start")!({}, ctx);
741
+ await handlers.get("turn_end")!(
742
+ { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
743
+ ctx,
744
+ );
745
+ await handlers.get("message_end")!(
746
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
747
+ ctx,
748
+ );
749
+
750
+ const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
751
+ const anchoredAtSummary = chainEntries.find(
752
+ (e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
753
+ );
754
+ expect(anchoredAtSummary).toBeUndefined();
755
+ });
756
+
757
+ it("frontier-gap trigger fires at turn_end when the un-pruned tail exceeds the threshold (#13)", async () => {
758
+ const { handlers, ctx, notifications, appended } = await boot({
759
+ autoBudgetThreshold: null,
760
+ frontierGapThresholdTokens: 10,
761
+ });
762
+
763
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
764
+
765
+ await handlers.get("session_start")!({}, ctx);
766
+
767
+ // defaultBranch() already carries an unsummarized ~400-char toolResult
768
+ // (~100 tokens), well past the threshold of 10.
769
+ await handlers.get("turn_end")!(
770
+ {
771
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
772
+ toolResults: [
773
+ { role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
774
+ ],
775
+ turnIndex: 2,
776
+ },
777
+ ctx,
778
+ );
779
+
780
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
781
+ expect(flushMetricsEntries.length).toBe(1);
782
+ const fm = flushMetricsEntries[0].data as any;
783
+ expect(fm.trigger).toBe("frontier-gap");
784
+ expect(fm.metrics.frontierGapTokens).toBeGreaterThanOrEqual(10);
785
+
786
+ expect(notifications.some((n) => n.includes("un-pruned tail exceeded frontier gap threshold"))).toBe(true);
787
+ });
788
+
789
+ it("budget trigger takes precedence over frontier-gap when both conditions are met at turn_end (#13)", async () => {
790
+ const { handlers, ctx, appended } = await boot({
791
+ autoBudgetThreshold: 0.5,
792
+ frontierGapThresholdTokens: 10,
793
+ });
794
+
795
+ await handlers.get("session_start")!({}, ctx);
796
+
797
+ // Usage fraction 0.9 crosses the 0.5 budget threshold; defaultBranch()'s
798
+ // un-pruned tail also crosses the 10-token gap threshold. Budget must win.
799
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
800
+
801
+ await handlers.get("turn_end")!(
802
+ {
803
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
804
+ toolResults: [
805
+ { role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
806
+ ],
807
+ turnIndex: 2,
808
+ },
809
+ ctx,
810
+ );
811
+
812
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
813
+ expect(flushMetricsEntries.length).toBe(1);
814
+ expect((flushMetricsEntries[0].data as any).trigger).toBe("budget");
815
+ });
816
+
817
+ it("frontier-gap trigger stays inert when frontierGapThresholdTokens is unset (default null), even with a huge un-pruned tail (#13)", async () => {
818
+ const { handlers, ctx, appended } = await boot({
819
+ autoBudgetThreshold: null,
820
+ });
821
+
822
+ await handlers.get("session_start")!({}, ctx);
823
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
824
+
825
+ await handlers.get("turn_end")!(
826
+ {
827
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
828
+ toolResults: [
829
+ { role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
830
+ ],
831
+ turnIndex: 2,
832
+ },
833
+ ctx,
834
+ );
835
+
836
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
837
+ });
838
+
839
+ it("frontier-gap cadence: a partial-failure flush persists the surviving prefix and advances the frontier; the next gap-triggered flush advances it further (#13)", async () => {
840
+ const { handlers, ctx, appended } = await boot({
841
+ autoBudgetThreshold: null,
842
+ frontierGapThresholdTokens: 10,
843
+ branch: [],
844
+ });
845
+
846
+ await handlers.get("session_start")!({}, ctx);
847
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
848
+
849
+ // Turn 1: branch still empty -> frontierGapTokens is 0 -> no flush, even
850
+ // though this turn's own toolResults are pushed into pendingBatches.
851
+ await handlers.get("turn_end")!(
852
+ {
853
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-warmup", name: "read", arguments: {} }] },
854
+ toolResults: [
855
+ { role: "toolResult", toolCallId: "tc-warmup", toolName: "read", content: [{ type: "text", text: "w".repeat(400) }], timestamp: Date.now() },
856
+ ],
857
+ turnIndex: 1,
858
+ },
859
+ ctx,
860
+ );
861
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
862
+
863
+ // Grow the branch with two independent unindexed batches (tc-a, tc-b) —
864
+ // capturePendingBatches rescans the branch, not the in-memory queue, so
865
+ // this is what actually makes the upcoming flush see two batches.
866
+ let t = Date.now();
867
+ ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-a", "a".repeat(400), (t += 1000)));
868
+ ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-b", "b".repeat(400), (t += 1000)));
869
+
870
+ let callCount = 0;
871
+ streamImpl = () => {
872
+ callCount++;
873
+ summarizerCalls++;
874
+ if (callCount === 2) return errStream("simulated summarizer failure on second batch");
875
+ return okStream();
876
+ };
877
+
878
+ // Turn 2: gap now over threshold (tc-a + tc-b unsummarized) -> flush fires,
879
+ // processes tc-a successfully, tc-b's summarization call fails.
880
+ await handlers.get("turn_end")!(
881
+ {
882
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-a", name: "read", arguments: {} }] },
883
+ toolResults: [
884
+ { role: "toolResult", toolCallId: "tc-a", toolName: "read", content: [{ type: "text", text: "a".repeat(400) }], timestamp: Date.now() },
885
+ ],
886
+ turnIndex: 2,
887
+ },
888
+ ctx,
889
+ );
890
+
891
+ let frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
892
+ expect(frontierEntries.length).toBe(1);
893
+ const firstFrontier = frontierEntries[0].data as any;
894
+ expect(firstFrontier.lastAttemptedToolCallId).toBe("tc-a");
895
+
896
+ // Grow the branch again (tc-b is still unsummarized/pending after the
897
+ // restore; add tc-c as this turn's new work) — gap stays over threshold.
898
+ ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-c", "c".repeat(400), (t += 1000)));
899
+
900
+ // Turn 3: gap still over threshold -> flush fires again, this time both
901
+ // tc-b (restored) and tc-c succeed (streamImpl only fails on call #2).
902
+ await handlers.get("turn_end")!(
903
+ {
904
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-c", name: "read", arguments: {} }] },
905
+ toolResults: [
906
+ { role: "toolResult", toolCallId: "tc-c", toolName: "read", content: [{ type: "text", text: "c".repeat(400) }], timestamp: Date.now() },
907
+ ],
908
+ turnIndex: 3,
909
+ },
910
+ ctx,
911
+ );
912
+
913
+ frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
914
+ expect(frontierEntries.length).toBe(2);
915
+ const secondFrontier = frontierEntries[1].data as any;
916
+ expect(secondFrontier.lastAttemptedTimestamp).toBeGreaterThan(firstFrontier.lastAttemptedTimestamp);
917
+ });
647
918
  });
package/src/types.ts CHANGED
@@ -414,6 +414,12 @@ export interface ContextPruneConfig {
414
414
  * null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
415
415
  */
416
416
  budgetTurnDelta: number | null;
417
+ /**
418
+ * Opt-in flush trigger: when the un-pruned tail past the frontier
419
+ * (frontierGapTokens) reaches this many tokens, flush at turn_end.
420
+ * null (default) disables. Config-file-only — no settings overlay row.
421
+ */
422
+ frontierGapThresholdTokens: number | null;
417
423
  }
418
424
 
419
425
  /**
@@ -426,7 +432,10 @@ export interface ContextPruneConfig {
426
432
  * into a ChainCompressionEntry by adding blockId, toolRefs, and compressedAt.
427
433
  */
428
434
  export interface ChainRange {
429
- /** Timestamp of the user message that opens the chain. */
435
+ /**
436
+ * Start anchor timestamp — a user message or an eligible (non-pruner)
437
+ * custom message. Field name kept for persisted-entry compatibility.
438
+ */
430
439
  startUserTimestamp: number;
431
440
  /**
432
441
  * All toolCallIds in the chain's middle (deduplicated). Collected from both
@@ -463,7 +472,11 @@ export interface ChainRange {
463
472
  export interface ChainCompressionEntry {
464
473
  /** Stable block ID, monotonic per session: "b1", "b2", ... */
465
474
  blockId: string;
466
- /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */
475
+ /**
476
+ * Start anchor timestamp — a user message or an eligible (non-pruner)
477
+ * custom message. Field name kept for persisted-entry compatibility.
478
+ * Keep raw; synthetic inserted after.
479
+ */
467
480
  startUserTimestamp: number;
468
481
  /**
469
482
  * All toolCallIds in the chain's middle. **Diagnostic only** since the
@@ -566,6 +579,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
566
579
  spillThreshold: 65536,
567
580
  spillPreviewBytes: 2048,
568
581
  budgetTurnDelta: null,
582
+ frontierGapThresholdTokens: null,
569
583
  };
570
584
 
571
585
  // ── Captured batch ─────────────────────────────────────────────────────────
@@ -708,7 +722,7 @@ export interface ContextMetricsSnapshot {
708
722
  frontierGapTokens: number;
709
723
  }
710
724
 
711
- export type FlushTrigger = "budget" | "delta" | "message-end" | "manual" | "rearmed";
725
+ export type FlushTrigger = "budget" | "delta" | "frontier-gap" | "message-end" | "manual" | "rearmed";
712
726
 
713
727
  /** Payload of CUSTOM_TYPE_FLUSH_METRICS. */
714
728
  export interface FlushMetricsEntry {