pi-condense 2.11.5 → 2.13.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.
@@ -1,17 +1,21 @@
1
+ import { occKey, resultTimestampOf } from "./occurrence-key.js";
1
2
  import type { ErrorPurgeConfig } from "./types.js";
2
3
 
3
- // Keep short paths and commands readable while reclaiming large failed-call bodies.
4
+ // Keep short paths and commands readable while reclaiming large completed-call bodies.
4
5
  const PURGE_STRING_MIN_CHARS = 200;
5
6
 
6
- function shrinkStrings(value: unknown): [unknown, boolean] {
7
+ function shrinkStrings(
8
+ value: unknown,
9
+ marker: (size: number) => string = (size) => `<purged-errored-args size="${size}"/>`,
10
+ ): [unknown, boolean] {
7
11
  if (typeof value === "string") {
8
- if (value.length > PURGE_STRING_MIN_CHARS) return [`<purged-errored-args size="${value.length}"/>`, true];
12
+ if (value.length > PURGE_STRING_MIN_CHARS) return [marker(value.length), true];
9
13
  return [value, false];
10
14
  }
11
15
  if (Array.isArray(value)) {
12
16
  let out: unknown[] | undefined;
13
17
  for (let i = 0; i < value.length; i++) {
14
- const [next, changed] = shrinkStrings(value[i]);
18
+ const [next, changed] = shrinkStrings(value[i], marker);
15
19
  if (!changed) continue;
16
20
  out ??= value.slice();
17
21
  out[i] = next;
@@ -22,7 +26,7 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
22
26
  const object = value as Record<string, unknown>;
23
27
  let out: Record<string, unknown> | undefined;
24
28
  for (const key of Object.keys(object)) {
25
- const [next, changed] = shrinkStrings(object[key]);
29
+ const [next, changed] = shrinkStrings(object[key], marker);
26
30
  if (!changed) continue;
27
31
  // Spread creates own data properties, including JSON's possible __proto__ key.
28
32
  out ??= { ...object };
@@ -34,65 +38,64 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
34
38
  }
35
39
 
36
40
  /**
37
- * Shrinks long strings in failed toolCall arguments after the cooldown window.
38
- * Keys and JSON types must survive because pi-ai validates grammar-tool input
39
- * properties when replaying calls.
40
- *
41
- * Why only the arguments, not the whole toolCall or its toolResult:
42
- * - The toolResult content (e.g. "Error: file not found") is small and carries
43
- * the failure signal the model needs to understand what went wrong.
44
- * - The toolCall block itself must remain so the provider can pair it with its
45
- * result and avoid injecting a synthetic "No result provided" error.
46
- * - The arguments body is what grows large — failed `write` / `edit` calls
47
- * embed the full file content that will never be acted on again.
48
- *
49
- * Why the cooldown:
50
- * - Gives the model 1–2 turns to retry before context is mutated. Purging
51
- * immediately would remove the call detail before the model has had a
52
- * chance to see the error and adapt.
53
- *
54
- * Turn index is computed internally by counting AssistantMessages in the input.
55
- * This avoids threading a turn counter through index.ts.
41
+ * Shrinks summarized arguments immediately and errored arguments after cooldown.
42
+ * Keys and JSON types survive pi-ai grammar validation during replay.
56
43
  */
57
- export function purgeErroredArgs(messages: any[], config: ErrorPurgeConfig): any[] {
58
- // Pass 1: collect errored toolCallIds → the turn index at which the error occurred.
59
- // Turn index = number of AssistantMessages seen up to and including the one that
60
- // issued the tool call (ToolResultMessages follow immediately after).
61
- const erroredAtTurn = new Map<string, number>();
44
+ export function purgeErroredArgs(
45
+ messages: any[],
46
+ config: ErrorPurgeConfig,
47
+ summarizedRefs: ReadonlyMap<string, string> = new Map(),
48
+ ): any[] {
49
+ if (!config.enabled) return messages;
50
+
51
+ const targets = new Map<number, Map<string, { turn: number; ref?: string }>>();
52
+ const open = new Map<string, { assistantIndex: number; turn: number }>();
62
53
  let turnCount = 0;
63
- for (const msg of messages) {
54
+ for (let i = 0; i < messages.length; i++) {
55
+ const msg = messages[i];
64
56
  if (msg.role === "assistant") {
65
- // Count each assistant turn; toolResults referencing the turn come next.
66
57
  turnCount++;
67
- } else if (msg.role === "toolResult" && msg.isError === true) {
68
- // Record the turn this errored call belongs to for cooldown comparison.
69
- erroredAtTurn.set(msg.toolCallId, turnCount);
58
+ open.clear();
59
+ for (const block of msg.content) {
60
+ if (block.type === "toolCall") open.set(block.id, { assistantIndex: i, turn: turnCount });
61
+ }
62
+ } else if (msg.role === "toolResult") {
63
+ const paired = open.get(msg.toolCallId);
64
+ if (!paired) continue;
65
+ open.delete(msg.toolCallId);
66
+ const ref = summarizedRefs.get(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
67
+ if (ref === undefined && msg.isError !== true) continue;
68
+ let blocks = targets.get(paired.assistantIndex);
69
+ if (!blocks) targets.set(paired.assistantIndex, blocks = new Map());
70
+ blocks.set(msg.toolCallId, { turn: paired.turn, ref });
71
+ } else {
72
+ open.clear();
70
73
  }
71
74
  }
72
75
 
73
- if (erroredAtTurn.size === 0) return messages;
74
-
75
- const currentTurnIndex = turnCount;
76
+ if (targets.size === 0) return messages;
76
77
 
77
78
  // Pass 2: rewrite AssistantMessages whose toolCall args should be purged.
78
79
  let anyModified = false;
79
- const result = messages.map((msg) => {
80
- if (msg.role !== "assistant") return msg;
80
+ const result = messages.map((msg, assistantIndex) => {
81
+ const targetsForTurn = targets.get(assistantIndex);
82
+ if (!targetsForTurn) return msg;
81
83
 
82
84
  let contentModified = false;
83
85
  const newContent = (msg.content as any[]).map((block) => {
84
86
  if (block.type !== "toolCall") return block;
85
87
 
86
- const errorTurn = erroredAtTurn.get(block.id);
87
- if (errorTurn === undefined) return block;
88
-
89
- const age = currentTurnIndex - errorTurn;
90
- if (age < config.cooldownTurns) return block;
88
+ const target = targetsForTurn.get(block.id);
89
+ if (!target) return block;
90
+ if (target.ref === undefined && turnCount - target.turn < config.cooldownTurns) return block;
91
91
 
92
92
  const argBody = JSON.stringify(block.arguments);
93
93
  if (argBody.length < config.minArgChars) return block;
94
94
 
95
- const [shrunk, changed] = shrinkStrings(block.arguments);
95
+ const [shrunk, changed] = shrinkStrings(
96
+ block.arguments,
97
+ target.ref === undefined ? undefined : (size) => `<purged-args size="${size}" ref="${target.ref}"/>`,
98
+ );
96
99
  if (!changed) return block;
97
100
 
98
101
  contentModified = true;
@@ -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);
@@ -1,8 +1,7 @@
1
- import { describe, it, expect, mock } from "bun:test";
1
+ import { describe, it, expect } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import * as actualCompat from "@earendil-works/pi-ai/compat";
6
5
  import { supersededStub } from "./supersede.js";
7
6
 
8
7
  // This must run before any module that transitively reads PI_CODING_AGENT_DIR
@@ -67,11 +66,6 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
67
66
  return okStream();
68
67
  };
69
68
 
70
- mock.module("@earendil-works/pi-ai/compat", () => ({
71
- ...actualCompat,
72
- stream: (...args: any[]) => streamImpl(...args),
73
- }));
74
-
75
69
  type AppendedEntry = { type: string; data: unknown };
76
70
 
77
71
  // The captured-but-unflushed batch: a user message, an assistant message with
@@ -305,7 +299,7 @@ function bootExtension(
305
299
  modelRegistry: {
306
300
  find: () => undefined,
307
301
  getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }),
308
- getProviderAuth: async () => undefined,
302
+ streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
309
303
  },
310
304
  ui: {
311
305
  setStatus() {},
@@ -1,22 +1,14 @@
1
- import { describe, it, expect, mock } from "bun:test";
2
- import * as actualCompat from "@earendil-works/pi-ai/compat";
3
-
4
- // Stub pi-ai's `stream` so runSummarization can be exercised without a network
5
- // call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
1
+ import { describe, it, expect } from "bun:test";
6
2
  let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
7
3
  throw new Error("streamImpl not set");
8
4
  };
9
- mock.module("@earendil-works/pi-ai/compat", () => ({
10
- ...actualCompat,
11
- stream: (...args: any[]) => streamImpl(...args),
12
- }));
13
-
14
- const { summarizeBatch } = await import("./summarizer.js");
5
+ const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
15
6
  const { FallbackController } = await import("./summarizer-fallback.js");
16
7
  const { DEFAULT_CONFIG } = await import("./types.js");
17
8
 
18
- const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary" };
19
- const SESSION = { id: "session-model", provider: "provider-b", name: "Session" };
9
+ const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
10
+ const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
11
+ const VIRTUAL = { id: "auto", provider: "router", name: "Auto", api: "pi-virtual" };
20
12
 
21
13
  const USAGE = {
22
14
  input: 1,
@@ -67,6 +59,19 @@ function hangingStream(opts: any) {
67
59
  };
68
60
  }
69
61
 
62
+ // Models a router that never settles and ignores cancellation.
63
+ function deafStream() {
64
+ const never = new Promise<never>(() => {});
65
+ return {
66
+ async *[Symbol.asyncIterator]() {
67
+ await never;
68
+ },
69
+ async result() {
70
+ return never;
71
+ },
72
+ };
73
+ }
74
+
70
75
  // Emits `events` thinking_delta events spaced `gapMs` apart, then completes
71
76
  // successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
72
77
  // current sleep rejects, exactly like a real provider stream cancelling on
@@ -107,13 +112,19 @@ interface Note {
107
112
  level: string;
108
113
  }
109
114
 
110
- function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY) {
115
+ function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY, authOk = true) {
116
+ const authCalls: any[] = [];
111
117
  return {
112
118
  model: sessionModel,
119
+ authCalls,
113
120
  modelRegistry: {
114
121
  find: () => primaryModel,
115
- getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
116
- getProviderAuth: async () => undefined,
122
+ getApiKeyAndHeaders: async (model: any) => {
123
+ authCalls.push(model);
124
+ return authOk ? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "no key for provider-a" };
125
+ },
126
+ getProviderAuth: async () => ({ auth: { baseUrl: "https://seat.example" } }),
127
+ streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
117
128
  },
118
129
  ui: { notify: (msg: string, level: string) => notes.push({ msg, level }) },
119
130
  } as any;
@@ -132,6 +143,90 @@ function makeBatch() {
132
143
 
133
144
  const distinctConfig = { ...DEFAULT_CONFIG, summarizerModel: "provider-a/primary-model" };
134
145
 
146
+ describe("virtual models through the registry", () => {
147
+ for (const operation of ["batch", "range"] as const) {
148
+ const summarize = (config: any, ctx: any, options: any = {}) => operation === "batch"
149
+ ? summarizeBatch(makeBatch(), config, ctx, options)
150
+ : summarizeRange("- step one", config, ctx, options);
151
+
152
+ it(`${operation}: virtual session model rescues a transient primary`, async () => {
153
+ const seen: any[] = [];
154
+ streamImpl = (model) => {
155
+ seen.push(model);
156
+ return model === PRIMARY ? errStream("down") : okStream("- routed summary");
157
+ };
158
+ const notes: Note[] = [];
159
+ const controller = new FallbackController();
160
+ const result = await summarize(distinctConfig, makeCtx(notes, VIRTUAL), { controller });
161
+ expect(result?.summaryText).toBe("- routed summary");
162
+ expect(controller.inFallback).toBe(true);
163
+ expect(seen[1]).toBe(VIRTUAL);
164
+ expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
165
+ expect(notes.filter(n => n.level === "error")).toHaveLength(0);
166
+ });
167
+
168
+ for (const selection of ["default", "router/auto"]) {
169
+ it(`${operation}: virtual ${selection} skips auth and sends only summarizer context`, async () => {
170
+ const calls: any[] = [];
171
+ streamImpl = (model, input, opts) => {
172
+ calls.push({ model, input, opts });
173
+ return okStream("- ok");
174
+ };
175
+ const notes: Note[] = [];
176
+ const ctx = makeCtx(notes, VIRTUAL, VIRTUAL);
177
+ const result = await summarize({ ...DEFAULT_CONFIG, summarizerModel: selection }, ctx);
178
+ expect(result?.summaryText).toBe("- ok");
179
+ expect(calls).toHaveLength(1);
180
+ expect(calls[0].model).toBe(VIRTUAL);
181
+ expect(ctx.authCalls).toHaveLength(0);
182
+ expect(Object.keys(calls[0].input)).toEqual(["messages"]);
183
+ expect(calls[0].input.messages).toHaveLength(1);
184
+ expect(calls[0].input.messages[0].role).toBe("user");
185
+ expect(calls[0].opts.apiKey).toBeUndefined();
186
+ expect(calls[0].opts.headers).toBeUndefined();
187
+ expect(notes).toHaveLength(0);
188
+ });
189
+ }
190
+ }
191
+
192
+ it("passes the physical model unchanged and pre-checks once", async () => {
193
+ const seen: any[] = [];
194
+ streamImpl = (model) => {
195
+ seen.push(model);
196
+ return okStream("- ok");
197
+ };
198
+ const ctx = makeCtx([]);
199
+ await summarizeBatch(makeBatch(), DEFAULT_CONFIG, ctx);
200
+ expect(seen[0]).toBe(SESSION);
201
+ expect(seen[0].baseUrl).toBeUndefined();
202
+ expect(ctx.authCalls).toEqual([SESSION]);
203
+ });
204
+
205
+ it("missing physical credentials never stream or trip the controller", async () => {
206
+ let called = 0;
207
+ streamImpl = () => { called++; return okStream("- never"); };
208
+ const notes: Note[] = [];
209
+ const controller = new FallbackController();
210
+ const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes, SESSION, PRIMARY, false), { controller });
211
+ expect(result).toBeNull();
212
+ expect(called).toBe(0);
213
+ expect(controller.inFallback).toBe(false);
214
+ expect(notes).toHaveLength(1);
215
+ expect(notes[0].level).toBe("error");
216
+ expect(notes[0].msg).toContain("no key for provider-a");
217
+ });
218
+
219
+ it("virtual stream failure returns null with the existing error notice", async () => {
220
+ streamImpl = () => errStream("router: no credentials for routed model");
221
+ const notes: Note[] = [];
222
+ const result = await summarizeBatch(makeBatch(), DEFAULT_CONFIG, makeCtx(notes, VIRTUAL));
223
+ expect(result).toBeNull();
224
+ expect(notes).toHaveLength(1);
225
+ expect(notes[0].level).toBe("error");
226
+ expect(notes[0].msg).toContain("router: no credentials for routed model");
227
+ });
228
+ });
229
+
135
230
  describe("runSummarization wiring — same-model no-op (legacy path)", () => {
136
231
  it("summarizerModel=default: transient failure notifies error, returns null, controller untouched", async () => {
137
232
  streamImpl = () => errStream("provider overloaded");
@@ -223,6 +318,33 @@ describe("runSummarization wiring — abort", () => {
223
318
  });
224
319
 
225
320
  describe("runSummarization wiring — timeouts", () => {
321
+ for (const [kind, idleMs, maxMs, message] of [
322
+ ["idle", 20, 0, /stalled/],
323
+ ["ceiling", 0, 20, /ceiling/],
324
+ ] as const) {
325
+ it(`stream that ignores the signal still times out (${kind})`, async () => {
326
+ streamImpl = () => deafStream();
327
+ const notes: Note[] = [];
328
+ const ctx = makeCtx(notes, VIRTUAL);
329
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: idleMs, summarizerMaxTimeoutMs: maxMs };
330
+ expect(await summarizeBatch(makeBatch(), cfg, ctx, {})).toBeNull();
331
+ expect(notes).toHaveLength(1);
332
+ expect(notes[0].level).toBe("warning");
333
+ expect(notes[0].msg).toMatch(message);
334
+ });
335
+ }
336
+
337
+ it("caller abort during a stream that ignores the signal throws, no warning", async () => {
338
+ streamImpl = () => deafStream();
339
+ const notes: Note[] = [];
340
+ const ctx = makeCtx(notes, VIRTUAL);
341
+ const ac = new AbortController();
342
+ setTimeout(() => ac.abort(), 20);
343
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
344
+ await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
345
+ expect(notes).toHaveLength(0);
346
+ });
347
+
226
348
  it("idle timeout (default model): transient warning, returns null", async () => {
227
349
  streamImpl = (_m, _i, opts) => hangingStream(opts);
228
350
  const notes: Note[] = [];