@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.
@@ -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
+ });