pi-condense 2.12.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.
@@ -85,6 +85,7 @@ describe("PruneFrontierTracker.reconstructFromSession", () => {
85
85
 
86
86
  const indexer = {
87
87
  isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
88
+ isDedupAlias: () => false,
88
89
  hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub",
89
90
  getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
90
91
  getRecord: () => undefined,
@@ -132,6 +132,28 @@ describe("occurrence keying", () => {
132
132
  expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]);
133
133
  });
134
134
 
135
+ test("isDedupAlias checks exact registered keys without resolving refs or sibling occurrences", () => {
136
+ const indexer = new ToolCallIndexer();
137
+ indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
138
+ indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "OTHER" }]), () => {});
139
+ indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
140
+ indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
141
+ indexer.registerDuplicate("bash_legacy", "bash_23@1150", () => {});
142
+
143
+ for (const [key, expected] of [
144
+ ["bash_23@9150", true],
145
+ ["bash_legacy", true],
146
+ ["bash_23@1150", false],
147
+ ["bash_23@2150", false],
148
+ ["bash_23", false],
149
+ ["bash_legacy@9150", false],
150
+ ["unknown", false],
151
+ ["t1", false],
152
+ ] as const) {
153
+ expect(indexer.isDedupAlias(key)).toBe(expected);
154
+ }
155
+ });
156
+
135
157
  test("summary body lookups are occurrence-keyed", () => {
136
158
  const indexer = new ToolCallIndexer();
137
159
  indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST");
@@ -302,6 +324,10 @@ describe("session rebuild", () => {
302
324
  expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST");
303
325
  expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
304
326
  expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text");
327
+ expect(indexer.isDedupAlias("bash_99@4150")).toBe(true);
328
+ expect(indexer.isDedupAlias("bash_99")).toBe(false);
329
+ expect(indexer.isDedupAlias("bash_23@1150")).toBe(false);
330
+ expect(indexer.isDedupAlias("t1")).toBe(false);
305
331
  });
306
332
 
307
333
  test("legacy entries without resultTimestamp keep bare-id keys", () => {
@@ -336,6 +362,8 @@ describe("session rebuild", () => {
336
362
  expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
337
363
  expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
338
364
  expect(indexer.isSummarized("bash_8")).toBe(true);
365
+ expect(indexer.isDedupAlias("bash_8")).toBe(true);
366
+ expect(indexer.isDedupAlias("bash_7")).toBe(false);
339
367
  });
340
368
  });
341
369
 
package/src/indexer.ts CHANGED
@@ -183,6 +183,10 @@ export class ToolCallIndexer {
183
183
  return this.index.has(occurrenceKey) || this.dedupAliasToOriginal.has(occurrenceKey);
184
184
  }
185
185
 
186
+ isDedupAlias(key: string): boolean {
187
+ return this.dedupAliasToOriginal.has(key);
188
+ }
189
+
186
190
  /**
187
191
  * Returns the full runtime index map.
188
192
  */
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { pruneMessages, sizeMessages } from "./pruner.js";
3
+ import { purgeErroredArgs } from "./error-purge.js";
3
4
  import { charsOf } from "./context-metrics.js";
4
5
  import { ToolCallIndexer } from "./indexer.js";
5
6
  import { CUSTOM_TYPE_INDEX } from "./types.js";
@@ -33,6 +34,7 @@ function makeMockIndexer({
33
34
  } = {}) {
34
35
  return {
35
36
  isSummarized: (id: string) => summarized.has(id),
37
+ isDedupAlias: (_key: string) => false,
36
38
  hasLegacyBareRecord: (id: string) => summarized.has(id),
37
39
  getShortRefForToolCallId: (id: string) => shortRefs.get(id),
38
40
  getRecord: (id: string) => records.get(id),
@@ -47,6 +49,116 @@ function makeMockIndexer({
47
49
  } as any;
48
50
  }
49
51
 
52
+ describe("summarized argument handoff", () => {
53
+ const prompt = "p".repeat(600);
54
+ const config = { enabled: true, minArgChars: 300, cooldownTurns: 2 };
55
+ const history = (name = "subagent"): any[] => [
56
+ { role: "assistant", content: [{ type: "toolCall", id: "call", name, arguments: { prompt } }] },
57
+ { role: "toolResult", toolCallId: "call", toolName: name, timestamp: 10, content: [{ type: "text", text: "original output" }], isError: false },
58
+ ];
59
+ const render = (messages: any[], indexer: any, options = config, protection?: any, grace = 0) => {
60
+ expectNoOrphanToolResults(messages);
61
+ const result = pruneWithZeroSweepAssertion(messages, indexer, undefined, options, protection, grace);
62
+ expectNoOrphanToolResults(result.messages);
63
+ return result.messages;
64
+ };
65
+ const indexed = (key = "call@10", record?: any) => makeMockIndexer({
66
+ summarized: new Set([key]), shortRefs: new Map([[key, "t6"]]),
67
+ records: new Map([[key, record]]),
68
+ });
69
+
70
+ for (const row of [
71
+ { label: "summary", record: undefined },
72
+ { label: "spill", record: { spillPath: "/tmp/output", toolName: "subagent" } },
73
+ { label: "legacy fallback", key: "call" },
74
+ { label: "timestamp-less", key: "call" },
75
+ ]) {
76
+ it(`stubs and shrinks together: ${row.label}`, () => {
77
+ const raw = history();
78
+ if (row.label === "timestamp-less") delete raw[1].timestamp;
79
+ const out = render(raw, indexed(row.key, row.record));
80
+ expect(out[1].content[0].text).toContain("`t6`");
81
+ expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
82
+ expect(raw[0].content[0].arguments.prompt).toBe(prompt);
83
+ expect(purgeErroredArgs(out, config, new Map([[row.label === "timestamp-less" ? "call" : "call@10", "t6"]]))).toBe(out);
84
+ });
85
+ }
86
+
87
+ for (const row of [
88
+ { label: "disabled", options: { ...config, enabled: false } },
89
+ { label: "body below gate", options: { ...config, minArgChars: 1000 } },
90
+ { label: "no long strings", args: { a: "a".repeat(180), b: "b".repeat(180) } },
91
+ { label: "unindexed", unindexed: true },
92
+ { label: "protected", record: { toolName: "read", args: { path: "keep.md" } }, protection: { protectedTools: [], protectedPaths: ["keep.md"] } },
93
+ ]) {
94
+ it(`retains arguments: ${row.label}`, () => {
95
+ const raw = history();
96
+ if (row.args) raw[0].content[0].arguments = row.args;
97
+ const out = render(raw, row.unindexed ? makeMockIndexer() : indexed(undefined, row.record), row.options, row.protection);
98
+ expect(out[0]).toBe(raw[0]);
99
+ expect(out[1] === raw[1]).toBe(!!row.unindexed || !!row.protection);
100
+ });
101
+ }
102
+
103
+ it("pairs timestamp-less handoff positionally without shrinking a reused-id sibling", () => {
104
+ const indexer = indexed("call");
105
+ indexer.hasLegacyBareRecord = () => false;
106
+ const raw = [...history(), { role: "user", content: "next" }, ...history()];
107
+ delete raw[4].timestamp;
108
+ const out = render(raw, indexer);
109
+ expect(out[0]).toBe(raw[0]);
110
+ expect(out[1]).toBe(raw[1]);
111
+ expect(out[3].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
112
+ expect(out[4].content[0].text).toContain("`t6`");
113
+ });
114
+
115
+ it("uses the same raw-id fallback ref in the stub and argument marker", () => {
116
+ const out = render(history(), makeMockIndexer({ summarized: new Set(["call@10"]) }));
117
+ expect(out[1].content[0].text).toContain("`call`");
118
+ expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="call"/>');
119
+ });
120
+
121
+ it("advances recovery grace with stub and shrink at the same boundary", () => {
122
+ const raw = history("context_tree_query");
123
+ const indexer = indexed();
124
+ expect(render(raw, indexer, config, undefined, 1)).toBe(raw);
125
+ raw.push({ role: "user", content: "next" }, { role: "assistant", content: [] }, { role: "user", content: "again" });
126
+ const out = render(raw, indexer, config, undefined, 1);
127
+ expect(out[1].content[0].text).toContain("`t6`");
128
+ expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="600" ref="t6"/>`);
129
+ });
130
+
131
+ it("switches aged error markers on indexing and keeps fresh-render prefixes stable", () => {
132
+ const raw = history();
133
+ raw[1].isError = true;
134
+ raw.push({ role: "assistant", content: [] }, { role: "user", content: "next" }, { role: "assistant", content: [] });
135
+ const before = render(structuredClone(raw), makeMockIndexer());
136
+ expect(before[0].content[0].arguments.prompt).toBe('<purged-errored-args size="600"/>');
137
+ const indexer = indexed();
138
+ const first = render(structuredClone(raw), indexer);
139
+ expect(first[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
140
+ expect(first[1].isError).toBe(false);
141
+ expect(JSON.stringify(render(structuredClone(raw), indexer))).toBe(JSON.stringify(first));
142
+ const extended = [...structuredClone(raw), { role: "user", content: "continue" }, { role: "assistant", content: [] }];
143
+ expect(JSON.stringify(render(extended, indexer).slice(0, raw.length))).toBe(JSON.stringify(first));
144
+ });
145
+
146
+ it("keeps dedup alias prompts while shrinking the recoverable canonical prompt", () => {
147
+ const indexer = new ToolCallIndexer();
148
+ const raw = history();
149
+ indexer.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "call", toolName: "subagent", args: raw[0].content[0].arguments, resultText: "original output", isError: false, resultTimestamp: 10 }] }, () => {});
150
+ indexer.registerSummaryRefs([{ shortId: "t6", toolCallId: "call", resultTimestamp: 10 }]);
151
+ indexer.registerDuplicate("alias@20", "call@10", () => {});
152
+ raw.push({ role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: "alias".repeat(120) } }] }, { ...raw[1], toolCallId: "alias", timestamp: 20 });
153
+ const out = render(raw, indexer);
154
+ expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
155
+ expect(out[2]).toBe(raw[2]);
156
+ expect(out[1].content[0].text).toContain("`t6`");
157
+ expect(out[3].content[0].text).toContain("`t6`");
158
+ expect(indexer.getRecord("call@10")?.args).toEqual({ prompt });
159
+ });
160
+ });
161
+
50
162
  const enabledCC: ChainCompressionConfig = {
51
163
  enabled: true,
52
164
  rollingWindow: 0,
package/src/pruner.ts CHANGED
@@ -46,9 +46,9 @@ export function sizeMessages(messages: any[]): number {
46
46
  * See src/supersede.ts. Runs before phase 3 so a superseded read inside a
47
47
  * compressed chain relocates as the stub, not the verbatim body.
48
48
  *
49
- * Phase 2 — error purge: replaces failed toolCall arg bodies with stubs after a
50
- * cooldown, reclaiming context from large `write`/`edit` arguments that will
51
- * never succeed. The toolResult error message stays visible.
49
+ * Phase 2 - argument purge: shrinks summarized toolCall arguments in the
50
+ * stub render and failed toolCall arguments after cooldown. Unsummarized
51
+ * toolResult error messages stay visible.
52
52
  *
53
53
  * Phase 3 — chain range prune: closed chains older than the rolling window
54
54
  * are dropped (middle assistant + toolResult messages) and replaced with a
@@ -75,9 +75,8 @@ export function sizeMessages(messages: any[]): number {
75
75
  * the only consumer (`index.ts` live-reclaim) reads them solely under
76
76
  * `if (result.pruned)`.
77
77
  *
78
- * AssistantMessage tool-call blocks (which carry the IDs) are kept
79
- * unchanged so the model can still reference them by id when calling
80
- * `context_tree_query`.
78
+ * Tool-call ids, names, and result pairing stay unchanged; eligible argument
79
+ * strings shrink with a ref for recovery through `context_tree_query`.
81
80
  */
82
81
  export function pruneMessages(
83
82
  messages: any[],
@@ -91,6 +90,7 @@ export function pruneMessages(
91
90
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
92
91
  // Phase 1: stub-replace summarized tool results
93
92
  let pruned = false;
93
+ const summarizedRefs = new Map<string, string>();
94
94
  const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
95
95
  const next = messages.map((msg) => {
96
96
  if (msg.role !== "toolResult") return msg;
@@ -124,6 +124,7 @@ export function pruneMessages(
124
124
  }
125
125
  pruned = true;
126
126
  const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId;
127
+ if (!indexer.isDedupAlias(lookupKey)) summarizedRefs.set(key, ref);
127
128
  const text = record?.spillPath
128
129
  ? [
129
130
  `[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
@@ -155,9 +156,9 @@ export function pruneMessages(
155
156
  }
156
157
  }
157
158
 
158
- // Phase 2: error purge - shrink long strings in failed toolCall args after cooldown
159
+ // Phase 2: argument purge - summarized now, errored after cooldown
159
160
  if (errorPurge?.enabled) {
160
- const afterPurge = purgeErroredArgs(current, errorPurge);
161
+ const afterPurge = purgeErroredArgs(current, errorPurge, summarizedRefs);
161
162
  if (afterPurge !== current) {
162
163
  current = afterPurge;
163
164
  pruned = true;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { pruneWithZeroSweepAssertion, expectNoOrphanToolResults } from "./test-support.js";
2
3
  import { registerQueryTool } from "./query-tool.js";
3
4
  import { imageDigest } from "./batch-capture.js";
4
5
  import { ToolCallIndexer } from "./indexer.js";
@@ -91,6 +92,33 @@ describe("context_tree_query image recovery", () => {
91
92
  });
92
93
 
93
94
  describe("context_tree_query occurrence handling", () => {
95
+ test("alias ref recovers unclipped canonical arguments after rendering, not alias arguments", async () => {
96
+ const idx = new ToolCallIndexer();
97
+ const prompt = "canonical".repeat(80);
98
+ const aliasPrompt = "alias".repeat(120);
99
+ idx.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "canonical", toolName: "subagent", args: { prompt }, resultText: "shared original output", isError: false, resultTimestamp: 10 }] }, () => {});
100
+ idx.registerSummaryRefs([{ shortId: "t6", toolCallId: "canonical", resultTimestamp: 10 }]);
101
+ idx.registerDuplicate("alias@20", "canonical@10", () => {});
102
+ const raw = [
103
+ { role: "assistant", content: [{ type: "toolCall", id: "canonical", name: "subagent", arguments: { prompt } }] },
104
+ { role: "toolResult", toolCallId: "canonical", toolName: "subagent", timestamp: 10, content: [{ type: "text", text: "shared original output" }] },
105
+ { role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: aliasPrompt } }] },
106
+ { role: "toolResult", toolCallId: "alias", toolName: "subagent", timestamp: 20, content: [{ type: "text", text: "shared original output" }] },
107
+ ];
108
+ expectNoOrphanToolResults(raw);
109
+ const out = pruneWithZeroSweepAssertion(raw, idx, undefined, { enabled: true, minArgChars: 300, cooldownTurns: 2 }).messages;
110
+ expectNoOrphanToolResults(out);
111
+ expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
112
+ expect(out[2].content[0].arguments.prompt).toBe(aliasPrompt);
113
+ const aliasRef = idx.getShortRefForToolCallId("alias@20");
114
+ expect(aliasRef).toBe("t6");
115
+ expect(idx.getRecordsForId(aliasRef!)[0].args).toEqual({ prompt });
116
+ const text = await runTool(idx, [aliasRef!]);
117
+ expect(text).toContain(`Args: ${JSON.stringify({ prompt }, null, 2)}`);
118
+ expect(text).toContain("shared original output");
119
+ expect(text).not.toContain(aliasPrompt);
120
+ expect(text).not.toContain("<purged-args");
121
+ });
94
122
  test("a bare id with two occurrences returns both blocks, chronologically", async () => {
95
123
  const idx = new ToolCallIndexer();
96
124
  capture(idx, "bash_23", 2150, "SECOND", 1);
@@ -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); // generic "enter" fallback warning
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
- ? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling`
157
- : `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
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
- emit(controller.onPrimaryFailFallbackOk(decision.wasProbe));
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
- notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r);
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
  }