pi-condense 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,647 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import * as actualCompat from "@earendil-works/pi-ai/compat";
6
+
7
+ // This must run before any module that transitively reads PI_CODING_AGENT_DIR
8
+ // (src/config.ts's getAgentDir()) is imported/executed.
9
+ const tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
10
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
11
+ writeFileSync(
12
+ join(tmpAgentDir, "settings.json"),
13
+ JSON.stringify({
14
+ contextPrune: {
15
+ enabled: true,
16
+ pruneOn: "agent-message",
17
+ batchingMode: "agent-message",
18
+ autoBudgetThreshold: 0.5,
19
+ summarizerModel: "default",
20
+ minBatchChars: 1,
21
+ showPruneStatusLine: true,
22
+ chainCompression: {
23
+ enabled: false,
24
+ rollingWindow: 3,
25
+ stripFinalAssistantThinking: true,
26
+ fuseRangeSummary: true,
27
+ },
28
+ },
29
+ }),
30
+ );
31
+
32
+ let summarizerCalls = 0;
33
+
34
+ const USAGE = {
35
+ input: 1,
36
+ output: 1,
37
+ cacheRead: 0,
38
+ cacheWrite: 0,
39
+ totalTokens: 2,
40
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
41
+ };
42
+
43
+ function okStream() {
44
+ return {
45
+ async *[Symbol.asyncIterator]() {},
46
+ async result() {
47
+ return { stopReason: "stop", content: [{ type: "text", text: "[[1:read]] summary" }], usage: USAGE };
48
+ },
49
+ };
50
+ }
51
+
52
+ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
53
+ summarizerCalls++;
54
+ return okStream();
55
+ };
56
+
57
+ mock.module("@earendil-works/pi-ai/compat", () => ({
58
+ ...actualCompat,
59
+ stream: (...args: any[]) => streamImpl(...args),
60
+ }));
61
+
62
+ type AppendedEntry = { type: string; data: unknown };
63
+
64
+ // The captured-but-unflushed batch: a user message, an assistant message with
65
+ // one open toolCall (no closing text-only assistant — chain stays open), and
66
+ // its toolResult. This is what a reload's branch rescan must pick up
67
+ // (src/batch-capture.ts captureUnindexedBatchesFromSession).
68
+ function defaultBranch(): any[] {
69
+ return [
70
+ { type: "message", message: { role: "user", content: [{ type: "text", text: "read the file" }] } },
71
+ {
72
+ type: "message",
73
+ message: {
74
+ role: "assistant",
75
+ content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }],
76
+ },
77
+ },
78
+ {
79
+ type: "message",
80
+ message: {
81
+ role: "toolResult",
82
+ toolCallId: "tc1",
83
+ toolName: "read",
84
+ content: [{ type: "text", text: "x".repeat(400) }],
85
+ timestamp: Date.now(),
86
+ },
87
+ },
88
+ ];
89
+ }
90
+
91
+ // Builds `count` independent closed chains (each: user -> assistant toolCall
92
+ // -> toolResult -> final text-only assistant), one per user turn, so each
93
+ // becomes its own captured batch under batchingMode "agent-message" and the
94
+ // chain detector (src/chain-detector.ts) sees `count` closed candidates.
95
+ // Used by the chain-compression-failure scenario below, which needs enough
96
+ // closed chains to clear the rolling window (hardcoded to 3 in this file's
97
+ // settings fixtures) and make at least one chain actually eligible for
98
+ // compression (src/chain-compressor.ts selectEligible).
99
+ function closedChainBranch(count: number): any[] {
100
+ const msgs: any[] = [];
101
+ let t = Date.now();
102
+ for (let i = 0; i < count; i++) {
103
+ t += 1000;
104
+ msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `do task ${i}` }], timestamp: t } });
105
+ msgs.push({
106
+ type: "message",
107
+ message: { role: "assistant", content: [{ type: "toolCall", id: `tc${i}`, name: "read", arguments: {} }] },
108
+ });
109
+ t += 1000;
110
+ msgs.push({
111
+ type: "message",
112
+ message: { role: "toolResult", toolCallId: `tc${i}`, toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: t },
113
+ });
114
+ t += 1000;
115
+ msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `done ${i}` }], timestamp: t } });
116
+ }
117
+ return msgs;
118
+ }
119
+
120
+ // Boots a fresh index.ts extension instance against an isolated agent dir +
121
+ // session, mirroring the fixtures shared across the three scenarios below.
122
+ //
123
+ // By default pi.appendEntry and ctx.sessionManager.appendCustomEntry push
124
+ // into the SAME `appended` array (matching flushPending's actual behavior:
125
+ // most delivery="session" writes go through sessionManager, with pi.appendEntry
126
+ // only used as the emit-time fallback for empty/aborted/pre-capture-failure
127
+ // exits) — so `appended` is the single chronological log the first two
128
+ // scenarios assert against.
129
+ //
130
+ // `separatePiAppended: true` (stale-runtime scenario) gives pi.appendEntry
131
+ // its own array so a test can assert an entry never reached it, independent
132
+ // of what landed in `sessionAppended`.
133
+ //
134
+ // `sessionAppendCustomEntry`/`piAppendEntry` wrap the underlying push (still
135
+ // targeting the same array) so a scenario can inject a throw for a specific
136
+ // customType without duplicating the harness.
137
+ function bootExtension(
138
+ options: {
139
+ chainCompressionEnabled?: boolean;
140
+ separatePiAppended?: boolean;
141
+ piAppendEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => void;
142
+ sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string;
143
+ branch?: any[];
144
+ protectedTools?: string[];
145
+ } = {},
146
+ ) {
147
+ const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
148
+ process.env.PI_CODING_AGENT_DIR = agentDir;
149
+ writeFileSync(
150
+ join(agentDir, "settings.json"),
151
+ JSON.stringify({
152
+ contextPrune: {
153
+ enabled: true,
154
+ pruneOn: "agent-message",
155
+ batchingMode: "agent-message",
156
+ autoBudgetThreshold: 0.5,
157
+ summarizerModel: "default",
158
+ minBatchChars: 1,
159
+ showPruneStatusLine: true,
160
+ protectedTools: options.protectedTools ?? [],
161
+ chainCompression: {
162
+ enabled: options.chainCompressionEnabled ?? false,
163
+ rollingWindow: 3,
164
+ stripFinalAssistantThinking: true,
165
+ fuseRangeSummary: true,
166
+ },
167
+ },
168
+ }),
169
+ );
170
+
171
+ const sessionDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-session-"));
172
+ const appended: AppendedEntry[] = [];
173
+ const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
174
+ const sessionAppended: AppendedEntry[] = appended;
175
+ const handlers = new Map<string, (event: any, ctx: any) => any>();
176
+
177
+ const pushPi = (type: string, data?: unknown) => {
178
+ piAppended.push({ type, data });
179
+ };
180
+ const pushSession = (type: string, data?: unknown) => {
181
+ sessionAppended.push({ type, data });
182
+ };
183
+
184
+ const pi: any = {
185
+ on(name: string, fn: (event: any, ctx: any) => any) {
186
+ handlers.set(name, fn);
187
+ },
188
+ appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
189
+ sendMessage() {},
190
+ registerCommand() {},
191
+ registerTool() {},
192
+ registerMessageRenderer() {},
193
+ events: { emit() {} },
194
+ };
195
+
196
+ const branch = options.branch ?? defaultBranch();
197
+
198
+ const ctx: any = {
199
+ sessionManager: {
200
+ getBranch: () => branch,
201
+ appendCustomEntry: options.sessionAppendCustomEntry
202
+ ? options.sessionAppendCustomEntry(pushSession)
203
+ : (type: string, data?: unknown) => {
204
+ pushSession(type, data);
205
+ return "id";
206
+ },
207
+ appendCustomMessageEntry(type: string, content: string, _display: boolean, details?: unknown) {
208
+ sessionAppended.push({ type, data: { content, details } });
209
+ return "id";
210
+ },
211
+ getSessionDir: () => sessionDir,
212
+ getSessionId: () => "test",
213
+ },
214
+ getContextUsage: () => ({ tokens: 600000, contextWindow: 1000000 }),
215
+ model: { id: "m", provider: "p", name: "M" },
216
+ modelRegistry: {
217
+ find: () => undefined,
218
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }),
219
+ getProviderAuth: async () => undefined,
220
+ },
221
+ ui: {
222
+ setStatus() {},
223
+ setWidget() {},
224
+ notify() {},
225
+ select: async () => undefined,
226
+ },
227
+ };
228
+
229
+ return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
230
+ }
231
+
232
+ async function boot(options?: Parameters<typeof bootExtension>[0]) {
233
+ const harness = bootExtension(options);
234
+ const extension = (await import("../index.js")).default;
235
+ extension(harness.pi);
236
+ return harness;
237
+ }
238
+
239
+ describe("reload rearm (issue #6)", () => {
240
+ it("rearms the turn_end budget gate after a reload so recovered pending work still flushes", async () => {
241
+ const { handlers, ctx, appended } = await boot();
242
+
243
+ await handlers.get("session_start")!({}, ctx);
244
+
245
+ // Gate reachable without a fresh turn_end batch: the reload probe found
246
+ // recoverable work, so the budget-crossing turn_end below must flush it
247
+ // even though event.toolResults is empty.
248
+ await handlers.get("turn_end")!(
249
+ { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
250
+ ctx,
251
+ );
252
+
253
+ expect(summarizerCalls).toBeGreaterThan(0);
254
+
255
+ const indexEntry = appended.find((e) => e.type === "context-prune-index");
256
+ expect(indexEntry).toBeDefined();
257
+
258
+ // Prune visibility: the raw tc1 toolResult must now render as a stub.
259
+ const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
260
+ const res = await handlers.get("context")!({ messages: rawMessages }, ctx);
261
+ const prunedToolResult = res.messages.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc1");
262
+ expect(prunedToolResult).toBeDefined();
263
+ const prunedText = Array.isArray(prunedToolResult.content)
264
+ ? prunedToolResult.content.map((c: any) => c.text).join("\n")
265
+ : String(prunedToolResult.content);
266
+ expect(prunedText).toContain("context_tree_query");
267
+
268
+ const callsAfterFirstFlush = summarizerCalls;
269
+
270
+ // Second identical turn_end: the flag was cleared by the first flush, and
271
+ // the work is now summarized, so this must not re-trigger the summarizer.
272
+ await handlers.get("turn_end")!(
273
+ { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 3 },
274
+ ctx,
275
+ );
276
+
277
+ expect(summarizerCalls).toBe(callsAfterFirstFlush);
278
+
279
+ // ── Per-attempt flush-metrics entry (issue #6, Task 5) ──────────────────
280
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
281
+ expect(flushMetricsEntries.length).toBe(1);
282
+ const fm = flushMetricsEntries[0].data as any;
283
+ expect(fm.trigger).toBe("rearmed");
284
+ expect(fm.outcome).toBe("summarized");
285
+ expect(fm.capturedBatches).toBe(1);
286
+ expect(fm.processedBatches).toBe(1);
287
+ expect(fm.metrics.frontierGapTokens).toBeGreaterThan(0);
288
+
289
+ // Empty-attempt: message_end's unconditional flushPending rescans and finds
290
+ // nothing (the only batch was already summarized above). One entry per
291
+ // attempt, including empty ones.
292
+ await handlers.get("message_end")!(
293
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
294
+ ctx,
295
+ );
296
+
297
+ const flushMetricsEntriesAfterEmpty = appended.filter((e) => e.type === "context-prune-flush-metrics");
298
+ expect(flushMetricsEntriesAfterEmpty.length).toBe(2);
299
+ const empty = flushMetricsEntriesAfterEmpty[1].data as any;
300
+ expect(empty.trigger).toBe("message-end");
301
+ expect(empty.outcome).toBe("empty");
302
+ expect(empty.capturedBatches).toBe(0);
303
+ expect(empty.processedBatches).toBe(0);
304
+ });
305
+
306
+ it("agent_end shows 'recovered pending (reload)' when rearmed but the in-memory queue is empty", async () => {
307
+ const { handlers, ctx } = await boot();
308
+
309
+ await handlers.get("session_start")!({}, ctx);
310
+
311
+ const statusCalls: unknown[] = [];
312
+ ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
313
+
314
+ await handlers.get("agent_end")!({}, ctx);
315
+
316
+ expect(statusCalls).toContain("\u2502 prune: recovered pending (reload)");
317
+ });
318
+
319
+ it("reports a rescan failure to console.error and leaves rearmedPending false, without failing session_start (G4)", async () => {
320
+ // Spec (Component 2, Rescan failure): "if the reload rearm probe throws,
321
+ // console.error ... and leave rearmedPending = false - reload must never
322
+ // fail because of the probe." The probe's own try/catch in session_start
323
+ // can only observe a failure if the branch rescan actually propagates
324
+ // one out of capturePendingBatches for this call site.
325
+ const { handlers, ctx } = await boot();
326
+
327
+ let getBranchCalls = 0;
328
+ const realGetBranch = ctx.sessionManager.getBranch;
329
+ ctx.sessionManager.getBranch = () => {
330
+ getBranchCalls++;
331
+ // Within session_start, getBranch() is called once each by
332
+ // indexer/stats/frontier reconstruction (calls 1-3) before the reload
333
+ // rearm probe's own rescan (call 4). Fail only call 4 so the earlier
334
+ // reconstruction steps are unaffected and the failure is isolated to
335
+ // the probe.
336
+ if (getBranchCalls === 4) {
337
+ throw new Error("simulated branch rescan failure");
338
+ }
339
+ return realGetBranch();
340
+ };
341
+
342
+ const errorSpy: unknown[][] = [];
343
+ const realConsoleError = console.error;
344
+ console.error = (...args: unknown[]) => {
345
+ errorSpy.push(args);
346
+ };
347
+
348
+ try {
349
+ await handlers.get("session_start")!({}, ctx);
350
+ } finally {
351
+ console.error = realConsoleError;
352
+ }
353
+
354
+ expect(errorSpy.some((args) => String(args[0]).includes("reload rearm probe"))).toBe(true);
355
+
356
+ // rearmedPending must have stayed false: a toolResult-free turn_end must
357
+ // not reach the budget/delta gate (the observable proxy for the flag,
358
+ // exercised elsewhere in this file), so no flush/summarizer call happens.
359
+ const callsBefore = summarizerCalls;
360
+ await handlers.get("turn_end")!(
361
+ { toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
362
+ ctx,
363
+ );
364
+ expect(summarizerCalls).toBe(callsBefore);
365
+ });
366
+
367
+ it("non-rearmed turn_end with an all-excluded batch does not evaluate the budget gate (main parity)", async () => {
368
+ // Regression for the rearmed=false path: a turn whose toolResults are
369
+ // entirely protected (so trimBatchToPendingRange yields null) must
370
+ // return before touching previousFraction or the budget/delta gate —
371
+ // exactly main's `if (!batch) return;` — even when pendingBatches
372
+ // already holds a batch queued by an earlier turn.
373
+ const { handlers, ctx, appended } = await boot({ branch: [], protectedTools: ["secret_tool"] });
374
+
375
+ await handlers.get("session_start")!({}, ctx);
376
+
377
+ // Turn 1: a non-protected batch, low usage — pushes into pendingBatches
378
+ // without triggering a flush.
379
+ ctx.getContextUsage = () => ({ tokens: 100, contextWindow: 1000000 });
380
+ await handlers.get("turn_end")!(
381
+ {
382
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-a", name: "read", arguments: {} }] },
383
+ toolResults: [
384
+ { role: "toolResult", toolCallId: "tc-a", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
385
+ ],
386
+ turnIndex: 5,
387
+ },
388
+ ctx,
389
+ );
390
+
391
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
392
+ const callsBeforeTurn2 = summarizerCalls;
393
+
394
+ // Turn 2: every tool call is protected, so trimBatchToPendingRange
395
+ // returns null — but usage now crosses the budget threshold. Main
396
+ // returns before the gate for this turn; the leftover batch from turn 1
397
+ // must not cause a flush here.
398
+ ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
399
+ await handlers.get("turn_end")!(
400
+ {
401
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-b", name: "secret_tool", arguments: {} }] },
402
+ toolResults: [
403
+ { role: "toolResult", toolCallId: "tc-b", toolName: "secret_tool", content: [{ type: "text", text: "y".repeat(400) }], timestamp: Date.now() },
404
+ ],
405
+ turnIndex: 6,
406
+ },
407
+ ctx,
408
+ );
409
+
410
+ expect(summarizerCalls).toBe(callsBeforeTurn2);
411
+ expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
412
+ });
413
+
414
+ it("still writes the flush-metrics entry when chain compression fails", async () => {
415
+ // The chain-compression block routes its appendEntry through the same
416
+ // sessionManager.appendCustomEntry as the rest of the session-delivery
417
+ // path (delivery: "session" here). Throwing only for the chain entry's
418
+ // customType breaks compressEligible's write without touching the index /
419
+ // frontier / stats writes the summarization phase already made.
420
+ //
421
+ // The compressor only attempts a write when a chain is actually eligible:
422
+ // closed (has a final text-only assistant turn) AND older than the
423
+ // rolling window (bootExtension hardcodes chainCompression.rollingWindow
424
+ // to 3 — see the settings fixture above). A single closed chain never
425
+ // clears that window, so the fixture below builds four independent
426
+ // closed chains (separate user turns, so each becomes its own captured
427
+ // batch and gets its own per-batch summary before compression runs) —
428
+ // the oldest one becomes eligible and drives the injected throw. A local
429
+ // counter proves the throw actually fired, so this test can never go
430
+ // vacuous again.
431
+ let chainWriteAttempts = 0;
432
+ const { handlers, ctx, appended } = await boot({
433
+ chainCompressionEnabled: true,
434
+ branch: closedChainBranch(4),
435
+ sessionAppendCustomEntry: (push) => (type: string, data?: unknown) => {
436
+ if (type === "context-prune-chain") {
437
+ chainWriteAttempts++;
438
+ throw new Error("simulated chain-compression persistence failure");
439
+ }
440
+ push(type, data);
441
+ return "id";
442
+ },
443
+ });
444
+
445
+ await handlers.get("session_start")!({}, ctx);
446
+
447
+ // message_end drives an agent-message flush directly (no reload rearm
448
+ // needed): captures the four batches, summarizes them, then attempts
449
+ // chain compression on the oldest eligible chain, which fails and is
450
+ // swallowed.
451
+ await handlers.get("message_end")!(
452
+ { message: { role: "assistant", content: [{ type: "text", text: "hi" }] } },
453
+ ctx,
454
+ );
455
+
456
+ // Positive proof the injected throw was actually exercised (not vacuous).
457
+ expect(chainWriteAttempts).toBeGreaterThan(0);
458
+
459
+ const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
460
+ expect(flushMetricsEntries.length).toBe(1);
461
+ const fm = flushMetricsEntries[0].data as any;
462
+ expect(fm.outcome).toBe("summarized");
463
+ expect(fm.trigger).toBe("message-end");
464
+ expect(fm.capturedBatches).toBe(4);
465
+ expect(fm.processedBatches).toBe(4);
466
+ });
467
+
468
+ it("binds the sessionManager appender before the empty-capture exit, so an empty session-delivery flush still lands via sessionManager", async () => {
469
+ // Regression: the sessionManager-backed appender used to bind only after
470
+ // a non-empty capture, so an empty rescan on a session-delivery flush
471
+ // fell back to pi.appendEntry for the flush-metrics emit — a stale-pi
472
+ // drop risk (print-mode, reload). message_end always flushes with
473
+ // delivery: "session"; an empty branch makes the rescan find nothing.
474
+ const { handlers, ctx, piAppended, sessionAppended } = await boot({
475
+ branch: [],
476
+ separatePiAppended: true,
477
+ piAppendEntry: (push) => (type: string, data?: unknown) => {
478
+ push(type, data);
479
+ throw new Error("simulated stale runtime: pi.appendEntry unavailable");
480
+ },
481
+ });
482
+
483
+ await handlers.get("session_start")!({}, ctx);
484
+
485
+ await handlers.get("message_end")!(
486
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
487
+ ctx,
488
+ );
489
+
490
+ const flushMetricsEntries = sessionAppended.filter((e) => e.type === "context-prune-flush-metrics");
491
+ expect(flushMetricsEntries.length).toBe(1);
492
+ expect((flushMetricsEntries[0].data as any).outcome).toBe("empty");
493
+ expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
494
+ });
495
+
496
+ it("still writes the flush-metrics entry via sessionManager when pi.appendEntry is stale (print-mode) during session delivery", async () => {
497
+ // Simulates a stale runtime `pi` reference during print-mode: any call
498
+ // that still routes through pi.appendEntry throws, as it would against a
499
+ // dead/replaced runtime.
500
+ const { handlers, ctx, piAppended, sessionAppended } = await boot({
501
+ separatePiAppended: true,
502
+ piAppendEntry: (push) => (type: string, data?: unknown) => {
503
+ push(type, data);
504
+ throw new Error("simulated stale runtime: pi.appendEntry unavailable");
505
+ },
506
+ });
507
+
508
+ await handlers.get("session_start")!({}, ctx);
509
+
510
+ // message_end drives a session-delivery flush (delivery: "session").
511
+ // Under the pre-fix unconditional pi.appendEntry, this entry is lost to
512
+ // the swallowing try/catch because pi.appendEntry throws above.
513
+ await handlers.get("message_end")!(
514
+ { message: { role: "assistant", content: [{ type: "text", text: "hi" }] } },
515
+ ctx,
516
+ );
517
+
518
+ const flushMetricsEntries = sessionAppended.filter((e) => e.type === "context-prune-flush-metrics");
519
+ expect(flushMetricsEntries.length).toBe(1);
520
+ const fm = flushMetricsEntries[0].data as any;
521
+ expect(fm.outcome).toBe("summarized");
522
+ expect(fm.trigger).toBe("message-end");
523
+
524
+ // Confirms the routing decision, not just a lucky duplicate write: the
525
+ // stale pi.appendEntry must never be the source of this entry.
526
+ expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
527
+ });
528
+
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
+ it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
599
+ // Component 1 (spec): denominator = per-message chars over the entire
600
+ // branch projection, INCLUDING retained custom_message summary entries.
601
+ // A pre-fix `e.type === "message"` filter drops them, so the chain's
602
+ // share comes out inflated (denominator too small).
603
+ //
604
+ // The custom_message sits between two final text-only assistant
605
+ // messages so it lands outside both the chain range and the open-cycle
606
+ // segment (see src/context-metrics.test.ts's matching pure-level test) --
607
+ // isolating the denominator effect from any open-segment interaction.
608
+ const closedChain = closedChainBranch(1); // user -> assistant toolCall -> toolResult -> final text-only assistant
609
+ const summaryEntry = {
610
+ type: "custom_message",
611
+ customType: "context-prune-summary",
612
+ content: "s".repeat(3000),
613
+ display: false,
614
+ details: {},
615
+ timestamp: new Date().toISOString(),
616
+ };
617
+ const closer = {
618
+ type: "message",
619
+ message: { role: "assistant", content: [{ type: "text", text: "ok" }], timestamp: Date.now() + 100000 },
620
+ };
621
+ const branch = [...closedChain, summaryEntry, closer];
622
+
623
+ const { handlers, ctx } = await boot({ branch });
624
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
625
+
626
+ const statusCalls: unknown[] = [];
627
+ ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
628
+
629
+ await handlers.get("session_start")!({}, ctx);
630
+
631
+ const rawMessages = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
632
+ await handlers.get("context")!({ messages: rawMessages }, ctx);
633
+ const text = statusCalls[statusCalls.length - 1] as string;
634
+
635
+ const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
636
+ const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
637
+ .map((m) => JSON.stringify(m).length)
638
+ .reduce((a, b) => a + b, 0);
639
+ const expectedPct = Math.round((100 * chainChars) / totalWithSummary);
640
+ const inflatedPct = Math.round(
641
+ (100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
642
+ );
643
+
644
+ expect(text).toContain(`chain ${expectedPct}%`);
645
+ expect(expectedPct).toBeLessThan(inflatedPct);
646
+ });
647
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
+ import * as actualCompat from "@earendil-works/pi-ai/compat";
2
3
 
3
4
  // Stub pi-ai's `stream` so runSummarization can be exercised without a network
4
5
  // call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
@@ -6,6 +7,7 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
6
7
  throw new Error("streamImpl not set");
7
8
  };
8
9
  mock.module("@earendil-works/pi-ai/compat", () => ({
10
+ ...actualCompat,
9
11
  stream: (...args: any[]) => streamImpl(...args),
10
12
  }));
11
13