pi-condense 2.4.2 → 2.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.5.0] - 2026-08-05
11
+
12
+ - **Removed the main-loop thinking strip (breaking: `contextPrune.thinkingStrip.*` no longer read).** The feature assumed thinking blocks we send are thinking blocks we are billed for. Verified against the live Anthropic API (`count_tokens` and real billed usage agree to the token), the actual rule is: thinking in a *closed* cycle bills 0 input tokens, and thinking in an *open* cycle survives only as an unbroken run starting at the cycle's first assistant turn — any gap discards everything after it. `keepLastTurns` kept the **last** K, so firing it punched a gap at the front and the API dropped all of it: the model received no thinking either way, and the no-gap → gap transition cost a full cache invalidation (measured +33% in a controlled 24-tool-call A/B, $0.6430 → $0.8531, with turn 17 rewriting 63113 tokens at 0% reuse). Across 58 sessions / 1233 open cycles the strip fired 253 times and reached its ~130-turn break-even twice. It was also redundant: chain compression's synthetic block is a `role: "user"` text message, which closes the cycle and frees all prior thinking server-side at zero cache cost. `pruneMessages` drops to three phases (`stub-replace → error-purge → chain-range-prune`); `src/thinking-strip.ts`, `ThinkingStripConfig`, `KEEP_LAST_TURNS_PRESETS`, the two `/pruner` settings entries, and `PruneFrontier.thinkingStripBoundaryTimestamp` are gone. A leftover `thinkingStrip` block in `settings.json` is inert and round-trips untouched (`normalize()` never filters unknown keys); a persisted `context-prune-frontier` entry carrying the old boundary field loads and is ignored. Rationale and measurements: `doc/specs/2026-08-05-remove-thinking-strip.md`.
13
+
14
+ ## [2.4.3] - 2026-08-04
15
+
16
+ - **Flush-gated, timestamp-keyed thinking strip ([#3](https://github.com/jjuraszek/pi-condense/issues/3)).** Phase 4 (`stripOldThinking`, `src/thinking-strip.ts`) recomputed its keep-window from the *live assistant count on every `context` render*, so each turn the `(count - keepLastTurns)`-th assistant slid forward and its thinking was stripped **deep in history**. pi-ai sets prompt-cache breakpoints only at `tools + system + last message` (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages` - no in-history breakpoint), so every deep mutation busted the cached suffix - roughly every render inside a tool loop. The strip boundary is now a **flush-computed, persisted assistant-message timestamp** (`thinkingStripBoundaryTimestamp`, added to the `PruneFrontier` snapshot on the existing `context-prune-frontier` entry): fixed between flushes so consecutive renders are byte-stable in their historical prefix (the cache survives a whole tool loop), advancing only on a non-empty flush (piggybacking summarization's own cache bust - zero marginal busts), monotonically clamped (never re-adds thinking to an already-stripped message, even when `keepLastTurns` is increased mid-session), and keyed by timestamp rather than array index so it is robust to phase-3 chain-range middle drops. An absent boundary (pre-feature sessions, pre-first-flush) falls back to the original live-count window verbatim. Additive optional field, fully backward-compatible; **no new config key**, `keepLastTurns` presets unchanged. `PRUNING.md` cache-impact model corrected. Turns the old k-busts-per-render into ~1-per-request.
17
+
10
18
  ## [2.4.2] - 2026-08-04
11
19
 
12
20
  - **No-op renders skip context serialization.** `pruneMessages` (`src/pruner.ts`) computed `sizeMessages` (a full `JSON.stringify` over the entire message array) unconditionally on every `context` render, including no-ops where the result is never read (`index.ts` consumes `beforeChars`/`afterChars` only under `if (result.pruned)`). It now computes both sizes lazily in the pruned branch and returns a `{ beforeChars: 0, afterChars: 0 }` sentinel on a no-op, so a render that prunes nothing does zero `JSON.stringify` over the array. CPU/GC only - zero token cost, no wire or return-shape change. Also corrects a stale fast-path comment in the `context` handler (`index.ts`): index/registry emptiness alone does not imply a no-op, because error-purge and thinking-strip prune independently.
package/PRUNING.md CHANGED
@@ -28,12 +28,11 @@
28
28
  10. [Chain Compression](#chain-compression)
29
29
  - [Protected-output relocation](#protected-output-relocation)
30
30
  11. [Error Purge](#error-purge)
31
- 12. [Main-loop Thinking Strip](#main-loop-thinking-strip)
32
- 13. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
31
+ 12. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
33
32
  - [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization)
34
33
  - [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents)
35
34
  - [ACON — Agent Context Optimization](#acon--agent-context-optimization)
36
- 14. [Summary](#summary)
35
+ 13. [Summary](#summary)
37
36
 
38
37
  ---
39
38
 
@@ -688,7 +687,7 @@ The last attempted prune boundary is persisted as `context-prune-frontier` so `f
688
687
  - **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
689
688
  - **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
690
689
  - **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
691
- - **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all four reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune, thinking-strip); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
690
+ - **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all three reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
692
691
  - **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
693
692
 
694
693
  ### Summarizer outage fallback
@@ -863,6 +862,8 @@ A **closed chain** is a span of messages from one user message through any numbe
863
862
  | Final text-only assistant | **Kept**, thinking blocks stripped (safe — no following tool cycle depends on the signature) |
864
863
  | Synthetic `<compressed-chain>` user message | **Injected** immediately after the start user message |
865
864
 
865
+ **Why there is no separate thinking strip.** The synthetic `<compressed-chain>` block is injected as a `role: "user"` text message, which Anthropic reads as a genuine user turn. That closes the assistant cycle, and the API drops every prior thinking block from the context window server-side - verified against the live API: the same 8-turn loop bills 3152 thinking tokens with a full `toolResult` history and 0 once a user text block precedes the final assistant. So chain compression already reclaims thinking mass for free, as a side effect of the range drop that was rewriting that region anyway. Stub replacement does not have this effect: it preserves `role: "toolResult"`, so the cycle stays open. A dedicated strip phase shipped through v2.4.3; removed in v2.5.0 - see `doc/specs/2026-08-05-remove-thinking-strip.md`.
866
+
866
867
  ### Transform composition order
867
868
 
868
869
  ```
@@ -870,14 +871,13 @@ raw messages from session
870
871
 
871
872
  ├─ [1] tool-result stub-replace (per-batch; existing)
872
873
  ├─ [2] error-purge (phase 2)
873
- ├─ [3] chain-range-prune (runs AFTER stubs)
874
- for each compressed chain:
875
- drop middle assistants (by toolCallId overlap)
876
- drop middle toolResults (by toolCallId)
877
- suppress per-batch summaries (by toolCallRefs overlap)
878
- inject <compressed-chain> after start user
879
- strip thinking from final assistant
880
- └─ [4] thinking-strip (keep thinking on last K assistant turns)
874
+ └─ [3] chain-range-prune (runs AFTER stubs)
875
+ for each compressed chain:
876
+ drop middle assistants (by toolCallId overlap)
877
+ drop middle toolResults (by toolCallId)
878
+ suppress per-batch summaries (by toolCallRefs overlap)
879
+ inject <compressed-chain> after start user
880
+ strip thinking from final assistant
881
881
  ```
882
882
 
883
883
  ### Identification model
@@ -981,7 +981,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
981
981
  **Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
982
982
 
983
983
  ```
984
- [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
984
+ [stub-replace] → [error-purge] → [chain-range-prune]
985
985
  ```
986
986
 
987
987
  **Config keys:**
@@ -994,52 +994,6 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
994
994
 
995
995
  ---
996
996
 
997
- ## Main-loop Thinking Strip
998
-
999
- Chain compression and the summarizer target *tool* mass. But in long single-agent sessions the dominant cost is often **assistant `thinking` blocks**: on Opus 4.5+/Sonnet 4.6+ the API retains every prior-turn thinking block by default, and pi-ai replays them all (with signatures) on every request. One autonomous ops session held ~405 K tokens (~80% of a 500 K window) in thinking alone, untouched by every other strategy — chain compression only fires on *closed* spans, and that session was one long open span.
1000
-
1001
- Thinking strip is a deterministic, zero-LLM transform (Phase 4) that keeps `thinking` blocks only on the last `keepLastTurns` **assistant turns** and strips them from older assistant messages, leaving each message's `text` and `toolCall` blocks intact.
1002
-
1003
- ### Turn unit
1004
-
1005
- `keepLastTurns` counts **assistant messages**, not user-bounded spans. The target failure mode is a single long open chain (zero subagents, near-zero user turns) where a span-based window would keep everything. Counting assistant turns directly bounds thinking accumulation regardless of whether any chain closes.
1006
-
1007
- ### Provider safety
1008
-
1009
- Anthropic's extended-thinking contract during tool use:
1010
-
1011
- - Only the **last assistant turn's** thinking is required; "you can omit thinking blocks from prior assistant role turns" and the API auto-filters them.
1012
- - A message's thinking blocks must be dropped **all-or-nothing** ("the entire sequence of consecutive thinking blocks must match the outputs … you can't rearrange or modify the sequence"). The strip reuses `withoutThinkingBlocks`, which removes every thinking block (and its signature) from a message.
1013
-
1014
- `keepLastTurns` is clamped to `>= 1`, so the most-recent assistant turn — the one that may be awaiting tool results — always keeps its thinking. This is the minimum safe window; the default of 16 is far above the floor and preserves recent reasoning continuity.
1015
-
1016
- ### Transform position
1017
-
1018
- Thinking strip runs **last**, after chain-range-prune, so "last K assistant turns" is measured over the turns that actually survive to the LLM:
1019
-
1020
- ```
1021
- [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
1022
- ```
1023
-
1024
- In a session with no closed chains, Phases 1–3 may be no-ops and thinking strip does all the work. Where chain compression *does* fire, the two cooperate: chain compression drops whole old middle turns (including their thinking); thinking strip mops up thinking in the surviving recent / in-flight turns beyond K.
1025
-
1026
- ### Cache impact
1027
-
1028
- Each new assistant turn slides the keep-window by one, stripping the turn that falls out and invalidating the prefix cache from that point (~K turns deep). The stable cached prefix (everything older than the window) still grows monotonically; only a K-deep tail churns. The trade vs the status quo: without stripping, thinking accrues without bound and is billed as cached input on every request until the window overflows; with stripping, total context is bounded at the cost of re-processing the last ~K turns' thinking each turn. Net-positive for long sessions; a literal no-op for sessions under `keepLastTurns` turns. Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).
1029
-
1030
- ### Recovery
1031
-
1032
- Stripped thinking is **not** recoverable via `context_tree_query` — unlike tool outputs, thinking blocks are not indexed. The raw thinking remains in the session JSONL on disk (the `context` hook never mutates storage); reloading the session without the extension, or reading the file directly, shows the original blocks. Thinking is transient model-internal reasoning, so drop-without-recovery is intentional.
1033
-
1034
- ### Config keys
1035
-
1036
- | Key | Default | Description |
1037
- |---|---|---|
1038
- | `thinkingStrip.enabled` | `true` | Master toggle (gated behind the top-level `enabled`) |
1039
- | `thinkingStrip.keepLastTurns` | `16` | Keep thinking on the last N assistant turns; strip older. Clamped to `>= 1` |
1040
-
1041
- ---
1042
-
1043
997
  ## Summary
1044
998
 
1045
999
  | Concern | How Pruning Addresses It |
package/index.ts CHANGED
@@ -486,6 +486,15 @@ export default function (pi: ExtensionAPI) {
486
486
  ? "skipped-deduped"
487
487
  : "skipped-trivial";
488
488
 
489
+ // Raw session branch, unwrapped once for the chain-compression block below.
490
+ // Only materialized when chain compression is enabled.
491
+ let branchMessages: any[] | undefined;
492
+ if (currentConfig.value.chainCompression.enabled) {
493
+ branchMessages = ctx.sessionManager.getBranch()
494
+ .filter((e: any) => e.type === "message" && e.message)
495
+ .map((e: any) => e.message);
496
+ }
497
+
489
498
  const frontierSnapshot: PruneFrontier = {
490
499
  lastAttemptedToolCallId: lastTC.toolCallId,
491
500
  lastAttemptedToolName: lastTC.toolName,
@@ -524,14 +533,11 @@ export default function (pi: ExtensionAPI) {
524
533
  // Non-fatal: a failure here does not roll back the successful summarization.
525
534
  if (currentConfig.value.chainCompression.enabled) {
526
535
  try {
527
- const branch = ctx.sessionManager.getBranch();
528
- const branchMessages = branch
529
- .filter((e: any) => e.type === "message" && e.message)
530
- .map((e: any) => e.message);
531
536
  // message_end fires before pi persists the closing assistant, so thread it
532
537
  // in here; otherwise the newest chain reads as open and K over-retains by 1.
533
- const chains = detectChains(withClosingMessage(branchMessages, options.closingMessage), protectionPredicate);
534
- const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
538
+ // branchMessages was unwrapped once above, gated on chainCompression.enabled.
539
+ const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
540
+ const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
535
541
  const { compressedEntries } = await compressEligible(
536
542
  chains,
537
543
  currentConfig.value.chainCompression.rollingWindow,
@@ -819,16 +825,14 @@ export default function (pi: ExtensionAPI) {
819
825
 
820
826
  // pruneMessages is the single source of truth for "is there work to do".
821
827
  // It returns the original array reference (pruned: false) only when none of
822
- // the four phases changed anything; index/registry emptiness alone does not
823
- // imply a no-op, since error-purge (phase 2) and thinking-strip (phase 4)
824
- // prune independently of them. Calling it unconditionally is safe and avoids
825
- // a split gate here.
828
+ // the three phases changed anything; index/registry emptiness alone does not
829
+ // imply a no-op, since error-purge (phase 2) prunes independently of them.
830
+ // Calling it unconditionally is safe and avoids a split gate here.
826
831
  const result = pruneMessages(
827
832
  messages,
828
833
  indexer,
829
834
  currentConfig.value.chainCompression,
830
835
  currentConfig.value.purgeErrors,
831
- currentConfig.value.thinkingStrip,
832
836
  currentConfig.value,
833
837
  currentConfig.value.recoveryGraceTurns,
834
838
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
package/src/commands.ts CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  SUMMARIZER_MAX_TIMEOUT_PRESETS,
17
17
  AUTO_BUDGET_PRESETS,
18
18
  ROLLING_WINDOW_PRESETS,
19
- KEEP_LAST_TURNS_PRESETS,
20
19
  PURGE_COOLDOWN_PRESETS,
21
20
  PURGE_MIN_ARG_PRESETS,
22
21
  DEFAULT_CONFIG,
@@ -648,22 +647,6 @@ export function registerCommands(
648
647
  currentValue: String(config.chainCompression.fuseRangeSummary),
649
648
  description: `Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span). Off keeps the per-batch concatenation. Currently ${config.chainCompression.fuseRangeSummary ? "ON" : "OFF"}.`,
650
649
  },
651
- {
652
- id: "thinkingStripEnabled",
653
- label: "Thinking strip",
654
- values: ["true", "false"],
655
- currentValue: String(config.thinkingStrip.enabled),
656
- description: `Strip thinking blocks from assistant turns older than the last ${config.thinkingStrip.keepLastTurns}. Reclaims main-loop thinking accumulation; no-op under ${config.thinkingStrip.keepLastTurns} turns. Currently ${config.thinkingStrip.enabled ? "ON" : "OFF"}.`,
657
- },
658
- {
659
- id: "thinkingStripKeepLastTurns",
660
- label: "Thinking keep (last N turns)",
661
- values: KEEP_LAST_TURNS_PRESETS.map((p) => p.value),
662
- currentValue: KEEP_LAST_TURNS_PRESETS.some((p) => p.value === String(config.thinkingStrip.keepLastTurns))
663
- ? String(config.thinkingStrip.keepLastTurns)
664
- : KEEP_LAST_TURNS_PRESETS[2].value,
665
- description: `Keep thinking on the last N assistant turns; strip older. Counts assistant turns, not chains. Currently ${config.thinkingStrip.keepLastTurns}.`,
666
- },
667
650
  {
668
651
  id: "purgeErrorsEnabled",
669
652
  label: "Error purge",
@@ -801,14 +784,6 @@ export function registerCommands(
801
784
  newConfig.chainCompression = { ...newConfig.chainCompression, stripFinalAssistantThinking: newValue === "true" };
802
785
  } else if (id === "chainCompressionFuseRange") {
803
786
  newConfig.chainCompression = { ...newConfig.chainCompression, fuseRangeSummary: newValue === "true" };
804
- } else if (id === "thinkingStripEnabled") {
805
- newConfig.thinkingStrip = { ...newConfig.thinkingStrip, enabled: newValue === "true" };
806
- } else if (id === "thinkingStripKeepLastTurns") {
807
- const parsed = Number.parseInt(newValue, 10);
808
- newConfig.thinkingStrip = {
809
- ...newConfig.thinkingStrip,
810
- keepLastTurns: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.thinkingStrip.keepLastTurns,
811
- };
812
787
  } else if (id === "purgeErrorsEnabled") {
813
788
  newConfig.purgeErrors = { ...newConfig.purgeErrors, enabled: newValue === "true" };
814
789
  } else if (id === "purgeErrorsCooldown") {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeAll, afterAll } from "bun:test";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DEFAULT_CONFIG } from "./types.js";
@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "./types.js";
14
14
  */
15
15
  let tmpDir: string;
16
16
  let loadConfig: typeof import("./config.js").loadConfig;
17
+ let saveConfig: typeof import("./config.js").saveConfig;
17
18
  let settingsPath: typeof import("./config.js").settingsPath;
18
19
 
19
20
  beforeAll(async () => {
@@ -21,6 +22,7 @@ beforeAll(async () => {
21
22
  process.env.PI_CODING_AGENT_DIR = tmpDir;
22
23
  const mod = await import("./config.js");
23
24
  loadConfig = mod.loadConfig;
25
+ saveConfig = mod.saveConfig;
24
26
  settingsPath = mod.settingsPath;
25
27
  });
26
28
 
@@ -99,3 +101,27 @@ describe("loadConfig summarizer timeout normalization", () => {
99
101
  expect(config.summarizerIdleTimeoutMs).toBe(1234);
100
102
  });
101
103
  });
104
+
105
+ describe("loadConfig backward compatibility with removed thinkingStrip key", () => {
106
+ it("loads without error and round-trips a stale contextPrune.thinkingStrip block unchanged", async () => {
107
+ const stale = { enabled: true, keepLastTurns: 16 };
108
+ await writeContextPrune({ thinkingStrip: stale });
109
+
110
+ const config = await loadConfig();
111
+
112
+ // thinkingStrip is no longer a recognized key: DEFAULT_CONFIG carries no
113
+ // such field, so nothing reads or acts on it.
114
+ expect((DEFAULT_CONFIG as unknown as Record<string, unknown>).thinkingStrip).toBeUndefined();
115
+ // normalize() spreads { ...DEFAULT_CONFIG, ...existing } and re-spreads
116
+ // the merge, so the unrecognized key survives verbatim on the loaded value.
117
+ expect((config as unknown as Record<string, unknown>).thinkingStrip).toEqual(stale);
118
+
119
+ // saveConfig() re-serializes the same config object it's given, so the
120
+ // stale block written above must still be present, byte-equivalent, after
121
+ // a full load -> save round trip through the real settingsPath() file.
122
+ await saveConfig(config);
123
+ const raw = await readFile(settingsPath(), "utf-8");
124
+ const written = JSON.parse(raw);
125
+ expect(written.contextPrune.thinkingStrip).toEqual(stale);
126
+ });
127
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PruneFrontierTracker } from "./frontier.js";
3
+ import { pruneMessages } from "./pruner.js";
4
+ import type { PruneFrontier } from "./types.js";
5
+
6
+ const base: PruneFrontier = {
7
+ lastAttemptedToolCallId: "tc1",
8
+ lastAttemptedToolName: "bash",
9
+ lastAttemptedTurnIndex: 3,
10
+ lastAttemptedTimestamp: 1000,
11
+ attemptedBatchCount: 1,
12
+ attemptedToolCallCount: 2,
13
+ rawCharCount: 500,
14
+ summaryCharCount: 100,
15
+ outcome: "summarized",
16
+ };
17
+
18
+ describe("PruneFrontierTracker.fromJSON", () => {
19
+ test("round-trips a full frontier", () => {
20
+ const t = new PruneFrontierTracker();
21
+ t.fromJSON({ ...base });
22
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
23
+ expect(t.get()?.outcome).toBe("summarized");
24
+ });
25
+
26
+ test("ignores an entry with no lastAttemptedToolCallId", () => {
27
+ const t = new PruneFrontierTracker();
28
+ t.fromJSON({} as PruneFrontier);
29
+ expect(t.get()).toBeNull();
30
+ });
31
+
32
+ test("tolerates a legacy entry carrying the removed thinkingStripBoundaryTimestamp", () => {
33
+ const t = new PruneFrontierTracker();
34
+ t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 } as PruneFrontier);
35
+ expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
36
+ expect((t.get() as any).thinkingStripBoundaryTimestamp).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("PruneFrontierTracker.reconstructFromSession", () => {
41
+ test("reconstructs from a persisted frontier entry", () => {
42
+ const t = new PruneFrontierTracker();
43
+ const entries = [
44
+ { type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } },
45
+ ];
46
+ const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
47
+ t.reconstructFromSession(fakeCtx);
48
+ expect(t.get()?.lastAttemptedTimestamp).toBe(2000);
49
+ });
50
+
51
+ // Spans frontier.ts + pruner.ts on purpose: proves a resumed legacy frontier carries
52
+ // no boundary into an *actively-pruning* pipeline, not just an inert one. Phase 1
53
+ // (stub-replace) and Phase 3 (chain-range-prune) are both wired live here -- a
54
+ // summarized toolResult gets stubbed and a chain entry produces a synthetic
55
+ // <compressed-chain> message -- and every surviving assistant turn, both older and
56
+ // newer than the legacy boundary, still carries its thinking block. This does not
57
+ // (and cannot) prove the deleted thinking-strip phase stays deleted; it proves the
58
+ // phases that remain do not touch thinking regardless of the legacy field's presence.
59
+ test("a legacy frontier entry with thinkingStripBoundaryTimestamp resumes without error and strips nothing", () => {
60
+ const legacyBoundary = 555;
61
+ const t = new PruneFrontierTracker();
62
+ const entries = [
63
+ {
64
+ type: "custom",
65
+ customType: "context-prune-frontier",
66
+ data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary },
67
+ },
68
+ ];
69
+ const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
70
+
71
+ expect(() => t.reconstructFromSession(fakeCtx)).not.toThrow();
72
+ const frontier = t.get();
73
+ expect(frontier).not.toBeNull();
74
+ expect(frontier?.lastAttemptedToolCallId).toBe("tc1");
75
+ expect((frontier as any).thinkingStripBoundaryTimestamp).toBeUndefined();
76
+
77
+ const chainEntry = {
78
+ blockId: "b1",
79
+ startUserTimestamp: 560,
80
+ droppedToolCallIds: ["tc-old"],
81
+ finalAssistantTimestamp: 600,
82
+ toolRefs: ["told"],
83
+ compressedAt: 9999,
84
+ };
85
+
86
+ const indexer = {
87
+ isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
88
+ getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
89
+ getRecord: () => undefined,
90
+ getChainEntries: () => [chainEntry],
91
+ getPerBatchSummaryTextForToolCallIds: () => "chain summary text",
92
+ findChainEntryByBlockId: () => undefined,
93
+ } as any;
94
+
95
+ const mkAsst = (ts: number) => ({
96
+ role: "assistant",
97
+ content: [
98
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
99
+ { type: "text", text: "x" },
100
+ ],
101
+ timestamp: ts,
102
+ usage: {},
103
+ stopReason: "end_turn",
104
+ });
105
+ const mkAsstWithCall = (ts: number, toolCallId: string) => ({
106
+ role: "assistant",
107
+ content: [
108
+ { type: "thinking", thinking: "t", thinkingSignature: "s" },
109
+ { type: "toolCall", id: toolCallId, name: "bash", arguments: {} },
110
+ ],
111
+ timestamp: ts,
112
+ usage: {},
113
+ stopReason: "tool_use",
114
+ });
115
+
116
+ // Timestamps straddle the legacy boundary: old code would have stripped the ones below it.
117
+ // tc-stub is a plain summarized tool result (phase 1 target, outside the chain).
118
+ // tc-old is dropped by the chain entry (phase 3 target).
119
+ const messages: any[] = [
120
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
121
+ mkAsst(legacyBoundary - 100),
122
+ mkAsstWithCall(legacyBoundary - 55, "tc-stub"),
123
+ {
124
+ role: "toolResult",
125
+ toolCallId: "tc-stub",
126
+ toolName: "bash",
127
+ content: [{ type: "text", text: "raw stub-target output" }],
128
+ isError: false,
129
+ timestamp: legacyBoundary - 50,
130
+ },
131
+ mkAsst(legacyBoundary - 1),
132
+ { role: "user", content: [{ type: "text", text: "do it" }], timestamp: 560 },
133
+ mkAsstWithCall(570, "tc-old"),
134
+ {
135
+ role: "toolResult",
136
+ toolCallId: "tc-old",
137
+ toolName: "bash",
138
+ content: [{ type: "text", text: "raw chain output" }],
139
+ isError: false,
140
+ timestamp: 575,
141
+ },
142
+ mkAsst(600),
143
+ mkAsst(legacyBoundary + 100),
144
+ ];
145
+
146
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
147
+ enabled: true,
148
+ rollingWindow: 0,
149
+ stripFinalAssistantThinking: false,
150
+ fuseRangeSummary: false,
151
+ });
152
+
153
+ // Non-vacuity: the pipeline actually did something.
154
+ expect(pruned).toBe(true);
155
+
156
+ // Phase 1 fired: the summarized-but-not-chained toolResult was stub-replaced.
157
+ const stubResult = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-stub") as any;
158
+ expect(stubResult).toBeDefined();
159
+ expect(stubResult.content[0].text).toContain("`t1`");
160
+ expect(stubResult.content[0].text).not.toContain("raw stub-target output");
161
+
162
+ // Phase 3 fired: the chain entry produced a synthetic compressed-chain message.
163
+ const synthetic = out.find(
164
+ (m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
165
+ );
166
+ expect(synthetic).toBeDefined();
167
+
168
+ // Every surviving assistant turn, older and newer than the legacy boundary, keeps thinking.
169
+ const assistants = out.filter((m: any) => m.role === "assistant");
170
+ expect(assistants.length).toBe(5);
171
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
172
+ });
173
+ });
@@ -374,7 +374,7 @@ describe("pruneMessages", () => {
374
374
  expect(pruned).toBe(false);
375
375
  });
376
376
 
377
- it("composes stub-replace (Phase 1) with thinking-strip (Phase 4)", () => {
377
+ it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => {
378
378
  const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
379
379
  const mkAsst = (ts: number) => ({
380
380
  role: "assistant",
@@ -393,21 +393,17 @@ describe("pruneMessages", () => {
393
393
  messages.push(mkAsst(10 + i));
394
394
  messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
395
395
  }
396
- const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, {
397
- enabled: true,
398
- keepLastTurns: 2,
396
+ const { messages: out, pruned } = pruneMessages(messages, indexer, {
397
+ enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false,
399
398
  });
399
+ // Phase 1 still fires: c10's toolResult is stub-replaced.
400
400
  expect(pruned).toBe(true);
401
-
402
- // Phase 1: c10 toolResult stub-replaced
403
401
  const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
404
402
  expect(tr.content[0].text).toContain("`t1`");
405
-
406
- // Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
403
+ // No phase strips thinking any more — all five assistants keep theirs.
407
404
  const assistants = out.filter((m: any) => m.role === "assistant");
408
- const hasThinking = (m: any) => m.content.some((c: any) => c.type === "thinking");
409
- expect(assistants.slice(0, 3).every((a: any) => !hasThinking(a))).toBe(true);
410
- expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
405
+ expect(assistants.length).toBe(5);
406
+ expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
411
407
  });
412
408
  });
413
409
 
@@ -437,7 +433,7 @@ describe("render-time protection re-check", () => {
437
433
 
438
434
  it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
439
435
  const { messages, pruned } = pruneMessages(
440
- [skillMsg], indexer as any, undefined, undefined, undefined,
436
+ [skillMsg], indexer as any, undefined, undefined,
441
437
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
442
438
  );
443
439
  expect(pruned).toBe(false);
@@ -468,7 +464,7 @@ describe("pruneMessages recovery grace", () => {
468
464
  shortRefs: new Map([["tc-recover", "t1"]]),
469
465
  });
470
466
  const messages = [mkQueryResult("tc-recover", 1)];
471
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
467
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
472
468
  expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
473
469
  });
474
470
 
@@ -478,7 +474,7 @@ describe("pruneMessages recovery grace", () => {
478
474
  shortRefs: new Map([["tc-recover", "t1"]]),
479
475
  });
480
476
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
481
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
477
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
482
478
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
483
479
  expect(tr.content[0].text).toContain("context_tree_query");
484
480
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
@@ -490,7 +486,7 @@ describe("pruneMessages recovery grace", () => {
490
486
  shortRefs: new Map([["tc-recover", "t1"]]),
491
487
  });
492
488
  const messages = [mkQueryResult("tc-recover", 1)];
493
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 0);
489
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
494
490
  expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
495
491
  expect(out[0].content[0].text).toContain("context_tree_query");
496
492
  });
@@ -510,7 +506,7 @@ describe("pruneMessages recovery grace", () => {
510
506
  timestamp: 1,
511
507
  },
512
508
  ];
513
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
509
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
514
510
  expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
515
511
  expect(out[0].content[0].text).toContain("context_tree_query");
516
512
  });
@@ -526,7 +522,7 @@ describe("pruneMessages recovery grace", () => {
526
522
  });
527
523
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
528
524
  const { messages: out } = pruneMessages(
529
- messages, indexer, undefined, undefined, undefined,
525
+ messages, indexer, undefined, undefined,
530
526
  { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
531
527
  0,
532
528
  );
@@ -545,7 +541,7 @@ describe("pruneMessages recovery grace", () => {
545
541
  }]]),
546
542
  });
547
543
  const messages = [mkQueryResult("tc-recover", 1)];
548
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
544
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
549
545
  expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
550
546
  });
551
547
 
@@ -560,7 +556,7 @@ describe("pruneMessages recovery grace", () => {
560
556
  }]]),
561
557
  });
562
558
  const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
563
- const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
559
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
564
560
  const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
565
561
  expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
566
562
  expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
@@ -572,7 +568,7 @@ describe("sizeMessages", () => {
572
568
  it("counts hidden fields (thinking blocks), not just visible text", () => {
573
569
  // Two messages with identical visible .text but different hidden content.
574
570
  // sizeMessages must count the full serialized weight so all reclaim
575
- // mechanisms (thinking-strip, error-purge, etc.) register correctly.
571
+ // mechanisms (stub-replace, error-purge, chain-range-prune) register correctly.
576
572
  const withThinking = [{
577
573
  role: "assistant",
578
574
  content: [
package/src/pruner.ts CHANGED
@@ -1,23 +1,22 @@
1
1
  import type { ToolCallIndexer } from "./indexer.js";
2
- import type { ChainCompressionConfig, ErrorPurgeConfig, ThinkingStripConfig } from "./types.js";
2
+ import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
3
3
  import { isProtected, type ProtectionConfig } from "./protected.js";
4
4
  import { applyChainCompressions } from "./chain-range-prune.js";
5
5
  import { purgeErroredArgs } from "./error-purge.js";
6
- import { stripOldThinking } from "./thinking-strip.js";
7
6
  import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
8
7
 
9
8
  /**
10
9
  * Estimate of a message array's context weight. Serializing the whole array
11
10
  * (not just visible text) is deliberate: it counts tool-call argument bodies
12
- * (error-purge), thinking blocks (thinking-strip), and tool-result arrays
13
- * (stub-replace / chain-range) so all reclaim mechanisms register.
11
+ * (error-purge) and tool-result arrays (stub-replace / chain-range) so all
12
+ * reclaim mechanisms register.
14
13
  */
15
14
  export function sizeMessages(messages: any[]): number {
16
15
  return JSON.stringify(messages).length;
17
16
  }
18
17
 
19
18
  /**
20
- * Transforms the `context` event message array in two passes:
19
+ * Transforms the `context` event message array in three phases:
21
20
  *
22
21
  * Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
23
22
  * replaced with short stubs pointing the model at `context_tree_query`.
@@ -43,12 +42,6 @@ export function sizeMessages(messages: any[]): number {
43
42
  * synthetic user message wrapping the existing per-batch summary text.
44
43
  * Only runs when `chainCompression.enabled` and chain entries exist.
45
44
  *
46
- * Phase 4 — thinking strip: keep `thinking` blocks only on the last
47
- * `keepLastTurns` assistant turns; strip them from older assistant messages
48
- * (preserving text + toolCall). Runs last so the window counts the assistant
49
- * turns that actually survive to the LLM. Only runs when
50
- * `thinkingStrip.enabled`.
51
- *
52
45
  * Return shape:
53
46
  * - `pruned: true` — at least one change happened; the returned
54
47
  * `messages` is a freshly allocated array.
@@ -71,7 +64,6 @@ export function pruneMessages(
71
64
  indexer: ToolCallIndexer,
72
65
  chainCompression?: ChainCompressionConfig,
73
66
  errorPurge?: ErrorPurgeConfig,
74
- thinkingStrip?: ThinkingStripConfig,
75
67
  protection?: ProtectionConfig,
76
68
  recoveryGraceTurns: number = 0,
77
69
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
@@ -154,15 +146,6 @@ export function pruneMessages(
154
146
  }
155
147
  }
156
148
 
157
- // Phase 4: thinking strip — keep thinking only on the last K assistant turns
158
- if (thinkingStrip?.enabled) {
159
- const afterStrip = stripOldThinking(current, thinkingStrip);
160
- if (afterStrip !== current) {
161
- current = afterStrip;
162
- pruned = true;
163
- }
164
- }
165
-
166
149
  return pruned
167
150
  ? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
168
151
  : { messages, pruned, beforeChars: 0, afterChars: 0 };
package/src/types.ts CHANGED
@@ -163,19 +163,6 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
163
163
  { value: "10", label: "10" },
164
164
  ];
165
165
 
166
- /**
167
- * Cycling preset values for the `thinkingStrip.keepLastTurns` setting.
168
- * Stored as strings because SettingsList cycles string values; converted to
169
- * number when applied. Counts ASSISTANT turns (messages), not closed chains.
170
- */
171
- export const KEEP_LAST_TURNS_PRESETS: { value: string; label: string }[] = [
172
- { value: "4", label: "4" },
173
- { value: "8", label: "8" },
174
- { value: "16", label: "16 (default)" },
175
- { value: "32", label: "32" },
176
- { value: "64", label: "64" },
177
- ];
178
-
179
166
  /**
180
167
  * Cycling preset values for the `minBatchChars` setting in the SettingsList.
181
168
  * Stored as strings because SettingsList cycles string values; converted to
@@ -349,8 +336,6 @@ export interface ContextPruneConfig {
349
336
  chainCompression: ChainCompressionConfig;
350
337
  /** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */
351
338
  purgeErrors: ErrorPurgeConfig;
352
- /** Rolling main-loop thinking-block strip: keep thinking only on the last K assistant turns. */
353
- thinkingStrip: ThinkingStripConfig;
354
339
  /**
355
340
  * Pre-flush content-hash dedup pass. When `true`, each captured tool call
356
341
  * is hashed by `(toolName, normalize(resultText))` and compared against
@@ -497,18 +482,6 @@ export interface ErrorPurgeConfig {
497
482
  minArgChars: number;
498
483
  }
499
484
 
500
- export interface ThinkingStripConfig {
501
- enabled: boolean;
502
- /**
503
- * Keep `thinking` blocks on the last K assistant turns; strip them from
504
- * older assistant messages (preserving text + toolCall blocks). Counts
505
- * assistant messages, not closed chains. Clamped to >= 1 so the most-recent
506
- * assistant turn always keeps its thinking (Anthropic requires the last
507
- * assistant turn's thinking during tool use). Default 16.
508
- */
509
- keepLastTurns: number;
510
- }
511
-
512
485
  export const DEFAULT_CONFIG: ContextPruneConfig = {
513
486
  enabled: false,
514
487
  showPruneStatusLine: true,
@@ -534,10 +507,6 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
534
507
  cooldownTurns: 2,
535
508
  minArgChars: 500,
536
509
  },
537
- thinkingStrip: {
538
- enabled: true,
539
- keepLastTurns: 16,
540
- },
541
510
  dedupByContentHash: true,
542
511
  autoBudgetThreshold: null,
543
512
  spillThreshold: 65536,
@@ -1,175 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { stripOldThinking } from "./thinking-strip.js";
3
- import type { ThinkingStripConfig } from "./types.js";
4
-
5
- const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
6
-
7
- function userMsg(ts: number): any {
8
- return { role: "user", content: [{ type: "text", text: "go" }], timestamp: ts };
9
- }
10
-
11
- function assistantToolsThinking(ts: number, toolCallIds: string[], thinkingBlocks = 1): any {
12
- const content: any[] = [];
13
- for (let i = 0; i < thinkingBlocks; i++) {
14
- content.push({ type: "thinking", thinking: `t${ts}-${i}`, thinkingSignature: `sig${ts}-${i}` });
15
- }
16
- content.push({ type: "text", text: "working" });
17
- for (const id of toolCallIds) content.push({ type: "toolCall", id, name: "bash", arguments: { cmd: "ls" } });
18
- return { role: "assistant", content, timestamp: ts, usage: {}, stopReason: "toolUse" };
19
- }
20
-
21
- function assistantTextThinking(ts: number): any {
22
- return {
23
- role: "assistant",
24
- content: [
25
- { type: "thinking", thinking: "final reasoning", thinkingSignature: "sigf" },
26
- { type: "text", text: "done" },
27
- ],
28
- timestamp: ts,
29
- usage: {},
30
- stopReason: "stop",
31
- };
32
- }
33
-
34
- function toolResult(ts: number, toolCallId: string): any {
35
- return {
36
- role: "toolResult",
37
- toolCallId,
38
- toolName: "bash",
39
- content: [{ type: "text", text: "out" }],
40
- isError: false,
41
- timestamp: ts,
42
- };
43
- }
44
-
45
- function hasThinking(msg: any): boolean {
46
- return Array.isArray(msg.content) && msg.content.some((c: any) => c.type === "thinking");
47
- }
48
-
49
- function countThinking(msg: any): number {
50
- return Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "thinking").length : 0;
51
- }
52
-
53
- /** user, then (n-1) tool-using assistant turns each followed by a toolResult, then 1 final text assistant. */
54
- function convo(nAssistantTurns: number): any[] {
55
- const msgs: any[] = [userMsg(1)];
56
- let ts = 2;
57
- for (let i = 0; i < nAssistantTurns - 1; i++) {
58
- const id = `tc${i}`;
59
- msgs.push(assistantToolsThinking(ts++, [id]));
60
- msgs.push(toolResult(ts++, id));
61
- }
62
- msgs.push(assistantTextThinking(ts++));
63
- return msgs;
64
- }
65
-
66
- describe("stripOldThinking", () => {
67
- test("disabled → same reference", () => {
68
- const msgs = convo(20);
69
- expect(stripOldThinking(msgs, cfg(false, 16))).toBe(msgs);
70
- });
71
-
72
- test("fewer assistant turns than keepLastTurns → same reference", () => {
73
- const msgs = convo(10);
74
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
75
- });
76
-
77
- test("exactly keepLastTurns assistant turns → same reference (nothing older)", () => {
78
- const msgs = convo(16);
79
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
80
- });
81
-
82
- test("strips thinking from turns older than the last K, keeps the last K", () => {
83
- const msgs = convo(20);
84
- const out = stripOldThinking(msgs, cfg(true, 16));
85
- expect(out).not.toBe(msgs);
86
- const assistants = out.filter((m) => m.role === "assistant");
87
- expect(assistants.length).toBe(20);
88
- for (const a of assistants.slice(-16)) expect(hasThinking(a)).toBe(true);
89
- for (const a of assistants.slice(0, 4)) expect(hasThinking(a)).toBe(false);
90
- });
91
-
92
- test("keepLastTurns=1 keeps only the most-recent assistant turn's thinking", () => {
93
- const msgs = convo(5);
94
- const out = stripOldThinking(msgs, cfg(true, 1));
95
- const assistants = out.filter((m) => m.role === "assistant");
96
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
97
- for (const a of assistants.slice(0, -1)) expect(hasThinking(a)).toBe(false);
98
- });
99
-
100
- test("keepLastTurns=0 is clamped to 1 (never strips the last assistant turn)", () => {
101
- const msgs = convo(5);
102
- const out = stripOldThinking(msgs, cfg(true, 0));
103
- const assistants = out.filter((m) => m.role === "assistant");
104
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
105
- expect(hasThinking(assistants[0])).toBe(false);
106
- });
107
-
108
- test("trailing tool-use assistant awaiting results keeps its thinking", () => {
109
- const msgs: any[] = [userMsg(1)];
110
- let ts = 2;
111
- for (let i = 0; i < 4; i++) {
112
- const id = `x${i}`;
113
- msgs.push(assistantToolsThinking(ts++, [id]));
114
- msgs.push(toolResult(ts++, id));
115
- }
116
- const out = stripOldThinking(msgs, cfg(true, 1));
117
- const assistants = out.filter((m) => m.role === "assistant");
118
- const last = assistants[assistants.length - 1];
119
- expect(hasThinking(last)).toBe(true);
120
- expect(last.content.some((c: any) => c.type === "toolCall")).toBe(true);
121
- });
122
-
123
- test("stripped assistant keeps its text and toolCall blocks", () => {
124
- const msgs = convo(20);
125
- const out = stripOldThinking(msgs, cfg(true, 16));
126
- const firstAssistant = out.find((m) => m.role === "assistant");
127
- expect(hasThinking(firstAssistant)).toBe(false);
128
- expect(firstAssistant.content.some((c: any) => c.type === "text")).toBe(true);
129
- expect(firstAssistant.content.some((c: any) => c.type === "toolCall")).toBe(true);
130
- });
131
-
132
- test("strips all thinking blocks from a message (all-or-nothing)", () => {
133
- const msgs: any[] = [userMsg(1), assistantToolsThinking(2, ["a"], 2), toolResult(3, "a")];
134
- let ts = 4;
135
- for (let i = 0; i < 3; i++) {
136
- const id = `b${i}`;
137
- msgs.push(assistantToolsThinking(ts++, [id], 2));
138
- msgs.push(toolResult(ts++, id));
139
- }
140
- msgs.push(assistantTextThinking(ts++));
141
- const out = stripOldThinking(msgs, cfg(true, 2));
142
- expect(countThinking(out[1])).toBe(0);
143
- });
144
-
145
- test("no thinking anywhere → same reference", () => {
146
- const msgs: any[] = [userMsg(1)];
147
- let ts = 2;
148
- for (let i = 0; i < 20; i++) {
149
- const id = `n${i}`;
150
- msgs.push({
151
- role: "assistant",
152
- content: [{ type: "text", text: "x" }, { type: "toolCall", id, name: "bash", arguments: {} }],
153
- timestamp: ts++,
154
- usage: {},
155
- stopReason: "toolUse",
156
- });
157
- msgs.push(toolResult(ts++, id));
158
- }
159
- expect(stripOldThinking(msgs, cfg(true, 4))).toBe(msgs);
160
- });
161
-
162
- test("idempotent: second pass returns same reference", () => {
163
- const msgs = convo(20);
164
- const once = stripOldThinking(msgs, cfg(true, 16));
165
- const twice = stripOldThinking(once, cfg(true, 16));
166
- expect(twice).toBe(once);
167
- });
168
-
169
- test("preserves message order and length", () => {
170
- const msgs = convo(20);
171
- const out = stripOldThinking(msgs, cfg(true, 16));
172
- expect(out.length).toBe(msgs.length);
173
- out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
174
- });
175
- });
@@ -1,42 +0,0 @@
1
- import { withoutThinkingBlocks } from "./chain-range-prune.js";
2
- import type { ThinkingStripConfig } from "./types.js";
3
-
4
- /**
5
- * Rolling main-loop thinking strip.
6
- *
7
- * Keeps `thinking` blocks on the last `keepLastTurns` assistant turns and
8
- * strips them from all older assistant messages, preserving each message's
9
- * `text` and `toolCall` blocks. "Turn" counts ASSISTANT messages, not
10
- * user-bounded spans — the target failure mode is a single long open chain
11
- * (zero subagents, near-zero user turns) where a span-based window keeps
12
- * everything.
13
- *
14
- * Provider safety (Anthropic): during tool use only the LAST assistant turn's
15
- * thinking is required; prior turns may be omitted, and a message's thinking
16
- * blocks must be dropped all-or-nothing. `keepLastTurns` is clamped to >= 1 so
17
- * the most-recent assistant turn always keeps its thinking. Stripping reuses
18
- * `withoutThinkingBlocks` (drops the whole block incl. signature).
19
- *
20
- * Returns the original array reference unchanged when nothing is stripped, so
21
- * `pruneMessages` can skip reconstruction.
22
- */
23
- export function stripOldThinking(messages: any[], config: ThinkingStripConfig): any[] {
24
- if (!config.enabled) return messages;
25
- const keep = Math.max(1, config.keepLastTurns);
26
-
27
- const assistantIdx: number[] = [];
28
- for (let i = 0; i < messages.length; i++) {
29
- if (messages[i]?.role === "assistant") assistantIdx.push(i);
30
- }
31
- if (assistantIdx.length <= keep) return messages;
32
-
33
- const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
34
- let changed = false;
35
- const out = messages.map((msg, i) => {
36
- if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
37
- if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
38
- changed = true;
39
- return withoutThinkingBlocks(msg);
40
- });
41
- return changed ? out : messages;
42
- }