pi-condense 2.13.0 → 2.13.1
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 +7 -0
- package/package.json +1 -1
- package/src/commands.test.ts +19 -2
- package/src/config.test.ts +8 -1
- package/src/summarizer-wiring.test.ts +237 -2
- package/src/summarizer.ts +17 -11
- package/src/types.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ 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.13.1] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The summarizer idle default is 45s instead of 20s, allowing queued providers longer before session-model takeover; the silent-primary wait is 45s on initial detection and each failed 10-minute probe. Explicit settings keep their values. (#21)
|
|
15
|
+
- Immediate session-model rescue notices include the primary failure reason, including idle/ceiling timeout or provider error text; deferred fallback notices remain unchanged. (#21)
|
|
16
|
+
|
|
10
17
|
## [2.13.0] - 2026-10-20
|
|
11
18
|
|
|
12
19
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.13.
|
|
3
|
+
"version": "2.13.1",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
package/src/commands.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
|
|
10
10
|
|
|
11
11
|
import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
|
|
12
|
-
import { settingsPath } from "./config.js";
|
|
12
|
+
import { settingsPath, loadConfig } from "./config.js";
|
|
13
13
|
import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
|
|
14
14
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
15
15
|
import { Container, SettingsList } from "@earendil-works/pi-tui";
|
|
@@ -148,6 +148,23 @@ describe("/pruner now (empty capture)", () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
describe("/pruner status context block", () => {
|
|
151
|
+
it("reports the resolved 45s idle default when the setting is absent", async () => {
|
|
152
|
+
const scratchDir = mkdtempSync(join(tmpdir(), "pi-condense-idle-status-"));
|
|
153
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
154
|
+
try {
|
|
155
|
+
process.env.PI_CODING_AGENT_DIR = scratchDir;
|
|
156
|
+
writeFileSync(settingsPath(), JSON.stringify({ contextPrune: { enabled: true } }));
|
|
157
|
+
const harness = setupPrunerCommand();
|
|
158
|
+
harness.currentConfig.value = await loadConfig();
|
|
159
|
+
await harness.run("status");
|
|
160
|
+
expect(harness.notifications[0]?.message).toContain("idle to: 45s");
|
|
161
|
+
} finally {
|
|
162
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
163
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
164
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
151
168
|
const metrics: ContextMetricsSnapshot = {
|
|
152
169
|
openCycleThinkingTokens: 12000,
|
|
153
170
|
largestChainSharePct: 62,
|
package/src/config.test.ts
CHANGED
|
@@ -99,8 +99,15 @@ describe("loadConfig summarizer timeout normalization", () => {
|
|
|
99
99
|
it("defaults both timeouts when absent", async () => {
|
|
100
100
|
await writeContextPrune({});
|
|
101
101
|
const config = await loadConfig();
|
|
102
|
-
expect(config.summarizerIdleTimeoutMs).toBe(
|
|
102
|
+
expect(config.summarizerIdleTimeoutMs).toBe(45000);
|
|
103
103
|
expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
|
|
104
|
+
expect(config.summarizerMaxTimeoutMs).toBe(180000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("preserves an explicit legacy 20s idle timeout", async () => {
|
|
108
|
+
await writeContextPrune({ summarizerIdleTimeoutMs: 20000 });
|
|
109
|
+
const config = await loadConfig();
|
|
110
|
+
expect(config.summarizerIdleTimeoutMs).toBe(20000);
|
|
104
111
|
});
|
|
105
112
|
|
|
106
113
|
it("preserves explicit 0 (disabled) for both", async () => {
|
|
@@ -4,7 +4,7 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
|
4
4
|
};
|
|
5
5
|
const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
|
|
6
6
|
const { FallbackController } = await import("./summarizer-fallback.js");
|
|
7
|
-
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
7
|
+
const { DEFAULT_CONFIG, SUMMARIZER_IDLE_TIMEOUT_PRESETS } = await import("./types.js");
|
|
8
8
|
|
|
9
9
|
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
|
|
10
10
|
const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
|
|
@@ -161,6 +161,7 @@ describe("virtual models through the registry", () => {
|
|
|
161
161
|
expect(result?.summaryText).toBe("- routed summary");
|
|
162
162
|
expect(controller.inFallback).toBe(true);
|
|
163
163
|
expect(seen[1]).toBe(VIRTUAL);
|
|
164
|
+
expect(notes[0]).toEqual({ msg: "pi-condense: summarizer model Primary failing (down), using session model Auto until it recovers", level: "warning" });
|
|
164
165
|
expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
|
|
165
166
|
expect(notes.filter(n => n.level === "error")).toHaveLength(0);
|
|
166
167
|
});
|
|
@@ -300,6 +301,7 @@ describe("runSummarization wiring — both-down + deferred warning", () => {
|
|
|
300
301
|
const r2 = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
301
302
|
expect(r2?.summaryText).toBe("- rescued");
|
|
302
303
|
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
304
|
+
expect(notes[0].msg).toBe("pi-condense: summarizer model Primary failing, using session model Session until it recovers");
|
|
303
305
|
});
|
|
304
306
|
});
|
|
305
307
|
|
|
@@ -319,6 +321,7 @@ describe("runSummarization wiring — abort", () => {
|
|
|
319
321
|
|
|
320
322
|
describe("runSummarization wiring — timeouts", () => {
|
|
321
323
|
for (const [kind, idleMs, maxMs, message] of [
|
|
324
|
+
// Millisecond-scale overrides exercise timer behavior, not the default.
|
|
322
325
|
["idle", 20, 0, /stalled/],
|
|
323
326
|
["ceiling", 0, 20, /ceiling/],
|
|
324
327
|
] as const) {
|
|
@@ -379,7 +382,7 @@ describe("runSummarization wiring — timeouts", () => {
|
|
|
379
382
|
const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
|
|
380
383
|
expect(r?.summaryText).toBe("- rescued");
|
|
381
384
|
expect(controller.inFallback).toBe(true);
|
|
382
|
-
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
385
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
383
386
|
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
|
384
387
|
});
|
|
385
388
|
|
|
@@ -430,3 +433,235 @@ describe("runSummarization wiring — idle reset keeps a flowing stream alive",
|
|
|
430
433
|
expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
|
|
431
434
|
});
|
|
432
435
|
});
|
|
436
|
+
|
|
437
|
+
async function withTimerClock(
|
|
438
|
+
run: (clock: { advance(ms: number): Promise<void>; now(): number }) => Promise<void>,
|
|
439
|
+
) {
|
|
440
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
441
|
+
const realClearTimeout = globalThis.clearTimeout;
|
|
442
|
+
const timers = new Map<number, { at: number; fire(): void }>();
|
|
443
|
+
let now = 0;
|
|
444
|
+
let nextId = 0;
|
|
445
|
+
const drain = () => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
|
|
446
|
+
globalThis.setTimeout = ((callback: (...args: any[]) => void, delay = 0, ...args: any[]) => {
|
|
447
|
+
const id = ++nextId;
|
|
448
|
+
timers.set(id, { at: now + Number(delay), fire: () => callback(...args) });
|
|
449
|
+
return id;
|
|
450
|
+
}) as unknown as typeof globalThis.setTimeout;
|
|
451
|
+
globalThis.clearTimeout = ((id: any) => { timers.delete(Number(id)); }) as unknown as typeof globalThis.clearTimeout;
|
|
452
|
+
try {
|
|
453
|
+
await run({
|
|
454
|
+
now: () => now,
|
|
455
|
+
async advance(ms) {
|
|
456
|
+
await drain();
|
|
457
|
+
const end = now + ms;
|
|
458
|
+
while (true) {
|
|
459
|
+
const next = [...timers.entries()]
|
|
460
|
+
.filter(([, timer]) => timer.at <= end)
|
|
461
|
+
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
|
462
|
+
if (!next) break;
|
|
463
|
+
now = next[1].at;
|
|
464
|
+
timers.delete(next[0]);
|
|
465
|
+
next[1].fire();
|
|
466
|
+
await drain();
|
|
467
|
+
}
|
|
468
|
+
now = end;
|
|
469
|
+
await drain();
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
} finally {
|
|
473
|
+
globalThis.setTimeout = realSetTimeout;
|
|
474
|
+
globalThis.clearTimeout = realClearTimeout;
|
|
475
|
+
timers.clear();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const genericEnter = "pi-condense: summarizer model Primary failing, using session model Session until it recovers";
|
|
480
|
+
|
|
481
|
+
describe("immediate fallback reasons", () => {
|
|
482
|
+
it("marks only the 45s preset as default", () => {
|
|
483
|
+
expect(DEFAULT_CONFIG.summarizerIdleTimeoutMs).toBe(45000);
|
|
484
|
+
expect(SUMMARIZER_IDLE_TIMEOUT_PRESETS.filter(p => p.label.includes("(default)")))
|
|
485
|
+
.toEqual([{ value: "45000", label: "45s (default)" }]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
for (const raw of [
|
|
489
|
+
{ summarizerModel: "provider-a/primary-model" },
|
|
490
|
+
{ summarizerModel: "provider-a/primary-model", summarizerIdleTimeoutMs: 20000 },
|
|
491
|
+
]) {
|
|
492
|
+
it(`delayed first event with ${raw.summarizerIdleTimeoutMs ?? "resolved default"}`, async () => {
|
|
493
|
+
await withTimerClock(async clock => {
|
|
494
|
+
const config = { ...DEFAULT_CONFIG, ...raw };
|
|
495
|
+
const notes: Note[] = [];
|
|
496
|
+
const attempts: any[] = [];
|
|
497
|
+
let primarySignal: AbortSignal;
|
|
498
|
+
streamImpl = (model, _input, opts) => {
|
|
499
|
+
attempts.push(model);
|
|
500
|
+
if (model === PRIMARY) {
|
|
501
|
+
primarySignal = opts.signal;
|
|
502
|
+
return drippingStream(opts, "- primary summary", 1, 25000);
|
|
503
|
+
}
|
|
504
|
+
return okStream("- rescued");
|
|
505
|
+
};
|
|
506
|
+
const controller = new FallbackController(clock.now);
|
|
507
|
+
let settled = false;
|
|
508
|
+
const pending = summarizeBatch(makeBatch(), config, makeCtx(notes), { controller });
|
|
509
|
+
void pending.then(() => { settled = true; });
|
|
510
|
+
await clock.advance(19999);
|
|
511
|
+
expect(settled).toBe(false);
|
|
512
|
+
await clock.advance(1);
|
|
513
|
+
if (raw.summarizerIdleTimeoutMs === 20000) {
|
|
514
|
+
expect(primarySignal!.aborted).toBe(true);
|
|
515
|
+
expect((await pending)?.summaryText).toBe("- rescued");
|
|
516
|
+
expect(attempts).toEqual([PRIMARY, SESSION]);
|
|
517
|
+
expect(notes).toEqual([{ msg: genericEnter.replace("failing,", "failing (stalled (no output for 20s)),"), level: "warning" }]);
|
|
518
|
+
} else {
|
|
519
|
+
expect(config.summarizerIdleTimeoutMs).toBe(45000);
|
|
520
|
+
expect(settled).toBe(false);
|
|
521
|
+
expect(primarySignal!.aborted).toBe(false);
|
|
522
|
+
await clock.advance(5000);
|
|
523
|
+
expect((await pending)?.summaryText).toBe("- primary summary");
|
|
524
|
+
expect(attempts).toEqual([PRIMARY]);
|
|
525
|
+
expect(controller.inFallback).toBe(false);
|
|
526
|
+
expect(notes).toEqual([]);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const kind of ["idle", "ceiling"] as const) {
|
|
533
|
+
it(`${kind} rescue carries the prefix-free reason`, async () => {
|
|
534
|
+
await withTimerClock(async clock => {
|
|
535
|
+
const notes: Note[] = [];
|
|
536
|
+
streamImpl = (model, _input, opts) => {
|
|
537
|
+
if (model === SESSION) return okStream("- rescued");
|
|
538
|
+
if (kind === "ceiling") return hangingStream(opts);
|
|
539
|
+
const drip = drippingStream(opts, "- partial", 1, 1000);
|
|
540
|
+
const hang = hangingStream(opts);
|
|
541
|
+
return {
|
|
542
|
+
async *[Symbol.asyncIterator]() { yield* drip; yield* hang; },
|
|
543
|
+
result: () => hang.result(),
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
let settled = false;
|
|
547
|
+
const pending = summarizeBatch(makeBatch(), {
|
|
548
|
+
...distinctConfig, summarizerIdleTimeoutMs: kind === "idle" ? 45000 : 0,
|
|
549
|
+
summarizerMaxTimeoutMs: 180000,
|
|
550
|
+
}, makeCtx(notes), { controller: new FallbackController(clock.now) });
|
|
551
|
+
void pending.then(() => { settled = true; });
|
|
552
|
+
await clock.advance(kind === "idle" ? 45000 : 179999);
|
|
553
|
+
expect(settled).toBe(false);
|
|
554
|
+
await clock.advance(kind === "idle" ? 1000 : 1);
|
|
555
|
+
expect((await pending)?.summaryText).toBe("- rescued");
|
|
556
|
+
const reason = kind === "idle" ? "stalled (no output for 45s)" : "exceeded 180s ceiling";
|
|
557
|
+
expect(notes).toEqual([{ msg: genericEnter.replace("failing,", `failing (${reason}),`), level: "warning" }]);
|
|
558
|
+
if (kind === "idle") expect(notes[0].msg).toMatch(/summarizer model .* failing \(stalled \(no output for \d+s\)\), using session model/);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
for (const path of ["response", "throw"] as const) {
|
|
564
|
+
for (const [name, message] of [
|
|
565
|
+
["non-empty", "boom"],
|
|
566
|
+
["multiline", " long provider message\n" + "detail ".repeat(60) + "\n "],
|
|
567
|
+
["empty", ""],
|
|
568
|
+
]) {
|
|
569
|
+
it(`${path} preserves ${name} message verbatim`, async () => {
|
|
570
|
+
streamImpl = model => {
|
|
571
|
+
if (model === SESSION) return okStream("- rescued");
|
|
572
|
+
if (path === "throw") throw new Error(message);
|
|
573
|
+
return errStream(message);
|
|
574
|
+
};
|
|
575
|
+
const notes: Note[] = [];
|
|
576
|
+
const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController() });
|
|
577
|
+
expect(result?.summaryText).toBe("- rescued");
|
|
578
|
+
expect(notes).toEqual([{ msg: message ? genericEnter.replace("failing,", `failing (${message}),`) : genericEnter, level: "warning" }]);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe("terminal timeout labels", () => {
|
|
585
|
+
for (const kind of ["idle", "ceiling"] as const) {
|
|
586
|
+
for (const mode of ["legacy", "fallback-only", "both-down", "unusable-retry"] as const) {
|
|
587
|
+
it(`${kind}: ${mode} labels the selected outcome`, async () => {
|
|
588
|
+
await withTimerClock(async clock => {
|
|
589
|
+
const notes: Note[] = [];
|
|
590
|
+
streamImpl = (model, _input, opts) => mode === "unusable-retry" && model === SESSION
|
|
591
|
+
? okStream(" ") : hangingStream(opts);
|
|
592
|
+
const controller = new FallbackController(clock.now);
|
|
593
|
+
if (mode === "fallback-only") controller.onBothDown();
|
|
594
|
+
const pending = summarizeBatch(makeBatch(), {
|
|
595
|
+
...distinctConfig,
|
|
596
|
+
summarizerModel: mode === "legacy" ? "default" : distinctConfig.summarizerModel,
|
|
597
|
+
summarizerIdleTimeoutMs: kind === "idle" ? 20 : 0,
|
|
598
|
+
summarizerMaxTimeoutMs: kind === "ceiling" ? 20 : 0,
|
|
599
|
+
}, makeCtx(notes), mode === "legacy" ? {} : { controller });
|
|
600
|
+
await clock.advance(40);
|
|
601
|
+
expect(await pending).toBeNull();
|
|
602
|
+
const label = mode === "unusable-retry" ? "Primary" : "Session";
|
|
603
|
+
const reason = kind === "idle" ? "stalled (no output for 0s)" : "exceeded 0s ceiling";
|
|
604
|
+
expect(notes).toEqual([{ msg: `pi-condense: summarizer ${label} ${reason}; summarizer call abandoned`, level: "warning" }]);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
it("session-only auth failure after transient primary keeps its error text", async () => {
|
|
611
|
+
streamImpl = () => errStream("down");
|
|
612
|
+
const notes: Note[] = [];
|
|
613
|
+
const ctx = makeCtx(notes);
|
|
614
|
+
ctx.modelRegistry.getApiKeyAndHeaders = async (model: any) => model === PRIMARY
|
|
615
|
+
? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "session key missing" };
|
|
616
|
+
expect(await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller: new FallbackController() })).toBeNull();
|
|
617
|
+
expect(notes).toEqual([{ msg: "pruner: summarization failed: session key missing", level: "error" }]);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("deferred entry and probe notices", () => {
|
|
622
|
+
for (const initial of ["both-down", "rescued"] as const) {
|
|
623
|
+
it(`${initial}: failed probe rescue then recovery`, async () => {
|
|
624
|
+
let now = 0;
|
|
625
|
+
const controller = new FallbackController(() => now);
|
|
626
|
+
const notes: Note[] = [];
|
|
627
|
+
const ctx = makeCtx(notes);
|
|
628
|
+
streamImpl = model => model === PRIMARY || initial === "both-down" ? errStream("down") : okStream("- ok");
|
|
629
|
+
await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
630
|
+
notes.length = 0;
|
|
631
|
+
now += 600001;
|
|
632
|
+
streamImpl = model => model === PRIMARY ? errStream("probe failed") : okStream("- rescued");
|
|
633
|
+
expect((await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }))?.summaryText).toBe("- rescued");
|
|
634
|
+
expect(notes).toEqual(initial === "both-down" ? [{ msg: genericEnter, level: "warning" }] : []);
|
|
635
|
+
notes.length = 0;
|
|
636
|
+
now += 600001;
|
|
637
|
+
streamImpl = () => okStream("- recovered");
|
|
638
|
+
await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
639
|
+
expect(notes).toEqual([{ msg: "pi-condense: summarizer model Primary recovered", level: "info" }]);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
it("blank primary never retries or enters fallback", async () => {
|
|
644
|
+
const attempts: any[] = [];
|
|
645
|
+
streamImpl = model => { attempts.push(model); return okStream(" "); };
|
|
646
|
+
const controller = new FallbackController();
|
|
647
|
+
const notes: Note[] = [];
|
|
648
|
+
expect(await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller })).toBeNull();
|
|
649
|
+
expect(attempts).toEqual([PRIMARY]);
|
|
650
|
+
expect(controller.inFallback).toBe(false);
|
|
651
|
+
expect(notes).toEqual([]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("abort during pending stream rejects without notices", async () => {
|
|
655
|
+
await withTimerClock(async clock => {
|
|
656
|
+
streamImpl = (_model, _input, opts) => hangingStream(opts);
|
|
657
|
+
const notes: Note[] = [];
|
|
658
|
+
const ac = new AbortController();
|
|
659
|
+
const pending = summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController(clock.now), signal: ac.signal });
|
|
660
|
+
const rejected = pending.then(() => null, error => error);
|
|
661
|
+
await clock.advance(1);
|
|
662
|
+
ac.abort();
|
|
663
|
+
expect(await rejected).toBeInstanceOf(Error);
|
|
664
|
+
expect(notes).toEqual([]);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
});
|
package/src/summarizer.ts
CHANGED
|
@@ -153,8 +153,8 @@ async function runOnce(
|
|
|
153
153
|
};
|
|
154
154
|
const timeoutMessage = () =>
|
|
155
155
|
timeoutKind === "ceiling"
|
|
156
|
-
? `
|
|
157
|
-
: `
|
|
156
|
+
? `exceeded ${Math.round(maxMs / 1000)}s ceiling`
|
|
157
|
+
: `stalled (no output for ${Math.round(idleMs / 1000)}s)`;
|
|
158
158
|
|
|
159
159
|
try {
|
|
160
160
|
// Preserve auth classification for physical models without pre-checking
|
|
@@ -286,10 +286,10 @@ async function runSummarization(
|
|
|
286
286
|
const controller = options.controller;
|
|
287
287
|
const sessionModel = ctx.model;
|
|
288
288
|
|
|
289
|
-
const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
|
|
289
|
+
const notifyFailure = (model: any, o: { message: string; timedOut?: boolean }) =>
|
|
290
290
|
ctx.ui.notify(
|
|
291
291
|
o.timedOut
|
|
292
|
-
? `pi-condense: ${o.message}; summarizer call abandoned`
|
|
292
|
+
? `pi-condense: summarizer ${modelLabel(model)} ${o.message}; summarizer call abandoned`
|
|
293
293
|
: `pruner: summarization failed: ${o.message}`,
|
|
294
294
|
o.timedOut ? "warning" : "error",
|
|
295
295
|
);
|
|
@@ -302,17 +302,18 @@ async function runSummarization(
|
|
|
302
302
|
return r.result;
|
|
303
303
|
case "auth":
|
|
304
304
|
case "transient":
|
|
305
|
-
notifyFailure(r);
|
|
305
|
+
notifyFailure(primary, r);
|
|
306
306
|
return null;
|
|
307
307
|
case "unusable":
|
|
308
308
|
return null;
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
const emit = (t: FallbackTransition) => {
|
|
312
|
+
const emit = (t: FallbackTransition, reason?: string) => {
|
|
313
313
|
if (t === "enter") {
|
|
314
|
+
const failureReason = typeof reason === "string" && reason.length > 0 ? ` (${reason})` : "";
|
|
314
315
|
ctx.ui.notify(
|
|
315
|
-
`pi-condense: summarizer model ${modelLabel(primary)} failing, using session model ${modelLabel(sessionModel)} until it recovers`,
|
|
316
|
+
`pi-condense: summarizer model ${modelLabel(primary)} failing${failureReason}, using session model ${modelLabel(sessionModel)} until it recovers`,
|
|
316
317
|
"warning"
|
|
317
318
|
);
|
|
318
319
|
} else if (t === "recover") {
|
|
@@ -330,24 +331,29 @@ async function runSummarization(
|
|
|
330
331
|
else emit(controller.onFallbackSuccess());
|
|
331
332
|
return r.result;
|
|
332
333
|
case "auth":
|
|
333
|
-
notifyFailure(r); // auth never trips the controller
|
|
334
|
+
notifyFailure(model, r); // auth never trips the controller
|
|
334
335
|
return null;
|
|
335
336
|
case "unusable":
|
|
336
337
|
return null; // probe unusable => stay (no state change)
|
|
337
338
|
case "transient": {
|
|
338
339
|
if (decision.target === "fallback") {
|
|
339
340
|
controller.onFallbackOnlyFail();
|
|
340
|
-
notifyFailure(r);
|
|
341
|
+
notifyFailure(model, r);
|
|
341
342
|
return null;
|
|
342
343
|
}
|
|
343
344
|
// target was primary (initial detection or probe): retry once on the session model.
|
|
344
345
|
const r2 = await runOnce(sessionModel, userMessage, config, ctx, options);
|
|
345
346
|
if (r2.kind === "ok") {
|
|
346
|
-
|
|
347
|
+
const wasInFallback = controller.inFallback;
|
|
348
|
+
emit(controller.onPrimaryFailFallbackOk(decision.wasProbe), wasInFallback ? undefined : r.message);
|
|
347
349
|
return r2.result; // suppress the legacy error notify — fallback rescued the call
|
|
348
350
|
}
|
|
349
351
|
controller.onBothDown();
|
|
350
|
-
|
|
352
|
+
if (r2.kind === "transient" || r2.kind === "auth") {
|
|
353
|
+
notifyFailure(sessionModel, r2);
|
|
354
|
+
} else {
|
|
355
|
+
notifyFailure(model, r);
|
|
356
|
+
}
|
|
351
357
|
return null;
|
|
352
358
|
}
|
|
353
359
|
}
|
package/src/types.ts
CHANGED
|
@@ -219,8 +219,8 @@ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
|
|
|
219
219
|
export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
|
|
220
220
|
{ value: "0", label: "0 (disabled)" },
|
|
221
221
|
{ value: "10000", label: "10s" },
|
|
222
|
-
{ value: "20000", label: "20s
|
|
223
|
-
{ value: "45000", label: "45s" },
|
|
222
|
+
{ value: "20000", label: "20s" },
|
|
223
|
+
{ value: "45000", label: "45s (default)" },
|
|
224
224
|
{ value: "90000", label: "90s" },
|
|
225
225
|
];
|
|
226
226
|
|
|
@@ -321,7 +321,7 @@ export interface ContextPruneConfig {
|
|
|
321
321
|
* Reset on every received stream event; armed before the first event so it
|
|
322
322
|
* also bounds time-to-first-token. If no event arrives within this window
|
|
323
323
|
* the call is aborted and classified transient (feeds the outage-fallback
|
|
324
|
-
* retry). 0 disables the idle timer. Default
|
|
324
|
+
* retry). 0 disables the idle timer. Default 45000.
|
|
325
325
|
*/
|
|
326
326
|
summarizerIdleTimeoutMs: number;
|
|
327
327
|
/**
|
|
@@ -569,7 +569,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
569
569
|
quietOversizedSkips: false,
|
|
570
570
|
minBatchChars: 2000,
|
|
571
571
|
recoveryGraceTurns: 3,
|
|
572
|
-
summarizerIdleTimeoutMs:
|
|
572
|
+
summarizerIdleTimeoutMs: 45000,
|
|
573
573
|
summarizerMaxTimeoutMs: 180000,
|
|
574
574
|
protectedTools: [],
|
|
575
575
|
protectedPaths: ["**/skills/**/*.md", "**/gauntlet-overrides.md"],
|