pi-condense 2.10.3 → 2.10.5
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 +19 -0
- package/PRUNING.md +18 -0
- package/README.md +1 -1
- package/index.ts +24 -3
- package/package.json +1 -1
- package/src/batch-capture.test.ts +40 -0
- package/src/batch-capture.ts +21 -2
- package/src/commands.test.ts +49 -0
- package/src/commands.ts +14 -13
- package/src/config.test.ts +99 -1
- package/src/config.ts +74 -17
- package/src/pruner.test.ts +21 -0
- package/src/reload-rearm.integration.test.ts +296 -1
- package/src/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ 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.5] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Mid-run auto-flush triggers (budget, delta, frontier-gap) stayed dead after every human reply until the new run's per-run turn index caught up with the session-wide persisted frontier: live `turn_end` batches carried Pi's run-local `event.turnIndex` while the frontier counts assistant messages session-wide. Live capture now derives the session-wide index from the session branch. Flush metrics entries gain a `stubCount` field. (#16)
|
|
15
|
+
|
|
16
|
+
## [2.10.4] - 2026-09-15
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `saveConfig` no longer overwrites a `settings.json` it could not read as a JSON object (unreadable, truncated, or non-object); only a missing file starts from empty. A failed `/pruner` save now shows an error notification naming the file instead of an unhandled promise rejection; the change still applies to the current session. (#15)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `release.sh <level>` promotes the CHANGELOG `## [Unreleased]` section to the versioned heading and commits it with `package.json` in the single `Release X.Y.Z` commit; a missing or empty section fails the run. New CONFIG field `CHANGELOG_HEADING`.
|
|
25
|
+
- Release skill: a user instruction naming the level is the approval - no proposal step or re-confirmation; bundled follow-ups run after `verify`.
|
|
26
|
+
- AGENTS.md rewritten to always-on essentials plus routing; shared core bumped to v3. Session entry types table moved to `PRUNING.md`.
|
|
27
|
+
- `.pi/gauntlet-overrides.md` gains `tracker: github`, the release path, and a write-gate carve-out for user-named writes; the Tickets section is superseded by the core Ticket convention.
|
|
28
|
+
|
|
10
29
|
## [2.10.3] - 2026-09-07
|
|
11
30
|
|
|
12
31
|
- **Protected-path supersession.** Only the newest read of a protected path (`protectedPaths` / `protectedTools` calls with a string `path`) stays verbatim; earlier reads of the same path become a one-line `[Superseded: ...]` stub. Applied at render time (`pruneMessages` phase 1b, `src/supersede.ts`) and only when the pruner is already rewriting at or before that position, or on a cold-cache event (`session_start`, `session_tree`, `model_select`, `session_compact`, `thinking_level_select`) - never as the sole mid-prefix change. No new session entry, index record, or config key; supersession stops exactly when no protected call remains (`protectedPaths: []` with the default `protectedTools: []`); a read protected by tool name alone still participates. Spec: `doc/specs/2026-09-07-protected-path-supersede.md` (partially supersedes the 2026-06-11 protected-paths spec's "verbatim forever" edge case).
|
package/PRUNING.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
2. [What Pruning Does](#what-pruning-does)
|
|
11
11
|
3. [Pruned Data Is Still Available](#pruned-data-is-still-available)
|
|
12
12
|
4. [What Actually Lives in the Pruner Index](#what-actually-lives-in-the-pruner-index)
|
|
13
|
+
- [Session entry types](#session-entry-types)
|
|
13
14
|
5. [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs)
|
|
14
15
|
6. [How Prefix Caching Works](#how-prefix-caching-works)
|
|
15
16
|
7. [Why Frequent Pruning Busts Cache](#why-frequent-pruning-busts-cache)
|
|
@@ -287,6 +288,21 @@ So after pruning, the model is working with a **two-layer memory**:
|
|
|
287
288
|
| Indexed tool-call record | **Stored in pruner index** (`context-prune-index` session entry) | Lets the model re-open the original raw output later via `context_tree_query` |
|
|
288
289
|
| Duplicate of an already-indexed record (same toolName + content) | **Aliased to the original; no new summary, no LLM call** (`context-prune-dedup-alias` session entry) | See [Content-hash dedup](#content-hash-dedup) |
|
|
289
290
|
|
|
291
|
+
### Session entry types
|
|
292
|
+
|
|
293
|
+
Custom session entry types written by the extension (NOT in LLM context unless noted). Rebuilt on `session_start` where stated; see the `Written by` column for the source.
|
|
294
|
+
|
|
295
|
+
| customType | Written by | Purpose |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `context-prune-index` | `indexer.addBatch`; also `indexer.backfillChainRecords` (uncovered-chain deterministic backfill, `src/chain-compressor.ts`) | One entry per summarized batch; rebuilds the in-memory `ToolCallRecord` map on `session_start`. A backfill-carrier entry additionally sets `backfilled: true` and carries `refs` (the allocated `t<N>` `SummaryToolCallRef[]`) - excluded from content-hash dedup canonical seeding on both the live path and `session_start` reconstruction; `refs` are re-registered via `registerSummaryRefs` on reconstruction since backfilled chains have no summary message to derive aliases from |
|
|
298
|
+
| `context-prune-summary` | `flushPending` (runtime: `pi.sendMessage` steer; session: `appendCustomMessageEntry`) | The summary message itself; IS in LLM context (replaces the pruned raw outputs) |
|
|
299
|
+
| `context-prune-stats` | `statsAccum.persist` | Cumulative summarizer token/cost snapshot |
|
|
300
|
+
| `context-prune-frontier` | `flushPending` | Last attempted prune boundary (advances even on `skipped-oversized` / `skipped-trivial` / `skipped-deduped`) |
|
|
301
|
+
| `context-prune-dedup-alias` | `indexer.registerDuplicate` | One entry per content-hash dedup hit; rebuilt on `session_start` to repopulate `dedupAliasToOriginal` |
|
|
302
|
+
| `context-prune-chain` | `chain-compressor.compressEligible` (called from `flushPending` in `index.ts` and from `/pruner compact`) | One entry per chain that has been range-dropped from LLM context; drops are decided **positionally** by `resolveRange` (`src/chain-range-prune.ts`), not by id. `droppedToolCallIds` is a diagnostic cross-check only (recorded-vs-actual mismatch emits `range-id-mismatch`); `droppedOccurrenceKeys` (optional) is load-bearing - it's what the occurrence-keyed synthetic-body lookup (per-batch summary text/coverage) is keyed against; protected-output text is NOT keyed off it - `src/chain-range-prune.ts` pulls `protectedToolCallIds` live by bare id within the resolved range instead. Also carries optional `rangeSummaryText` (fused LLM range summary) when `fuseRangeSummary` is on, and optional `protectedToolCallIds` (verbatim protected outputs - ids protected by tool name or path glob - are relocated into the synthetic body as `<protected-output>` tags at render time). Optional `bodySource: "deterministic"` marks a chain that had zero per-batch summary coverage: `rangeSummaryText` then holds a zero-LLM stub (call count, tool histogram, span duration, `t<N>` refs) built by the uncovered-chain backfill path in `chain-compressor.ts`, instead of a summarizer-derived body. Rebuilt on `session_start` to repopulate the chain registry. |
|
|
303
|
+
| `context-prune-diagnostic` | `pruneMessages` / `applyChainCompressions` / `chain-compressor.compressEligible` (via `DiagnosticSink.report`, `src/diagnostics.ts`) | One entry per distinct `(kind, dedupKey)` prune-time degradation (`unresolved-range` / `range-id-mismatch` / `orphan-sweep` / `backfill-empty`). Never in LLM context; deduped in-memory; reset on `session_start` and `session_tree`. Surfaced on the footer status widget as `diag u<N>/m<N>/o<N>/b<N>`. See [Diagnostics](#diagnostics). |
|
|
304
|
+
| `context-prune-flush-metrics` | `flushPending` (end of every non-concurrent attempt, single `finally` emit site, outside the chain-compression try/catch) | One entry per flush attempt, all outcomes (incl. `empty`/`error`): trigger, batch counts, `stubCount` (tool calls newly made stub-eligible by the flush: dedup aliases on processed batches plus calls from actually-indexed batches; `0` when nothing was indexed or aliased), and the pre-flush `ContextMetricsSnapshot` (open-cycle thinking, largest-chain share, frontier gap). Append-only observability log - never in LLM context, never reconstructed on `session_start`. |
|
|
305
|
+
|
|
290
306
|
## How the Model Re-reads Raw Outputs
|
|
291
307
|
|
|
292
308
|
The intended recovery flow is:
|
|
@@ -692,6 +708,8 @@ This is rare in practice once `minBatchChars` is on, because the cases where sum
|
|
|
692
708
|
|
|
693
709
|
The last attempted prune boundary is persisted as `context-prune-frontier` so `flushPending` knows where the previous attempt left off, even if that attempt was a skip rather than a real summary. Without this, a batch that's been skipped as oversized would be re-attempted (with the same LLM call, the same oversize result, the same skip) on every subsequent flush.
|
|
694
710
|
|
|
711
|
+
The frontier's `lastAttemptedTurnIndex` uses a session-wide numbering domain: it counts every projected assistant message from the start of the session, matching the rescan rule in `src/batch-capture.ts`. Pi's `event.turnIndex` resets on each `agent_start`, so live `turn_end` capture instead derives this session-wide index from the current session branch via `deriveLiveTurnIndex`. If `getBranch()` throws, capture temporarily falls back to the run-local index. When the branch is healthy at flush time, the rescan re-derives the session-wide index and the fallback value never persists; when branch reads keep failing through the flush, the queued run-local index persists and numbering self-corrects on the next healthy flush. This domain never decreases across compaction because compaction appends entries rather than removing them.
|
|
712
|
+
|
|
695
713
|
### Other UI / observability features
|
|
696
714
|
|
|
697
715
|
- **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.
|
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ By default the extension is **off**. `/pruner on` enables it and it stays enable
|
|
|
160
160
|
|
|
161
161
|
## Configuration - the knobs most people touch
|
|
162
162
|
|
|
163
|
-
Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings.
|
|
163
|
+
Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings. A `settings.json` that cannot be read as a JSON object is never overwritten by a `/pruner` change: the change applies to the current session and an error notification names the file.
|
|
164
164
|
|
|
165
165
|
| Key | Default | Notes |
|
|
166
166
|
|---|---|---|
|
package/index.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* Usage: pi -e .
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { loadConfig } from "./src/config.js";
|
|
18
|
-
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode, projectBranchMessages } from "./src/batch-capture.js";
|
|
18
|
+
import { captureBatch, captureUnindexedBatchesFromSession, deriveLiveTurnIndex, 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";
|
|
@@ -241,6 +241,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
241
|
// the emitter falls back to pi.appendEntry.
|
|
242
242
|
let capturedBatches = 0;
|
|
243
243
|
let processedCount = 0;
|
|
244
|
+
let stubCount = 0;
|
|
244
245
|
let outcome: FlushMetricsEntry["outcome"] = "empty";
|
|
245
246
|
let appendEntry: ((customType: string, data?: unknown) => void) | undefined;
|
|
246
247
|
|
|
@@ -251,6 +252,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
252
|
trigger,
|
|
252
253
|
capturedBatches,
|
|
253
254
|
processedBatches: processedCount,
|
|
255
|
+
stubCount,
|
|
254
256
|
outcome,
|
|
255
257
|
metrics: entryMetrics,
|
|
256
258
|
};
|
|
@@ -486,6 +488,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
486
488
|
totalRawCharCount += dedupRawChars;
|
|
487
489
|
totalToolCallCount += dedupCount;
|
|
488
490
|
totalDedupedCount += dedupCount;
|
|
491
|
+
stubCount += dedupCount;
|
|
489
492
|
dedupedBatches.push(batch);
|
|
490
493
|
processedBatches.push(batch);
|
|
491
494
|
continue;
|
|
@@ -500,6 +503,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
500
503
|
totalRawCharCount += batchRawCharCount + dedupRawChars;
|
|
501
504
|
totalToolCallCount += batch.toolCalls.length + dedupCount;
|
|
502
505
|
totalDedupedCount += dedupCount;
|
|
506
|
+
stubCount += dedupCount;
|
|
503
507
|
trivialBatches.push(batch);
|
|
504
508
|
processedBatches.push(batch);
|
|
505
509
|
continue;
|
|
@@ -541,8 +545,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
541
545
|
// Keep the in-memory summary-body registry current so chain compression
|
|
542
546
|
// can build synthetic chain messages without rescanning session entries.
|
|
543
547
|
indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
|
|
548
|
+
stubCount += batch.toolCalls.length + dedupCount;
|
|
544
549
|
floorSources.push(...batch.toolCalls);
|
|
545
550
|
} else {
|
|
551
|
+
stubCount += dedupCount;
|
|
546
552
|
oversizedBatches.push(batch);
|
|
547
553
|
}
|
|
548
554
|
} catch (err) {
|
|
@@ -889,10 +895,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
889
895
|
|
|
890
896
|
let pushedBatch = false;
|
|
891
897
|
if (hasToolResults) {
|
|
898
|
+
// Live batches must be numbered in the frontier's session-wide domain, not
|
|
899
|
+
// Pi's run-local event.turnIndex (which resets on agent_start, #16). The
|
|
900
|
+
// branch at turn_end already holds the just-ended assistant message (pi
|
|
901
|
+
// persists it at message_end, a strictly earlier event), so the derived
|
|
902
|
+
// index is the rescan index of this turn.
|
|
903
|
+
let liveTurnIndex = event.turnIndex;
|
|
904
|
+
let branch: SessionEntry[] | undefined;
|
|
905
|
+
try {
|
|
906
|
+
branch = ctx.sessionManager.getBranch();
|
|
907
|
+
} catch {
|
|
908
|
+
// Transient getBranch failure must never block the turn; fall back to
|
|
909
|
+
// the run-local index (pre-#16 behavior). The flush-time rescan still
|
|
910
|
+
// recovers the batch.
|
|
911
|
+
}
|
|
912
|
+
if (branch) liveTurnIndex = deriveLiveTurnIndex(branch);
|
|
892
913
|
const capturedBatch = captureBatch(
|
|
893
914
|
event.message,
|
|
894
915
|
event.toolResults,
|
|
895
|
-
|
|
916
|
+
liveTurnIndex,
|
|
896
917
|
Date.now()
|
|
897
918
|
);
|
|
898
919
|
// Drop user-protected tool/path results so they stay verbatim in context.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.5",
|
|
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",
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
captureBatch,
|
|
4
4
|
captureUnindexedBatchesFromSession,
|
|
5
|
+
deriveLiveTurnIndex,
|
|
5
6
|
projectBranchMessages,
|
|
6
7
|
serializeBatchForSummarizer,
|
|
7
8
|
} from "./batch-capture.js";
|
|
@@ -180,6 +181,45 @@ describe("projectBranchMessages", () => {
|
|
|
180
181
|
});
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
describe("deriveLiveTurnIndex (#16)", () => {
|
|
185
|
+
// Branch mixing every entry class the projection must classify: tool-calling
|
|
186
|
+
// assistants, a text-only assistant, a custom_message steer, a pruner custom
|
|
187
|
+
// entry, a compaction entry. The last assistant carries a ready, unsummarized
|
|
188
|
+
// tool call so the rescan emits a batch for it.
|
|
189
|
+
function mixedBranch(): any[] {
|
|
190
|
+
return [
|
|
191
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "start" }] } },
|
|
192
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "tc-old", name: "read", arguments: {} }] } },
|
|
193
|
+
{ type: "message", message: { role: "toolResult", toolCallId: "tc-old", toolName: "read", content: [{ type: "text", text: "y".repeat(200) }], timestamp: 1 } },
|
|
194
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "text only" }] } },
|
|
195
|
+
{ type: "custom_message", customType: "gauntlet-gate", content: "go", display: true },
|
|
196
|
+
{ type: "custom", customType: "context-prune-summary", data: {} },
|
|
197
|
+
{ type: "compaction", summary: "..." },
|
|
198
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "again" }] } },
|
|
199
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "tc-live", name: "read", arguments: {} }] } },
|
|
200
|
+
{ type: "message", message: { role: "toolResult", toolCallId: "tc-live", toolName: "read", content: [{ type: "text", text: "x".repeat(200) }], timestamp: 2 } },
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test("returns the rescan index of the branch's last assistant message (AC5 parity)", () => {
|
|
205
|
+
const branch = mixedBranch();
|
|
206
|
+
// 3 projected assistant messages (tc-old, text-only, tc-live) -> last index 2.
|
|
207
|
+
// custom_message/custom/compaction entries never count.
|
|
208
|
+
expect(deriveLiveTurnIndex(branch)).toBe(2);
|
|
209
|
+
const rescan = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
210
|
+
const liveBatch = rescan.find((b) => b.toolCalls.some((tc) => tc.toolCallId === "tc-live"));
|
|
211
|
+
expect(liveBatch).toBeDefined();
|
|
212
|
+
expect(liveBatch!.turnIndex).toBe(deriveLiveTurnIndex(branch));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("returns -1 when the branch has no projected assistant message", () => {
|
|
216
|
+
expect(deriveLiveTurnIndex([])).toBe(-1);
|
|
217
|
+
expect(
|
|
218
|
+
deriveLiveTurnIndex([{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } }]),
|
|
219
|
+
).toBe(-1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
183
223
|
describe("custom-anchor group boundary", () => {
|
|
184
224
|
function buildBranch(customType: string) {
|
|
185
225
|
const entry = (message: any) => ({ type: "message", message });
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
3
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
4
|
import { isChainAnchorCustom } from "./chain-detector.js";
|
|
@@ -17,6 +18,22 @@ export function projectBranchMessages(branch: any[]): any[] {
|
|
|
17
18
|
.map((e: any) => (e.type === "custom_message" ? projectCustomMessageEntry(e) : e.message));
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Session-wide index for the live turn at `turn_end`: the index the rescan
|
|
23
|
+
* below assigns to the branch's last assistant message. Shares the rescan's
|
|
24
|
+
* counting rule (every projected assistant message, text-only included) so
|
|
25
|
+
* live capture and the persisted flush frontier live in one numbering domain.
|
|
26
|
+
* Returns -1 when the branch has no projected assistant message (harness-only;
|
|
27
|
+
* a real `turn_end` always follows a persisted assistant message).
|
|
28
|
+
*/
|
|
29
|
+
export function deriveLiveTurnIndex(branch: SessionEntry[]): number {
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const msg of projectBranchMessages(branch)) {
|
|
32
|
+
if (msg.role === "assistant") count++;
|
|
33
|
+
}
|
|
34
|
+
return count - 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
/** True for SessionEntry shapes that project into an AgentMessage-like object (see projectBranchMessages). */
|
|
21
38
|
function isProjectableEntry(e: any): boolean {
|
|
22
39
|
return (e.type === "message" && e.message) || e.type === "custom_message";
|
|
@@ -109,8 +126,10 @@ export function captureUnindexedBatchesFromSession(
|
|
|
109
126
|
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
110
127
|
// This makes turnIndex stable across multiple prune cycles: pruning removes
|
|
111
128
|
// ToolResultMessages from the context event but leaves AssistantMessages in the
|
|
112
|
-
// session branch, so the count of all assistant messages never decreases
|
|
113
|
-
//
|
|
129
|
+
// session branch, so the count of all assistant messages never decreases. This
|
|
130
|
+
// session-wide count is the frontier's numbering domain; Pi's event.turnIndex
|
|
131
|
+
// matches it only inside one agent run (it resets on agent_start), so the live
|
|
132
|
+
// capture path derives the same index from the branch via deriveLiveTurnIndex.
|
|
114
133
|
let turnCounter = 0;
|
|
115
134
|
|
|
116
135
|
// userTurnGroup increments on every user message or eligible custom anchor seen
|
package/src/commands.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Command handlers persist through src/config.ts, which resolves the settings
|
|
7
|
+
// path lazily from PI_CODING_AGENT_DIR; point it at a scratch dir so no test
|
|
8
|
+
// ever touches the developer's real settings.json.
|
|
9
|
+
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
|
|
10
|
+
|
|
2
11
|
import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
|
|
12
|
+
import { settingsPath } from "./config.js";
|
|
3
13
|
import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
|
|
4
14
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
5
15
|
|
|
@@ -26,6 +36,7 @@ function setupPrunerCommand(overrides: {
|
|
|
26
36
|
flushPending?: (ctx: any, options?: any) => Promise<any>;
|
|
27
37
|
getRearmed?: () => boolean;
|
|
28
38
|
getContextMetrics?: (ctx: any) => ContextMetricsSnapshot;
|
|
39
|
+
save?: (config: ContextPruneConfig) => Promise<void>;
|
|
29
40
|
} = {}) {
|
|
30
41
|
let handler: (args: string, ctx: any) => Promise<void>;
|
|
31
42
|
const notifications: { message: string; type?: string }[] = [];
|
|
@@ -59,6 +70,7 @@ function setupPrunerCommand(overrides: {
|
|
|
59
70
|
undefined,
|
|
60
71
|
overrides.getContextMetrics,
|
|
61
72
|
overrides.getRearmed,
|
|
73
|
+
overrides.save,
|
|
62
74
|
);
|
|
63
75
|
|
|
64
76
|
const ctx: any = {
|
|
@@ -66,6 +78,7 @@ function setupPrunerCommand(overrides: {
|
|
|
66
78
|
notify(message: string, type?: string) {
|
|
67
79
|
notifications.push({ message, type });
|
|
68
80
|
},
|
|
81
|
+
setStatus() {},
|
|
69
82
|
},
|
|
70
83
|
};
|
|
71
84
|
|
|
@@ -73,6 +86,7 @@ function setupPrunerCommand(overrides: {
|
|
|
73
86
|
run: (args: string) => handler(args, ctx),
|
|
74
87
|
notifications,
|
|
75
88
|
flushCalls,
|
|
89
|
+
currentConfig,
|
|
76
90
|
};
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -176,6 +190,41 @@ describe("setPruneStatusWidget", () => {
|
|
|
176
190
|
});
|
|
177
191
|
});
|
|
178
192
|
|
|
193
|
+
describe("/pruner off with a rejecting save (#15)", () => {
|
|
194
|
+
it("keeps the in-memory change, notifies an error naming the settings path, and raises no unhandledRejection", async () => {
|
|
195
|
+
const unhandled: unknown[] = [];
|
|
196
|
+
const recorder = (reason: unknown) => { unhandled.push(reason); };
|
|
197
|
+
process.on("unhandledRejection", recorder);
|
|
198
|
+
try {
|
|
199
|
+
const { run, notifications, currentConfig } = setupPrunerCommand({
|
|
200
|
+
save: () => Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" })),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await run("off");
|
|
204
|
+
|
|
205
|
+
// The error toast lands after the handler returns; wait for it with a
|
|
206
|
+
// bounded poll rather than a microtask hop.
|
|
207
|
+
const deadline = Date.now() + 2000;
|
|
208
|
+
while (!notifications.some((n) => n.type === "error") && Date.now() < deadline) {
|
|
209
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
210
|
+
}
|
|
211
|
+
await new Promise((r) => setImmediate(r));
|
|
212
|
+
|
|
213
|
+
const errorIdx = notifications.findIndex((n) => n.type === "error");
|
|
214
|
+
const successIdx = notifications.findIndex((n) => n.message === "Context pruning disabled.");
|
|
215
|
+
expect(errorIdx).toBeGreaterThan(-1);
|
|
216
|
+
expect(successIdx).toBeGreaterThan(-1);
|
|
217
|
+
expect(successIdx).toBeLessThan(errorIdx);
|
|
218
|
+
expect(notifications[errorIdx].message).toContain(settingsPath());
|
|
219
|
+
expect(notifications[errorIdx].message).toContain("EACCES");
|
|
220
|
+
expect(currentConfig.value.enabled).toBe(false);
|
|
221
|
+
expect(unhandled).toEqual([]);
|
|
222
|
+
} finally {
|
|
223
|
+
process.off("unhandledRejection", recorder);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
179
228
|
describe("diagnostic counters on the status line", () => {
|
|
180
229
|
const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const;
|
|
181
230
|
const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
|
package/src/commands.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
DEFAULT_CONFIG,
|
|
24
24
|
} from "./types.js";
|
|
25
25
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { saveConfig } from "./config.js";
|
|
26
|
+
import { saveConfig, persistConfig } from "./config.js";
|
|
27
27
|
import { MAX_BUDGET_WINDOW } from "./budget.js";
|
|
28
28
|
import { formatTokens, formatCost, formatCharProgress, formatCompactCount } from "./stats.js";
|
|
29
29
|
import { Container, Text, SettingsList, type SettingItem } from "@earendil-works/pi-tui";
|
|
@@ -477,6 +477,7 @@ export function registerCommands(
|
|
|
477
477
|
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
478
478
|
getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
|
|
479
479
|
getRearmed?: () => boolean,
|
|
480
|
+
save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
|
|
480
481
|
): void {
|
|
481
482
|
// Register the /pruner command
|
|
482
483
|
pi.registerCommand("pruner", {
|
|
@@ -822,7 +823,7 @@ export function registerCommands(
|
|
|
822
823
|
};
|
|
823
824
|
}
|
|
824
825
|
currentConfig.value = newConfig;
|
|
825
|
-
|
|
826
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), newConfig, save);
|
|
826
827
|
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
827
828
|
settingsList?.invalidate();
|
|
828
829
|
};
|
|
@@ -856,7 +857,7 @@ export function registerCommands(
|
|
|
856
857
|
// ── /pruner on ──
|
|
857
858
|
case "on": {
|
|
858
859
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
859
|
-
|
|
860
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
860
861
|
ctx.ui.notify("Context pruning enabled.");
|
|
861
862
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
862
863
|
break;
|
|
@@ -865,7 +866,7 @@ export function registerCommands(
|
|
|
865
866
|
// ── /pruner off ──
|
|
866
867
|
case "off": {
|
|
867
868
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
868
|
-
|
|
869
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
869
870
|
ctx.ui.notify("Context pruning disabled.");
|
|
870
871
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
871
872
|
break;
|
|
@@ -943,7 +944,7 @@ export function registerCommands(
|
|
|
943
944
|
summarizerModel: parsed.model,
|
|
944
945
|
summarizerThinking: parsed.thinking ?? currentConfig.value.summarizerThinking,
|
|
945
946
|
};
|
|
946
|
-
|
|
947
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
947
948
|
const thinkingText = parsed.thinking ? ` with thinking ${parsed.thinking}` : "";
|
|
948
949
|
ctx.ui.notify(`Summarizer model set to: ${parsed.model}${thinkingText}`);
|
|
949
950
|
}
|
|
@@ -971,7 +972,7 @@ export function registerCommands(
|
|
|
971
972
|
);
|
|
972
973
|
return;
|
|
973
974
|
}
|
|
974
|
-
|
|
975
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
975
976
|
ctx.ui.notify(`Summarizer thinking set to: ${currentConfig.value.summarizerThinking}`);
|
|
976
977
|
break;
|
|
977
978
|
}
|
|
@@ -989,7 +990,7 @@ export function registerCommands(
|
|
|
989
990
|
} else {
|
|
990
991
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
991
992
|
}
|
|
992
|
-
|
|
993
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
993
994
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
994
995
|
break;
|
|
995
996
|
}
|
|
@@ -1013,7 +1014,7 @@ export function registerCommands(
|
|
|
1013
1014
|
}
|
|
1014
1015
|
currentConfig.value = { ...currentConfig.value, batchingMode: batchArg as ContextPruneConfig["batchingMode"] };
|
|
1015
1016
|
}
|
|
1016
|
-
|
|
1017
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1017
1018
|
ctx.ui.notify(`Batching mode set to: ${batchingModeLabel(currentConfig.value.batchingMode)}`);
|
|
1018
1019
|
break;
|
|
1019
1020
|
}
|
|
@@ -1164,7 +1165,7 @@ export function registerCommands(
|
|
|
1164
1165
|
}
|
|
1165
1166
|
|
|
1166
1167
|
currentConfig.value = { ...currentConfig.value, protectedTools: nextList };
|
|
1167
|
-
|
|
1168
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1168
1169
|
ctx.ui.notify(`Protected tools: ${protectedToolsDisplay(nextList)}`);
|
|
1169
1170
|
break;
|
|
1170
1171
|
}
|
|
@@ -1197,7 +1198,7 @@ export function registerCommands(
|
|
|
1197
1198
|
}
|
|
1198
1199
|
|
|
1199
1200
|
currentConfig.value = { ...currentConfig.value, protectedPaths: nextList };
|
|
1200
|
-
|
|
1201
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1201
1202
|
ctx.ui.notify(`Protected paths: ${protectedToolsDisplay(nextList)}`);
|
|
1202
1203
|
break;
|
|
1203
1204
|
}
|
|
@@ -1221,7 +1222,7 @@ export function registerCommands(
|
|
|
1221
1222
|
break;
|
|
1222
1223
|
}
|
|
1223
1224
|
currentConfig.value = { ...currentConfig.value, minBatchChars: parsed };
|
|
1224
|
-
|
|
1225
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1225
1226
|
ctx.ui.notify(
|
|
1226
1227
|
parsed === 0
|
|
1227
1228
|
? "minBatchChars set to 0 — pre-flush trivial-batch skipping disabled."
|
|
@@ -1244,7 +1245,7 @@ export function registerCommands(
|
|
|
1244
1245
|
break;
|
|
1245
1246
|
}
|
|
1246
1247
|
currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed };
|
|
1247
|
-
|
|
1248
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1248
1249
|
ctx.ui.notify(
|
|
1249
1250
|
parsed === 0
|
|
1250
1251
|
? "recovery-grace set to 0 - context_tree_query output stubs immediately."
|
|
@@ -1269,7 +1270,7 @@ export function registerCommands(
|
|
|
1269
1270
|
}
|
|
1270
1271
|
const next = arg === "on" || arg === "true";
|
|
1271
1272
|
currentConfig.value = { ...currentConfig.value, dedupByContentHash: next };
|
|
1272
|
-
|
|
1273
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1273
1274
|
ctx.ui.notify(`Content-hash dedup turned ${next ? "ON" : "OFF"}.`);
|
|
1274
1275
|
break;
|
|
1275
1276
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
|
1
|
+
import { describe, expect, it, beforeAll, afterAll, afterEach } from "bun:test";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
7
|
+
import type { ContextPruneConfig } from "./types.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* config.ts resolves the settings path from getAgentDir() lazily on each
|
|
@@ -15,6 +17,8 @@ import { DEFAULT_CONFIG } from "./types.js";
|
|
|
15
17
|
let tmpDir: string;
|
|
16
18
|
let loadConfig: typeof import("./config.js").loadConfig;
|
|
17
19
|
let saveConfig: typeof import("./config.js").saveConfig;
|
|
20
|
+
let persistConfig: typeof import("./config.js").persistConfig;
|
|
21
|
+
let SettingsReadError: typeof import("./config.js").SettingsReadError;
|
|
18
22
|
let settingsPath: typeof import("./config.js").settingsPath;
|
|
19
23
|
|
|
20
24
|
beforeAll(async () => {
|
|
@@ -23,9 +27,17 @@ beforeAll(async () => {
|
|
|
23
27
|
const mod = await import("./config.js");
|
|
24
28
|
loadConfig = mod.loadConfig;
|
|
25
29
|
saveConfig = mod.saveConfig;
|
|
30
|
+
persistConfig = mod.persistConfig;
|
|
31
|
+
SettingsReadError = mod.SettingsReadError;
|
|
26
32
|
settingsPath = mod.settingsPath;
|
|
27
33
|
});
|
|
28
34
|
|
|
35
|
+
// Every case below may leave a malformed settings.json behind; remove it so
|
|
36
|
+
// the shared path is clean for the next case.
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await rm(settingsPath(), { force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
29
41
|
afterAll(async () => {
|
|
30
42
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
31
43
|
await rm(tmpDir, { recursive: true, force: true });
|
|
@@ -163,3 +175,89 @@ describe("loadConfig frontierGapThresholdTokens normalization", () => {
|
|
|
163
175
|
}
|
|
164
176
|
});
|
|
165
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("saveConfig fails closed (#15)", () => {
|
|
180
|
+
const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
|
|
181
|
+
|
|
182
|
+
it("creates settings.json containing only contextPrune when the file is absent", async () => {
|
|
183
|
+
await rm(settingsPath(), { force: true });
|
|
184
|
+
await saveConfig(config);
|
|
185
|
+
const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
|
|
186
|
+
expect(Object.keys(written)).toEqual(["contextPrune"]);
|
|
187
|
+
expect(written.contextPrune.enabled).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("preserves other top-level keys and replaces contextPrune", async () => {
|
|
191
|
+
await writeFile(settingsPath(), '{"foo":1,"contextPrune":{"enabled":true}}');
|
|
192
|
+
await saveConfig(config);
|
|
193
|
+
const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
|
|
194
|
+
expect(written.foo).toBe(1);
|
|
195
|
+
expect(written.contextPrune.enabled).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const [label, raw] of [
|
|
199
|
+
["0-byte file", ""],
|
|
200
|
+
["truncated JSON", '{"foo":'],
|
|
201
|
+
["array", "[]"],
|
|
202
|
+
["null", "null"],
|
|
203
|
+
["string", '"str"'],
|
|
204
|
+
["number", "42"],
|
|
205
|
+
] as const) {
|
|
206
|
+
it(`rejects and leaves the file byte-identical for ${label}; loadConfig returns defaults`, async () => {
|
|
207
|
+
await writeFile(settingsPath(), raw);
|
|
208
|
+
const before = readFileSync(settingsPath());
|
|
209
|
+
await expect(saveConfig(config)).rejects.toBeInstanceOf(SettingsReadError);
|
|
210
|
+
expect(readFileSync(settingsPath()).equals(before)).toBe(true);
|
|
211
|
+
expect(await loadConfig()).toEqual({ ...DEFAULT_CONFIG });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("rejects with reason EACCES when the injected read fails, leaving the file byte-identical", async () => {
|
|
216
|
+
await writeFile(settingsPath(), '{"foo":1}');
|
|
217
|
+
const before = readFileSync(settingsPath());
|
|
218
|
+
const read = (async () => {
|
|
219
|
+
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
220
|
+
}) as unknown as typeof import("node:fs/promises").readFile;
|
|
221
|
+
const err = await saveConfig(config, read).catch((e) => e);
|
|
222
|
+
expect(err).toBeInstanceOf(SettingsReadError);
|
|
223
|
+
expect(err.reason).toBe("EACCES");
|
|
224
|
+
expect(err.path).toBe(settingsPath());
|
|
225
|
+
expect(readFileSync(settingsPath()).equals(before)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("persistConfig (#15)", () => {
|
|
230
|
+
const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
|
|
231
|
+
|
|
232
|
+
it("notifies once with type error and the settings path when the file is truncated", async () => {
|
|
233
|
+
await writeFile(settingsPath(), '{"foo":');
|
|
234
|
+
const calls: { message: string; type?: string }[] = [];
|
|
235
|
+
await persistConfig((message, type) => calls.push({ message, type }), config);
|
|
236
|
+
expect(calls).toHaveLength(1);
|
|
237
|
+
expect(calls[0].type).toBe("error");
|
|
238
|
+
expect(calls[0].message).toContain(settingsPath());
|
|
239
|
+
expect(calls[0].message).toContain("invalid JSON");
|
|
240
|
+
expect(calls[0].message).toContain("Change applies to this session only.");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does not notify when the file is missing", async () => {
|
|
244
|
+
await rm(settingsPath(), { force: true });
|
|
245
|
+
const calls: unknown[] = [];
|
|
246
|
+
await persistConfig((message, type) => calls.push({ message, type }), config);
|
|
247
|
+
expect(calls).toHaveLength(0);
|
|
248
|
+
expect(JSON.parse(await readFile(settingsPath(), "utf-8")).contextPrune.enabled).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("handles a save that rejects with undefined", async () => {
|
|
252
|
+
const calls: { message: string; type?: string }[] = [];
|
|
253
|
+
await persistConfig(
|
|
254
|
+
(message, type) => calls.push({ message, type }),
|
|
255
|
+
config,
|
|
256
|
+
() => Promise.reject(undefined),
|
|
257
|
+
);
|
|
258
|
+
expect(calls).toHaveLength(1);
|
|
259
|
+
expect(calls[0].type).toBe("error");
|
|
260
|
+
expect(calls[0].message).toContain(settingsPath());
|
|
261
|
+
expect(calls[0].message).toContain("undefined");
|
|
262
|
+
});
|
|
263
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -115,22 +115,58 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
export class SettingsReadError extends Error {
|
|
119
|
+
constructor(
|
|
120
|
+
public readonly path: string,
|
|
121
|
+
public readonly reason: string,
|
|
122
|
+
) {
|
|
123
|
+
super(`settings.json unreadable at ${path}: ${reason}`);
|
|
124
|
+
this.name = "SettingsReadError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Single classifier for settings.json read outcomes. Only ENOENT means "no
|
|
130
|
+
* file"; every other failure throws so a save never starts from `{}` over a
|
|
131
|
+
* file it could not read.
|
|
132
|
+
*/
|
|
133
|
+
async function readJsonObject(
|
|
134
|
+
path: string,
|
|
135
|
+
read: typeof readFile = readFile,
|
|
136
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
137
|
+
let raw: string;
|
|
119
138
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
raw = await read(path, "utf-8");
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const e = err as NodeJS.ErrnoException;
|
|
142
|
+
if (e.code === "ENOENT") return undefined;
|
|
143
|
+
throw new SettingsReadError(path, e.code ?? e.message);
|
|
144
|
+
}
|
|
145
|
+
let parsed: unknown;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(raw);
|
|
126
148
|
} catch {
|
|
127
|
-
|
|
149
|
+
throw new SettingsReadError(path, "invalid JSON");
|
|
150
|
+
}
|
|
151
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
152
|
+
return parsed as Record<string, unknown>;
|
|
128
153
|
}
|
|
154
|
+
throw new SettingsReadError(path, "not a JSON object");
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or
|
|
159
|
+
* defaults. Fail-soft: an unreadable or malformed file yields defaults, since
|
|
160
|
+
* a broken settings.json is pi-wide and not this extension's to report.
|
|
161
|
+
*/
|
|
132
162
|
export async function loadConfig(): Promise<ContextPruneConfig> {
|
|
133
|
-
|
|
163
|
+
let main: Record<string, unknown> | undefined;
|
|
164
|
+
try {
|
|
165
|
+
main = await readJsonObject(settingsPath());
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof SettingsReadError) return { ...DEFAULT_CONFIG };
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
134
170
|
const namespaced = main?.[SETTINGS_KEY];
|
|
135
171
|
if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
|
|
136
172
|
return normalize(namespaced as Partial<ContextPruneConfig>);
|
|
@@ -140,18 +176,39 @@ export async function loadConfig(): Promise<ContextPruneConfig> {
|
|
|
140
176
|
|
|
141
177
|
/**
|
|
142
178
|
* Writes the full config back to `<agent-dir>/settings.json` under
|
|
143
|
-
* {@link SETTINGS_KEY}, preserving every other top-level key in the file.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* last-write-wins race
|
|
179
|
+
* {@link SETTINGS_KEY}, preserving every other top-level key in the file.
|
|
180
|
+
* Tmp-file + atomic rename, so a concurrent reader never observes a partial
|
|
181
|
+
* file. A file that cannot be read as a JSON object is never replaced: the
|
|
182
|
+
* read throws {@link SettingsReadError} before anything is written. Concurrent
|
|
183
|
+
* saves (ours or pi's own) are last-write-wins; that race is not coordinated.
|
|
148
184
|
*/
|
|
149
|
-
export async function saveConfig(config: ContextPruneConfig): Promise<void> {
|
|
185
|
+
export async function saveConfig(config: ContextPruneConfig, read: typeof readFile = readFile): Promise<void> {
|
|
150
186
|
const path = settingsPath();
|
|
151
|
-
const current = (await readJsonObject(path)) ?? {};
|
|
187
|
+
const current = (await readJsonObject(path, read)) ?? {};
|
|
152
188
|
const next = { ...current, [SETTINGS_KEY]: config };
|
|
153
189
|
await mkdir(dirname(path), { recursive: true });
|
|
154
190
|
const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
155
191
|
await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
156
192
|
await rename(tmpPath, path);
|
|
157
193
|
}
|
|
194
|
+
|
|
195
|
+
type Notify = (message: string, type?: "info" | "warning" | "error") => void;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Saves and reports failure through `notify` instead of rejecting, so callers
|
|
199
|
+
* can fire-and-forget. The in-memory change stands; only persistence failed.
|
|
200
|
+
*/
|
|
201
|
+
export async function persistConfig(
|
|
202
|
+
notify: Notify,
|
|
203
|
+
config: ContextPruneConfig,
|
|
204
|
+
save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
try {
|
|
207
|
+
await save(config);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const reason = err instanceof SettingsReadError
|
|
210
|
+
? err.reason
|
|
211
|
+
: ((err as NodeJS.ErrnoException | null | undefined)?.code ?? String(err));
|
|
212
|
+
notify(`Could not save settings to ${settingsPath()}: ${reason}. Change applies to this session only.`, "error");
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/pruner.test.ts
CHANGED
|
@@ -76,6 +76,27 @@ describe("pruneMessages", () => {
|
|
|
76
76
|
expect(out[1].content[0].text).toContain("context_tree_query");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
it("two renders of unchanged history are byte-identical (AC6 cache-prefix guard)", () => {
|
|
80
|
+
const indexer = makeMockIndexer({
|
|
81
|
+
summarized: new Set(["tc1"]),
|
|
82
|
+
shortRefs: new Map([["tc1", "t1"]]),
|
|
83
|
+
});
|
|
84
|
+
const build = () => [
|
|
85
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", input: {} }], timestamp: 0 },
|
|
86
|
+
{
|
|
87
|
+
role: "toolResult",
|
|
88
|
+
toolCallId: "tc1",
|
|
89
|
+
toolName: "bash",
|
|
90
|
+
content: [{ type: "text", text: "big output" }],
|
|
91
|
+
isError: false,
|
|
92
|
+
timestamp: 1,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
const first = pruneMessages(build(), indexer);
|
|
96
|
+
const second = pruneMessages(build(), indexer);
|
|
97
|
+
expect(JSON.stringify(second.messages)).toBe(JSON.stringify(first.messages));
|
|
98
|
+
});
|
|
99
|
+
|
|
79
100
|
it("returns original array reference when nothing is summarized or compressed", () => {
|
|
80
101
|
const indexer = makeMockIndexer();
|
|
81
102
|
const messages = [{ role: "user", content: "hello", timestamp: 1 }];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
@@ -150,6 +150,44 @@ function pendingBatchEntries(toolCallId: string, text: string, timestamp: number
|
|
|
150
150
|
];
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function textOnlyTurns(count: number, startTs: number): any[] {
|
|
154
|
+
const msgs: any[] = [];
|
|
155
|
+
let t = startTs;
|
|
156
|
+
for (let i = 0; i < count; i++) {
|
|
157
|
+
msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `turn ${i}` }], timestamp: (t += 1000) } });
|
|
158
|
+
msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `answer ${i}` }], timestamp: (t += 1000) } });
|
|
159
|
+
}
|
|
160
|
+
return msgs;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function frontierEntry(turnIndex: number, toolCallId: string): any {
|
|
164
|
+
return {
|
|
165
|
+
type: "custom",
|
|
166
|
+
customType: "context-prune-frontier",
|
|
167
|
+
data: {
|
|
168
|
+
lastAttemptedToolCallId: toolCallId,
|
|
169
|
+
lastAttemptedToolName: "read",
|
|
170
|
+
lastAttemptedTurnIndex: turnIndex,
|
|
171
|
+
lastAttemptedTimestamp: Date.now(),
|
|
172
|
+
attemptedBatchCount: 1,
|
|
173
|
+
attemptedToolCallCount: 1,
|
|
174
|
+
rawCharCount: 1000,
|
|
175
|
+
summaryCharCount: 100,
|
|
176
|
+
outcome: "summarized",
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fireTurn(handlers: Map<string, any>, ctx: any, branch: any[], toolCallId: string, text: string, runLocalIndex: number) {
|
|
182
|
+
const t = Date.now();
|
|
183
|
+
const [, assistant, result] = pendingBatchEntries(toolCallId, text, t);
|
|
184
|
+
branch.push(assistant, result);
|
|
185
|
+
await handlers.get("turn_end")!(
|
|
186
|
+
{ message: assistant.message, toolResults: [result.message], turnIndex: runLocalIndex },
|
|
187
|
+
ctx,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
153
191
|
// Boots a fresh index.ts extension instance against an isolated agent dir +
|
|
154
192
|
// session, mirroring the fixtures shared across the three scenarios below.
|
|
155
193
|
//
|
|
@@ -288,6 +326,110 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
|
288
326
|
}
|
|
289
327
|
|
|
290
328
|
describe("reload rearm (issue #6)", () => {
|
|
329
|
+
it("falls back to the run-local index at capture when getBranch throws, and the flush-time rescan re-derives the session-wide index", async () => {
|
|
330
|
+
const { handlers, ctx, notifications, appended } = await boot();
|
|
331
|
+
|
|
332
|
+
await handlers.get("session_start")!({}, ctx);
|
|
333
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
334
|
+
const healthyGetBranch = ctx.sessionManager.getBranch;
|
|
335
|
+
ctx.sessionManager.getBranch = () => {
|
|
336
|
+
throw new Error("boom");
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
await expect(
|
|
340
|
+
handlers.get("turn_end")!(
|
|
341
|
+
{
|
|
342
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
|
|
343
|
+
toolResults: [
|
|
344
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "result" }], timestamp: Date.now() },
|
|
345
|
+
],
|
|
346
|
+
turnIndex: 7,
|
|
347
|
+
},
|
|
348
|
+
ctx,
|
|
349
|
+
),
|
|
350
|
+
).resolves.toBeUndefined();
|
|
351
|
+
expect(notifications.filter((message) => message.includes("pruner: 1 turn queued"))).toHaveLength(1);
|
|
352
|
+
|
|
353
|
+
ctx.sessionManager.getBranch = healthyGetBranch;
|
|
354
|
+
await handlers.get("message_end")!(
|
|
355
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
356
|
+
ctx,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const flushMetrics = appended.find((entry) => entry.type === "context-prune-flush-metrics");
|
|
360
|
+
expect(flushMetrics).toBeDefined();
|
|
361
|
+
expect((flushMetrics!.data as any).outcome).not.toBe("error");
|
|
362
|
+
|
|
363
|
+
const frontier = appended.find((entry) => entry.type === "context-prune-frontier");
|
|
364
|
+
expect(frontier).toBeDefined();
|
|
365
|
+
// The flush-time rescan re-derives the session-wide index; the run-local fallback value never persists.
|
|
366
|
+
expect((frontier!.data as any).lastAttemptedTurnIndex).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("persists the queued batch's run-local index when the flush itself runs on the getBranch fallback", async () => {
|
|
370
|
+
const { handlers, ctx, appended } = await boot();
|
|
371
|
+
|
|
372
|
+
await handlers.get("session_start")!({}, ctx);
|
|
373
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
374
|
+
ctx.sessionManager.getBranch = () => {
|
|
375
|
+
throw new Error("boom");
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Long result text so the queued batch clears the oversized-summary guard
|
|
379
|
+
// and reaches indexer.addBatch (a 6-char result would be skipped-oversized
|
|
380
|
+
// and leave no index entry to assert against).
|
|
381
|
+
await handlers.get("turn_end")!(
|
|
382
|
+
{
|
|
383
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
|
|
384
|
+
toolResults: [
|
|
385
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
386
|
+
],
|
|
387
|
+
turnIndex: 7,
|
|
388
|
+
},
|
|
389
|
+
ctx,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// getBranch is still down at flush time: capturePendingBatches' catch
|
|
393
|
+
// branch serves pendingBatches.slice() (index.ts), so the queued batch's
|
|
394
|
+
// captured run-local index (7) is what reaches persistence.
|
|
395
|
+
await handlers.get("message_end")!(
|
|
396
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
397
|
+
ctx,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const flushMetrics = appended.find((entry) => entry.type === "context-prune-flush-metrics");
|
|
401
|
+
expect(flushMetrics).toBeDefined();
|
|
402
|
+
expect((flushMetrics!.data as any).outcome).toBe("summarized");
|
|
403
|
+
|
|
404
|
+
const indexEntry = appended.find((entry) => entry.type === "context-prune-index");
|
|
405
|
+
expect(indexEntry).toBeDefined();
|
|
406
|
+
expect((indexEntry!.data as any).toolCalls[0].turnIndex).toBe(7);
|
|
407
|
+
|
|
408
|
+
const frontier = appended.find((entry) => entry.type === "context-prune-frontier");
|
|
409
|
+
expect(frontier).toBeDefined();
|
|
410
|
+
expect((frontier!.data as any).lastAttemptedTurnIndex).toBe(7);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("propagates turn-index derivation errors at turn_end", async () => {
|
|
414
|
+
const { handlers, ctx, branch } = await boot();
|
|
415
|
+
|
|
416
|
+
await handlers.get("session_start")!({}, ctx);
|
|
417
|
+
branch.push(null);
|
|
418
|
+
|
|
419
|
+
await expect(
|
|
420
|
+
handlers.get("turn_end")!(
|
|
421
|
+
{
|
|
422
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "read", arguments: {} }] },
|
|
423
|
+
toolResults: [
|
|
424
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "read", content: [{ type: "text", text: "result" }], timestamp: Date.now() },
|
|
425
|
+
],
|
|
426
|
+
turnIndex: 0,
|
|
427
|
+
},
|
|
428
|
+
ctx,
|
|
429
|
+
),
|
|
430
|
+
).rejects.toThrow();
|
|
431
|
+
});
|
|
432
|
+
|
|
291
433
|
it("rearms the turn_end budget gate after a reload so recovered pending work still flushes", async () => {
|
|
292
434
|
const { handlers, ctx, appended } = await boot();
|
|
293
435
|
|
|
@@ -784,6 +926,8 @@ describe("reload rearm (issue #6)", () => {
|
|
|
784
926
|
expect(flushMetricsEntries.length).toBe(1);
|
|
785
927
|
const fm = flushMetricsEntries[0].data as any;
|
|
786
928
|
expect(fm.trigger).toBe("frontier-gap");
|
|
929
|
+
// defaultBranch's single tc1 call is summarized+indexed by this flush.
|
|
930
|
+
expect(fm.stubCount).toBe(1);
|
|
787
931
|
expect(fm.metrics.frontierGapTokens).toBeGreaterThanOrEqual(10);
|
|
788
932
|
|
|
789
933
|
expect(notifications.some((n) => n.includes("un-pruned tail exceeded frontier gap threshold"))).toBe(true);
|
|
@@ -895,6 +1039,9 @@ describe("reload rearm (issue #6)", () => {
|
|
|
895
1039
|
expect(frontierEntries.length).toBe(1);
|
|
896
1040
|
const firstFrontier = frontierEntries[0].data as any;
|
|
897
1041
|
expect(firstFrontier.lastAttemptedToolCallId).toBe("tc-a");
|
|
1042
|
+
// Turn 2's flush indexed tc-a (1 call) and aliased nothing else.
|
|
1043
|
+
const firstMetrics = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1044
|
+
expect((firstMetrics[0].data as any).stubCount).toBe(1);
|
|
898
1045
|
|
|
899
1046
|
// Grow the branch again (tc-b is still unsummarized/pending after the
|
|
900
1047
|
// restore; add tc-c as this turn's new work) — gap stays over threshold.
|
|
@@ -1228,12 +1375,47 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
|
|
|
1228
1375
|
const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
1229
1376
|
expect(frontierEntries.length).toBe(1);
|
|
1230
1377
|
expect((frontierEntries[0].data as any).outcome).toBe("skipped-trivial");
|
|
1378
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1379
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
1380
|
+
expect((flushMetricsEntries[0].data as any).outcome).toBe("skipped-trivial");
|
|
1381
|
+
expect((flushMetricsEntries[0].data as any).stubCount).toBe(0);
|
|
1231
1382
|
|
|
1232
1383
|
const rendered = await render(branch, handlers, ctx);
|
|
1233
1384
|
expect(toolResultText(rendered, "r1")).toBe("content-r1");
|
|
1234
1385
|
expect(toolResultText(rendered, "r2")).toBe("content-r2");
|
|
1235
1386
|
});
|
|
1236
1387
|
|
|
1388
|
+
it("a partially deduped skipped-trivial batch counts only its alias in stubCount", async () => {
|
|
1389
|
+
const R = "R".repeat(400);
|
|
1390
|
+
const branch: any[] = [];
|
|
1391
|
+
const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
|
|
1392
|
+
|
|
1393
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1394
|
+
await fireTurn(handlers, ctx, branch, "bashOrig", R, 1);
|
|
1395
|
+
expect(appended.some((e) => e.type === "context-prune-index")).toBe(true);
|
|
1396
|
+
|
|
1397
|
+
const assistant = {
|
|
1398
|
+
role: "assistant",
|
|
1399
|
+
content: [
|
|
1400
|
+
{ type: "toolCall", id: "bashDup", name: "read", arguments: {} },
|
|
1401
|
+
{ type: "toolCall", id: "trivial", name: "read", arguments: {} },
|
|
1402
|
+
],
|
|
1403
|
+
};
|
|
1404
|
+
const toolResults = [
|
|
1405
|
+
{ role: "toolResult", toolCallId: "bashDup", toolName: "read", content: [{ type: "text", text: R }], timestamp: 90 },
|
|
1406
|
+
{ role: "toolResult", toolCallId: "trivial", toolName: "read", content: [{ type: "text", text: "" }], timestamp: 91 },
|
|
1407
|
+
];
|
|
1408
|
+
branch.push({ type: "message", message: assistant }, ...toolResults.map((message) => ({ type: "message", message })));
|
|
1409
|
+
await handlers.get("turn_end")!({ message: assistant, toolResults, turnIndex: 2 }, ctx);
|
|
1410
|
+
|
|
1411
|
+
const aliasEntries = appended.filter((e) => e.type === "context-prune-dedup-alias");
|
|
1412
|
+
expect(aliasEntries.length).toBe(1);
|
|
1413
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1414
|
+
const metrics = flushMetricsEntries[flushMetricsEntries.length - 1].data as any;
|
|
1415
|
+
expect(metrics.outcome).toBe("skipped-trivial");
|
|
1416
|
+
expect(metrics.stubCount).toBe(1);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1237
1419
|
it("a skipped-oversized batch (summary longer than raw) sets no floor", async () => {
|
|
1238
1420
|
const branch: any[] = [];
|
|
1239
1421
|
const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
|
|
@@ -1263,6 +1445,10 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
|
|
|
1263
1445
|
const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
1264
1446
|
expect(frontierEntries.length).toBe(1);
|
|
1265
1447
|
expect((frontierEntries[0].data as any).outcome).toBe("skipped-oversized");
|
|
1448
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1449
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
1450
|
+
expect((flushMetricsEntries[0].data as any).outcome).toBe("skipped-oversized");
|
|
1451
|
+
expect((flushMetricsEntries[0].data as any).stubCount).toBe(0);
|
|
1266
1452
|
|
|
1267
1453
|
const rendered = await render(branch, handlers, ctx);
|
|
1268
1454
|
expect(toolResultText(rendered, "r1")).toBe("content-r1");
|
|
@@ -1316,6 +1502,10 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
|
|
|
1316
1502
|
expect(frontierEntries[frontierEntries.length - 1].data && (frontierEntries[frontierEntries.length - 1].data as any).outcome).toBe(
|
|
1317
1503
|
"skipped-deduped",
|
|
1318
1504
|
);
|
|
1505
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1506
|
+
const dedupMetrics = flushMetricsEntries[flushMetricsEntries.length - 1].data as any;
|
|
1507
|
+
expect(dedupMetrics.outcome).toBe("skipped-deduped");
|
|
1508
|
+
expect(dedupMetrics.stubCount).toBe(1);
|
|
1319
1509
|
// The alias's own resultTimestamp (90) is <= the older read's (100) — the
|
|
1320
1510
|
// property that lets it, alone, explain the activation below.
|
|
1321
1511
|
|
|
@@ -1434,3 +1624,108 @@ describe("supersede floor cadence (spec 2026-09-07)", () => {
|
|
|
1434
1624
|
expect(toolResultText(rendered, "r_new")).toBe("content-r_new");
|
|
1435
1625
|
});
|
|
1436
1626
|
});
|
|
1627
|
+
|
|
1628
|
+
describe("session-wide live turn index (#16)", () => {
|
|
1629
|
+
it("AC8: pre-fix fixture with frontier 83 flushes one eligible live turn past the frontier", async () => {
|
|
1630
|
+
const fixture = readFileSync(join(import.meta.dirname, "fixtures", "gh16-frontier-83.jsonl"), "utf8")
|
|
1631
|
+
.trim()
|
|
1632
|
+
.split("\n")
|
|
1633
|
+
.map((line) => JSON.parse(line));
|
|
1634
|
+
const { handlers, ctx, appended } = await boot({
|
|
1635
|
+
branch: fixture,
|
|
1636
|
+
autoBudgetThreshold: null,
|
|
1637
|
+
budgetTurnDelta: null,
|
|
1638
|
+
frontierGapThresholdTokens: 100,
|
|
1639
|
+
});
|
|
1640
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
1641
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1642
|
+
|
|
1643
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-live", "x".repeat(800), 0);
|
|
1644
|
+
|
|
1645
|
+
const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1646
|
+
expect(fm.length).toBe(1);
|
|
1647
|
+
expect((fm[0].data as any).trigger).toBe("frontier-gap");
|
|
1648
|
+
expect((fm[0].data as any).outcome).toBe("summarized");
|
|
1649
|
+
expect((fm[0].data as any).stubCount).toBeGreaterThan(0);
|
|
1650
|
+
|
|
1651
|
+
const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
1652
|
+
const next = frontierEntries[frontierEntries.length - 1].data as any;
|
|
1653
|
+
expect(next.lastAttemptedTurnIndex).toBeGreaterThanOrEqual(83);
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
it("AC1: a new run's first batch survives the frontier and reaches the budget gate", async () => {
|
|
1657
|
+
const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
|
|
1658
|
+
const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
|
|
1659
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
1660
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1661
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-new", "x".repeat(400), 0);
|
|
1662
|
+
const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1663
|
+
expect(fm.length).toBe(1);
|
|
1664
|
+
expect((fm[0].data as any).trigger).toBe("budget");
|
|
1665
|
+
expect((fm[0].data as any).trigger).not.toBe("rearmed");
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
it("AC2: an already-summarized live batch is still dropped (no re-summarization)", async () => {
|
|
1669
|
+
const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
|
|
1670
|
+
const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
|
|
1671
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
1672
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1673
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-new", "x".repeat(400), 0);
|
|
1674
|
+
const callsAfterFirst = summarizerCalls;
|
|
1675
|
+
expect(appended.filter((e) => e.type === "context-prune-flush-metrics").length).toBe(1);
|
|
1676
|
+
const [assistant, result] = ctx.sessionManager.getBranch().slice(-2);
|
|
1677
|
+
await handlers.get("turn_end")!({ message: assistant.message, toolResults: [result.message], turnIndex: 1 }, ctx);
|
|
1678
|
+
expect(summarizerCalls).toBe(callsAfterFirst);
|
|
1679
|
+
expect(appended.filter((e) => e.type === "context-prune-flush-metrics").length).toBe(1);
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it("AC3: above-frontier live batches in a continuing run keep flushing", async () => {
|
|
1683
|
+
const branch = [...textOnlyTurns(84, 1700000000000), frontierEntry(83, "tc-old")];
|
|
1684
|
+
const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
|
|
1685
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
1686
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1687
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-a", "a".repeat(400), 0);
|
|
1688
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-b", "b".repeat(400), 1);
|
|
1689
|
+
const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1690
|
+
expect(fm.length).toBe(2);
|
|
1691
|
+
expect((fm[1].data as any).trigger).toBe("budget");
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
it("AC4: equal-index partial turn keeps only the suffix after the recorded call", async () => {
|
|
1695
|
+
const branch = [...textOnlyTurns(2, 1700000000000), frontierEntry(2, "tc-2")];
|
|
1696
|
+
const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: 0.5 });
|
|
1697
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
1698
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1699
|
+
const t = Date.now();
|
|
1700
|
+
const assistant = { type: "message", message: { role: "assistant", content: [
|
|
1701
|
+
{ type: "toolCall", id: "tc-1", name: "read", arguments: {} },
|
|
1702
|
+
{ type: "toolCall", id: "tc-2", name: "read", arguments: {} },
|
|
1703
|
+
{ type: "toolCall", id: "tc-3", name: "read", arguments: {} },
|
|
1704
|
+
] }, timestamp: t };
|
|
1705
|
+
const results = ["tc-1", "tc-2", "tc-3"].map((id, i) => ({
|
|
1706
|
+
type: "message", message: { role: "toolResult", toolCallId: id, toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: t + 1 + i },
|
|
1707
|
+
}));
|
|
1708
|
+
ctx.sessionManager.getBranch().push(assistant, ...results);
|
|
1709
|
+
await handlers.get("turn_end")!({ message: assistant.message, toolResults: results.map((r) => r.message), turnIndex: 0 }, ctx);
|
|
1710
|
+
const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1711
|
+
expect(fm.length).toBe(1);
|
|
1712
|
+
expect((fm[0].data as any).stubCount).toBe(1);
|
|
1713
|
+
const indexEntries = appended.filter((e) => e.type === "context-prune-index");
|
|
1714
|
+
const indexPayload = JSON.stringify(indexEntries.map((e) => e.data));
|
|
1715
|
+
expect(indexPayload).toContain("tc-3");
|
|
1716
|
+
expect(indexPayload).not.toContain("tc-1");
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it("AC7: frontier-gap fires on the second post-reply turn, not before", async () => {
|
|
1720
|
+
const branch = [...textOnlyTurns(51, 1700000000000), frontierEntry(50, "tc-old")];
|
|
1721
|
+
const { handlers, ctx, appended } = await boot({ branch, autoBudgetThreshold: null, budgetTurnDelta: null, frontierGapThresholdTokens: 1000 });
|
|
1722
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
1723
|
+
await handlers.get("session_start")!({}, ctx);
|
|
1724
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-g1", "g".repeat(3500), 0);
|
|
1725
|
+
expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
1726
|
+
await fireTurn(handlers, ctx, ctx.sessionManager.getBranch(), "tc-g2", "g".repeat(3500), 1);
|
|
1727
|
+
const fm = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
1728
|
+
expect(fm.length).toBe(1);
|
|
1729
|
+
expect((fm[0].data as any).trigger).toBe("frontier-gap");
|
|
1730
|
+
});
|
|
1731
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -732,6 +732,8 @@ export interface FlushMetricsEntry {
|
|
|
732
732
|
/** Batches after rescan+trim, before processing. */
|
|
733
733
|
capturedBatches: number;
|
|
734
734
|
processedBatches: number;
|
|
735
|
+
/** Tool calls this flush newly made stub-eligible: dedup aliases on processed batches plus calls of batches actually indexed. 0 when nothing was indexed or aliased (all-trivial/oversized, or failure before any batch was processed). */
|
|
736
|
+
stubCount: number;
|
|
735
737
|
outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error";
|
|
736
738
|
/** Computed at flush ENTRY (pre-flush pressure). */
|
|
737
739
|
metrics: ContextMetricsSnapshot;
|