pi-condense 2.0.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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- package/src/types.ts +764 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
applyChainCompressions,
|
|
4
|
+
buildSyntheticChainMessage,
|
|
5
|
+
isPerBatchSummaryMessage,
|
|
6
|
+
perBatchSummaryOverlapsDropped,
|
|
7
|
+
withoutThinkingBlocks,
|
|
8
|
+
} from "./chain-range-prune.js";
|
|
9
|
+
import type { ChainCompressionEntry } from "./types.js";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
function userMsg(timestamp: number, text = "do the thing"): any {
|
|
13
|
+
return { role: "user", content: [{ type: "text", text }], timestamp };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assistantWithTools(timestamp: number, toolCallIds: string[]): any {
|
|
17
|
+
return {
|
|
18
|
+
role: "assistant",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: "working..." },
|
|
21
|
+
...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
|
|
22
|
+
],
|
|
23
|
+
timestamp,
|
|
24
|
+
usage: {},
|
|
25
|
+
stopReason: "toolUse",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolResult(timestamp: number, toolCallId: string): any {
|
|
30
|
+
return {
|
|
31
|
+
role: "toolResult",
|
|
32
|
+
toolCallId,
|
|
33
|
+
toolName: "bash",
|
|
34
|
+
content: [{ type: "text", text: "output" }],
|
|
35
|
+
isError: false,
|
|
36
|
+
timestamp,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assistantText(timestamp: number, includeThinking = false): any {
|
|
41
|
+
const content: any[] = [{ type: "text", text: "done" }];
|
|
42
|
+
if (includeThinking) {
|
|
43
|
+
content.push({ type: "thinking", thinking: "deep thoughts", thinkingSignature: "sig123" });
|
|
44
|
+
}
|
|
45
|
+
return { role: "assistant", content, timestamp, usage: {}, stopReason: "stop" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function summaryMsg(timestamp: number, toolCallIds: string[]): any {
|
|
49
|
+
return {
|
|
50
|
+
role: "custom",
|
|
51
|
+
customType: "context-prune-summary",
|
|
52
|
+
content: "summary text",
|
|
53
|
+
display: true,
|
|
54
|
+
details: { toolCallRefs: toolCallIds.map((id, i) => ({ shortId: `t${i + 1}`, toolCallId: id })) },
|
|
55
|
+
timestamp,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function entry(
|
|
60
|
+
blockId: string,
|
|
61
|
+
startUserTimestamp: number,
|
|
62
|
+
droppedToolCallIds: string[],
|
|
63
|
+
finalAssistantTimestamp: number | null,
|
|
64
|
+
toolRefs: string[] = [],
|
|
65
|
+
): ChainCompressionEntry {
|
|
66
|
+
return {
|
|
67
|
+
blockId,
|
|
68
|
+
startUserTimestamp,
|
|
69
|
+
droppedToolCallIds,
|
|
70
|
+
finalAssistantTimestamp,
|
|
71
|
+
toolRefs,
|
|
72
|
+
compressedAt: startUserTimestamp + 9999,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const noopSummary = (_e: ChainCompressionEntry) => "chain summary";
|
|
77
|
+
|
|
78
|
+
describe("isPerBatchSummaryMessage", () => {
|
|
79
|
+
test("returns true for context-prune-summary custom message", () => {
|
|
80
|
+
expect(isPerBatchSummaryMessage({ role: "custom", customType: "context-prune-summary" })).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns false for other custom messages", () => {
|
|
84
|
+
expect(isPerBatchSummaryMessage({ role: "custom", customType: "context-prune-index" })).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns false for user/assistant/toolResult roles", () => {
|
|
88
|
+
expect(isPerBatchSummaryMessage({ role: "user" })).toBe(false);
|
|
89
|
+
expect(isPerBatchSummaryMessage({ role: "assistant" })).toBe(false);
|
|
90
|
+
expect(isPerBatchSummaryMessage({ role: "toolResult" })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("perBatchSummaryOverlapsDropped", () => {
|
|
95
|
+
test("returns true when at least one toolCallRef is in the dropped set", () => {
|
|
96
|
+
const msg = summaryMsg(999, ["tc1", "tc2"]);
|
|
97
|
+
expect(perBatchSummaryOverlapsDropped(msg, new Set(["tc1"]))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("returns false when no toolCallRefs are in the dropped set", () => {
|
|
101
|
+
const msg = summaryMsg(999, ["tc3"]);
|
|
102
|
+
expect(perBatchSummaryOverlapsDropped(msg, new Set(["tc1", "tc2"]))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns false when details is missing", () => {
|
|
106
|
+
const msg = { role: "custom", customType: "context-prune-summary", content: "x", timestamp: 1 };
|
|
107
|
+
expect(perBatchSummaryOverlapsDropped(msg, new Set(["tc1"]))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("withoutThinkingBlocks", () => {
|
|
112
|
+
test("removes thinking blocks, keeps text blocks", () => {
|
|
113
|
+
const msg = assistantText(100, true);
|
|
114
|
+
const result = withoutThinkingBlocks(msg);
|
|
115
|
+
expect(result.content).toHaveLength(1);
|
|
116
|
+
expect(result.content[0].type).toBe("text");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns copy, not mutation", () => {
|
|
120
|
+
const msg = assistantText(100, true);
|
|
121
|
+
const result = withoutThinkingBlocks(msg);
|
|
122
|
+
expect(result).not.toBe(msg);
|
|
123
|
+
expect(msg.content).toHaveLength(2); // original unchanged
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("no-op when no thinking blocks present", () => {
|
|
127
|
+
const msg = assistantText(100, false);
|
|
128
|
+
const result = withoutThinkingBlocks(msg);
|
|
129
|
+
expect(result.content).toHaveLength(1);
|
|
130
|
+
expect(result.content[0].type).toBe("text");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("buildSyntheticChainMessage", () => {
|
|
135
|
+
test("produces a user-role message with F2 XML wrapper", () => {
|
|
136
|
+
const e = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
137
|
+
const msg = buildSyntheticChainMessage(e, "the summary");
|
|
138
|
+
expect(msg.role).toBe("user");
|
|
139
|
+
expect(msg.content[0].type).toBe("text");
|
|
140
|
+
expect(msg.content[0].text).toContain(`id="b1"`);
|
|
141
|
+
expect(msg.content[0].text).toContain(`tools="t1"`);
|
|
142
|
+
expect(msg.content[0].text).toContain("the summary");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("uses compressedAt as timestamp", () => {
|
|
146
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
147
|
+
const msg = buildSyntheticChainMessage(e, "summary");
|
|
148
|
+
expect(msg.timestamp).toBe(e.compressedAt);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("multiple toolRefs are comma-joined", () => {
|
|
152
|
+
const e = entry("b2", 200, ["tc1", "tc2"], 500, ["t1", "t2"]);
|
|
153
|
+
const msg = buildSyntheticChainMessage(e, "summary");
|
|
154
|
+
expect(msg.content[0].text).toContain(`tools="t1,t2"`);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("applyChainCompressions", () => {
|
|
159
|
+
test("no-op when chainEntries is empty", () => {
|
|
160
|
+
const msgs = [userMsg(100), assistantText(200)];
|
|
161
|
+
const result = applyChainCompressions(msgs, [], noopSummary, true);
|
|
162
|
+
expect(result).toBe(msgs); // same reference
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("drops ToolResultMessage whose toolCallId is in droppedToolCallIds", () => {
|
|
166
|
+
const msgs = [
|
|
167
|
+
userMsg(100),
|
|
168
|
+
assistantWithTools(200, ["tc1"]),
|
|
169
|
+
toolResult(300, "tc1"),
|
|
170
|
+
assistantText(400),
|
|
171
|
+
];
|
|
172
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
173
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
174
|
+
const roles = result.map((m: any) => m.role);
|
|
175
|
+
expect(roles).not.toContain("toolResult");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("drops AssistantMessage whose ToolCall blocks include a dropped id", () => {
|
|
179
|
+
const msgs = [
|
|
180
|
+
userMsg(100),
|
|
181
|
+
assistantWithTools(200, ["tc1"]),
|
|
182
|
+
toolResult(300, "tc1"),
|
|
183
|
+
assistantText(400),
|
|
184
|
+
];
|
|
185
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
186
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
187
|
+
// Only the final text-only assistant should remain (the one at 400)
|
|
188
|
+
const assistants = result.filter((m: any) => m.role === "assistant");
|
|
189
|
+
expect(assistants).toHaveLength(1);
|
|
190
|
+
expect(assistants[0].timestamp).toBe(400);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("inserts synthetic chain message immediately after the start user message", () => {
|
|
194
|
+
const msgs = [
|
|
195
|
+
userMsg(100),
|
|
196
|
+
assistantWithTools(200, ["tc1"]),
|
|
197
|
+
toolResult(300, "tc1"),
|
|
198
|
+
assistantText(400),
|
|
199
|
+
];
|
|
200
|
+
const e = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
201
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
202
|
+
|
|
203
|
+
const userIdx = result.findIndex((m: any) => m.role === "user" && m.timestamp === 100);
|
|
204
|
+
expect(userIdx).not.toBe(-1);
|
|
205
|
+
const nextMsg = result[userIdx + 1];
|
|
206
|
+
expect(nextMsg.role).toBe("user");
|
|
207
|
+
expect(nextMsg.content[0].text).toContain("compressed-chain");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("ordering invariant: output preserves input order for surviving messages", () => {
|
|
211
|
+
const msgs = [
|
|
212
|
+
userMsg(100),
|
|
213
|
+
assistantWithTools(200, ["tc1"]),
|
|
214
|
+
toolResult(300, "tc1"),
|
|
215
|
+
assistantText(400),
|
|
216
|
+
userMsg(500),
|
|
217
|
+
assistantText(600),
|
|
218
|
+
];
|
|
219
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
220
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
221
|
+
|
|
222
|
+
// Timestamps of remaining real messages should be in ascending order
|
|
223
|
+
const timestamps = result
|
|
224
|
+
.filter((m: any) => !(m.content?.[0]?.text ?? "").includes("compressed-chain"))
|
|
225
|
+
.map((m: any) => m.timestamp);
|
|
226
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
227
|
+
expect(timestamps[i]).toBeGreaterThan(timestamps[i - 1]);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("suppresses per-batch summary whose toolCallRefs overlap droppedToolCallIds", () => {
|
|
232
|
+
const msgs = [
|
|
233
|
+
userMsg(100),
|
|
234
|
+
assistantWithTools(200, ["tc1"]),
|
|
235
|
+
toolResult(300, "tc1"),
|
|
236
|
+
summaryMsg(350, ["tc1"]),
|
|
237
|
+
assistantText(400),
|
|
238
|
+
];
|
|
239
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
240
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
241
|
+
const hasCustomSummary = result.some(
|
|
242
|
+
(m: any) => m.role === "custom" && m.customType === "context-prune-summary",
|
|
243
|
+
);
|
|
244
|
+
expect(hasCustomSummary).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("does not suppress per-batch summary whose toolCallRefs do not overlap", () => {
|
|
248
|
+
const msgs = [
|
|
249
|
+
userMsg(100),
|
|
250
|
+
assistantWithTools(200, ["tc1"]),
|
|
251
|
+
toolResult(300, "tc1"),
|
|
252
|
+
summaryMsg(350, ["tc2"]), // different toolCallId
|
|
253
|
+
assistantText(400),
|
|
254
|
+
];
|
|
255
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
256
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
257
|
+
const hasCustomSummary = result.some(
|
|
258
|
+
(m: any) => m.role === "custom" && m.customType === "context-prune-summary",
|
|
259
|
+
);
|
|
260
|
+
expect(hasCustomSummary).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("strips thinking blocks from final assistant when stripFinalThinking=true", () => {
|
|
264
|
+
const msgs = [
|
|
265
|
+
userMsg(100),
|
|
266
|
+
assistantWithTools(200, ["tc1"]),
|
|
267
|
+
toolResult(300, "tc1"),
|
|
268
|
+
assistantText(400, true), // has thinking block
|
|
269
|
+
];
|
|
270
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
271
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, true);
|
|
272
|
+
const finalAssistant = result.find((m: any) => m.role === "assistant" && m.timestamp === 400);
|
|
273
|
+
expect(finalAssistant).toBeDefined();
|
|
274
|
+
expect(finalAssistant.content.some((c: any) => c.type === "thinking")).toBe(false);
|
|
275
|
+
expect(finalAssistant.content.some((c: any) => c.type === "text")).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("keeps thinking blocks on final assistant when stripFinalThinking=false", () => {
|
|
279
|
+
const msgs = [
|
|
280
|
+
userMsg(100),
|
|
281
|
+
assistantWithTools(200, ["tc1"]),
|
|
282
|
+
toolResult(300, "tc1"),
|
|
283
|
+
assistantText(400, true),
|
|
284
|
+
];
|
|
285
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
286
|
+
const result = applyChainCompressions(msgs, [e], noopSummary, false);
|
|
287
|
+
const finalAssistant = result.find((m: any) => m.role === "assistant" && m.timestamp === 400);
|
|
288
|
+
expect(finalAssistant.content.some((c: any) => c.type === "thinking")).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("idempotency: calling twice with same chainEntries yields same output", () => {
|
|
292
|
+
const msgs = [
|
|
293
|
+
userMsg(100),
|
|
294
|
+
assistantWithTools(200, ["tc1"]),
|
|
295
|
+
toolResult(300, "tc1"),
|
|
296
|
+
assistantText(400),
|
|
297
|
+
userMsg(500),
|
|
298
|
+
assistantText(600),
|
|
299
|
+
];
|
|
300
|
+
const e = entry("b1", 100, ["tc1"], 400);
|
|
301
|
+
const first = applyChainCompressions(msgs, [e], noopSummary, true);
|
|
302
|
+
const second = applyChainCompressions(first, [e], noopSummary, true);
|
|
303
|
+
expect(second).toEqual(first);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("idempotency: stable with blockSummaryLookup active", () => {
|
|
307
|
+
// Exercises the substitution code path across two passes.
|
|
308
|
+
const msgs = [
|
|
309
|
+
userMsg(100),
|
|
310
|
+
assistantWithTools(200, ["tc1"]),
|
|
311
|
+
toolResult(300, "tc1"),
|
|
312
|
+
assistantText(400),
|
|
313
|
+
userMsg(500),
|
|
314
|
+
assistantText(600),
|
|
315
|
+
];
|
|
316
|
+
const e = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
317
|
+
const summaryFn = (_: ChainCompressionEntry) => "chain summary text";
|
|
318
|
+
const blockLookup = (id: string) => (id === "b1" ? "chain summary text" : undefined);
|
|
319
|
+
const first = applyChainCompressions(msgs, [e], summaryFn, false, blockLookup);
|
|
320
|
+
const second = applyChainCompressions(first, [e], summaryFn, false, blockLookup);
|
|
321
|
+
expect(second).toEqual(first);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("multiple chains in one pass: each behaves independently", () => {
|
|
325
|
+
const msgs = [
|
|
326
|
+
userMsg(100),
|
|
327
|
+
assistantWithTools(200, ["tc1"]),
|
|
328
|
+
toolResult(300, "tc1"),
|
|
329
|
+
assistantText(400),
|
|
330
|
+
userMsg(500),
|
|
331
|
+
assistantWithTools(600, ["tc2"]),
|
|
332
|
+
toolResult(700, "tc2"),
|
|
333
|
+
assistantText(800),
|
|
334
|
+
userMsg(900),
|
|
335
|
+
assistantText(1000),
|
|
336
|
+
];
|
|
337
|
+
const e1 = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
338
|
+
const e2 = entry("b2", 500, ["tc2"], 800, ["t2"]);
|
|
339
|
+
const result = applyChainCompressions(msgs, [e1, e2], noopSummary, false);
|
|
340
|
+
|
|
341
|
+
// Both toolResult messages should be gone
|
|
342
|
+
const toolResults = result.filter((m: any) => m.role === "toolResult");
|
|
343
|
+
expect(toolResults).toHaveLength(0);
|
|
344
|
+
|
|
345
|
+
// Both synthetic chain messages should be present
|
|
346
|
+
const synthetics = result.filter(
|
|
347
|
+
(m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain"),
|
|
348
|
+
);
|
|
349
|
+
expect(synthetics).toHaveLength(2);
|
|
350
|
+
expect(synthetics[0].content[0].text).toContain(`id="b1"`);
|
|
351
|
+
expect(synthetics[1].content[0].text).toContain(`id="b2"`);
|
|
352
|
+
|
|
353
|
+
// The uncompressed chain (userMsg 900 + assistantText 1000) should survive intact
|
|
354
|
+
expect(result.some((m: any) => m.role === "user" && m.timestamp === 900)).toBe(true);
|
|
355
|
+
expect(result.some((m: any) => m.role === "assistant" && m.timestamp === 1000)).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("summaryTextForChain callback receives the correct entry", () => {
|
|
359
|
+
const msgs = [
|
|
360
|
+
userMsg(100),
|
|
361
|
+
assistantWithTools(200, ["tc1"]),
|
|
362
|
+
toolResult(300, "tc1"),
|
|
363
|
+
assistantText(400),
|
|
364
|
+
];
|
|
365
|
+
const e = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
366
|
+
const capturedEntries: ChainCompressionEntry[] = [];
|
|
367
|
+
const summary = (entry: ChainCompressionEntry) => {
|
|
368
|
+
capturedEntries.push(entry);
|
|
369
|
+
return "custom summary for " + entry.blockId;
|
|
370
|
+
};
|
|
371
|
+
const result = applyChainCompressions(msgs, [e], summary, false);
|
|
372
|
+
expect(capturedEntries).toHaveLength(1);
|
|
373
|
+
expect(capturedEntries[0].blockId).toBe("b1");
|
|
374
|
+
const synthetic = result.find((m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain"));
|
|
375
|
+
expect(synthetic?.content[0].text).toContain("custom summary for b1");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("blockSummaryLookup: {bN} in summary text is substituted", () => {
|
|
379
|
+
// Two chains: b1 (startUser=100) and b2 (startUser=500).
|
|
380
|
+
// b2's summary references {b1}. With a lookup, {b1} should be replaced inline.
|
|
381
|
+
const msgs = [
|
|
382
|
+
userMsg(100),
|
|
383
|
+
assistantWithTools(200, ["tc1"]),
|
|
384
|
+
toolResult(300, "tc1"),
|
|
385
|
+
assistantText(400),
|
|
386
|
+
userMsg(500),
|
|
387
|
+
assistantWithTools(600, ["tc2"]),
|
|
388
|
+
toolResult(700, "tc2"),
|
|
389
|
+
assistantText(800),
|
|
390
|
+
];
|
|
391
|
+
const e1 = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
392
|
+
const e2 = entry("b2", 500, ["tc2"], 800, ["t2"]);
|
|
393
|
+
const b1SummaryText = "b1 summary text";
|
|
394
|
+
const summaryLookup = (entry: ChainCompressionEntry) =>
|
|
395
|
+
entry.blockId === "b1" ? b1SummaryText : "see {b1} for details";
|
|
396
|
+
const blockLookup = (blockId: string) =>
|
|
397
|
+
blockId === "b1" ? b1SummaryText : undefined;
|
|
398
|
+
const result = applyChainCompressions(msgs, [e1, e2], summaryLookup, false, blockLookup);
|
|
399
|
+
const b2Synthetic = result.find(
|
|
400
|
+
(m: any) => (m.content?.[0]?.text ?? "").includes('id="b2"'),
|
|
401
|
+
);
|
|
402
|
+
// {b1} inside b2's summary should be expanded
|
|
403
|
+
expect(b2Synthetic?.content[0].text).toContain(`see ${b1SummaryText} for details`);
|
|
404
|
+
expect(b2Synthetic?.content[0].text).not.toContain("{b1}");
|
|
405
|
+
// b1's own synthetic should not be affected
|
|
406
|
+
const b1Synthetic = result.find(
|
|
407
|
+
(m: any) => (m.content?.[0]?.text ?? "").includes('id="b1"'),
|
|
408
|
+
);
|
|
409
|
+
expect(b1Synthetic?.content[0].text).toContain(b1SummaryText);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("relocates protected output verbatim into the compressed-chain body and still drops it from position", () => {
|
|
413
|
+
const e = {
|
|
414
|
+
blockId: "b1",
|
|
415
|
+
startUserTimestamp: 1,
|
|
416
|
+
droppedToolCallIds: ["tc-read", "tc-todo"],
|
|
417
|
+
protectedToolCallIds: ["tc-todo"],
|
|
418
|
+
finalAssistantTimestamp: 9,
|
|
419
|
+
toolRefs: ["t1", "t2"],
|
|
420
|
+
compressedAt: 100,
|
|
421
|
+
};
|
|
422
|
+
const messages = [
|
|
423
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] },
|
|
424
|
+
{ role: "assistant", timestamp: 2, content: [
|
|
425
|
+
{ type: "toolCall", id: "tc-read", name: "read" },
|
|
426
|
+
{ type: "toolCall", id: "tc-todo", name: "todowrite" },
|
|
427
|
+
] },
|
|
428
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] },
|
|
429
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] },
|
|
430
|
+
{ role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
|
|
431
|
+
];
|
|
432
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
433
|
+
// protected toolResult dropped from original position
|
|
434
|
+
expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-todo")).toBeUndefined();
|
|
435
|
+
// text relocated into the synthetic block, under a labeled tag
|
|
436
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
437
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
|
|
438
|
+
expect(synthetic.content[0].text).toContain("PLAN-STATE");
|
|
439
|
+
// non-protected output is NOT relocated
|
|
440
|
+
expect(synthetic.content[0].text).not.toContain("FILE");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("renders byte-identical to pre-feature output when no protected ids", () => {
|
|
444
|
+
const e = {
|
|
445
|
+
blockId: "b1", startUserTimestamp: 1, droppedToolCallIds: ["tc-read"],
|
|
446
|
+
finalAssistantTimestamp: 9, toolRefs: ["t1"], compressedAt: 100,
|
|
447
|
+
};
|
|
448
|
+
const messages = [
|
|
449
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] },
|
|
450
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-read", name: "read" }] },
|
|
451
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] },
|
|
452
|
+
{ role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
|
|
453
|
+
];
|
|
454
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
455
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
456
|
+
expect(synthetic.content[0].text).toBe('<compressed-chain id="b1" tools="t1">\nSUMMARY\n</compressed-chain>');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("relocates multiple protected outputs in message order within one block", () => {
|
|
460
|
+
const e = {
|
|
461
|
+
blockId: "b1",
|
|
462
|
+
startUserTimestamp: 1,
|
|
463
|
+
droppedToolCallIds: ["tc-a", "tc-b"],
|
|
464
|
+
protectedToolCallIds: ["tc-a", "tc-b"],
|
|
465
|
+
finalAssistantTimestamp: 9,
|
|
466
|
+
toolRefs: ["t1", "t2"],
|
|
467
|
+
compressedAt: 100,
|
|
468
|
+
};
|
|
469
|
+
const messages = [
|
|
470
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] },
|
|
471
|
+
{ role: "assistant", timestamp: 2, content: [
|
|
472
|
+
{ type: "toolCall", id: "tc-a", name: "todowrite" },
|
|
473
|
+
{ type: "toolCall", id: "tc-b", name: "todoread" },
|
|
474
|
+
] },
|
|
475
|
+
{ role: "toolResult", toolCallId: "tc-a", toolName: "todowrite", content: [{ type: "text", text: "FIRST" }] },
|
|
476
|
+
{ role: "toolResult", toolCallId: "tc-b", toolName: "todoread", content: [{ type: "text", text: "SECOND" }] },
|
|
477
|
+
{ role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
|
|
478
|
+
];
|
|
479
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
480
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
481
|
+
const text = synthetic.content[0].text as string;
|
|
482
|
+
expect(text).toContain('<protected-output tool="todowrite">\nFIRST\n</protected-output>');
|
|
483
|
+
expect(text).toContain('<protected-output tool="todoread">\nSECOND\n</protected-output>');
|
|
484
|
+
expect(text.indexOf("FIRST")).toBeLessThan(text.indexOf("SECOND"));
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("skips a protected id whose toolResult is absent from input", () => {
|
|
488
|
+
const e = {
|
|
489
|
+
blockId: "b1",
|
|
490
|
+
startUserTimestamp: 1,
|
|
491
|
+
droppedToolCallIds: ["tc-gone"],
|
|
492
|
+
protectedToolCallIds: ["tc-gone"],
|
|
493
|
+
finalAssistantTimestamp: 9,
|
|
494
|
+
toolRefs: ["t1"],
|
|
495
|
+
compressedAt: 100,
|
|
496
|
+
};
|
|
497
|
+
const messages = [
|
|
498
|
+
{ role: "user", timestamp: 1, content: [{ type: "text", text: "go" }] },
|
|
499
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "text", text: "done" }] },
|
|
500
|
+
];
|
|
501
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
502
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
503
|
+
expect(synthetic).toBeDefined();
|
|
504
|
+
expect(synthetic.content[0].text).not.toContain("<protected-output");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("blockSummaryLookup: missing lookup leaves placeholder literal", () => {
|
|
508
|
+
const msgs = [
|
|
509
|
+
userMsg(100),
|
|
510
|
+
assistantWithTools(200, ["tc1"]),
|
|
511
|
+
toolResult(300, "tc1"),
|
|
512
|
+
assistantText(400),
|
|
513
|
+
];
|
|
514
|
+
const e = entry("b1", 100, ["tc1"], 400, ["t1"]);
|
|
515
|
+
const summaryFn = () => "refers to {b99} unknown";
|
|
516
|
+
const blockLookup = (_: string) => undefined;
|
|
517
|
+
const result = applyChainCompressions(msgs, [e], summaryFn, false, blockLookup);
|
|
518
|
+
const synthetic = result.find((m: any) => (m.content?.[0]?.text ?? "").includes("compressed-chain"));
|
|
519
|
+
// {b99} unknown block stays as literal
|
|
520
|
+
expect(synthetic?.content[0].text).toContain("{b99}");
|
|
521
|
+
});
|
|
522
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
3
|
+
import type { ChainCompressionEntry } from "./types.js";
|
|
4
|
+
import { substituteBlockRefs } from "./nested-placeholders.js";
|
|
5
|
+
import { extractToolResultText } from "./batch-capture.js";
|
|
6
|
+
|
|
7
|
+
export function isPerBatchSummaryMessage(msg: any): boolean {
|
|
8
|
+
return msg.role === "custom" && msg.customType === CUSTOM_TYPE_SUMMARY;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function perBatchSummaryOverlapsDropped(msg: any, droppedSet: Set<string>): boolean {
|
|
12
|
+
const refs: { toolCallId: string }[] = msg.details?.toolCallRefs ?? [];
|
|
13
|
+
return refs.some((r) => droppedSet.has(r.toolCallId));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function withoutThinkingBlocks(msg: AssistantMessage): AssistantMessage {
|
|
17
|
+
return { ...msg, content: msg.content.filter((c) => c.type !== "thinking") };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildSyntheticChainMessage(
|
|
21
|
+
entry: ChainCompressionEntry,
|
|
22
|
+
summary: string,
|
|
23
|
+
blockSummaryLookup?: (blockId: string) => string | undefined,
|
|
24
|
+
protectedOutputs: { tool: string; text: string }[] = [],
|
|
25
|
+
): UserMessage {
|
|
26
|
+
const resolvedSummary = blockSummaryLookup
|
|
27
|
+
? substituteBlockRefs(summary, blockSummaryLookup, { selfBlockId: entry.blockId })
|
|
28
|
+
: summary;
|
|
29
|
+
const tools = entry.toolRefs.join(",");
|
|
30
|
+
const protectedBlocks = protectedOutputs
|
|
31
|
+
.map((p) => `\n\n<protected-output tool="${p.tool}">\n${p.text}\n</protected-output>`)
|
|
32
|
+
.join("");
|
|
33
|
+
return {
|
|
34
|
+
role: "user",
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: `<compressed-chain id="${entry.blockId}" tools="${tools}">\n${resolvedSummary}${protectedBlocks}\n</compressed-chain>`,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
// compressedAt is the deterministic timestamp — set at compression-decision time,
|
|
42
|
+
// never collides with real user messages whose timestamps come from the live session clock
|
|
43
|
+
timestamp: entry.compressedAt,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function applyChainCompressions(
|
|
48
|
+
messages: any[],
|
|
49
|
+
chainEntries: ChainCompressionEntry[],
|
|
50
|
+
summaryTextForChain: (entry: ChainCompressionEntry) => string,
|
|
51
|
+
stripFinalThinking: boolean,
|
|
52
|
+
blockSummaryLookup?: (blockId: string) => string | undefined,
|
|
53
|
+
): any[] {
|
|
54
|
+
if (chainEntries.length === 0) return messages;
|
|
55
|
+
|
|
56
|
+
// Pre-scan: collect blockIds of synthetic chain messages already in the input.
|
|
57
|
+
// Skipping re-insertion for matching blockIds makes the transform idempotent —
|
|
58
|
+
// calling twice with the same chainEntries yields the same output.
|
|
59
|
+
const existingSyntheticBlockIds = new Set<string>();
|
|
60
|
+
for (const msg of messages) {
|
|
61
|
+
if (msg.role === "user") {
|
|
62
|
+
const text: string = msg.content?.[0]?.text ?? "";
|
|
63
|
+
const m = /^<compressed-chain id="([^"]+)"/.exec(text);
|
|
64
|
+
if (m) existingSyntheticBlockIds.add(m[1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const droppedToolCallIds = new Set<string>();
|
|
69
|
+
const stripFinalAtTimestamp = new Set<number>();
|
|
70
|
+
|
|
71
|
+
const protectedIdToBlock = new Map<string, string>();
|
|
72
|
+
for (const e of chainEntries) {
|
|
73
|
+
for (const id of e.droppedToolCallIds) droppedToolCallIds.add(id);
|
|
74
|
+
for (const id of e.protectedToolCallIds ?? []) protectedIdToBlock.set(id, e.blockId);
|
|
75
|
+
if (e.finalAssistantTimestamp !== null && stripFinalThinking) {
|
|
76
|
+
stripFinalAtTimestamp.add(e.finalAssistantTimestamp);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const protectedByBlock = new Map<string, { tool: string; text: string }[]>();
|
|
81
|
+
if (protectedIdToBlock.size > 0) {
|
|
82
|
+
for (const msg of messages) {
|
|
83
|
+
if (msg.role === "toolResult" && protectedIdToBlock.has(msg.toolCallId)) {
|
|
84
|
+
const blockId = protectedIdToBlock.get(msg.toolCallId)!;
|
|
85
|
+
const arr = protectedByBlock.get(blockId) ?? [];
|
|
86
|
+
arr.push({ tool: msg.toolName, text: extractToolResultText(msg) });
|
|
87
|
+
protectedByBlock.set(blockId, arr);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const insertAfterUserTimestamp = new Map<number, { synthetic: any; blockId: string }>();
|
|
93
|
+
for (const e of chainEntries) {
|
|
94
|
+
// Each ChainCompressionEntry has a distinct startUserTimestamp — enforced by chain-compressor at the orchestration layer.
|
|
95
|
+
insertAfterUserTimestamp.set(e.startUserTimestamp, {
|
|
96
|
+
synthetic: buildSyntheticChainMessage(e, summaryTextForChain(e), blockSummaryLookup, protectedByBlock.get(e.blockId) ?? []),
|
|
97
|
+
blockId: e.blockId,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const out: any[] = [];
|
|
102
|
+
for (const msg of messages) {
|
|
103
|
+
if (msg.role === "toolResult" && droppedToolCallIds.has(msg.toolCallId)) continue;
|
|
104
|
+
|
|
105
|
+
if (msg.role === "assistant") {
|
|
106
|
+
const callIds: string[] = (msg.content ?? [])
|
|
107
|
+
.filter((c: any) => c.type === "toolCall")
|
|
108
|
+
.map((c: any) => c.id as string);
|
|
109
|
+
if (callIds.some((id) => droppedToolCallIds.has(id))) continue;
|
|
110
|
+
if (stripFinalAtTimestamp.has(msg.timestamp)) {
|
|
111
|
+
out.push(withoutThinkingBlocks(msg));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isPerBatchSummaryMessage(msg) && perBatchSummaryOverlapsDropped(msg, droppedToolCallIds)) continue;
|
|
117
|
+
|
|
118
|
+
out.push(msg);
|
|
119
|
+
|
|
120
|
+
if (msg.role === "user") {
|
|
121
|
+
const info = insertAfterUserTimestamp.get(msg.timestamp);
|
|
122
|
+
if (info && !existingSyntheticBlockIds.has(info.blockId)) {
|
|
123
|
+
out.push(info.synthetic);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|