@zhushanwen/pi-context-engineering 0.1.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/index.ts +1 -0
- package/package.json +23 -0
- package/src/__tests__/compressor.test.ts +911 -0
- package/src/__tests__/frozen-fresh.test.ts +44 -0
- package/src/__tests__/integration.test.ts +440 -0
- package/src/commands.ts +154 -0
- package/src/compressor.ts +798 -0
- package/src/config.ts +172 -0
- package/src/frozen-fresh.ts +36 -0
- package/src/index.ts +105 -0
- package/src/recall-store.ts +63 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
compressContext,
|
|
4
|
+
validateToolPairing,
|
|
5
|
+
getToolResultText,
|
|
6
|
+
processMicrocompact,
|
|
7
|
+
processBudget,
|
|
8
|
+
findCompactBoundary,
|
|
9
|
+
type AgentMessage,
|
|
10
|
+
type ToolCall,
|
|
11
|
+
type AssistantMessage,
|
|
12
|
+
type ToolResultMessage,
|
|
13
|
+
type BashExecutionMessage,
|
|
14
|
+
type UserMessage,
|
|
15
|
+
type TextContent,
|
|
16
|
+
type ThinkingContent,
|
|
17
|
+
type ContextUsage,
|
|
18
|
+
} from "../compressor";
|
|
19
|
+
import { createRecallStore } from "../recall-store";
|
|
20
|
+
import { createFrozenFreshState } from "../frozen-fresh";
|
|
21
|
+
import { DEFAULT_CONFIG } from "../config";
|
|
22
|
+
|
|
23
|
+
// ── Test helpers ──
|
|
24
|
+
|
|
25
|
+
function makeToolResult(text: string, ageMs: number, toolCallId: string): ToolResultMessage {
|
|
26
|
+
return {
|
|
27
|
+
role: "toolResult",
|
|
28
|
+
toolCallId,
|
|
29
|
+
toolName: "read",
|
|
30
|
+
content: [{ type: "text" as const, text }],
|
|
31
|
+
isError: false,
|
|
32
|
+
timestamp: Date.now() - ageMs,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeAssistant(
|
|
37
|
+
toolCalls: ToolCall[],
|
|
38
|
+
thinking?: string,
|
|
39
|
+
ageMs: number = 0,
|
|
40
|
+
): AssistantMessage {
|
|
41
|
+
const content: (TextContent | ThinkingContent | ToolCall)[] = [];
|
|
42
|
+
if (thinking) {
|
|
43
|
+
content.push({ type: "thinking" as const, thinking });
|
|
44
|
+
}
|
|
45
|
+
content.push(...toolCalls);
|
|
46
|
+
if (toolCalls.length === 0 && !thinking) {
|
|
47
|
+
content.push({ type: "text" as const, text: "ok" });
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
role: "assistant",
|
|
51
|
+
content,
|
|
52
|
+
api: "anthropic-messages",
|
|
53
|
+
provider: "anthropic",
|
|
54
|
+
model: "test",
|
|
55
|
+
usage: {
|
|
56
|
+
input: 0,
|
|
57
|
+
output: 0,
|
|
58
|
+
cacheRead: 0,
|
|
59
|
+
cacheWrite: 0,
|
|
60
|
+
totalTokens: 0,
|
|
61
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
62
|
+
},
|
|
63
|
+
stopReason: "stop",
|
|
64
|
+
timestamp: Date.now() - ageMs,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeUser(text: string, ageMs: number = 0): UserMessage {
|
|
69
|
+
return { role: "user", content: text, timestamp: Date.now() - ageMs };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeCompactionSummary(summary: string, ageMs: number = 0) {
|
|
73
|
+
return { role: "compactionSummary" as const, summary, tokensBefore: 0, timestamp: Date.now() - ageMs };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeBashExecution(output: string, ageMs: number = 0): BashExecutionMessage {
|
|
77
|
+
return {
|
|
78
|
+
role: "bashExecution",
|
|
79
|
+
command: "cat file.txt",
|
|
80
|
+
output,
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
cancelled: false,
|
|
83
|
+
truncated: false,
|
|
84
|
+
timestamp: Date.now() - ageMs,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tc(id: string, name: string = "read"): ToolCall {
|
|
89
|
+
return {
|
|
90
|
+
type: "toolCall" as const,
|
|
91
|
+
id,
|
|
92
|
+
name,
|
|
93
|
+
arguments: { path: "file.ts" } as Record<string, unknown>,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Tests ──
|
|
98
|
+
|
|
99
|
+
describe("compressor", () => {
|
|
100
|
+
const ffState = createFrozenFreshState();
|
|
101
|
+
const MINUTE = 60 * 1000;
|
|
102
|
+
|
|
103
|
+
it("AC-1: 过期清理 — 过期的 ToolResult 被替换,保护 turn 内的保留", () => {
|
|
104
|
+
const store = createRecallStore();
|
|
105
|
+
const config = { ...DEFAULT_CONFIG, l0: { ...DEFAULT_CONFIG.l0, keepRecent: 0 } };
|
|
106
|
+
|
|
107
|
+
// Turn 1: user(40min) → assistant(tc1) → toolResult(35min, 应过期)
|
|
108
|
+
// Turn 2: user(20min) → assistant(tc2) → toolResult(15min, 在保护 turn 内)
|
|
109
|
+
// Turn 3: user(1min) — 让 Turn 2+3 成为保护 turn (protectRecentTurns=2)
|
|
110
|
+
const messages: AgentMessage[] = [
|
|
111
|
+
makeUser("task 1", 40 * MINUTE),
|
|
112
|
+
makeAssistant([tc("c1")], undefined, 40 * MINUTE),
|
|
113
|
+
makeToolResult("content of file A", 35 * MINUTE, "c1"),
|
|
114
|
+
makeUser("task 2", 20 * MINUTE),
|
|
115
|
+
makeAssistant([tc("c2")], undefined, 20 * MINUTE),
|
|
116
|
+
makeToolResult("content of file B", 15 * MINUTE, "c2"),
|
|
117
|
+
makeUser("task 3", 1 * MINUTE),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
121
|
+
|
|
122
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
123
|
+
|
|
124
|
+
// Turn 1 的 toolResult 应被过期
|
|
125
|
+
const expired = result.messages[2] as ToolResultMessage;
|
|
126
|
+
expect(expired.role).toBe("toolResult");
|
|
127
|
+
const expiredText = getToolResultText(expired);
|
|
128
|
+
expect(expiredText).toContain("[Tool result expired");
|
|
129
|
+
expect(expiredText).toContain("ID: ctx-");
|
|
130
|
+
expect(expiredText).not.toContain("content of file A");
|
|
131
|
+
expect(result.stats.l0Expired).toBe(1);
|
|
132
|
+
|
|
133
|
+
// Turn 2 的 toolResult 应保留原文
|
|
134
|
+
const kept = result.messages[5] as ToolResultMessage;
|
|
135
|
+
expect(getToolResultText(kept)).toBe("content of file B");
|
|
136
|
+
|
|
137
|
+
// store 中应有一条记录
|
|
138
|
+
expect(store.size()).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("AC-2: Bash 截断 — 超长 output 被截断", () => {
|
|
142
|
+
const store = createRecallStore();
|
|
143
|
+
const longOutput = "x".repeat(10000);
|
|
144
|
+
|
|
145
|
+
const messages: AgentMessage[] = [makeBashExecution(longOutput)];
|
|
146
|
+
|
|
147
|
+
const result = compressContext(messages, DEFAULT_CONFIG, store, undefined, ffState);
|
|
148
|
+
|
|
149
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
150
|
+
expect(result.stats.l0Truncated).toBe(1);
|
|
151
|
+
|
|
152
|
+
const bash = result.messages[0] as BashExecutionMessage;
|
|
153
|
+
expect(bash.output.length).toBeLessThan(longOutput.length);
|
|
154
|
+
expect(bash.output).toContain("[truncated");
|
|
155
|
+
expect(bash.output).toContain("ID: ctx-");
|
|
156
|
+
expect(bash.output).toContain(`Total: ${longOutput.length} chars`);
|
|
157
|
+
|
|
158
|
+
// 首尾内容保留
|
|
159
|
+
// Tail retention: last bashTruncateChars chars preserved
|
|
160
|
+
const tailChars = DEFAULT_CONFIG.l0.bashTruncateChars;
|
|
161
|
+
expect(bash.output).toContain("x".repeat(tailChars));
|
|
162
|
+
|
|
163
|
+
expect(store.size()).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("AC-3: Thinking 清理 — 过期的 thinking 被清空", () => {
|
|
167
|
+
const store = createRecallStore();
|
|
168
|
+
|
|
169
|
+
const messages: AgentMessage[] = [
|
|
170
|
+
makeAssistant([], "deep analysis of the problem...", 6 * MINUTE),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const result = compressContext(messages, DEFAULT_CONFIG, store, undefined, ffState);
|
|
174
|
+
|
|
175
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
176
|
+
expect(result.stats.l0ThinkingCleared).toBe(1);
|
|
177
|
+
|
|
178
|
+
const asst = result.messages[0] as AssistantMessage;
|
|
179
|
+
const thinkingBlock = asst.content.find((c) => c.type === "thinking");
|
|
180
|
+
expect(thinkingBlock).toBeDefined();
|
|
181
|
+
expect((thinkingBlock as ThinkingContent).thinking).toBe("[thinking expired]");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("AC-4: 配对校验 — 正常和损坏序列", () => {
|
|
185
|
+
// 正常序列
|
|
186
|
+
const good: AgentMessage[] = [
|
|
187
|
+
makeAssistant([tc("c1")]),
|
|
188
|
+
makeToolResult("ok", 0, "c1"),
|
|
189
|
+
];
|
|
190
|
+
expect(validateToolPairing(good)).toBe(true);
|
|
191
|
+
|
|
192
|
+
// 损坏序列: toolResult 没有对应的 toolCall
|
|
193
|
+
const orphaned: AgentMessage[] = [
|
|
194
|
+
makeToolResult("orphan", 0, "c-missing"),
|
|
195
|
+
];
|
|
196
|
+
expect(validateToolPairing(orphaned)).toBe(false);
|
|
197
|
+
|
|
198
|
+
// 损坏序列: toolCall 没有对应的 toolResult
|
|
199
|
+
const unmatched: AgentMessage[] = [
|
|
200
|
+
makeAssistant([tc("c-unmatched")]),
|
|
201
|
+
];
|
|
202
|
+
expect(validateToolPairing(unmatched)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("AC-7: L1 摘要 — 长文本被结构化摘要", () => {
|
|
206
|
+
const store = createRecallStore();
|
|
207
|
+
const config = { ...DEFAULT_CONFIG, l1: { ...DEFAULT_CONFIG.l1, protectRecentTurns: 0 } };
|
|
208
|
+
|
|
209
|
+
// 构造 > 8000 chars 的内容,包含 import/definition 行
|
|
210
|
+
const headLines = Array.from({ length: 10 }, (_, i) => `// head comment line ${i}`);
|
|
211
|
+
const middleImportLines = [
|
|
212
|
+
"import { readFile } from 'fs';",
|
|
213
|
+
"import { parse } from 'path';",
|
|
214
|
+
];
|
|
215
|
+
const middleDefLines = [
|
|
216
|
+
"function processData(input: string): string {",
|
|
217
|
+
"export type Config = { debug: boolean };",
|
|
218
|
+
];
|
|
219
|
+
const junkLines = Array.from({ length: 400 }, () => " return someValueWithALongerNameToPadLength();");
|
|
220
|
+
const tailLines = Array.from({ length: 5 }, (_, i) => `// tail comment ${i}`);
|
|
221
|
+
|
|
222
|
+
const allLines = [
|
|
223
|
+
...headLines,
|
|
224
|
+
...middleImportLines,
|
|
225
|
+
...junkLines.slice(0, 100),
|
|
226
|
+
...middleDefLines,
|
|
227
|
+
...junkLines.slice(100),
|
|
228
|
+
...tailLines,
|
|
229
|
+
];
|
|
230
|
+
const longContent = allLines.join("\n");
|
|
231
|
+
expect(longContent.length).toBeGreaterThan(8000);
|
|
232
|
+
|
|
233
|
+
const messages: AgentMessage[] = [
|
|
234
|
+
makeUser("read file"),
|
|
235
|
+
makeAssistant([tc("c1")]),
|
|
236
|
+
makeToolResult(longContent, 0, "c1"),
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
240
|
+
|
|
241
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
242
|
+
expect(result.stats.l1Condensed).toBe(1);
|
|
243
|
+
|
|
244
|
+
const condensed = result.messages[2] as ToolResultMessage;
|
|
245
|
+
const text = getToolResultText(condensed);
|
|
246
|
+
|
|
247
|
+
// 验证摘要格式
|
|
248
|
+
expect(text).toContain("[Condensed (ID: ctx-");
|
|
249
|
+
expect(text).toContain("import { readFile }");
|
|
250
|
+
expect(text).toContain("function processData");
|
|
251
|
+
expect(text).toContain("export type Config");
|
|
252
|
+
expect(text).toContain("[...");
|
|
253
|
+
|
|
254
|
+
// 摘要应比原文短
|
|
255
|
+
expect(text.length).toBeLessThan(longContent.length);
|
|
256
|
+
expect(store.size()).toBeGreaterThanOrEqual(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("AC-8: L2 紧急 — 高使用率时强制过期保护 turn 外的 toolResult", () => {
|
|
260
|
+
const store = createRecallStore();
|
|
261
|
+
const contextUsage: ContextUsage = {
|
|
262
|
+
tokens: null,
|
|
263
|
+
contextWindow: 200000,
|
|
264
|
+
percent: 0.91,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Turn 1: 20min 前 — 不在 L2 保护范围内 (protectRecentTurns=3, 但有 4 个 turn)
|
|
268
|
+
// Turn 2-4: 较新 — 保护范围内
|
|
269
|
+
const messages: AgentMessage[] = [
|
|
270
|
+
makeUser("t1", 20 * MINUTE),
|
|
271
|
+
makeAssistant([tc("c1")], undefined, 20 * MINUTE),
|
|
272
|
+
makeToolResult("content-1", 20 * MINUTE, "c1"), // 20min < 30min → L0 不处理
|
|
273
|
+
makeUser("t2", 10 * MINUTE),
|
|
274
|
+
makeAssistant([tc("c2")], undefined, 10 * MINUTE),
|
|
275
|
+
makeToolResult("content-2", 10 * MINUTE, "c2"),
|
|
276
|
+
makeUser("t3", 5 * MINUTE),
|
|
277
|
+
makeAssistant([tc("c3")], undefined, 5 * MINUTE),
|
|
278
|
+
makeToolResult("content-3", 5 * MINUTE, "c3"),
|
|
279
|
+
makeUser("t4", 1 * MINUTE),
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
const result = compressContext(messages, DEFAULT_CONFIG, store, contextUsage, ffState);
|
|
283
|
+
|
|
284
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
285
|
+
expect(result.stats.l2Triggered).toBe(true);
|
|
286
|
+
|
|
287
|
+
// Turn 1 的 toolResult (index 2) 应被 L2 强制过期
|
|
288
|
+
const forced = result.messages[2] as ToolResultMessage;
|
|
289
|
+
expect(getToolResultText(forced)).toContain("[Tool result expired");
|
|
290
|
+
|
|
291
|
+
// Turn 2 的 toolResult (index 5) 应保留
|
|
292
|
+
const kept = result.messages[5] as ToolResultMessage;
|
|
293
|
+
expect(getToolResultText(kept)).toBe("content-2");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("AC-10: 全局禁用 — enabled=false 返回原始消息", () => {
|
|
297
|
+
const store = createRecallStore();
|
|
298
|
+
const config = { ...DEFAULT_CONFIG, enabled: false };
|
|
299
|
+
|
|
300
|
+
const messages: AgentMessage[] = [
|
|
301
|
+
makeUser("hello"),
|
|
302
|
+
makeAssistant([tc("c1")]),
|
|
303
|
+
makeToolResult("big content here", 60 * MINUTE, "c1"),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
307
|
+
|
|
308
|
+
// 应返回同一引用
|
|
309
|
+
expect(result.messages).toBe(messages);
|
|
310
|
+
expect(result.stats.l0Expired).toBe(0);
|
|
311
|
+
expect(result.stats.l0Truncated).toBe(0);
|
|
312
|
+
expect(result.stats.l0ThinkingCleared).toBe(0);
|
|
313
|
+
expect(result.stats.l1Condensed).toBe(0);
|
|
314
|
+
expect(result.stats.l2Triggered).toBe(false);
|
|
315
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
316
|
+
expect(store.size()).toBe(0);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── Microcompact (AC-1) ──
|
|
321
|
+
|
|
322
|
+
describe("Microcompact (AC-1)", () => {
|
|
323
|
+
const _ffState = createFrozenFreshState();
|
|
324
|
+
const MINUTE = 60 * 1000;
|
|
325
|
+
|
|
326
|
+
it("8 个 read toolResult,最后一个 assistant 65 分钟前 → 前 3 个被清理", () => {
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
const mcConfig = {
|
|
329
|
+
enabled: true,
|
|
330
|
+
gapThresholdMinutes: 60,
|
|
331
|
+
keepRecent: 5,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// assistant 在 65 分钟前
|
|
335
|
+
const _assistantTs = now - 65 * MINUTE;
|
|
336
|
+
|
|
337
|
+
// 8 个 toolResult,每个关联一个 assistant(但 MC 只看最后一个 assistant 的 timestamp)
|
|
338
|
+
const _messages: AgentMessage[] = [
|
|
339
|
+
makeUser("task", 70 * MINUTE),
|
|
340
|
+
makeAssistant([tc("c1")], undefined, 68 * MINUTE),
|
|
341
|
+
makeToolResult("content-1", 67 * MINUTE, "c1"),
|
|
342
|
+
makeToolResult("content-2", 66 * MINUTE, "c2"),
|
|
343
|
+
makeToolResult("content-3", 66 * MINUTE, "c3"),
|
|
344
|
+
makeToolResult("content-4", 66 * MINUTE, "c4"),
|
|
345
|
+
makeToolResult("content-5", 66 * MINUTE, "c5"),
|
|
346
|
+
makeToolResult("content-6", 66 * MINUTE, "c6"),
|
|
347
|
+
makeToolResult("content-7", 66 * MINUTE, "c7"),
|
|
348
|
+
makeToolResult("content-8", 66 * MINUTE, "c8"),
|
|
349
|
+
// 最后一个 assistant 在 65 分钟前
|
|
350
|
+
{ ...makeAssistant([], undefined, 65 * MINUTE), content: [{ type: "text" as const, text: "done" }] },
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
// 需要给 toolResult 配对 toolCall
|
|
354
|
+
// 为 c2-c8 添加 assistant toolCall
|
|
355
|
+
// 重新构造消息序列确保配对正确
|
|
356
|
+
const pairedMessages: AgentMessage[] = [
|
|
357
|
+
makeUser("task", 70 * MINUTE),
|
|
358
|
+
makeAssistant([tc("c1")], undefined, 68 * MINUTE),
|
|
359
|
+
makeToolResult("content-1", 67 * MINUTE, "c1"),
|
|
360
|
+
makeAssistant([tc("c2")], undefined, 67 * MINUTE),
|
|
361
|
+
makeToolResult("content-2", 66 * MINUTE, "c2"),
|
|
362
|
+
makeAssistant([tc("c3")], undefined, 66 * MINUTE),
|
|
363
|
+
makeToolResult("content-3", 66 * MINUTE, "c3"),
|
|
364
|
+
makeAssistant([tc("c4")], undefined, 66 * MINUTE),
|
|
365
|
+
makeToolResult("content-4", 66 * MINUTE, "c4"),
|
|
366
|
+
makeAssistant([tc("c5")], undefined, 66 * MINUTE),
|
|
367
|
+
makeToolResult("content-5", 66 * MINUTE, "c5"),
|
|
368
|
+
makeAssistant([tc("c6")], undefined, 66 * MINUTE),
|
|
369
|
+
makeToolResult("content-6", 66 * MINUTE, "c6"),
|
|
370
|
+
makeAssistant([tc("c7")], undefined, 66 * MINUTE),
|
|
371
|
+
makeToolResult("content-7", 66 * MINUTE, "c7"),
|
|
372
|
+
makeAssistant([tc("c8")], undefined, 66 * MINUTE),
|
|
373
|
+
makeToolResult("content-8", 66 * MINUTE, "c8"),
|
|
374
|
+
// 最后一个 assistant 在 65 分钟前
|
|
375
|
+
{ ...makeAssistant([], undefined, 65 * MINUTE), content: [{ type: "text" as const, text: "done" }] },
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const store = createRecallStore();
|
|
379
|
+
const { messages: result, stats } = processMicrocompact(pairedMessages, mcConfig, store, now, null);
|
|
380
|
+
|
|
381
|
+
expect(stats.triggered).toBe(true);
|
|
382
|
+
expect(stats.cleared).toBe(3); // 8 - 5 keepRecent = 3
|
|
383
|
+
|
|
384
|
+
// 前 3 个 toolResult (index 2, 4, 6) 被清理,包含 recall ID
|
|
385
|
+
const tr1 = result[2] as ToolResultMessage;
|
|
386
|
+
expect(getToolResultText(tr1)).toContain("[Old tool result expired");
|
|
387
|
+
expect(getToolResultText(tr1)).toContain("ID: ctx-");
|
|
388
|
+
|
|
389
|
+
const tr2 = result[4] as ToolResultMessage;
|
|
390
|
+
expect(getToolResultText(tr2)).toContain("[Old tool result expired");
|
|
391
|
+
|
|
392
|
+
const tr3 = result[6] as ToolResultMessage;
|
|
393
|
+
expect(getToolResultText(tr3)).toContain("[Old tool result expired");
|
|
394
|
+
|
|
395
|
+
// 后 5 个保留原文 (index 8, 10, 12, 14, 16)
|
|
396
|
+
const tr4 = result[8] as ToolResultMessage;
|
|
397
|
+
expect(getToolResultText(tr4)).toBe("content-4");
|
|
398
|
+
|
|
399
|
+
const tr5 = result[10] as ToolResultMessage;
|
|
400
|
+
expect(getToolResultText(tr5)).toBe("content-5");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("30 分钟内不触发 MC", () => {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const mcConfig = {
|
|
406
|
+
enabled: true,
|
|
407
|
+
gapThresholdMinutes: 60,
|
|
408
|
+
keepRecent: 5,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const messages: AgentMessage[] = [
|
|
412
|
+
makeUser("task", 30 * MINUTE),
|
|
413
|
+
makeAssistant([tc("c1")], undefined, 30 * MINUTE),
|
|
414
|
+
makeToolResult("content-1", 29 * MINUTE, "c1"),
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
const { messages: result, stats } = processMicrocompact(messages, mcConfig, now, null);
|
|
418
|
+
|
|
419
|
+
expect(stats.triggered).toBe(false);
|
|
420
|
+
expect(stats.cleared).toBe(0);
|
|
421
|
+
expect(getToolResultText(result[2] as ToolResultMessage)).toBe("content-1");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("非 compactable 工具(recall_context)不被清理", () => {
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const mcConfig = {
|
|
427
|
+
enabled: true,
|
|
428
|
+
gapThresholdMinutes: 60,
|
|
429
|
+
keepRecent: 5,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const recallResult: ToolResultMessage = {
|
|
433
|
+
role: "toolResult",
|
|
434
|
+
toolCallId: "c-recall",
|
|
435
|
+
toolName: "recall_context",
|
|
436
|
+
content: [{ type: "text" as const, text: "recalled content here" }],
|
|
437
|
+
isError: false,
|
|
438
|
+
timestamp: now - 65 * MINUTE,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const messages: AgentMessage[] = [
|
|
442
|
+
makeUser("task", 70 * MINUTE),
|
|
443
|
+
makeAssistant([tc("c1"), { type: "toolCall" as const, id: "c-recall", name: "recall_context", arguments: {} }], undefined, 65 * MINUTE),
|
|
444
|
+
makeToolResult("content-1", 66 * MINUTE, "c1"),
|
|
445
|
+
recallResult,
|
|
446
|
+
// 最后一个 assistant 65 分钟前
|
|
447
|
+
{ ...makeAssistant([], undefined, 65 * MINUTE), content: [{ type: "text" as const, text: "done" }] },
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
const store = createRecallStore();
|
|
451
|
+
const { messages: result, stats } = processMicrocompact(messages, mcConfig, store, now, null);
|
|
452
|
+
|
|
453
|
+
expect(stats.triggered).toBe(true);
|
|
454
|
+
expect(stats.cleared).toBe(0); // 只有 1 个 compactable,keepRecent=5,不清理
|
|
455
|
+
|
|
456
|
+
// recall_context 的结果应保持原样
|
|
457
|
+
const kept = result[3] as ToolResultMessage;
|
|
458
|
+
expect(getToolResultText(kept)).toBe("recalled content here");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ── Budget (AC-2, AC-3) ──
|
|
463
|
+
|
|
464
|
+
describe("Budget (AC-2, AC-3)", () => {
|
|
465
|
+
it("5 个 toolResult 总计 250K chars,最大被持久化", async () => {
|
|
466
|
+
const store = createRecallStore();
|
|
467
|
+
const _now = Date.now();
|
|
468
|
+
const budgetConfig = {
|
|
469
|
+
enabled: true,
|
|
470
|
+
maxToolResultCharsPerMessage: 200_000,
|
|
471
|
+
previewSize: 2000,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// 5 个 toolResult: 4x 20K + 1x 170K = 250K total
|
|
475
|
+
// 最大的 170K 应被持久化
|
|
476
|
+
const small = "a".repeat(20_000);
|
|
477
|
+
const big = "B".repeat(170_000);
|
|
478
|
+
|
|
479
|
+
const ffState = createFrozenFreshState();
|
|
480
|
+
|
|
481
|
+
const messages: AgentMessage[] = [
|
|
482
|
+
makeUser("task"),
|
|
483
|
+
makeAssistant([tc("c1"), tc("c2"), tc("c3"), tc("c4"), tc("c5")]),
|
|
484
|
+
makeToolResult(small, 0, "c1"),
|
|
485
|
+
makeToolResult(small, 0, "c2"),
|
|
486
|
+
makeToolResult(small, 0, "c3"),
|
|
487
|
+
makeToolResult(small, 0, "c4"),
|
|
488
|
+
makeToolResult(big, 0, "c5"),
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
const { messages: result, stats } = processBudget(
|
|
492
|
+
messages, budgetConfig, store, ffState, null,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
expect(stats.persisted).toBe(1);
|
|
496
|
+
|
|
497
|
+
// 最大的 (c5, index 6) 应被持久化
|
|
498
|
+
const persisted = result[6] as ToolResultMessage;
|
|
499
|
+
const text = getToolResultText(persisted);
|
|
500
|
+
expect(text).toContain("[Persisted output");
|
|
501
|
+
expect(text).toContain("Total: 170000 chars");
|
|
502
|
+
|
|
503
|
+
// 原文存入 recall store
|
|
504
|
+
expect(store.size()).toBe(1);
|
|
505
|
+
|
|
506
|
+
// 小的应保留
|
|
507
|
+
const kept = result[2] as ToolResultMessage;
|
|
508
|
+
expect(getToolResultText(kept)).toBe(small);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ── Compact Boundary (AC-4, AC-7) ──
|
|
513
|
+
|
|
514
|
+
describe("Compact Boundary (AC-4, AC-7)", () => {
|
|
515
|
+
const _ffState = createFrozenFreshState();
|
|
516
|
+
const MINUTE = 60 * 1000;
|
|
517
|
+
|
|
518
|
+
it("compactionSummary 在 index 5,之前的 toolResult 不被 MC 处理", () => {
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
const mcConfig = {
|
|
521
|
+
enabled: true,
|
|
522
|
+
gapThresholdMinutes: 60,
|
|
523
|
+
keepRecent: 1,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// compactionSummary 在 index 5
|
|
527
|
+
const messages: AgentMessage[] = [
|
|
528
|
+
makeUser("t0", 120 * MINUTE), // 0
|
|
529
|
+
makeAssistant([tc("c0")], undefined, 119 * MINUTE), // 1
|
|
530
|
+
makeToolResult("old-1", 119 * MINUTE, "c0"), // 2 - 边界前
|
|
531
|
+
makeUser("t1", 110 * MINUTE), // 3
|
|
532
|
+
makeToolResult("old-2", 109 * MINUTE, "c2"), // 4 - 边界前,非 compactable 位置
|
|
533
|
+
// compactionSummary 边界
|
|
534
|
+
makeCompactionSummary("summary of prior work", 100 * MINUTE), // 5 - boundary
|
|
535
|
+
makeAssistant([tc("c3"), tc("c4"), tc("c5"), tc("c6")], undefined, 80 * MINUTE), // 6
|
|
536
|
+
makeToolResult("fresh-1", 79 * MINUTE, "c3"), // 7 - 边界后
|
|
537
|
+
makeToolResult("fresh-2", 78 * MINUTE, "c4"), // 8 - 边界后
|
|
538
|
+
makeToolResult("fresh-3", 77 * MINUTE, "c5"), // 9 - 边界后
|
|
539
|
+
makeToolResult("fresh-4", 76 * MINUTE, "c6"), // 10 - 边界后
|
|
540
|
+
makeAssistant([], undefined, 65 * MINUTE), // 11 - 65min 前,触发 MC
|
|
541
|
+
];
|
|
542
|
+
|
|
543
|
+
const boundary = findCompactBoundary(messages);
|
|
544
|
+
expect(boundary).toBe(5);
|
|
545
|
+
|
|
546
|
+
const store = createRecallStore();
|
|
547
|
+
const { messages: result, stats } = processMicrocompact(messages, mcConfig, store, now, boundary);
|
|
548
|
+
|
|
549
|
+
// MC 应触发(最后一个 assistant 65min 前 > 60min)
|
|
550
|
+
expect(stats.triggered).toBe(true);
|
|
551
|
+
|
|
552
|
+
// 边界前的 toolResult (index 2) 不应被 MC 处理
|
|
553
|
+
const beforeBoundary = result[2] as ToolResultMessage;
|
|
554
|
+
expect(getToolResultText(beforeBoundary)).toBe("old-1");
|
|
555
|
+
|
|
556
|
+
// 边界后有 4 个 compactable (7,8,9,10),keepRecent=1 → 清理 3 个
|
|
557
|
+
expect(stats.cleared).toBe(3);
|
|
558
|
+
const cleared = result[7] as ToolResultMessage;
|
|
559
|
+
expect(getToolResultText(cleared)).toContain("[Old tool result expired");
|
|
560
|
+
|
|
561
|
+
// 最近 1 个保留
|
|
562
|
+
const kept = result[10] as ToolResultMessage;
|
|
563
|
+
expect(getToolResultText(kept)).toBe("fresh-4");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("无 compactionSummary 时正常处理", () => {
|
|
567
|
+
const messages: AgentMessage[] = [
|
|
568
|
+
makeUser("t0", 120 * 60 * 1000),
|
|
569
|
+
makeToolResult("content", 119 * 60 * 1000, "c1"),
|
|
570
|
+
];
|
|
571
|
+
|
|
572
|
+
const boundary = findCompactBoundary(messages);
|
|
573
|
+
expect(boundary).toBeNull();
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ── L1 Protected Turn (AC-5) ──
|
|
578
|
+
|
|
579
|
+
describe("L1 Protected Turn (AC-5)", () => {
|
|
580
|
+
const ffState = createFrozenFreshState();
|
|
581
|
+
const MINUTE = 60 * 1000;
|
|
582
|
+
|
|
583
|
+
it("12K chars toolResult 在最近 2 轮内 → 不被 condense,原文保留", () => {
|
|
584
|
+
const store = createRecallStore();
|
|
585
|
+
const config = {
|
|
586
|
+
...DEFAULT_CONFIG,
|
|
587
|
+
l1: {
|
|
588
|
+
...DEFAULT_CONFIG.l1,
|
|
589
|
+
summaryThresholdChars: 8000,
|
|
590
|
+
protectRecentTurns: 2,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// 构造 12K chars 的 toolResult
|
|
595
|
+
const bigContent = "x".repeat(12_000);
|
|
596
|
+
|
|
597
|
+
// Turn 1: user → assistant → toolResult(12K) → 保护范围内(最近 2 轮)
|
|
598
|
+
const messages: AgentMessage[] = [
|
|
599
|
+
makeUser("task", 1 * MINUTE),
|
|
600
|
+
makeAssistant([tc("c1")], undefined, 1 * MINUTE),
|
|
601
|
+
makeToolResult(bigContent, 0, "c1"),
|
|
602
|
+
makeUser("followup", 0),
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
606
|
+
|
|
607
|
+
// 应保留原文
|
|
608
|
+
const tr = result.messages[2] as ToolResultMessage;
|
|
609
|
+
expect(getToolResultText(tr)).toBe(bigContent);
|
|
610
|
+
expect(result.stats.l1Condensed).toBe(0);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("12K chars toolResult 在保护范围外(3 轮之前)→ 被 condense", () => {
|
|
614
|
+
const store = createRecallStore();
|
|
615
|
+
const config = {
|
|
616
|
+
...DEFAULT_CONFIG,
|
|
617
|
+
l1: {
|
|
618
|
+
...DEFAULT_CONFIG.l1,
|
|
619
|
+
summaryThresholdChars: 8000,
|
|
620
|
+
protectRecentTurns: 2,
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const bigContent = "line\n".repeat(3000); // ~15K chars
|
|
625
|
+
expect(bigContent.length).toBeGreaterThan(8000);
|
|
626
|
+
|
|
627
|
+
// Turn 1: toolResult 在 Turn 0,protectRecentTurns=2 保护 Turn 1+2
|
|
628
|
+
const messages: AgentMessage[] = [
|
|
629
|
+
makeUser("task1", 10 * MINUTE),
|
|
630
|
+
makeAssistant([tc("c1")], undefined, 10 * MINUTE),
|
|
631
|
+
makeToolResult(bigContent, 9 * MINUTE, "c1"),
|
|
632
|
+
makeUser("task2", 5 * MINUTE),
|
|
633
|
+
makeAssistant([tc("c2")], undefined, 5 * MINUTE),
|
|
634
|
+
makeToolResult("small", 4 * MINUTE, "c2"),
|
|
635
|
+
makeUser("task3", 1 * MINUTE),
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
639
|
+
|
|
640
|
+
// Turn 1 的 toolResult 应被 condense
|
|
641
|
+
const condensed = result.messages[2] as ToolResultMessage;
|
|
642
|
+
const text = getToolResultText(condensed);
|
|
643
|
+
expect(text).toContain("[Condensed (ID: ctx-");
|
|
644
|
+
expect(result.stats.l1Condensed).toBe(1);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("L2 + compact boundary: compactionSummary 在索引 3,之前的 toolResult 不被 L2 处理", () => {
|
|
648
|
+
const store = createRecallStore();
|
|
649
|
+
const contextUsage: ContextUsage = {
|
|
650
|
+
tokens: null,
|
|
651
|
+
contextWindow: 200000,
|
|
652
|
+
percent: 0.95,
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// compactionSummary 在 index 3
|
|
656
|
+
const messages: AgentMessage[] = [
|
|
657
|
+
makeUser("t0", 30 * MINUTE), // 0
|
|
658
|
+
makeAssistant([tc("c1")], undefined, 30 * MINUTE), // 1
|
|
659
|
+
makeToolResult("pre-compact", 29 * MINUTE, "c1"), // 2 - 边界前
|
|
660
|
+
makeCompactionSummary("summary", 20 * MINUTE), // 3 - boundary
|
|
661
|
+
makeUser("t1", 15 * MINUTE), // 4
|
|
662
|
+
makeAssistant([tc("c2")], undefined, 15 * MINUTE), // 5
|
|
663
|
+
makeToolResult("post-compact", 14 * MINUTE, "c2"), // 6 - 边界后
|
|
664
|
+
makeUser("t2", 1 * MINUTE), // 7
|
|
665
|
+
];
|
|
666
|
+
|
|
667
|
+
const result = compressContext(messages, DEFAULT_CONFIG, store, contextUsage, ffState);
|
|
668
|
+
|
|
669
|
+
// 边界前的 toolResult (index 2) 不被 L2 处理
|
|
670
|
+
const preCompact = result.messages[2] as ToolResultMessage;
|
|
671
|
+
expect(getToolResultText(preCompact)).toBe("pre-compact");
|
|
672
|
+
|
|
673
|
+
// 边界后的 toolResult (index 6) 应被 L2 处理(如果在保护范围外)
|
|
674
|
+
// Turn boundaries: [0-3), [3-4), [4-7), [7-8)
|
|
675
|
+
// L2 protectRecentTurns=3 → Turn 1,2,3 被保护 → Turn 0 的不受 L2 保护
|
|
676
|
+
// index 6 在 Turn 2 内(保护范围),所以也不应被 L2 处理
|
|
677
|
+
// 需要构造一个边界后且不在保护范围内的场景
|
|
678
|
+
// 实际上 index 6 在 Turn 2 (4-7),protectRecentTurns=3 保护最后 3 轮
|
|
679
|
+
// 有 4 个 boundary → 保护 Turn 1,2,3 → Turn 0 不保护 → 但 index 2 在 Turn 0 边界前
|
|
680
|
+
// 所以实际上所有 post-boundary 的都在保护范围内
|
|
681
|
+
// L2 triggered 应该为 false(没有可 force-expire 的)
|
|
682
|
+
expect(result.stats.l2Triggered).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// ── L0 keepRecent ──
|
|
687
|
+
|
|
688
|
+
describe("L0 keepRecent", () => {
|
|
689
|
+
const ffState = createFrozenFreshState();
|
|
690
|
+
const MINUTE = 60 * 1000;
|
|
691
|
+
|
|
692
|
+
it("8 个 toolResult 全部超 30 分钟,keepRecent=5,protectRecentTurns=0 → 最新 5 个不过期,前 3 个过期", () => {
|
|
693
|
+
const store = createRecallStore();
|
|
694
|
+
const config = {
|
|
695
|
+
...DEFAULT_CONFIG,
|
|
696
|
+
l0: {
|
|
697
|
+
...DEFAULT_CONFIG.l0,
|
|
698
|
+
keepRecent: 5,
|
|
699
|
+
protectRecentTurns: 0,
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const messages: AgentMessage[] = [
|
|
704
|
+
makeUser("task", 60 * MINUTE),
|
|
705
|
+
makeAssistant([tc("c1")], undefined, 59 * MINUTE),
|
|
706
|
+
makeToolResult("content-1", 58 * MINUTE, "c1"),
|
|
707
|
+
makeAssistant([tc("c2")], undefined, 55 * MINUTE),
|
|
708
|
+
makeToolResult("content-2", 54 * MINUTE, "c2"),
|
|
709
|
+
makeAssistant([tc("c3")], undefined, 50 * MINUTE),
|
|
710
|
+
makeToolResult("content-3", 49 * MINUTE, "c3"),
|
|
711
|
+
makeAssistant([tc("c4")], undefined, 45 * MINUTE),
|
|
712
|
+
makeToolResult("content-4", 44 * MINUTE, "c4"),
|
|
713
|
+
makeAssistant([tc("c5")], undefined, 40 * MINUTE),
|
|
714
|
+
makeToolResult("content-5", 39 * MINUTE, "c5"),
|
|
715
|
+
makeAssistant([tc("c6")], undefined, 37 * MINUTE),
|
|
716
|
+
makeToolResult("content-6", 36 * MINUTE, "c6"),
|
|
717
|
+
makeAssistant([tc("c7")], undefined, 35 * MINUTE),
|
|
718
|
+
makeToolResult("content-7", 34 * MINUTE, "c7"),
|
|
719
|
+
makeAssistant([tc("c8")], undefined, 33 * MINUTE),
|
|
720
|
+
makeToolResult("content-8", 32 * MINUTE, "c8"),
|
|
721
|
+
makeUser("end", 0),
|
|
722
|
+
];
|
|
723
|
+
|
|
724
|
+
const result = compressContext(messages, config, store, undefined, ffState);
|
|
725
|
+
|
|
726
|
+
// 前 3 个应过期
|
|
727
|
+
expect(result.stats.l0Expired).toBe(3);
|
|
728
|
+
|
|
729
|
+
const tr1 = result.messages[2] as ToolResultMessage;
|
|
730
|
+
expect(getToolResultText(tr1)).toContain("[Tool result expired");
|
|
731
|
+
|
|
732
|
+
const tr2 = result.messages[4] as ToolResultMessage;
|
|
733
|
+
expect(getToolResultText(tr2)).toContain("[Tool result expired");
|
|
734
|
+
|
|
735
|
+
const tr3 = result.messages[6] as ToolResultMessage;
|
|
736
|
+
expect(getToolResultText(tr3)).toContain("[Tool result expired");
|
|
737
|
+
|
|
738
|
+
// 后 5 个保留原文
|
|
739
|
+
const tr4 = result.messages[8] as ToolResultMessage;
|
|
740
|
+
expect(getToolResultText(tr4)).toBe("content-4");
|
|
741
|
+
|
|
742
|
+
const tr8 = result.messages[16] as ToolResultMessage;
|
|
743
|
+
expect(getToolResultText(tr8)).toBe("content-8");
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// ── TC-2-02: Budget per-message isolation ──
|
|
748
|
+
|
|
749
|
+
describe("Budget per-message isolation (TC-2-02)", () => {
|
|
750
|
+
it("each user message group evaluated independently", () => {
|
|
751
|
+
const store = createRecallStore();
|
|
752
|
+
const ffState = createFrozenFreshState();
|
|
753
|
+
const budgetConfig = {
|
|
754
|
+
enabled: true,
|
|
755
|
+
maxToolResultCharsPerMessage: 200_000,
|
|
756
|
+
previewSize: 2000,
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// Group 1: 3x 20K = 60K (under budget)
|
|
760
|
+
const small = "a".repeat(20_000);
|
|
761
|
+
// Group 2: 3x 20K = 60K (under budget)
|
|
762
|
+
const messages: AgentMessage[] = [
|
|
763
|
+
makeUser("task1"),
|
|
764
|
+
makeAssistant([tc("c1"), tc("c2"), tc("c3")]),
|
|
765
|
+
makeToolResult(small, 0, "c1"),
|
|
766
|
+
makeToolResult(small, 0, "c2"),
|
|
767
|
+
makeToolResult(small, 0, "c3"),
|
|
768
|
+
makeUser("task2"),
|
|
769
|
+
makeAssistant([tc("c4"), tc("c5"), tc("c6")]),
|
|
770
|
+
makeToolResult(small, 0, "c4"),
|
|
771
|
+
makeToolResult(small, 0, "c5"),
|
|
772
|
+
makeToolResult(small, 0, "c6"),
|
|
773
|
+
];
|
|
774
|
+
|
|
775
|
+
const { stats } = processBudget(messages, budgetConfig, store, ffState, null);
|
|
776
|
+
expect(stats.persisted).toBe(0);
|
|
777
|
+
expect(store.size()).toBe(0);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// ── TC-3-01: Frozen keeps same replacement across turns ──
|
|
782
|
+
|
|
783
|
+
describe("Frozen replacement across turns (TC-3-01)", () => {
|
|
784
|
+
it("frozen toolResult uses identical replacement in Turn 2", () => {
|
|
785
|
+
const store = createRecallStore();
|
|
786
|
+
const ffState = createFrozenFreshState();
|
|
787
|
+
const budgetConfig = {
|
|
788
|
+
enabled: true,
|
|
789
|
+
maxToolResultCharsPerMessage: 100_000,
|
|
790
|
+
previewSize: 100,
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const big = "X".repeat(150_000);
|
|
794
|
+
|
|
795
|
+
// Turn 1: big toolResult → persisted, frozen
|
|
796
|
+
const turn1: AgentMessage[] = [
|
|
797
|
+
makeUser("task1"),
|
|
798
|
+
makeAssistant([tc("c1")]),
|
|
799
|
+
makeToolResult(big, 0, "c1"),
|
|
800
|
+
];
|
|
801
|
+
const r1 = processBudget(turn1, budgetConfig, store, ffState, null);
|
|
802
|
+
expect(r1.stats.persisted).toBe(1);
|
|
803
|
+
const text1 = getToolResultText(r1.messages[2] as ToolResultMessage);
|
|
804
|
+
expect(text1).toContain("[Persisted output");
|
|
805
|
+
|
|
806
|
+
// Turn 2: same toolResult present → should use frozen replacement
|
|
807
|
+
const turn2: AgentMessage[] = [
|
|
808
|
+
...r1.messages,
|
|
809
|
+
makeUser("task2"),
|
|
810
|
+
makeAssistant([tc("c2")]),
|
|
811
|
+
makeToolResult("small", 0, "c2"),
|
|
812
|
+
];
|
|
813
|
+
const r2 = processBudget(turn2, budgetConfig, store, ffState, null);
|
|
814
|
+
const text2 = getToolResultText(r2.messages[2] as ToolResultMessage);
|
|
815
|
+
// frozen replacement 应该和 turn 1 完全相同
|
|
816
|
+
expect(text2).toBe(text1);
|
|
817
|
+
expect(ffState.isFrozen("c1")).toBe(true);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// ── TC-3-02: Fresh toolResult evaluated normally ──
|
|
822
|
+
|
|
823
|
+
describe("Fresh evaluation (TC-3-02)", () => {
|
|
824
|
+
it("new toolResult not in frozen set is evaluated by budget logic", () => {
|
|
825
|
+
const store = createRecallStore();
|
|
826
|
+
const ffState = createFrozenFreshState();
|
|
827
|
+
const budgetConfig = {
|
|
828
|
+
enabled: true,
|
|
829
|
+
maxToolResultCharsPerMessage: 100_000,
|
|
830
|
+
previewSize: 100,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const big = "Y".repeat(150_000);
|
|
834
|
+
|
|
835
|
+
// 只有一个 fresh toolResult,超预算
|
|
836
|
+
const messages: AgentMessage[] = [
|
|
837
|
+
makeUser("task"),
|
|
838
|
+
makeAssistant([tc("c-new")]),
|
|
839
|
+
makeToolResult(big, 0, "c-new"),
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
const { stats } = processBudget(messages, budgetConfig, store, ffState, null);
|
|
843
|
+
expect(stats.persisted).toBe(1);
|
|
844
|
+
expect(ffState.isFrozen("c-new")).toBe(true);
|
|
845
|
+
expect(store.size()).toBe(1);
|
|
846
|
+
// recall store 有一个条目(budget 持久化的)
|
|
847
|
+
expect(store.size()).toBe(1);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ── TC-9-01: Full pipeline order ──
|
|
852
|
+
|
|
853
|
+
describe("Full pipeline order (TC-9-01)", () => {
|
|
854
|
+
it("MC → Budget → L0 → L1 → L2 executes in correct order", () => {
|
|
855
|
+
const store = createRecallStore();
|
|
856
|
+
const ffState = createFrozenFreshState();
|
|
857
|
+
const MINUTE = 60 * 1000;
|
|
858
|
+
|
|
859
|
+
const config = {
|
|
860
|
+
...DEFAULT_CONFIG,
|
|
861
|
+
enabled: true,
|
|
862
|
+
mc: { enabled: true, gapThresholdMinutes: 60, keepRecent: 5 },
|
|
863
|
+
budget: { enabled: true, maxToolResultCharsPerMessage: 200_000, previewSize: 2000 },
|
|
864
|
+
l0: { enabled: true, expireMinutes: 30, bashTruncateChars: 4000, thinkingExpireMinutes: 5, protectRecentTurns: 2, keepRecent: 5 },
|
|
865
|
+
l1: { enabled: true, summaryThresholdChars: 8000, keepHeadLines: 10, keepTailLines: 5, protectRecentTurns: 2 },
|
|
866
|
+
l2: { enabled: true, emergencyThreshold: 0.9, protectRecentTurns: 1 },
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const big = "Z".repeat(12_000);
|
|
870
|
+
const expired = "old-" + "x".repeat(100);
|
|
871
|
+
|
|
872
|
+
// 构造 4+ turns,让最老的 toolResult 在 protectRecentTurns 外
|
|
873
|
+
const messages: AgentMessage[] = [
|
|
874
|
+
makeUser("task1", 120 * MINUTE),
|
|
875
|
+
makeAssistant([tc("c1")], undefined, 119 * MINUTE),
|
|
876
|
+
makeToolResult(expired, 118 * MINUTE, "c1"), // Turn 1 — L0: expired, outside protected
|
|
877
|
+
makeUser("task2", 100 * MINUTE),
|
|
878
|
+
makeAssistant([tc("c2")], undefined, 25 * MINUTE),
|
|
879
|
+
makeToolResult(big, 24 * MINUTE, "c2"), // Turn 2 — L1: 12K > 8K, NOT expired (<30min), outside protected turns
|
|
880
|
+
makeUser("task3", 50 * MINUTE),
|
|
881
|
+
makeAssistant([tc("c3"), tc("c4")], undefined, 45 * MINUTE),
|
|
882
|
+
makeToolResult("compactable-1", 44 * MINUTE, "c3"), // Turn 3 — MC candidate
|
|
883
|
+
makeToolResult("compactable-2", 43 * MINUTE, "c4"), // Turn 3
|
|
884
|
+
makeUser("task4", 5 * MINUTE),
|
|
885
|
+
makeAssistant([tc("c5"), tc("c6"), tc("c7")], undefined, 4 * MINUTE),
|
|
886
|
+
makeToolResult("recent-1", 3 * MINUTE, "c5"), // Turn 4 — MC keepRecent
|
|
887
|
+
makeToolResult("recent-2", 2 * MINUTE, "c6"), // Turn 4
|
|
888
|
+
makeToolResult("recent-3", 1 * MINUTE, "c7"), // Turn 4
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
const contextUsage: ContextUsage = { percent: 0.95, usedTokens: 190000, totalTokens: 200000 };
|
|
892
|
+
const result = compressContext(messages, config, store, contextUsage, ffState);
|
|
893
|
+
|
|
894
|
+
// MC: triggered because last assistant 5min ago < 60min gap → NOT triggered
|
|
895
|
+
// Wait, last assistant is at 5min, now - 5min < 60min, so MC should NOT trigger
|
|
896
|
+
// Let's verify stats reflect pipeline
|
|
897
|
+
expect(result.stats).toBeDefined();
|
|
898
|
+
|
|
899
|
+
// L0: expired toolResult at index 2 should be expired
|
|
900
|
+
expect(result.stats.l0Expired).toBeGreaterThanOrEqual(1);
|
|
901
|
+
|
|
902
|
+
// L1: big (12K) at index 4, outside protected 2 turns → condensed
|
|
903
|
+
expect(result.stats.l1Condensed).toBeGreaterThanOrEqual(1);
|
|
904
|
+
|
|
905
|
+
// L2: 95% > 90% → should trigger
|
|
906
|
+
expect(result.stats.l2Triggered).toBe(true);
|
|
907
|
+
|
|
908
|
+
// Validation should pass
|
|
909
|
+
expect(result.stats.validationFailed).toBe(false);
|
|
910
|
+
});
|
|
911
|
+
});
|