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.
- package/CHANGELOG.md +17 -0
- package/PRUNING.md +50 -32
- package/package.json +1 -1
- package/src/commands.test.ts +65 -2
- package/src/commands.ts +4 -4
- package/src/config.test.ts +8 -1
- package/src/error-purge.test.ts +91 -1
- package/src/error-purge.ts +48 -45
- package/src/frontier.test.ts +1 -0
- package/src/indexer.test.ts +28 -0
- package/src/indexer.ts +4 -0
- package/src/pruner.test.ts +112 -0
- package/src/pruner.ts +9 -8
- package/src/query-tool.test.ts +28 -0
- package/src/summarizer-wiring.test.ts +237 -2
- package/src/summarizer.ts +17 -11
- package/src/supersede.test.ts +73 -3
- package/src/supersede.ts +37 -9
- package/src/types.ts +4 -4
package/src/frontier.test.ts
CHANGED
|
@@ -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,
|
package/src/indexer.test.ts
CHANGED
|
@@ -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
|
*/
|
package/src/pruner.test.ts
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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:
|
|
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;
|
package/src/query-tool.test.ts
CHANGED
|
@@ -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);
|
|
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
|
}
|