pi-condense 2.9.0 → 2.9.2
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 +8 -0
- package/PRUNING.md +3 -3
- package/README.md +8 -3
- package/index.ts +8 -23
- package/package.json +1 -1
- package/src/chain-range-prune.test.ts +7 -0
- package/src/commands.test.ts +1 -28
- package/src/commands.ts +8 -14
- package/src/orphan-sweep.test.ts +43 -3
- package/src/orphan-sweep.ts +11 -1
- package/src/pruner.ts +5 -4
- package/src/reload-rearm.integration.test.ts +13 -80
- package/src/test-support.ts +2 -0
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.9.2] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
## [2.9.1] - 2026-08-18
|
|
15
|
+
|
|
16
|
+
- **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).
|
|
17
|
+
|
|
10
18
|
## [2.9.0] - 2026-08-14
|
|
11
19
|
|
|
12
20
|
- **Uncovered chains now compress deterministically instead of stranding forever ([#10](https://github.com/jjuraszek/pi-condense/issues/10)).** Chain compression (Phase 3) required per-batch summary coverage; a closed eligible chain whose span produced no summaries (trivial batches, `skipped-oversized`, fully-deduped spans, capture misses) hit a permanent `no-summary` skip - once the prune frontier passed it, nothing could ever reclaim it. The motivating incident left a 639-call, ~935k-char chain (~62% of a 620k window) live in context indefinitely.
|
package/PRUNING.md
CHANGED
|
@@ -995,7 +995,7 @@ Phase 1 (per-batch summarization) is unaffected by chain closure and remains the
|
|
|
995
995
|
|
|
996
996
|
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
997
|
|
|
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)
|
|
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) and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
|
|
999
999
|
|
|
1000
1000
|
| Metric | Definition |
|
|
1001
1001
|
|---|---|
|
|
@@ -1096,11 +1096,11 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
1096
1096
|
|
|
1097
1097
|
## Orphan Sweep
|
|
1098
1098
|
|
|
1099
|
-
`pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id
|
|
1099
|
+
`pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id is not open is removed, where "open" means: opened by the most recent `assistant` message and not interrupted by any barrier since. It runs after every other phase, over whatever the previous three produced, as a final post-condition rather than a targeted fix for one code path.
|
|
1100
1100
|
|
|
1101
1101
|
**Why it exists.** pi-ai's auto-repair (`insertSyntheticToolResults`) only fills in a missing `toolResult` for an orphaned `toolCall` - it has no equivalent repair for the opposite shape, an orphaned `toolResult` with no matching `toolCall`. Providers reject that shape outright (Anthropic: `unexpected tool_use_id found in tool_result blocks`; Kimi K3: a tool message needs a resolvable preceding tool_call). An orphaned `toolResult` can appear from any combination of id reuse, an unresolved chain range, or a bug in an upstream phase; the sweep is the last line of defense regardless of cause, and it is intentionally not keyed to any provider's specific error string - the fix is structural ("does this id have an open call"), not reactive to how one provider happens to phrase rejection.
|
|
1102
1102
|
|
|
1103
|
-
**Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the
|
|
1103
|
+
**Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. Any message that is neither `assistant` nor `toolResult` is a **barrier** that clears the open set. This tracks the post-`convertToLlm` flush points of pi-ai's `insertSyntheticToolResults` - `convertToLlm` maps `custom`, `branchSummary`, `compactionSummary`, and `bashExecution` to `role: "user"`, and pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries - **plus** a deliberate conservative over-sweep: `bashExecution` with `excludeFromContext: true` and unknown roles are dropped by `convertToLlm` and strictly need not be barriers, but a role allowlist would have to track an open interface for zero observed benefit. Not an exact mirror, and not an allowlist. Trade-off: a foreign message spliced mid-cycle now costs one tool output - the interleaved real result is swept and pi-ai injects its repairable `{isError: true, "No result provided"}` synthetic - converting an unrepairable provider shape (duplicate `tool_use_id`, permanent Anthropic 400) into a visible-but-recoverable tool failure. See `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md`. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the most recent assistant turn, uninterrupted by a barrier, can vouch for a `toolResult`'s id.
|
|
1104
1104
|
|
|
1105
1105
|
**Provider-agnostic and reference-preserving.** The sweep has no knowledge of which provider is in play; it operates purely on message shape. When nothing is swept it returns the **same array reference** it was given, so a clean render is a true no-op and the no-op / prompt-cache-prefix invariant of `doc/specs/2026-08-04-pruner-noop-serialization.md` holds - the sweep never forces a cache bust on a session where nothing is actually orphaned.
|
|
1106
1106
|
|
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.
|
|
@@ -180,6 +181,10 @@ pi-condense is the context-economy layer: it has no code dependency on the other
|
|
|
180
181
|
|
|
181
182
|
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
183
|
|
|
184
|
+
## Contributing
|
|
185
|
+
|
|
186
|
+
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).
|
|
187
|
+
|
|
183
188
|
## Support
|
|
184
189
|
|
|
185
190
|
If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
|
package/index.ts
CHANGED
|
@@ -93,10 +93,6 @@ 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
|
|
@@ -114,7 +110,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
110
|
? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
|
|
115
111
|
: e.message,
|
|
116
112
|
);
|
|
117
|
-
|
|
113
|
+
return computeContextMetrics(
|
|
118
114
|
branch,
|
|
119
115
|
frontier.get(),
|
|
120
116
|
(k: string) => indexer.isSummarized(k),
|
|
@@ -122,8 +118,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
118
|
);
|
|
123
119
|
} catch (err) {
|
|
124
120
|
console.error("pi-condense: context metrics computation failed", err);
|
|
121
|
+
return undefined;
|
|
125
122
|
}
|
|
126
|
-
return metricsCache;
|
|
127
123
|
};
|
|
128
124
|
|
|
129
125
|
type FlushResult =
|
|
@@ -566,7 +562,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
562
|
|
|
567
563
|
if (processedBatches.length === 0) {
|
|
568
564
|
// Nothing was persisted (all calls failed or first call failed)
|
|
569
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
565
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
570
566
|
outcome = "error";
|
|
571
567
|
return { ok: false, reason: "summarizer-failed" };
|
|
572
568
|
}
|
|
@@ -644,7 +640,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
640
|
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
|
|
645
641
|
}
|
|
646
642
|
|
|
647
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
643
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
648
644
|
emitExternalCost(pi, statsAccum);
|
|
649
645
|
|
|
650
646
|
// Chain compression — compress closed chains beyond the rolling window.
|
|
@@ -771,7 +767,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
771
767
|
// When the abort signal fired, summarizeBatch rethrows rather than
|
|
772
768
|
// swallowing the error. Don't show a UI error — the user intended this.
|
|
773
769
|
if (options.signal?.aborted) {
|
|
774
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
770
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
775
771
|
return { ok: false, reason: "aborted" };
|
|
776
772
|
}
|
|
777
773
|
if (isStaleContextError(err)) {
|
|
@@ -816,10 +812,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
816
812
|
}
|
|
817
813
|
}
|
|
818
814
|
|
|
819
|
-
computeMetricsSnapshot(ctx);
|
|
820
|
-
|
|
821
815
|
// Update footer status
|
|
822
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
816
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
823
817
|
|
|
824
818
|
ctx.ui.setWidget(
|
|
825
819
|
"pruner-boot",
|
|
@@ -856,8 +850,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
856
850
|
}
|
|
857
851
|
}
|
|
858
852
|
|
|
859
|
-
|
|
860
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
|
|
853
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
861
854
|
});
|
|
862
855
|
|
|
863
856
|
// ── turn_end: capture batch, flush immediately or queue ──────────────────
|
|
@@ -934,13 +927,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
934
927
|
}
|
|
935
928
|
}
|
|
936
929
|
|
|
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
930
|
// Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
|
|
945
931
|
// means no gate evaluation, regardless of leftover pendingBatches from an
|
|
946
932
|
// earlier turn — UNLESS a reload probe armed rearmedPending, in which case
|
|
@@ -1026,7 +1012,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1026
1012
|
changed = true;
|
|
1027
1013
|
statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
|
|
1028
1014
|
}
|
|
1029
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
1015
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
1030
1016
|
|
|
1031
1017
|
if (!changed) return undefined;
|
|
1032
1018
|
return { messages };
|
|
@@ -1082,7 +1068,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1082
1068
|
compactChains,
|
|
1083
1069
|
() => diagnostics.counts(),
|
|
1084
1070
|
(ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
|
|
1085
|
-
() => metricsCache,
|
|
1086
1071
|
() => rearmedPending,
|
|
1087
1072
|
);
|
|
1088
1073
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
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",
|
|
@@ -796,6 +796,13 @@ describe("applyChainCompressions - positional", () => {
|
|
|
796
796
|
msgs[3] = { ...msgs[3], content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "done 1" }] } as any;
|
|
797
797
|
return applyChainCompressions(msgs, entries as any, summaryFor, true);
|
|
798
798
|
}],
|
|
799
|
+
["a context-prune-summary after a completed cycle", () => {
|
|
800
|
+
const msgs = incident();
|
|
801
|
+
msgs.splice(8, 0, { role: "custom", customType: CUSTOM_TYPE_SUMMARY, content: "batch summary", timestamp: 2250 } as any);
|
|
802
|
+
const out = applyChainCompressions(msgs, entries as any, summaryFor, false);
|
|
803
|
+
expect(out.some((m: any) => m.customType === CUSTOM_TYPE_SUMMARY)).toBe(true);
|
|
804
|
+
return out;
|
|
805
|
+
}],
|
|
799
806
|
["skips an entry whose start falls strictly inside another entry's range", () => {
|
|
800
807
|
const wide = { blockId: "b8", startUserTimestamp: 1000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9002 };
|
|
801
808
|
const nestedInner = { blockId: "b9", startUserTimestamp: 2000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9003 };
|
package/src/commands.test.ts
CHANGED
|
@@ -11,10 +11,9 @@ function captureStatus(
|
|
|
11
11
|
config: ContextPruneConfig,
|
|
12
12
|
value?: Parameters<typeof setPruneStatusWidget>[2],
|
|
13
13
|
diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
|
|
14
|
-
metrics?: Parameters<typeof setPruneStatusWidget>[4],
|
|
15
14
|
): string | undefined {
|
|
16
15
|
let captured: string | undefined;
|
|
17
|
-
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics
|
|
16
|
+
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
|
|
18
17
|
return captured;
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -59,7 +58,6 @@ function setupPrunerCommand(overrides: {
|
|
|
59
58
|
async () => ({ compressedEntries: [], skipped: 0 }),
|
|
60
59
|
undefined,
|
|
61
60
|
overrides.getContextMetrics,
|
|
62
|
-
undefined,
|
|
63
61
|
overrides.getRearmed,
|
|
64
62
|
);
|
|
65
63
|
|
|
@@ -213,28 +211,3 @@ describe("diagnostic counters on the status line", () => {
|
|
|
213
211
|
});
|
|
214
212
|
});
|
|
215
213
|
|
|
216
|
-
describe("context metrics suffix on the status line", () => {
|
|
217
|
-
const metrics: ContextMetricsSnapshot = {
|
|
218
|
-
openCycleThinkingTokens: 12000,
|
|
219
|
-
largestChainSharePct: 62,
|
|
220
|
-
frontierGapTokens: 195000,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
|
|
224
|
-
const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
|
|
225
|
-
expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("omits the suffix when frontierGapTokens is 0", () => {
|
|
229
|
-
const withZeroGap = { ...metrics, frontierGapTokens: 0 };
|
|
230
|
-
expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
|
|
231
|
-
pruneStatusText(cfg(true)),
|
|
232
|
-
);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("composes after the diag suffix when both are present", () => {
|
|
236
|
-
const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
|
|
237
|
-
const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
|
|
238
|
-
expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
239
|
-
});
|
|
240
|
-
});
|
package/src/commands.ts
CHANGED
|
@@ -64,7 +64,6 @@ export function pruneStatusText(
|
|
|
64
64
|
config: ContextPruneConfig,
|
|
65
65
|
reclaim?: LiveReclaim,
|
|
66
66
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
67
|
-
metrics?: ContextMetricsSnapshot,
|
|
68
67
|
): string {
|
|
69
68
|
if (!config.enabled) return "prune: OFF";
|
|
70
69
|
const diag = diagnostics
|
|
@@ -76,14 +75,11 @@ export function pruneStatusText(
|
|
|
76
75
|
].filter(Boolean)
|
|
77
76
|
: [];
|
|
78
77
|
const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
|
|
79
|
-
|
|
80
|
-
? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
|
|
81
|
-
: "";
|
|
82
|
-
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
|
|
78
|
+
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
|
|
83
79
|
const beforeTok = Math.round(reclaim.beforeChars / 4);
|
|
84
80
|
const afterTok = Math.round(reclaim.afterChars / 4);
|
|
85
81
|
const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
|
|
86
|
-
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}
|
|
82
|
+
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
export function setPruneStatusWidget(
|
|
@@ -91,13 +87,12 @@ export function setPruneStatusWidget(
|
|
|
91
87
|
config: ContextPruneConfig,
|
|
92
88
|
value?: LiveReclaim | string,
|
|
93
89
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
94
|
-
metrics?: ContextMetricsSnapshot,
|
|
95
90
|
): void {
|
|
96
91
|
if (!config.showPruneStatusLine) {
|
|
97
92
|
ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
|
|
98
93
|
return;
|
|
99
94
|
}
|
|
100
|
-
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics
|
|
95
|
+
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
|
|
101
96
|
// Leading-only separator: the footer joins extension status segments with a
|
|
102
97
|
// single space, so a trailing divider collides with the next segment's leading
|
|
103
98
|
// one and renders doubled. One leading bar yields single dividers between
|
|
@@ -481,7 +476,6 @@ export function registerCommands(
|
|
|
481
476
|
compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
|
|
482
477
|
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
483
478
|
getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
|
|
484
|
-
getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
|
|
485
479
|
getRearmed?: () => boolean,
|
|
486
480
|
): void {
|
|
487
481
|
// Register the /pruner command
|
|
@@ -829,7 +823,7 @@ export function registerCommands(
|
|
|
829
823
|
}
|
|
830
824
|
currentConfig.value = newConfig;
|
|
831
825
|
saveConfig(newConfig);
|
|
832
|
-
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.()
|
|
826
|
+
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
833
827
|
settingsList?.invalidate();
|
|
834
828
|
};
|
|
835
829
|
|
|
@@ -864,7 +858,7 @@ export function registerCommands(
|
|
|
864
858
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
865
859
|
saveConfig(currentConfig.value);
|
|
866
860
|
ctx.ui.notify("Context pruning enabled.");
|
|
867
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
861
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
868
862
|
break;
|
|
869
863
|
}
|
|
870
864
|
|
|
@@ -873,7 +867,7 @@ export function registerCommands(
|
|
|
873
867
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
874
868
|
saveConfig(currentConfig.value);
|
|
875
869
|
ctx.ui.notify("Context pruning disabled.");
|
|
876
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
870
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
877
871
|
break;
|
|
878
872
|
}
|
|
879
873
|
|
|
@@ -996,7 +990,7 @@ export function registerCommands(
|
|
|
996
990
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
997
991
|
}
|
|
998
992
|
saveConfig(currentConfig.value);
|
|
999
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
993
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1000
994
|
break;
|
|
1001
995
|
}
|
|
1002
996
|
|
|
@@ -1098,7 +1092,7 @@ export function registerCommands(
|
|
|
1098
1092
|
|
|
1099
1093
|
// Remove the widget and restore the normal footer status.
|
|
1100
1094
|
clearWidget();
|
|
1101
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
1095
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1102
1096
|
|
|
1103
1097
|
if (!result.ok) {
|
|
1104
1098
|
const suffix = "error" in result && result.error ? ` (${result.error})` : "";
|
package/src/orphan-sweep.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { sweepOrphanToolResults } from "./orphan-sweep.js";
|
|
3
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
3
4
|
|
|
4
5
|
const asst = (ts: number, ids: string[]) => ({
|
|
5
6
|
role: "assistant",
|
|
@@ -60,8 +61,47 @@ describe("sweepOrphanToolResults", () => {
|
|
|
60
61
|
expect(sweepOrphanToolResults(msgs).messages).toBe(msgs);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// AC1: any non-assistant/non-toolResult role is a barrier - including an
|
|
65
|
+
// unknown role, so a role-allowlist implementation cannot pass.
|
|
66
|
+
const barrierRoles: Array<[string, any]> = [
|
|
67
|
+
["custom", { role: "custom", customType: "x", timestamp: 2 }],
|
|
68
|
+
["user", user(2)],
|
|
69
|
+
["branchSummary", { role: "branchSummary", timestamp: 2 }],
|
|
70
|
+
["compactionSummary", { role: "compactionSummary", timestamp: 2 }],
|
|
71
|
+
["bashExecution", { role: "bashExecution", timestamp: 2 }],
|
|
72
|
+
["unknown future role", { role: "future-role", timestamp: 2 }],
|
|
73
|
+
];
|
|
74
|
+
for (const [name, barrier] of barrierRoles) {
|
|
75
|
+
test(`a ${name} message between a call and its result is a barrier: the result is swept`, () => {
|
|
76
|
+
const msgs = [asst(1, ["a"]), barrier, res(3, "a")];
|
|
77
|
+
const out = sweepOrphanToolResults(msgs);
|
|
78
|
+
expect(out.sweptIds).toEqual(["a"]);
|
|
79
|
+
expect(out.messages).toHaveLength(2);
|
|
80
|
+
expect(out.messages.some((m: any) => m.role === "toolResult")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// AC2: barrier clears only what is still open; results consumed before it stay.
|
|
85
|
+
test("multi-call turn: barrier sweeps only the not-yet-consumed result", () => {
|
|
86
|
+
const msgs = [asst(1, ["a", "b"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }, res(4, "b")];
|
|
87
|
+
const out = sweepOrphanToolResults(msgs);
|
|
88
|
+
expect(out.sweptIds).toEqual(["b"]);
|
|
89
|
+
expect(out.messages).toHaveLength(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// AC3: a barrier after a completed cycle is untouched - same array reference.
|
|
93
|
+
test("trailing barrier after a completed cycle is a no-op (same array reference)", () => {
|
|
94
|
+
const msgs = [asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }];
|
|
95
|
+
const out = sweepOrphanToolResults(msgs);
|
|
96
|
+
expect(out.messages).toBe(msgs);
|
|
97
|
+
expect(out.sweptIds).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// AC5: the test helper enforces the same barrier rule (it copies the sweep's
|
|
101
|
+
// orphan definition; delegation would make sweep tests tautological).
|
|
102
|
+
test("expectNoOrphanToolResults throws on a mid-cycle barrier orphan and passes on a clean trailing barrier", () => {
|
|
103
|
+
const bad = [asst(1, ["a"]), { role: "custom", customType: "x", timestamp: 2 }, res(3, "a")];
|
|
104
|
+
expect(() => expectNoOrphanToolResults(bad)).toThrow();
|
|
105
|
+
expectNoOrphanToolResults([asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }]);
|
|
66
106
|
});
|
|
67
107
|
});
|
package/src/orphan-sweep.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* pi-ai repairs orphan tool calls only; providers reject orphan tool results.
|
|
3
3
|
*
|
|
4
4
|
* Open-call tracking is PER TURN: an assistant message replaces the open set
|
|
5
|
-
* with its own toolCall ids
|
|
5
|
+
* with its own toolCall ids, and any message that is neither assistant nor
|
|
6
|
+
* toolResult is a barrier that clears it (matching where pi-ai flushes
|
|
7
|
+
* synthetic tool results). A cumulative seen-set would let an id used
|
|
6
8
|
* validly in an early turn license a later genuine orphan - exactly the
|
|
7
9
|
* id-collision case this exists for.
|
|
8
10
|
*
|
|
@@ -32,7 +34,15 @@ export function sweepOrphanToolResults(messages: any[]): { messages: any[]; swep
|
|
|
32
34
|
orphanIndices.add(i);
|
|
33
35
|
sweptIds.push(msg.toolCallId);
|
|
34
36
|
}
|
|
37
|
+
continue;
|
|
35
38
|
}
|
|
39
|
+
// Barrier: any other role converts to a user-boundary at the provider
|
|
40
|
+
// (convertToLlm maps custom/branchSummary/compactionSummary/bashExecution
|
|
41
|
+
// to role "user"; pi-ai flushes synthetic tool results there), so a
|
|
42
|
+
// still-open call can no longer be legally answered after it. Unknown
|
|
43
|
+
// roles and excludeFromContext bashExecutions are a deliberate
|
|
44
|
+
// conservative over-sweep - no allowlist.
|
|
45
|
+
open = new Set();
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
if (orphanIndices.size === 0) return { messages, sweptIds: [] };
|
package/src/pruner.ts
CHANGED
|
@@ -47,10 +47,11 @@ export function sizeMessages(messages: any[]): number {
|
|
|
47
47
|
* Only runs when `chainCompression.enabled` and chain entries exist.
|
|
48
48
|
*
|
|
49
49
|
* Phase 4 — orphan sweep: structural post-condition run unconditionally over
|
|
50
|
-
* the final array. Removes any toolResult whose matching toolCall id
|
|
51
|
-
* opened by the
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* the final array. Removes any toolResult whose matching toolCall id is not
|
|
51
|
+
* open: opened by the most recent assistant turn and uninterrupted by a
|
|
52
|
+
* barrier (any non-assistant/non-toolResult message) — see
|
|
53
|
+
* src/orphan-sweep.ts. Reference-preserving when nothing is swept, so a
|
|
54
|
+
* clean render still returns the identical input array.
|
|
54
55
|
*
|
|
55
56
|
* Return shape:
|
|
56
57
|
* - `pruned: true` — at least one change happened; the returned
|
|
@@ -173,6 +173,8 @@ function bootExtension(
|
|
|
173
173
|
const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
|
|
174
174
|
const sessionAppended: AppendedEntry[] = appended;
|
|
175
175
|
const handlers = new Map<string, (event: any, ctx: any) => any>();
|
|
176
|
+
const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
|
|
177
|
+
const notifications: string[] = [];
|
|
176
178
|
|
|
177
179
|
const pushPi = (type: string, data?: unknown) => {
|
|
178
180
|
piAppended.push({ type, data });
|
|
@@ -187,7 +189,9 @@ function bootExtension(
|
|
|
187
189
|
},
|
|
188
190
|
appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
|
|
189
191
|
sendMessage() {},
|
|
190
|
-
registerCommand() {
|
|
192
|
+
registerCommand(name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
|
|
193
|
+
commands.set(name, spec.handler);
|
|
194
|
+
},
|
|
191
195
|
registerTool() {},
|
|
192
196
|
registerMessageRenderer() {},
|
|
193
197
|
events: { emit() {} },
|
|
@@ -221,12 +225,14 @@ function bootExtension(
|
|
|
221
225
|
ui: {
|
|
222
226
|
setStatus() {},
|
|
223
227
|
setWidget() {},
|
|
224
|
-
notify() {
|
|
228
|
+
notify(message: string) {
|
|
229
|
+
notifications.push(message);
|
|
230
|
+
},
|
|
225
231
|
select: async () => undefined,
|
|
226
232
|
},
|
|
227
233
|
};
|
|
228
234
|
|
|
229
|
-
return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
235
|
+
return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
@@ -526,75 +532,6 @@ describe("reload rearm (issue #6)", () => {
|
|
|
526
532
|
expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
527
533
|
});
|
|
528
534
|
|
|
529
|
-
it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => {
|
|
530
|
-
// Component 4 (spec): the snapshot cache recomputes at every enabled
|
|
531
|
-
// turn_end carrying toolResults, unconditional on whether trim yields a
|
|
532
|
-
// batch to push. Observed via the footer widget suffix (commands.ts's
|
|
533
|
-
// pruneStatusText), which is rendered from the cache, not recomputed
|
|
534
|
-
// itself — the honest seam here since the harness's pi.registerCommand
|
|
535
|
-
// is a no-op stub and the registerCommands getCachedMetrics callback is
|
|
536
|
-
// therefore unreachable from a test.
|
|
537
|
-
const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] });
|
|
538
|
-
|
|
539
|
-
// Neutralize the budget/delta gate so this test only observes the
|
|
540
|
-
// recompute, not a side-effect flush (harness default usage is 0.6,
|
|
541
|
-
// above the fixture's 0.5 autoBudgetThreshold).
|
|
542
|
-
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
543
|
-
|
|
544
|
-
await handlers.get("session_start")!({}, ctx);
|
|
545
|
-
|
|
546
|
-
const statusCalls: unknown[] = [];
|
|
547
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
548
|
-
|
|
549
|
-
// Force a render of the current (session_start-computed) cache.
|
|
550
|
-
const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
551
|
-
await handlers.get("context")!({ messages: rawBefore }, ctx);
|
|
552
|
-
const textBefore = statusCalls[statusCalls.length - 1];
|
|
553
|
-
|
|
554
|
-
// Grow the branch as Pi would before firing turn_end: a new assistant
|
|
555
|
-
// turn with a large thinking block and a protected tool call, plus its
|
|
556
|
-
// toolResult. Protected content is excluded from frontierGapTokens by
|
|
557
|
-
// design, but NOT from openCycleThinkingTokens or largestChainSharePct —
|
|
558
|
-
// so this turn still moves the cache if recomputed.
|
|
559
|
-
const newAssistant = {
|
|
560
|
-
type: "message",
|
|
561
|
-
message: {
|
|
562
|
-
role: "assistant",
|
|
563
|
-
content: [
|
|
564
|
-
{ type: "thinking", text: "t".repeat(4000) },
|
|
565
|
-
{ type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} },
|
|
566
|
-
],
|
|
567
|
-
},
|
|
568
|
-
};
|
|
569
|
-
const newToolResult = {
|
|
570
|
-
type: "message",
|
|
571
|
-
message: {
|
|
572
|
-
role: "toolResult",
|
|
573
|
-
toolCallId: "tc2",
|
|
574
|
-
toolName: "secret_tool",
|
|
575
|
-
content: [{ type: "text", text: "s".repeat(400) }],
|
|
576
|
-
timestamp: Date.now(),
|
|
577
|
-
},
|
|
578
|
-
};
|
|
579
|
-
branch.push(newAssistant, newToolResult);
|
|
580
|
-
|
|
581
|
-
// This turn's toolResults are entirely protected, so trimBatchToPendingRange
|
|
582
|
-
// returns null and no batch is pushed — the case this fix targets.
|
|
583
|
-
await handlers.get("turn_end")!(
|
|
584
|
-
{ message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 },
|
|
585
|
-
ctx,
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
589
|
-
await handlers.get("context")!({ messages: rawAfter }, ctx);
|
|
590
|
-
const textAfter = statusCalls[statusCalls.length - 1];
|
|
591
|
-
|
|
592
|
-
// Pre-fix, the cache is stale (computed once at session_start, on the
|
|
593
|
-
// pre-growth branch) — the widget text does not move. Post-fix, the
|
|
594
|
-
// turn_end recompute picks up the larger open segment/thinking.
|
|
595
|
-
expect(textAfter).not.toBe(textBefore);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
535
|
it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
|
|
599
536
|
// Component 1 (spec): denominator = per-message chars over the entire
|
|
600
537
|
// branch projection, INCLUDING retained custom_message summary entries.
|
|
@@ -620,17 +557,13 @@ describe("reload rearm (issue #6)", () => {
|
|
|
620
557
|
};
|
|
621
558
|
const branch = [...closedChain, summaryEntry, closer];
|
|
622
559
|
|
|
623
|
-
const { handlers, ctx } = await boot({ branch });
|
|
560
|
+
const { handlers, commands, notifications, ctx } = await boot({ branch });
|
|
624
561
|
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
625
562
|
|
|
626
|
-
const statusCalls: unknown[] = [];
|
|
627
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
628
|
-
|
|
629
563
|
await handlers.get("session_start")!({}, ctx);
|
|
630
564
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const text = statusCalls[statusCalls.length - 1] as string;
|
|
565
|
+
await commands.get("pruner")!("status", ctx);
|
|
566
|
+
const text = notifications[notifications.length - 1] as string;
|
|
634
567
|
|
|
635
568
|
const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
|
|
636
569
|
const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
|
|
@@ -641,7 +574,7 @@ describe("reload rearm (issue #6)", () => {
|
|
|
641
574
|
(100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
|
|
642
575
|
);
|
|
643
576
|
|
|
644
|
-
expect(text).toContain(`chain ${expectedPct}%`);
|
|
577
|
+
expect(text).toContain(`chain share: ${expectedPct}%`);
|
|
645
578
|
expect(expectedPct).toBeLessThan(inflatedPct);
|
|
646
579
|
});
|
|
647
580
|
});
|
package/src/test-support.ts
CHANGED