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.
- package/CHANGELOG.md +9 -0
- package/PRUNING.md +11 -3
- package/README.md +9 -3
- package/index.ts +32 -51
- package/package.json +1 -1
- package/src/batch-capture.test.ts +116 -1
- package/src/batch-capture.ts +47 -16
- package/src/budget.test.ts +21 -1
- package/src/budget.ts +10 -0
- package/src/chain-compressor.test.ts +17 -0
- package/src/chain-detector.test.ts +77 -1
- package/src/chain-detector.ts +26 -7
- package/src/chain-range-prune.test.ts +83 -0
- package/src/chain-range-prune.ts +7 -5
- package/src/commands.test.ts +1 -28
- package/src/commands.ts +8 -14
- package/src/config.test.ts +22 -0
- package/src/config.ts +6 -0
- package/src/context-metrics.test.ts +35 -5
- package/src/context-metrics.ts +5 -2
- package/src/reload-rearm.integration.test.ts +369 -98
- package/src/types.ts +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ 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.10.0] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
- **Custom-message chain anchors ([#13](https://github.com/jjuraszek/pi-condense/issues/13)).** A non-pruner `role: "custom"` message (`customType` not prefixed `context-prune-`) can now open a chain, but only while the chain detector is idle - a non-pruner custom seen mid-chain stays passthrough, not a new anchor. `resolveRange` accepts these as start anchors fail-closed; persisted `custom_message` steers reach chain detection through a shared projection (`src/batch-capture.ts` `projectBranchMessages`); in `agent-message` batching, eligible customs also bound summary groups.
|
|
13
|
+
- **Opt-in `frontierGapThresholdTokens` flush trigger.** New absolute-token flush trigger (default `null`, disabled), ORed with `autoBudgetThreshold`/`budgetTurnDelta` (precedence: budget, then delta, then frontier-gap) - fires at `turn_end` once the un-pruned tail past the prune frontier (`frontierGapTokens`) reaches the configured token count, independent of window size. Config-file-only, no `/pruner settings` row; self-throttling via frontier advance on processed flush outcomes (empty attempts advance nothing and rewrite nothing) - after a mid-flush summarizer failure the next gated turn may re-fire while consuming the remaining backlog, so the cadence bound is amortized per new tail growth rather than per-turn-exact. New `"frontier-gap"` value on `context-prune-flush-metrics` entries.
|
|
14
|
+
|
|
15
|
+
## [2.9.2] - 2026-08-31
|
|
16
|
+
|
|
17
|
+
- **Context metrics removed from the footer status line.** The ` · think Nk · gap Nk · chain P%` suffix rendered on nearly every non-idle session and crowded the footer for no actionable signal. The metrics stay on `/pruner status` (`--- context ---`) and in the `context-prune-flush-metrics` session entries; the footer is back to prune state, reclaim, and `diag`. The snapshot cache that existed only to feed the widget is gone - both remaining consumers compute on demand.
|
|
18
|
+
|
|
10
19
|
## [2.9.1] - 2026-08-18
|
|
11
20
|
|
|
12
21
|
- **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C).
|
package/PRUNING.md
CHANGED
|
@@ -838,6 +838,14 @@ Use case: a single enormous tool result can jump context usage by 20–30 percen
|
|
|
838
838
|
|
|
839
839
|
`null` = off (default).
|
|
840
840
|
|
|
841
|
+
### Frontier-gap flush trigger
|
|
842
|
+
|
|
843
|
+
`frontierGapThresholdTokens: number | null` (default `null`) is a third flush trigger, ORed with `autoBudgetThreshold` and `budgetTurnDelta` at `turn_end` (precedence within that handler: budget, then delta, then frontier-gap - only the first that fires flushes that turn). Unlike the other two, which measure a *fraction* of the context window, this one is an absolute token count against `frontierGapTokens` (the un-pruned tail past the persisted prune frontier, same metric as `/pruner status` and `computeContextMetrics`). Rationale: a window-fraction trigger becomes unreachable on a large enough advertised window (the same problem `MAX_BUDGET_WINDOW` addresses for the level form) long before the un-pruned tail is actually a problem in tokens - this trigger is the absolute-token complement, independent of window size.
|
|
844
|
+
|
|
845
|
+
It only evaluates at `turn_end`, same call site as the other two triggers, and is self-throttling: `frontierGapTokens` is measured against the persisted prune frontier, which advances on every processed flush outcome (summarized and skipped-* alike, including one this trigger itself causes) - an attempt that finds zero capturable batches does not advance it, and rewrites nothing, so it cannot churn the cache - so under normal operation it cannot fire again until the un-pruned tail regrows by another threshold-worth of tokens; on a mid-flush summarizer failure the frontier advances only to the persisted prefix, so the next gated turn may re-fire while consuming the remaining backlog, making the bound amortized (one extra prefix rewrite per threshold-worth of new tail growth) rather than per-turn-exact under failures. Recommended starting value `80000` - well above the ~5k-15k tokens a session typically accumulates per flush, so it only fires when flushes stop happening for an unusually long stretch (e.g. a long auto-continued run). Config-file-only - no `/pruner settings` row.
|
|
846
|
+
|
|
847
|
+
`null` = off (default).
|
|
848
|
+
|
|
841
849
|
---
|
|
842
850
|
|
|
843
851
|
## Chain Compression
|
|
@@ -846,7 +854,7 @@ Chain compression is a second layer on top of the per-batch tool-result stub pru
|
|
|
846
854
|
|
|
847
855
|
### What a closed chain is
|
|
848
856
|
|
|
849
|
-
A **closed chain** is a span of messages from one user message through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply:
|
|
857
|
+
A **closed chain** is a span of messages from one user message - or a non-pruner custom message (`role: "custom"` with a `customType` not prefixed `context-prune-`), the latter only accepted as a chain start while the chain detector is idle (mid-chain, a non-pruner custom is passthrough, not a new anchor) - through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply:
|
|
850
858
|
|
|
851
859
|
```
|
|
852
860
|
[user msg] ← chain start (kept raw)
|
|
@@ -861,7 +869,7 @@ A **closed chain** is a span of messages from one user message through any numbe
|
|
|
861
869
|
|
|
862
870
|
| Part | After chain compression |
|
|
863
871
|
|---|---|
|
|
864
|
-
| Start user message | **Kept raw** |
|
|
872
|
+
| Start user message (or idle-anchored custom message) | **Kept raw** |
|
|
865
873
|
| Middle assistant turns (all) | **Dropped** — assistant thinking + signatures + toolCall argument blocks |
|
|
866
874
|
| Middle tool results (all) | **Dropped** — already stub-replaced by the per-batch pruner; now fully removed |
|
|
867
875
|
| Per-batch summary message(s) for this chain | **Suppressed** — replaced by the chain-level synthetic |
|
|
@@ -995,7 +1003,7 @@ Phase 1 (per-batch summarization) is unaffected by chain closure and remains the
|
|
|
995
1003
|
|
|
996
1004
|
Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run.
|
|
997
1005
|
|
|
998
|
-
**Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block)
|
|
1006
|
+
**Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block) and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
|
|
999
1007
|
|
|
1000
1008
|
| Metric | Definition |
|
|
1001
1009
|
|---|---|
|
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ pi install npm:pi-condense
|
|
|
69
69
|
| `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch |
|
|
70
70
|
| `on-demand` | Only when you run `/pruner now` | None until you ask |
|
|
71
71
|
|
|
72
|
+
With the default `agent-message` trigger (and `autoBudgetThreshold`/`budgetTurnDelta` unset), a non-interactive (`pi -p`) session sees its first flush only at the final reply - set `autoBudgetThreshold` (e.g. `0.8`) so flushes also fire mid-run. This is a property of single-prompt sessions, not a defect in the default.
|
|
73
|
+
|
|
72
74
|
Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression).
|
|
73
75
|
|
|
74
76
|
### External cost channel
|
|
@@ -84,7 +86,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
|
|
|
84
86
|
| Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
|
|
85
87
|
| Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
|
|
86
88
|
| Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
|
|
87
|
-
| Context metrics (`
|
|
89
|
+
| Context metrics (`thinking`/`chain share`/`frontier gap`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. Shown on `/pruner status`, never on the footer. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
|
|
88
90
|
| Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
|
|
89
91
|
| `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
|
|
90
92
|
|
|
@@ -100,10 +102,9 @@ The status-line `diag u<N>/m<N>/o<N>/b<N>` segment above is backed by `context-p
|
|
|
100
102
|
|
|
101
103
|
### Context metrics (`context-prune-flush-metrics`)
|
|
102
104
|
|
|
103
|
-
Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in
|
|
105
|
+
Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in two places, both backed by `computeContextMetrics` (`src/context-metrics.ts`). They are deliberately kept off the footer status line, which stays limited to prune state, reclaim, and diagnostics:
|
|
104
106
|
|
|
105
107
|
- `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed.
|
|
106
|
-
- The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged.
|
|
107
108
|
- Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload.
|
|
108
109
|
|
|
109
110
|
These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger.
|
|
@@ -167,6 +168,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
167
168
|
| `summarizerModel` | `"default"` | Pin a cheap model instead of reusing your active one - see the plan-by-plan table in [doc/configuration.md](doc/configuration.md#choosing-a-summarizer-model) |
|
|
168
169
|
| `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
|
|
169
170
|
| `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
|
|
171
|
+
| `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
|
|
170
172
|
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
|
|
171
173
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
172
174
|
|
|
@@ -180,6 +182,10 @@ pi-condense is the context-economy layer: it has no code dependency on the other
|
|
|
180
182
|
|
|
181
183
|
No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first.
|
|
182
184
|
|
|
185
|
+
## Contributing
|
|
186
|
+
|
|
187
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
|
|
188
|
+
|
|
183
189
|
## Support
|
|
184
190
|
|
|
185
191
|
If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
|
package/index.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { loadConfig } from "./src/config.js";
|
|
18
|
-
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode } from "./src/batch-capture.js";
|
|
18
|
+
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode, projectBranchMessages } from "./src/batch-capture.js";
|
|
19
19
|
import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js";
|
|
20
20
|
import { FallbackController } from "./src/summarizer-fallback.js";
|
|
21
21
|
import { ToolCallIndexer } from "./src/indexer.js";
|
|
@@ -47,7 +47,7 @@ import { BlockRefIssuer } from "./src/block-refs.js";
|
|
|
47
47
|
import { compressEligible } from "./src/chain-compressor.js";
|
|
48
48
|
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
49
49
|
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
50
|
-
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
|
|
50
|
+
import { shouldBudgetFlush, shouldDeltaFlush, shouldFrontierGapFlush, usageFraction } from "./src/budget.js";
|
|
51
51
|
import { spillOversizedBatch } from "./src/spill.js";
|
|
52
52
|
import { occKey } from "./src/occurrence-key.js";
|
|
53
53
|
import { DiagnosticSink } from "./src/diagnostics.js";
|
|
@@ -93,28 +93,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
93
|
// Cleared on every non-concurrent flushPending invocation.
|
|
94
94
|
let rearmedPending = false;
|
|
95
95
|
|
|
96
|
-
// Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
|
|
97
|
-
// and flush entry. Cached (rather than recomputed on every widget refresh)
|
|
98
|
-
// because computeContextMetrics walks the full branch.
|
|
99
|
-
let metricsCache: ContextMetricsSnapshot | undefined;
|
|
100
96
|
const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
|
|
101
97
|
try {
|
|
102
98
|
// Includes persisted custom_message entries (e.g. this extension's own
|
|
103
99
|
// summary messages) alongside plain "message" entries: both are retained
|
|
104
100
|
// LLM context, so both belong in the largest-chain-share denominator.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// roles computeContextMetrics keys off, so it only inflates totalChars.
|
|
110
|
-
const branch = ctx.sessionManager.getBranch()
|
|
111
|
-
.filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message")
|
|
112
|
-
.map((e: any) =>
|
|
113
|
-
e.type === "custom_message"
|
|
114
|
-
? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
|
|
115
|
-
: e.message,
|
|
116
|
-
);
|
|
117
|
-
metricsCache = computeContextMetrics(
|
|
101
|
+
// Shared projection (src/batch-capture.ts projectBranchMessages) so this
|
|
102
|
+
// matches the chain-detection feed sites exactly.
|
|
103
|
+
const branch = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
104
|
+
return computeContextMetrics(
|
|
118
105
|
branch,
|
|
119
106
|
frontier.get(),
|
|
120
107
|
(k: string) => indexer.isSummarized(k),
|
|
@@ -122,8 +109,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
109
|
);
|
|
123
110
|
} catch (err) {
|
|
124
111
|
console.error("pi-condense: context metrics computation failed", err);
|
|
112
|
+
return undefined;
|
|
125
113
|
}
|
|
126
|
-
return metricsCache;
|
|
127
114
|
};
|
|
128
115
|
|
|
129
116
|
type FlushResult =
|
|
@@ -566,7 +553,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
553
|
|
|
567
554
|
if (processedBatches.length === 0) {
|
|
568
555
|
// Nothing was persisted (all calls failed or first call failed)
|
|
569
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
556
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
570
557
|
outcome = "error";
|
|
571
558
|
return { ok: false, reason: "summarizer-failed" };
|
|
572
559
|
}
|
|
@@ -601,13 +588,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
601
588
|
? "skipped-deduped"
|
|
602
589
|
: "skipped-trivial";
|
|
603
590
|
|
|
604
|
-
//
|
|
591
|
+
// Projected session branch (message + custom_message entries) for the chain-compression block below.
|
|
605
592
|
// Only materialized when chain compression is enabled.
|
|
606
593
|
let branchMessages: any[] | undefined;
|
|
607
594
|
if (currentConfig.value.chainCompression.enabled) {
|
|
608
|
-
branchMessages = ctx.sessionManager.getBranch()
|
|
609
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
610
|
-
.map((e: any) => e.message);
|
|
595
|
+
branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
611
596
|
}
|
|
612
597
|
|
|
613
598
|
const frontierSnapshot: PruneFrontier = {
|
|
@@ -644,7 +629,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
629
|
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
|
|
645
630
|
}
|
|
646
631
|
|
|
647
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
632
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
648
633
|
emitExternalCost(pi, statsAccum);
|
|
649
634
|
|
|
650
635
|
// Chain compression — compress closed chains beyond the rolling window.
|
|
@@ -771,7 +756,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
771
756
|
// When the abort signal fired, summarizeBatch rethrows rather than
|
|
772
757
|
// swallowing the error. Don't show a UI error — the user intended this.
|
|
773
758
|
if (options.signal?.aborted) {
|
|
774
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
759
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
775
760
|
return { ok: false, reason: "aborted" };
|
|
776
761
|
}
|
|
777
762
|
if (isStaleContextError(err)) {
|
|
@@ -816,10 +801,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
816
801
|
}
|
|
817
802
|
}
|
|
818
803
|
|
|
819
|
-
computeMetricsSnapshot(ctx);
|
|
820
|
-
|
|
821
804
|
// Update footer status
|
|
822
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
805
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
823
806
|
|
|
824
807
|
ctx.ui.setWidget(
|
|
825
808
|
"pruner-boot",
|
|
@@ -856,8 +839,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
856
839
|
}
|
|
857
840
|
}
|
|
858
841
|
|
|
859
|
-
|
|
860
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
|
|
842
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
861
843
|
});
|
|
862
844
|
|
|
863
845
|
// ── turn_end: capture batch, flush immediately or queue ──────────────────
|
|
@@ -934,13 +916,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
934
916
|
}
|
|
935
917
|
}
|
|
936
918
|
|
|
937
|
-
// Recompute regardless of whether trim produced a batch: a turn whose
|
|
938
|
-
// toolResults are all protected/spilled/summarized/trimmed-empty still
|
|
939
|
-
// changes the branch (thinking, open-segment size), so the cache must not
|
|
940
|
-
// go stale on it. Placed before the pushedBatch/rearmedPending early
|
|
941
|
-
// return below — a cache write is not gate evaluation.
|
|
942
|
-
if (hasToolResults) computeMetricsSnapshot(ctx);
|
|
943
|
-
|
|
944
919
|
// Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
|
|
945
920
|
// means no gate evaluation, regardless of leftover pendingBatches from an
|
|
946
921
|
// earlier turn — UNLESS a reload probe armed rearmedPending, in which case
|
|
@@ -954,24 +929,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
954
929
|
const usage = ctx.getContextUsage?.();
|
|
955
930
|
const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
|
|
956
931
|
const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
|
|
932
|
+
// Frontier-gap auto-flush (opt-in): absolute un-pruned tail size, for huge
|
|
933
|
+
// windows where fractional thresholds never trip. Threshold null (default)
|
|
934
|
+
// skips the metrics snapshot entirely; a failed snapshot fails closed.
|
|
935
|
+
const gapThreshold = currentConfig.value.frontierGapThresholdTokens;
|
|
936
|
+
const gapHit = gapThreshold != null && shouldFrontierGapFlush(computeMetricsSnapshot(ctx), gapThreshold);
|
|
957
937
|
// Update the per-turn baseline; leave it unchanged when tokens is null (e.g.
|
|
958
938
|
// right after a compaction) so the next real reading compares to the last known.
|
|
959
939
|
const f = usageFraction(usage);
|
|
960
940
|
if (f != null) previousFraction = f;
|
|
961
941
|
|
|
962
942
|
const n = pendingBatches.length;
|
|
963
|
-
if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) {
|
|
943
|
+
if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit || gapHit)) {
|
|
944
|
+
const reason = budgetHit ? "context budget reached" : deltaHit ? "context jumped this turn" : "un-pruned tail exceeded frontier gap threshold";
|
|
964
945
|
// Always surface this flush (even when the routine status line is off): it's a
|
|
965
|
-
// significant, infrequent event — context crossed a threshold
|
|
966
|
-
// this turn
|
|
946
|
+
// significant, infrequent event — context crossed a threshold, jumped sharply
|
|
947
|
+
// this turn, or the un-pruned tail grew past the gap threshold — and it
|
|
948
|
+
// self-throttles because pendingBatches is drained right after.
|
|
967
949
|
safeNotify(
|
|
968
950
|
ctx,
|
|
969
951
|
n > 0
|
|
970
|
-
? `pruner: ${
|
|
971
|
-
: `pruner: ${
|
|
952
|
+
? `pruner: ${reason} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
|
|
953
|
+
: `pruner: ${reason} — compacting work recovered after reload`,
|
|
972
954
|
"info",
|
|
973
955
|
);
|
|
974
|
-
await flushPending(ctx, {
|
|
956
|
+
await flushPending(ctx, {
|
|
957
|
+
delivery: "session",
|
|
958
|
+
trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : deltaHit ? "delta" : "frontier-gap",
|
|
959
|
+
});
|
|
975
960
|
}
|
|
976
961
|
});
|
|
977
962
|
|
|
@@ -1026,7 +1011,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1026
1011
|
changed = true;
|
|
1027
1012
|
statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
|
|
1028
1013
|
}
|
|
1029
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
1014
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
1030
1015
|
|
|
1031
1016
|
if (!changed) return undefined;
|
|
1032
1017
|
return { messages };
|
|
@@ -1037,10 +1022,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1037
1022
|
|
|
1038
1023
|
// ── Register /pruner command + summary message renderer ────────────
|
|
1039
1024
|
const compactChains = async (ctx: any) => {
|
|
1040
|
-
const
|
|
1041
|
-
const branchMessages = branch
|
|
1042
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
1043
|
-
.map((e: any) => e.message);
|
|
1025
|
+
const branchMessages = projectBranchMessages(ctx.sessionManager.getBranch());
|
|
1044
1026
|
const chains = detectChains(branchMessages, protectionPredicate);
|
|
1045
1027
|
const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
|
|
1046
1028
|
const result = await compressEligible(
|
|
@@ -1082,7 +1064,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1082
1064
|
compactChains,
|
|
1083
1065
|
() => diagnostics.counts(),
|
|
1084
1066
|
(ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
|
|
1085
|
-
() => metricsCache,
|
|
1086
1067
|
() => rearmedPending,
|
|
1087
1068
|
);
|
|
1088
1069
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.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",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
captureBatch,
|
|
4
|
+
captureUnindexedBatchesFromSession,
|
|
5
|
+
projectBranchMessages,
|
|
6
|
+
serializeBatchForSummarizer,
|
|
7
|
+
} from "./batch-capture.js";
|
|
3
8
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
9
|
|
|
5
10
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
@@ -123,3 +128,113 @@ describe("occurrence capture", () => {
|
|
|
123
128
|
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
124
129
|
});
|
|
125
130
|
});
|
|
131
|
+
|
|
132
|
+
describe("captureUnindexedBatchesFromSession entry timestamp fallback", () => {
|
|
133
|
+
test("uses the entry's timestamp when the inner message lacks one", () => {
|
|
134
|
+
const branch = [
|
|
135
|
+
{
|
|
136
|
+
type: "message",
|
|
137
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
138
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }] },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: "message",
|
|
142
|
+
message: { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1150 },
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
147
|
+
|
|
148
|
+
expect(batches).toHaveLength(1);
|
|
149
|
+
expect(batches[0].timestamp).toBe(new Date("2026-08-31T10:00:00.000Z").getTime());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("projectBranchMessages", () => {
|
|
154
|
+
test("projects custom_message entries as role custom and drops unknown entry types", () => {
|
|
155
|
+
const branch = [
|
|
156
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
|
|
157
|
+
{
|
|
158
|
+
type: "custom_message",
|
|
159
|
+
customType: "x",
|
|
160
|
+
content: "c",
|
|
161
|
+
display: true,
|
|
162
|
+
details: {},
|
|
163
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
164
|
+
},
|
|
165
|
+
{ type: "other" },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const msgs = projectBranchMessages(branch);
|
|
169
|
+
|
|
170
|
+
expect(msgs).toHaveLength(2);
|
|
171
|
+
expect(msgs[0]).toBe((branch[0] as any).message);
|
|
172
|
+
expect(msgs[1]).toEqual({
|
|
173
|
+
role: "custom",
|
|
174
|
+
customType: "x",
|
|
175
|
+
content: "c",
|
|
176
|
+
display: true,
|
|
177
|
+
details: {},
|
|
178
|
+
timestamp: new Date("2026-08-31T10:00:00.000Z").getTime(),
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("custom-anchor group boundary", () => {
|
|
184
|
+
function buildBranch(customType: string) {
|
|
185
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
186
|
+
return [
|
|
187
|
+
entry({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }),
|
|
188
|
+
entry({
|
|
189
|
+
role: "assistant",
|
|
190
|
+
content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }],
|
|
191
|
+
timestamp: 1100,
|
|
192
|
+
}),
|
|
193
|
+
entry({
|
|
194
|
+
role: "toolResult",
|
|
195
|
+
toolCallId: "tc1",
|
|
196
|
+
toolName: "bash",
|
|
197
|
+
content: [{ type: "text", text: "ok1" }],
|
|
198
|
+
isError: false,
|
|
199
|
+
timestamp: 1150,
|
|
200
|
+
}),
|
|
201
|
+
{
|
|
202
|
+
type: "custom_message",
|
|
203
|
+
customType,
|
|
204
|
+
content: "c",
|
|
205
|
+
display: true,
|
|
206
|
+
details: {},
|
|
207
|
+
timestamp: "2026-08-31T10:00:00.000Z",
|
|
208
|
+
},
|
|
209
|
+
entry({
|
|
210
|
+
role: "assistant",
|
|
211
|
+
content: [{ type: "toolCall", id: "tc2", name: "bash", input: {} }],
|
|
212
|
+
timestamp: 2100,
|
|
213
|
+
}),
|
|
214
|
+
entry({
|
|
215
|
+
role: "toolResult",
|
|
216
|
+
toolCallId: "tc2",
|
|
217
|
+
toolName: "bash",
|
|
218
|
+
content: [{ type: "text", text: "ok2" }],
|
|
219
|
+
isError: false,
|
|
220
|
+
timestamp: 2150,
|
|
221
|
+
}),
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("an eligible custom anchor (pi-gauntlet-transition-recovery) pins a new userTurnGroup", () => {
|
|
226
|
+
const branch = buildBranch("pi-gauntlet-transition-recovery");
|
|
227
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
228
|
+
|
|
229
|
+
expect(batches).toHaveLength(2);
|
|
230
|
+
expect(batches[0].userTurnGroup).not.toBe(batches[1].userTurnGroup);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a pruner custom (context-prune-summary) passes through without a new group", () => {
|
|
234
|
+
const branch = buildBranch("context-prune-summary");
|
|
235
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
236
|
+
|
|
237
|
+
expect(batches).toHaveLength(2);
|
|
238
|
+
expect(batches[0].userTurnGroup).toBe(batches[1].userTurnGroup);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
|
+
import { isChainAnchorCustom } from "./chain-detector.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unwraps a SessionEntry[] branch into AgentMessage-like objects, including
|
|
7
|
+
* persisted custom_message entries (extension steers) projected as
|
|
8
|
+
* role "custom". Shared by computeMetricsSnapshot, flushPending chain detection,
|
|
9
|
+
* compactChains, and the rescan below so chain anchor timestamps are identical
|
|
10
|
+
* at every site. Projected inline (rather than importing pi-coding-agent's
|
|
11
|
+
* createCustomMessage) because that helper isn't re-exported from the
|
|
12
|
+
* package's "." export map.
|
|
13
|
+
*/
|
|
14
|
+
export function projectBranchMessages(branch: any[]): any[] {
|
|
15
|
+
return branch
|
|
16
|
+
.filter(isProjectableEntry)
|
|
17
|
+
.map((e: any) => (e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True for SessionEntry shapes that project into an AgentMessage-like object (see projectBranchMessages). */
|
|
21
|
+
function isProjectableEntry(e: any): boolean {
|
|
22
|
+
return (e.type === "message" && e.message) || e.type === "custom_message";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Projects a single custom_message SessionEntry into its role "custom" message shape. */
|
|
26
|
+
function projectCustomMessageEntry(e: any): any {
|
|
27
|
+
return { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() };
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
/** Joins the text blocks of a ToolResultMessage into a single string. */
|
|
5
31
|
export function extractToolResultText(msg: any): string {
|
|
@@ -71,9 +97,13 @@ export function captureUnindexedBatchesFromSession(
|
|
|
71
97
|
indexer: { isSummarized(id: string): boolean },
|
|
72
98
|
exclude: (toolName: string, args: unknown) => boolean = () => false
|
|
73
99
|
): CapturedBatch[] {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
100
|
+
// Keep the SessionEntry wrapper alongside each projected message so the
|
|
101
|
+
// entry's own timestamp remains available as the preferred source below
|
|
102
|
+
// (projection alone loses that wrapper for "message" entries).
|
|
103
|
+
const projected = branch
|
|
104
|
+
.filter(isProjectableEntry)
|
|
105
|
+
.map((e: any) => ({ entry: e, msg: e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message }));
|
|
106
|
+
const msgs = projected.map((p) => p.msg);
|
|
77
107
|
|
|
78
108
|
const batches: CapturedBatch[] = [];
|
|
79
109
|
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
@@ -83,19 +113,19 @@ export function captureUnindexedBatchesFromSession(
|
|
|
83
113
|
// always matches Pi's own event.turnIndex numbering.
|
|
84
114
|
let turnCounter = 0;
|
|
85
115
|
|
|
86
|
-
// userTurnGroup increments on every user message
|
|
87
|
-
// All assistant tool-call batches between two
|
|
88
|
-
// same userTurnGroup. This is used by
|
|
89
|
-
// a single user → final-agent-message
|
|
116
|
+
// userTurnGroup increments on every user message or eligible custom anchor seen
|
|
117
|
+
// while walking the branch. All assistant tool-call batches between two
|
|
118
|
+
// consecutive boundaries share the same userTurnGroup. This is used by
|
|
119
|
+
// groupBatchesByMode to merge turns within a single user → final-agent-message
|
|
120
|
+
// span when batchingMode === "agent-message".
|
|
90
121
|
let userTurnGroup = 0;
|
|
91
122
|
|
|
92
|
-
for (let i = 0; i <
|
|
93
|
-
const
|
|
94
|
-
const msg = entry.message;
|
|
123
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
124
|
+
const msg = msgs[i];
|
|
95
125
|
|
|
96
|
-
// Advance userTurnGroup on every user message
|
|
97
|
-
// batches get a new group number.
|
|
98
|
-
if (msg.role === "user") {
|
|
126
|
+
// Advance userTurnGroup on every user message or eligible custom anchor so
|
|
127
|
+
// all subsequent assistant batches get a new group number.
|
|
128
|
+
if (msg.role === "user" || isChainAnchorCustom(msg)) {
|
|
99
129
|
userTurnGroup++;
|
|
100
130
|
continue;
|
|
101
131
|
}
|
|
@@ -108,8 +138,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
108
138
|
// Per-turn result map: only the results between this assistant message and
|
|
109
139
|
// the next one. A branch-wide map is last-wins and mis-pairs repeated ids.
|
|
110
140
|
const turnResults = new Map<string, any>();
|
|
111
|
-
for (let j = i + 1; j <
|
|
112
|
-
const m =
|
|
141
|
+
for (let j = i + 1; j < msgs.length; j++) {
|
|
142
|
+
const m = msgs[j];
|
|
113
143
|
if (m.role === "assistant") break;
|
|
114
144
|
if (m.role === "toolResult" && m.toolCallId && !turnResults.has(m.toolCallId)) {
|
|
115
145
|
turnResults.set(m.toolCallId, m);
|
|
@@ -138,7 +168,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
138
168
|
// an intermediate completed subset in the middle of a longer tool chain
|
|
139
169
|
// without accidentally capturing later unresolved calls from the same
|
|
140
170
|
// assistant message as "(no result)" placeholders.
|
|
141
|
-
const
|
|
171
|
+
const entryTimestamp = projected[i].entry.timestamp;
|
|
172
|
+
const ts = entryTimestamp ? new Date(entryTimestamp).getTime() : (msg.timestamp ?? Date.now());
|
|
142
173
|
const batch = captureBatch(msg, results, currentTurnIndex, ts);
|
|
143
174
|
batches.push({
|
|
144
175
|
...batch,
|
package/src/budget.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
shouldBudgetFlush,
|
|
4
|
+
shouldDeltaFlush,
|
|
5
|
+
shouldFrontierGapFlush,
|
|
6
|
+
usageFraction,
|
|
7
|
+
MAX_BUDGET_WINDOW,
|
|
8
|
+
} from "./budget.js";
|
|
3
9
|
|
|
4
10
|
const usage = (tokens: number | null, contextWindow: number) =>
|
|
5
11
|
({ tokens, contextWindow, percent: null }) as any;
|
|
@@ -111,3 +117,17 @@ describe("shouldDeltaFlush", () => {
|
|
|
111
117
|
expect(shouldDeltaFlush(usage(620_000, 1_000_000), prev, 0.1)).toBe(false);
|
|
112
118
|
});
|
|
113
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("shouldFrontierGapFlush", () => {
|
|
122
|
+
it("returns false when threshold is null", () => {
|
|
123
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 999999 }, null)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
it("returns false when snapshot is undefined (fail closed)", () => {
|
|
126
|
+
// the spec's `snap === undefined` branch: metrics computation failed
|
|
127
|
+
expect(shouldFrontierGapFlush(undefined, 80000)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it("fires at and above the threshold", () => {
|
|
130
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 80000 }, 80000)).toBe(true);
|
|
131
|
+
expect(shouldFrontierGapFlush({ frontierGapTokens: 79999 }, 80000)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/budget.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContextUsage } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ContextMetricsSnapshot } from "./types.js";
|
|
2
3
|
|
|
3
4
|
// Ceiling on what the budget triggers treat as the context window. Advertised
|
|
4
5
|
// windows reach 1M, which makes any (0,1] fraction unreachable in a real session.
|
|
@@ -51,3 +52,12 @@ export function shouldDeltaFlush(
|
|
|
51
52
|
if (current == null) return false;
|
|
52
53
|
return current - previousFraction >= delta;
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Fail-closed: an undefined snapshot (metrics computation failed) never fires. */
|
|
57
|
+
export function shouldFrontierGapFlush(
|
|
58
|
+
snapshot: ContextMetricsSnapshot | undefined,
|
|
59
|
+
threshold: number | null,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (threshold == null) return false;
|
|
62
|
+
return snapshot != null && snapshot.frontierGapTokens >= threshold;
|
|
63
|
+
}
|
|
@@ -710,6 +710,23 @@ describe("compressEligible - deterministic zero-LLM branch", () => {
|
|
|
710
710
|
expect(registerChainCalls).toHaveLength(1);
|
|
711
711
|
});
|
|
712
712
|
|
|
713
|
+
test("custom-anchored uncovered chain still compresses deterministically and backfills", async () => {
|
|
714
|
+
const messages = [
|
|
715
|
+
{ role: "custom", customType: "pi-gauntlet-transition-recovery", timestamp: 1000 },
|
|
716
|
+
{ role: "assistant", timestamp: 1001, content: [{ type: "toolCall", id: "c1", name: "bash", input: { cmd: "a" } }] },
|
|
717
|
+
{ role: "toolResult", toolCallId: "c1", toolName: "bash", timestamp: 1050, isError: false, content: [{ type: "text", text: "out1" }] },
|
|
718
|
+
{ role: "assistant", timestamp: 1002, content: [{ type: "toolCall", id: "c2", name: "read", input: { path: "x" } }] },
|
|
719
|
+
{ role: "toolResult", toolCallId: "c2", toolName: "read", timestamp: 1150, isError: false, content: [{ type: "text", text: "out2" }] },
|
|
720
|
+
{ role: "assistant", timestamp: 1200, content: [{ type: "text", text: "done" }] },
|
|
721
|
+
];
|
|
722
|
+
const { deps, backfillCalls } = makeDeterministicDeps({ messages });
|
|
723
|
+
const result = await compressEligible([uncoveredChain()], 0, deps as any);
|
|
724
|
+
expect(result.compressedEntries).toHaveLength(1);
|
|
725
|
+
expect(result.compressedEntries[0].bodySource).toBe("deterministic");
|
|
726
|
+
expect(result.compressedEntries[0].startUserTimestamp).toBe(1000);
|
|
727
|
+
expect(backfillCalls).toHaveLength(1);
|
|
728
|
+
});
|
|
729
|
+
|
|
713
730
|
test("covered path is untouched: backfill never invoked, entry matches identity pin", async () => {
|
|
714
731
|
const { deps, backfillCalls } = makeDeterministicDeps();
|
|
715
732
|
// Override to simulate coverage so the covered branch (not the deterministic one) runs.
|