@monotykamary/pi-vcc 0.3.16 → 0.4.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/README.md +5 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/core/build-sections.ts +12 -131
- package/src/core/format.ts +4 -14
- package/src/core/summarize.ts +98 -22
- package/src/hooks/before-compact.ts +16 -28
- package/src/sections.ts +0 -4
- package/stress-debug.ts +202 -0
- package/stress-degradation-curve.ts +251 -0
- package/tests/breadcrumbs.test.ts +312 -0
- package/tests/deep-extraction.test.ts +28 -19
- package/tests/model-threshold-hook.test.ts +60 -126
- package/tests/stress-100-compactions.test.ts +373 -0
- package/tests/threshold-integration.test.ts +26 -50
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stress test: 100 successive compactions measuring information degradation.
|
|
3
|
+
*
|
|
4
|
+
* Each compaction cycle:
|
|
5
|
+
* 1. Adds a fresh "round" of messages (user request → edits → reads → response)
|
|
6
|
+
* 2. Compiles with the previous summary as previousSummary
|
|
7
|
+
* 3. Measures what survived and what was lost
|
|
8
|
+
*
|
|
9
|
+
* Degradation metrics per round:
|
|
10
|
+
* - Output size (chars, lines)
|
|
11
|
+
* - Section counts (how many of the 7 header sections present)
|
|
12
|
+
* - Files known (paths in Files And Changes)
|
|
13
|
+
* - Breadcrumbs present (count of - ...recall: lines and +recall: markers)
|
|
14
|
+
* - Goals known (Session Goal lines)
|
|
15
|
+
* - Type catalog entries
|
|
16
|
+
* - Brief transcript size (lines after ---)
|
|
17
|
+
* - Recall recoverability: vcc_recall search for a given file/goal term
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from "bun:test";
|
|
21
|
+
import { compile } from "../src/core/summarize";
|
|
22
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
23
|
+
|
|
24
|
+
// Deterministic message factories
|
|
25
|
+
|
|
26
|
+
const makeUserMsg = (text: string): Message => ({
|
|
27
|
+
role: "user",
|
|
28
|
+
content: text,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const makeAssistantMsg = (text: string): Message => ({
|
|
32
|
+
role: "assistant",
|
|
33
|
+
content: text,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const makeToolCall = (
|
|
37
|
+
name: string,
|
|
38
|
+
id: string,
|
|
39
|
+
args: Record<string, unknown>,
|
|
40
|
+
): Message => ({
|
|
41
|
+
role: "assistant",
|
|
42
|
+
content: [
|
|
43
|
+
{ type: "text", text: "" },
|
|
44
|
+
{ type: "toolCall", name, id, arguments: args },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const makeToolResult = (
|
|
49
|
+
toolCallId: string,
|
|
50
|
+
toolName: string,
|
|
51
|
+
content: string,
|
|
52
|
+
isError = false,
|
|
53
|
+
): Message => ({
|
|
54
|
+
role: "toolResult",
|
|
55
|
+
toolCallId,
|
|
56
|
+
toolName,
|
|
57
|
+
content,
|
|
58
|
+
isError,
|
|
59
|
+
} as any);
|
|
60
|
+
|
|
61
|
+
// Each "round" simulates a realistic compaction-worthy conversation chunk.
|
|
62
|
+
// We vary the files and goals to create accumulation pressure.
|
|
63
|
+
|
|
64
|
+
const FILES = [
|
|
65
|
+
"src/auth.ts", "src/users.ts", "src/api.ts", "src/db.ts",
|
|
66
|
+
"src/utils.ts", "src/config.ts", "src/routes.ts", "src/middleware.ts",
|
|
67
|
+
"src/models.ts", "src/services.ts", "src/handlers.ts", "src/types.ts",
|
|
68
|
+
"src/errors.ts", "src/validators.ts", "src/cache.ts", "src/logger.ts",
|
|
69
|
+
"src/session.ts", "src/token.ts", "src/oauth.ts", "src/crypto.ts",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const GOALS = [
|
|
73
|
+
"Build an authentication system",
|
|
74
|
+
"Add user management endpoints",
|
|
75
|
+
"Create the REST API layer",
|
|
76
|
+
"Set up the database connection pool",
|
|
77
|
+
"Implement utility functions",
|
|
78
|
+
"Load configuration from environment",
|
|
79
|
+
"Define route handlers",
|
|
80
|
+
"Add authentication middleware",
|
|
81
|
+
"Create data models",
|
|
82
|
+
"Build the service layer",
|
|
83
|
+
"Implement request handlers",
|
|
84
|
+
"Add TypeScript type definitions",
|
|
85
|
+
"Create error handling utilities",
|
|
86
|
+
"Build input validators",
|
|
87
|
+
"Add caching layer",
|
|
88
|
+
"Set up structured logging",
|
|
89
|
+
"Implement session management",
|
|
90
|
+
"Add JWT token handling",
|
|
91
|
+
"Build OAuth2 integration",
|
|
92
|
+
"Add cryptographic utilities",
|
|
93
|
+
"Refactor auth to support SSO",
|
|
94
|
+
"Add rate limiting to API",
|
|
95
|
+
"Migrate database to PostgreSQL",
|
|
96
|
+
"Add integration tests",
|
|
97
|
+
"Set up CI pipeline",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** Generate messages for one "round" of work */
|
|
101
|
+
const makeRound = (roundIdx: number): Message[] => {
|
|
102
|
+
const file1 = FILES[roundIdx % FILES.length];
|
|
103
|
+
const file2 = FILES[(roundIdx + 7) % FILES.length];
|
|
104
|
+
const goal = GOALS[roundIdx % GOALS.length];
|
|
105
|
+
const id1 = `r${roundIdx}-1`;
|
|
106
|
+
const id2 = `r${roundIdx}-2`;
|
|
107
|
+
const id3 = `r${roundIdx}-3`;
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
makeUserMsg(goal),
|
|
111
|
+
makeToolCall("Edit", id1, {
|
|
112
|
+
file_path: file1,
|
|
113
|
+
oldText: "// placeholder",
|
|
114
|
+
newText: `export function fn${roundIdx}() { return ${roundIdx}; }`,
|
|
115
|
+
}),
|
|
116
|
+
makeToolResult(id1, "Edit", "OK"),
|
|
117
|
+
makeAssistantMsg(`Edited ${file1} with function fn${roundIdx}.`),
|
|
118
|
+
makeToolCall("read", id2, { file_path: file2 }),
|
|
119
|
+
makeToolResult(id2, "read", `// ${file2}\nexport const DATA = ${roundIdx};`),
|
|
120
|
+
makeAssistantMsg(`Read ${file2} to understand the interface.`),
|
|
121
|
+
// Sometimes add a commit
|
|
122
|
+
...(roundIdx % 3 === 0
|
|
123
|
+
? [
|
|
124
|
+
makeToolCall("bash", id3, { command: `git commit -m "feat: ${goal.toLowerCase()}"` }),
|
|
125
|
+
makeToolResult(id3, "bash", `[main abc${roundIdx.toString().padStart(4, "0")}] feat: ${goal.toLowerCase()}`),
|
|
126
|
+
makeAssistantMsg(`Committed: feat: ${goal.toLowerCase()}.`),
|
|
127
|
+
]
|
|
128
|
+
: []),
|
|
129
|
+
];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Metrics extraction
|
|
133
|
+
|
|
134
|
+
const countBreadcrumbs = (text: string): number => {
|
|
135
|
+
let count = 0;
|
|
136
|
+
// - ...recall: lines in header sections
|
|
137
|
+
for (const line of text.split("\n")) {
|
|
138
|
+
if (line.startsWith("- ...recall:")) count++;
|
|
139
|
+
}
|
|
140
|
+
// +recall: markers in Files And Changes
|
|
141
|
+
const plusRecall = text.match(/\+recall:/g);
|
|
142
|
+
count += plusRecall ? plusRecall.length : 0;
|
|
143
|
+
return count;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const extractFilePaths = (text: string): Set<string> => {
|
|
147
|
+
const paths = new Set<string>();
|
|
148
|
+
for (const line of text.split("\n")) {
|
|
149
|
+
const match = line.match(/^-\s*(?:Modified|Created|Read):\s*(.*)/);
|
|
150
|
+
if (!match) continue;
|
|
151
|
+
const rest = match[1]
|
|
152
|
+
.replace(/\+recall:\s*/g, "")
|
|
153
|
+
.replace(/\s*\([^)]*\)/g, "");
|
|
154
|
+
for (const p of rest.split(",")) {
|
|
155
|
+
const trimmed = p.trim();
|
|
156
|
+
if (trimmed && !trimmed.startsWith("+")) paths.add(trimmed);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return paths;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const extractGoals = (text: string): string[] => {
|
|
163
|
+
const section = text.indexOf("[Session Goal]");
|
|
164
|
+
if (section < 0) return [];
|
|
165
|
+
const after = text.slice(section);
|
|
166
|
+
const nextSection = after.indexOf("\n[", 1);
|
|
167
|
+
const block = (nextSection > 0 ? after.slice(0, nextSection) : after);
|
|
168
|
+
return block.split("\n")
|
|
169
|
+
.filter(l => l.startsWith("- ") && !l.startsWith("- ...recall:"))
|
|
170
|
+
.map(l => l.slice(2));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const countTypeCatalogEntries = (text: string): number => {
|
|
174
|
+
const section = text.indexOf("[Type Catalog]");
|
|
175
|
+
if (section < 0) return 0;
|
|
176
|
+
const after = text.slice(section);
|
|
177
|
+
const nextSection = after.indexOf("\n\n[", 1);
|
|
178
|
+
const nextSep = after.indexOf("\n\n---\n\n", 1);
|
|
179
|
+
const end = Math.min(
|
|
180
|
+
nextSection > 0 ? nextSection : Infinity,
|
|
181
|
+
nextSep > 0 ? nextSep : Infinity,
|
|
182
|
+
);
|
|
183
|
+
const block = after.slice(0, end);
|
|
184
|
+
return block.split("\n").filter(l => l.match(/^\s*src\//) || l.match(/^\s*\S+\.\w{1,12}:$/)).length;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const countSections = (text: string): number => {
|
|
188
|
+
const matches = text.match(/^\[.+\]/gm);
|
|
189
|
+
return matches ? matches.length : 0;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const briefTranscriptSize = (text: string): { lines: number; chars: number } => {
|
|
193
|
+
const idx = text.indexOf("\n\n---\n\n");
|
|
194
|
+
if (idx < 0) return { lines: 0, chars: 0 };
|
|
195
|
+
const brief = text.slice(idx + 6);
|
|
196
|
+
return { lines: brief.split("\n").filter(Boolean).length, chars: brief.length };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const previousSummaryChars = (text: string): number => text.length;
|
|
200
|
+
|
|
201
|
+
/** Check if a file path is recoverable via breadcrumb or section content */
|
|
202
|
+
const isFileRecoverable = (text: string, filePath: string): boolean => {
|
|
203
|
+
if (text.includes(filePath)) return true;
|
|
204
|
+
// Check breadcrumbs for partial path match
|
|
205
|
+
const shortName = filePath.split("/").pop() ?? filePath;
|
|
206
|
+
for (const line of text.split("\n")) {
|
|
207
|
+
if (line.includes("+recall:") || line.startsWith("- ...recall:")) {
|
|
208
|
+
if (line.includes(shortName)) return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Check if a goal is recoverable via breadcrumb or section content */
|
|
215
|
+
const isGoalRecoverable = (text: string, goal: string): boolean => {
|
|
216
|
+
if (text.includes(goal)) return true;
|
|
217
|
+
// Check breadcrumbs for keywords from the goal
|
|
218
|
+
const keywords = goal.split(/\s+/).filter(w => w.length > 3).slice(0, 3);
|
|
219
|
+
for (const line of text.split("\n")) {
|
|
220
|
+
if (line.startsWith("- ...recall:")) {
|
|
221
|
+
const allPresent = keywords.every(kw => line.toLowerCase().includes(kw.toLowerCase()));
|
|
222
|
+
if (allPresent) return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
describe("stress: 100 compactions", () => {
|
|
229
|
+
test("100 successive compactions with degradation metrics", () => {
|
|
230
|
+
const TOTAL_ROUNDS = 100;
|
|
231
|
+
const previousSummary: string[] = [""];
|
|
232
|
+
const metrics: Array<{
|
|
233
|
+
round: number;
|
|
234
|
+
outputChars: number;
|
|
235
|
+
outputLines: number;
|
|
236
|
+
sections: number;
|
|
237
|
+
breadcrumbs: number;
|
|
238
|
+
filesKnown: number;
|
|
239
|
+
goalsKnown: number;
|
|
240
|
+
typeCatalogEntries: number;
|
|
241
|
+
briefLines: number;
|
|
242
|
+
briefChars: number;
|
|
243
|
+
// Cumulative tracking
|
|
244
|
+
totalFilesTouched: number;
|
|
245
|
+
totalGoalsSet: number;
|
|
246
|
+
fileRecallRate: number;
|
|
247
|
+
goalRecallRate: number;
|
|
248
|
+
}> = [];
|
|
249
|
+
|
|
250
|
+
const allFilesEver = new Set<string>();
|
|
251
|
+
const allGoalsEver: string[] = [];
|
|
252
|
+
|
|
253
|
+
for (let round = 0; round < TOTAL_ROUNDS; round++) {
|
|
254
|
+
const messages = makeRound(round);
|
|
255
|
+
|
|
256
|
+
// Track what was introduced this round
|
|
257
|
+
const goal = GOALS[round % GOALS.length];
|
|
258
|
+
allGoalsEver.push(goal);
|
|
259
|
+
allFilesEver.add(FILES[round % FILES.length]);
|
|
260
|
+
allFilesEver.add(FILES[(round + 7) % FILES.length]);
|
|
261
|
+
|
|
262
|
+
const result = compile({
|
|
263
|
+
messages,
|
|
264
|
+
previousSummary: previousSummary[0] || undefined,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
previousSummary[0] = result;
|
|
268
|
+
|
|
269
|
+
// Extract metrics
|
|
270
|
+
const filePaths = extractFilePaths(result);
|
|
271
|
+
const goals = extractGoals(result);
|
|
272
|
+
const bc = countBreadcrumbs(result);
|
|
273
|
+
const tcEntries = countTypeCatalogEntries(result);
|
|
274
|
+
const sections = countSections(result);
|
|
275
|
+
const brief = briefTranscriptSize(result);
|
|
276
|
+
|
|
277
|
+
// Recall rates: how many of all-ever files/goals can be found?
|
|
278
|
+
let filesRecoverable = 0;
|
|
279
|
+
for (const f of allFilesEver) {
|
|
280
|
+
if (isFileRecoverable(result, f)) filesRecoverable++;
|
|
281
|
+
}
|
|
282
|
+
let goalsRecoverable = 0;
|
|
283
|
+
for (const g of allGoalsEver) {
|
|
284
|
+
if (isGoalRecoverable(result, g)) goalsRecoverable++;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
metrics.push({
|
|
288
|
+
round: round + 1,
|
|
289
|
+
outputChars: result.length,
|
|
290
|
+
outputLines: result.split("\n").length,
|
|
291
|
+
sections,
|
|
292
|
+
breadcrumbs: bc,
|
|
293
|
+
filesKnown: filePaths.size,
|
|
294
|
+
goalsKnown: goals.length,
|
|
295
|
+
typeCatalogEntries: tcEntries,
|
|
296
|
+
briefLines: brief.lines,
|
|
297
|
+
briefChars: brief.chars,
|
|
298
|
+
totalFilesTouched: allFilesEver.size,
|
|
299
|
+
totalGoalsSet: allGoalsEver.length,
|
|
300
|
+
fileRecallRate: allFilesEver.size > 0 ? filesRecoverable / allFilesEver.size : 1,
|
|
301
|
+
goalRecallRate: allGoalsEver.length > 0 ? goalsRecoverable / allGoalsEver.length : 1,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Assertions on degradation
|
|
306
|
+
|
|
307
|
+
// 1. Output should never be empty
|
|
308
|
+
for (const m of metrics) {
|
|
309
|
+
expect(m.outputChars).toBeGreaterThan(0);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 2. Sections should stabilize (at least Session Goal + Files + brief)
|
|
313
|
+
const lateMetrics = metrics.slice(-20);
|
|
314
|
+
for (const m of lateMetrics) {
|
|
315
|
+
expect(m.sections).toBeGreaterThanOrEqual(2);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 3. File recall rate should not collapse to 0
|
|
319
|
+
// With +recall: breadcrumbs, even capped files should be partially recoverable
|
|
320
|
+
const finalMetric = metrics[metrics.length - 1];
|
|
321
|
+
expect(finalMetric.fileRecallRate).toBeGreaterThan(0);
|
|
322
|
+
|
|
323
|
+
// 4. Goal recall should not collapse to 0
|
|
324
|
+
expect(finalMetric.goalRecallRate).toBeGreaterThan(0);
|
|
325
|
+
|
|
326
|
+
// 5. Output size should be bounded — not growing unboundedly
|
|
327
|
+
// The output should plateau, not grow linearly
|
|
328
|
+
const charGrowthRate = (finalMetric.outputChars - metrics[49].outputChars) / 50;
|
|
329
|
+
const firstHalfGrowthRate = (metrics[49].outputChars - metrics[0].outputChars) / 50;
|
|
330
|
+
// Second half growth rate should be <= first half (plateauing)
|
|
331
|
+
expect(charGrowthRate).toBeLessThanOrEqual(firstHalfGrowthRate * 1.5);
|
|
332
|
+
|
|
333
|
+
// 6. Breadcrumbs should appear by round 20+ (sections start capping)
|
|
334
|
+
const breadcrumbsBy20 = metrics[19].breadcrumbs;
|
|
335
|
+
expect(breadcrumbsBy20).toBeGreaterThanOrEqual(0); // might be 0 if no section capped yet
|
|
336
|
+
|
|
337
|
+
// 7. By round 50, breadcrumbs should definitely exist
|
|
338
|
+
const breadcrumbsBy50 = metrics[49].breadcrumbs;
|
|
339
|
+
expect(breadcrumbsBy50).toBeGreaterThan(0);
|
|
340
|
+
|
|
341
|
+
// Print summary table for manual inspection
|
|
342
|
+
const sampleRounds = [1, 10, 25, 50, 75, 100];
|
|
343
|
+
console.log("\n╔══════════════════════════════════════════════════════════════════════════════════════════╗");
|
|
344
|
+
console.log("║ 100-COMPACTION STRESS TEST — DEGRADATION METRICS ║");
|
|
345
|
+
console.log("╠═══════╦═════════╦═════════╦═══════════╦═══════════╦══════════╦══════════╦══════════════╣");
|
|
346
|
+
console.log("║ Round ║ Chars ║ Lines ║ Sections ║ Breadcrumbs║ Files ║ Goals ║ Recall Rate ║");
|
|
347
|
+
console.log("║ ║ ║ ║ ║ ║ Known ║ Known ║ F / G ║");
|
|
348
|
+
console.log("╠═══════╬═════════╬═════════╬═══════════╬═══════════╬══════════╬══════════╬══════════════╣");
|
|
349
|
+
|
|
350
|
+
for (const r of sampleRounds) {
|
|
351
|
+
const m = metrics[r - 1];
|
|
352
|
+
const fileRate = `${(m.fileRecallRate * 100).toFixed(0)}%`;
|
|
353
|
+
const goalRate = `${(m.goalRecallRate * 100).toFixed(0)}%`;
|
|
354
|
+
console.log(
|
|
355
|
+
`║ ${String(r).padStart(5)} ║ ${String(m.outputChars).padStart(7)} ║ ${String(m.outputLines).padStart(7)} ║ ${String(m.sections).padStart(9)} ║ ${String(m.breadcrumbs).padStart(9)} ║ ${String(m.filesKnown).padStart(7)}/${String(m.totalFilesTouched).padStart(2)} ║ ${String(m.goalsKnown).padStart(7)}/${String(m.totalGoalsSet).padStart(3)} ║ ${fileRate.padStart(4)} / ${goalRate.padStart(4)} ║`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.log("╚═══════╩═════════╩═════════╩═══════════╩═══════════╩══════════╩══════════╩══════════════╝");
|
|
360
|
+
|
|
361
|
+
// Brief transcript degradation
|
|
362
|
+
console.log("\nBrief transcript size over time:");
|
|
363
|
+
for (const r of sampleRounds) {
|
|
364
|
+
const m = metrics[r - 1];
|
|
365
|
+
console.log(` Round ${String(r).padStart(3)}: ${m.briefLines} lines, ${m.briefChars} chars`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Final state check
|
|
369
|
+
console.log(`\nFinal output (${finalMetric.outputChars} chars, ${finalMetric.outputLines} lines):`);
|
|
370
|
+
console.log(previousSummary[0].slice(0, 800));
|
|
371
|
+
console.log("...");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* - When proactive trigger calls ctx.compact(), session_before_compact
|
|
6
6
|
* must NOT cancel the compaction even if tokensBefore differs from
|
|
7
7
|
* getContextUsage().
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - The per-model threshold guard was removed from session_before_compact
|
|
9
|
+
* because the event carries no "reason" field — manual /compact and
|
|
10
|
+
* auto-compaction are indistinguishable. The proactive trigger handles
|
|
11
|
+
* the "compact earlier than pi-core" direction.
|
|
11
12
|
*/
|
|
12
13
|
import { describe, test, expect, afterEach, beforeAll, afterAll } from "bun:test";
|
|
13
14
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from "fs";
|
|
@@ -100,11 +101,6 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
test("proactive trigger then before-compact: does NOT cancel when proactiveTriggerActive", () => {
|
|
103
|
-
// Scenario: agent_end fires, getContextUsage says 110k tokens,
|
|
104
|
-
// per-model threshold is 95232 (128k - 32768). Proactive triggers compact.
|
|
105
|
-
// Then session_before_compact fires with tokensBefore = 94000
|
|
106
|
-
// (slightly different due to estimation).
|
|
107
|
-
// Without the proactiveTriggerActive guard, this would be cancelled.
|
|
108
104
|
setConfig({
|
|
109
105
|
debug: false,
|
|
110
106
|
overrideDefaultCompaction: true,
|
|
@@ -119,13 +115,9 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
119
115
|
registerProactiveThresholdHook(pi);
|
|
120
116
|
registerBeforeCompactHook(pi);
|
|
121
117
|
|
|
122
|
-
// Step 1: agent_end fires → proactive trigger calls ctx.compact()
|
|
123
118
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
124
119
|
expect(compactCalls).toHaveLength(1);
|
|
125
120
|
|
|
126
|
-
// Step 2: session_before_compact fires with tokensBefore = 94000
|
|
127
|
-
// (below the 95232 threshold — but we should NOT cancel because
|
|
128
|
-
// we ourselves triggered this compaction)
|
|
129
121
|
const entries = [
|
|
130
122
|
msg("m1", "user", "hello"),
|
|
131
123
|
msg("m2", "assistant", "hi"),
|
|
@@ -136,14 +128,14 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
136
128
|
|
|
137
129
|
// Should NOT cancel — proactiveTriggerActive is true
|
|
138
130
|
expect(result?.cancel).toBeUndefined();
|
|
139
|
-
// Should proceed with compaction (pi-vcc's own summary)
|
|
140
131
|
expect(result?.compaction).toBeDefined();
|
|
141
132
|
});
|
|
142
133
|
|
|
143
|
-
test("global trigger + before-compact:
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
134
|
+
test("global trigger + before-compact: compaction proceeds (threshold guard removed)", () => {
|
|
135
|
+
// Previously: pi-core's global threshold triggers compaction below the
|
|
136
|
+
// per-model threshold → cancelled. Now: the threshold guard is removed
|
|
137
|
+
// because manual /compact can't be distinguished from auto-compaction,
|
|
138
|
+
// so compaction proceeds.
|
|
147
139
|
setConfig({
|
|
148
140
|
debug: false,
|
|
149
141
|
overrideDefaultCompaction: true,
|
|
@@ -151,24 +143,23 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
151
143
|
"neuralwatt/GLM-5.1": { reserveTokens: 32768 },
|
|
152
144
|
},
|
|
153
145
|
});
|
|
154
|
-
const { pi, ctx, emit
|
|
146
|
+
const { pi, ctx, emit } = createMockPi(
|
|
155
147
|
{ id: "GLM-5.1", provider: "neuralwatt", contextWindow: 128000 },
|
|
156
|
-
// getContextUsage says 80k — well below per-model threshold
|
|
157
148
|
{ tokens: 80000, contextWindow: 128000, percent: 63 },
|
|
158
149
|
);
|
|
159
150
|
registerProactiveThresholdHook(pi);
|
|
160
151
|
registerBeforeCompactHook(pi);
|
|
161
152
|
|
|
162
|
-
// No agent_end fired (global threshold triggered this)
|
|
163
153
|
const entries = [
|
|
164
154
|
msg("m1", "user", "hello"),
|
|
165
155
|
msg("m2", "assistant", "hi"),
|
|
166
156
|
msg("m3", "user", "do work"),
|
|
167
157
|
msg("m4", "assistant", "done"),
|
|
168
158
|
];
|
|
169
|
-
// tokensBefore = 90k < 95232 — should cancel
|
|
170
159
|
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, undefined, 90000));
|
|
171
|
-
|
|
160
|
+
// No longer cancelled — threshold guard removed
|
|
161
|
+
expect(result?.cancel).toBeUndefined();
|
|
162
|
+
expect(result?.compaction).toBeDefined();
|
|
172
163
|
});
|
|
173
164
|
|
|
174
165
|
test("proactiveTriggerActive is cleared after session_compact", () => {
|
|
@@ -186,16 +177,13 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
186
177
|
registerProactiveThresholdHook(pi);
|
|
187
178
|
registerBeforeCompactHook(pi);
|
|
188
179
|
|
|
189
|
-
// Step 1: proactive trigger
|
|
190
180
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
191
181
|
expect(compactCalls).toHaveLength(1);
|
|
192
182
|
|
|
193
|
-
// Step 2: compaction completes
|
|
194
183
|
emit("session_compact", { type: "session_compact", compactionEntry: {} });
|
|
195
184
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// because proactiveTriggerActive was cleared by session_compact
|
|
185
|
+
// After session_compact, proactiveTriggerActive is cleared.
|
|
186
|
+
// But the threshold guard is also removed, so compaction proceeds.
|
|
199
187
|
const entries = [
|
|
200
188
|
msg("m1", "user", "hello"),
|
|
201
189
|
msg("m2", "assistant", "hi"),
|
|
@@ -203,10 +191,11 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
203
191
|
msg("m4", "assistant", "done"),
|
|
204
192
|
];
|
|
205
193
|
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, undefined, 90000));
|
|
206
|
-
expect(result).
|
|
194
|
+
expect(result?.cancel).toBeUndefined();
|
|
195
|
+
expect(result?.compaction).toBeDefined();
|
|
207
196
|
});
|
|
208
197
|
|
|
209
|
-
test("explicit /pi-vcc
|
|
198
|
+
test("explicit /pi-vcc proceeds regardless of context level", () => {
|
|
210
199
|
setConfig({
|
|
211
200
|
debug: false,
|
|
212
201
|
overrideDefaultCompaction: true,
|
|
@@ -216,19 +205,18 @@ describe("integration: proactive trigger + before-compact", () => {
|
|
|
216
205
|
});
|
|
217
206
|
const { pi, ctx, emit } = createMockPi(
|
|
218
207
|
{ id: "GLM-5.1", provider: "neuralwatt", contextWindow: 128000 },
|
|
219
|
-
{ tokens:
|
|
208
|
+
{ tokens: 5000, contextWindow: 128000, percent: 4 },
|
|
220
209
|
);
|
|
221
210
|
registerProactiveThresholdHook(pi);
|
|
222
211
|
registerBeforeCompactHook(pi);
|
|
223
212
|
|
|
224
|
-
// Direct /pi-vcc with tokens well below threshold — should still proceed
|
|
225
213
|
const entries = [
|
|
226
214
|
msg("m1", "user", "hello"),
|
|
227
215
|
msg("m2", "assistant", "hi"),
|
|
228
216
|
msg("m3", "user", "do work"),
|
|
229
217
|
msg("m4", "assistant", "done"),
|
|
230
218
|
];
|
|
231
|
-
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, "__pi_vcc__",
|
|
219
|
+
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, "__pi_vcc__", 5000));
|
|
232
220
|
expect(result?.cancel).toBeUndefined();
|
|
233
221
|
expect(result?.compaction).toBeDefined();
|
|
234
222
|
});
|
|
@@ -241,10 +229,6 @@ describe("integration: proactive trigger + before-compact with overrideDefaultCo
|
|
|
241
229
|
});
|
|
242
230
|
|
|
243
231
|
test("proactive trigger fires, before-compact doesn't cancel, then pi-core handles summary", () => {
|
|
244
|
-
// When overrideDefaultCompaction: false, pi-vcc doesn't handle the summary
|
|
245
|
-
// for non-/pi-vcc compactions. But the threshold guard and proactive trigger
|
|
246
|
-
// should still work — the threshold guard controls WHEN compaction triggers,
|
|
247
|
-
// and pi-core handles the actual summary.
|
|
248
232
|
setConfig({
|
|
249
233
|
debug: false,
|
|
250
234
|
overrideDefaultCompaction: false,
|
|
@@ -259,14 +243,9 @@ describe("integration: proactive trigger + before-compact with overrideDefaultCo
|
|
|
259
243
|
registerProactiveThresholdHook(pi);
|
|
260
244
|
registerBeforeCompactHook(pi);
|
|
261
245
|
|
|
262
|
-
// agent_end fires → proactive trigger
|
|
263
246
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
264
247
|
expect(compactCalls).toHaveLength(1);
|
|
265
248
|
|
|
266
|
-
// session_before_compact fires: threshold is crossed (tokensBefore = 110k
|
|
267
|
-
// > 95232), and our guard allows it because proactiveTriggerActive.
|
|
268
|
-
// Then overrideDefaultCompaction: false means pi-vcc returns undefined,
|
|
269
|
-
// letting pi-core handle the summary.
|
|
270
249
|
const entries = [
|
|
271
250
|
msg("m1", "user", "hello"),
|
|
272
251
|
msg("m2", "assistant", "hi"),
|
|
@@ -275,11 +254,10 @@ describe("integration: proactive trigger + before-compact with overrideDefaultCo
|
|
|
275
254
|
];
|
|
276
255
|
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, undefined, 110000));
|
|
277
256
|
// pi-vcc doesn't handle it (overrideDefaultCompaction: false) → returns undefined
|
|
278
|
-
// pi-core will handle the summary
|
|
279
257
|
expect(result).toBeUndefined();
|
|
280
258
|
});
|
|
281
259
|
|
|
282
|
-
test("
|
|
260
|
+
test("compaction proceeds even with overrideDefaultCompaction: false (threshold guard removed)", () => {
|
|
283
261
|
setConfig({
|
|
284
262
|
debug: false,
|
|
285
263
|
overrideDefaultCompaction: false,
|
|
@@ -294,15 +272,16 @@ describe("integration: proactive trigger + before-compact with overrideDefaultCo
|
|
|
294
272
|
registerProactiveThresholdHook(pi);
|
|
295
273
|
registerBeforeCompactHook(pi);
|
|
296
274
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// says the model can handle more → cancel
|
|
275
|
+
// Previously this would cancel (threshold guard). Now: compaction
|
|
276
|
+
// proceeds (threshold guard removed).
|
|
300
277
|
const entries = [
|
|
301
278
|
msg("m1", "user", "hello"),
|
|
302
279
|
msg("m2", "assistant", "hi"),
|
|
303
280
|
];
|
|
304
281
|
const result = emit("session_before_compact", makeBeforeCompactEvent(entries, undefined, 80000));
|
|
305
|
-
|
|
282
|
+
// No longer cancelled — returns undefined (pi-vcc doesn't handle it,
|
|
283
|
+
// but doesn't block it either)
|
|
284
|
+
expect(result).toBeUndefined();
|
|
306
285
|
});
|
|
307
286
|
});
|
|
308
287
|
|
|
@@ -326,11 +305,9 @@ describe("integration: cooldown prevents double compaction", () => {
|
|
|
326
305
|
);
|
|
327
306
|
registerProactiveThresholdHook(pi);
|
|
328
307
|
|
|
329
|
-
// agent_end triggers compact and sets cooldown
|
|
330
308
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
331
309
|
expect(compactCalls).toHaveLength(1);
|
|
332
310
|
|
|
333
|
-
// model_select fires on same turn — blocked by cooldown
|
|
334
311
|
emit("model_select", { type: "model_select" });
|
|
335
312
|
expect(compactCalls).toHaveLength(1);
|
|
336
313
|
});
|
|
@@ -352,7 +329,6 @@ describe("integration: cooldown prevents double compaction", () => {
|
|
|
352
329
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
353
330
|
expect(compactCalls).toHaveLength(1);
|
|
354
331
|
|
|
355
|
-
// No session_compact between — cooldown still active
|
|
356
332
|
emit("agent_end", { type: "agent_end", messages: [] });
|
|
357
333
|
expect(compactCalls).toHaveLength(1);
|
|
358
334
|
});
|