@zhushanwen/pi-smart-context 0.4.4 → 0.5.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 +1 -1
- package/package.json +6 -5
- package/skills/smart-context-ext-config/SKILL.md +6 -2
- package/src/__tests__/compact-handler.test.ts +1178 -6
- package/src/__tests__/index-compaction-signal.test.ts +207 -0
- package/src/__tests__/index-generation.test.ts +34 -39
- package/src/__tests__/index-manual-compact-command.test.ts +262 -0
- package/src/__tests__/index-reminder-delivery.test.ts +1 -1
- package/src/__tests__/notices.test.ts +194 -0
- package/src/__tests__/pure.test.ts +20 -2
- package/src/__tests__/summary.test.ts +11 -8
- package/src/__tests__/tool.test.ts +88 -79
- package/src/compact-handler.ts +947 -28
- package/src/index.ts +236 -17
- package/src/notices.ts +189 -3
- package/src/pure.ts +17 -1
- package/src/reminder.ts +4 -3
- package/src/tool.ts +85 -125
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
2
2
|
|
|
3
3
|
// mock pi SDK:接管路径的单测不触网(same-mode 输入链 + cross-mode 原生组装都替换)。
|
|
4
4
|
// importOriginal 保留真实模块(含 pure.js 依赖的 estimateTokens),其余覆盖项在后
|
|
@@ -12,18 +12,39 @@ vi.mock("../llm.js", () => ({
|
|
|
12
12
|
callSameModelCompaction: vi.fn(),
|
|
13
13
|
projectTools: (tools: unknown[]) => tools.map((t) => ({ name: (t as { name: string }).name })),
|
|
14
14
|
}));
|
|
15
|
+
// 配置加载覆写(U5 工具端到端用例经 index.ts 缺省 gatingProbe 消费本函数——真实实现读
|
|
16
|
+
// agentDir 磁盘配置,测试必须确定且不触真实数据目录):其余纯函数保持实装
|
|
17
|
+
vi.mock("../pure.js", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("../pure.js")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
loadSmartContextConfig: vi.fn(() => actual.normalizeSmartContextConfig({})),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
15
24
|
|
|
16
|
-
import { compact as nativeCompact } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { compact as nativeCompact, buildContextEntries, buildSessionProjection, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
27
|
import { callSameModelCompaction } from "../llm.js";
|
|
18
28
|
import {
|
|
29
|
+
buildBackgroundPreparation,
|
|
30
|
+
createBackgroundCompactionManager,
|
|
31
|
+
createBackgroundCompactionState,
|
|
19
32
|
createBeforeCompactHandler,
|
|
20
33
|
createTakeoverState,
|
|
34
|
+
isAutoCompactTriggered,
|
|
35
|
+
persistBackgroundCompaction,
|
|
36
|
+
validateBackgroundPersist,
|
|
37
|
+
type AfterGenerationHook,
|
|
38
|
+
type BackgroundCompactionState,
|
|
39
|
+
type BackgroundTaskSettlement,
|
|
40
|
+
type BackgroundTaskSnapshot,
|
|
21
41
|
} from "../compact-handler.js";
|
|
42
|
+
import smartContextExtension from "../index.js";
|
|
22
43
|
import { normalizeSmartContextConfig, type SmartContextConfig } from "../pure.js";
|
|
23
44
|
import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
24
45
|
|
|
25
|
-
// assistant 消息形态锁定为单 text
|
|
26
|
-
//
|
|
46
|
+
// assistant 消息形态锁定为单 text 块:必填标量字段补齐 + content 不得追加 thinking/toolCall
|
|
47
|
+
// 块——追加会抬高 shadowedTokens(收缩校验分母),使「新旧口径数值等价」断言静默失效
|
|
27
48
|
function makeEvent(overrides?: Partial<SessionBeforeCompactEvent["preparation"]>): SessionBeforeCompactEvent {
|
|
28
49
|
return {
|
|
29
50
|
type: "session_before_compact",
|
|
@@ -108,7 +129,7 @@ describe("session_before_compact 接管 handler", () => {
|
|
|
108
129
|
mockedCall.mockResolvedValue({ ok: true, text: "summary body", usage: { input: 1, output: 2 } });
|
|
109
130
|
const { handler } = makeHandler(normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "" } }));
|
|
110
131
|
const decision = await handler(makeEvent(), makeCtx());
|
|
111
|
-
// details.model = `${ctx.model.provider}/${ctx.model.id}
|
|
132
|
+
// details.model = `${ctx.model.provider}/${ctx.model.id}`(用量页 compaction 归属数据源,pi-semantics PS-28)
|
|
112
133
|
expect(decision.compaction?.details).toEqual({ engine: "smart-context", mode: "same-model", model: "zai/glm" });
|
|
113
134
|
expect(decision.compaction?.summary).toContain("summary body");
|
|
114
135
|
// D13-9 preamble 在最前;D11-2 fileOps;D13-4 transcript
|
|
@@ -151,7 +172,7 @@ describe("session_before_compact 接管 handler", () => {
|
|
|
151
172
|
normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "xiaomi/mimo" } }),
|
|
152
173
|
);
|
|
153
174
|
const decision = await handler(makeEvent(), makeCtx({ provider: "xiaomi", id: "mimo" }));
|
|
154
|
-
// details.model = `${resolveModel(...).provider}/${id}
|
|
175
|
+
// details.model = `${resolveModel(...).provider}/${id}`(用量页 compaction 归属数据源,pi-semantics PS-28);native details 字段原样保留(spread 增量,不改变其他字段)
|
|
155
176
|
expect(decision.compaction?.details).toEqual({
|
|
156
177
|
engine: "smart-context",
|
|
157
178
|
mode: "cross-model",
|
|
@@ -186,3 +207,1154 @@ describe("session_before_compact 接管 handler", () => {
|
|
|
186
207
|
expect(mockedCall).not.toHaveBeenCalled(); // 熔断后不再尝试
|
|
187
208
|
});
|
|
188
209
|
});
|
|
210
|
+
|
|
211
|
+
// ──────────────────────── U2:后台压缩任务管理(组装对照 / RL1 / 触发线) ────────────────────────
|
|
212
|
+
|
|
213
|
+
/** 大文本(每条约 7.5K tokens@chars/4):驱动 findCutPoint 在 20000 保留预算内落点可控。 */
|
|
214
|
+
const BIG = "x".repeat(30_000);
|
|
215
|
+
|
|
216
|
+
function makeUserEntry(id: string, parentId: string | null, text: string): SessionEntry {
|
|
217
|
+
return {
|
|
218
|
+
type: "message",
|
|
219
|
+
id,
|
|
220
|
+
parentId,
|
|
221
|
+
timestamp: new Date(0).toISOString(),
|
|
222
|
+
message: { role: "user", content: text, timestamp: 0 },
|
|
223
|
+
} as SessionEntry;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function makeAssistantEntry(
|
|
227
|
+
id: string,
|
|
228
|
+
parentId: string | null,
|
|
229
|
+
text: string,
|
|
230
|
+
toolCall?: { name: string; path: string },
|
|
231
|
+
): SessionEntry {
|
|
232
|
+
const content: Array<Record<string, unknown>> = [{ type: "text", text }];
|
|
233
|
+
if (toolCall) content.push({ type: "toolCall", id: `${id}-tc`, name: toolCall.name, arguments: { path: toolCall.path } });
|
|
234
|
+
return {
|
|
235
|
+
type: "message",
|
|
236
|
+
id,
|
|
237
|
+
parentId,
|
|
238
|
+
timestamp: new Date(0).toISOString(),
|
|
239
|
+
message: {
|
|
240
|
+
role: "assistant",
|
|
241
|
+
content,
|
|
242
|
+
api: "openai-completions",
|
|
243
|
+
provider: "zai",
|
|
244
|
+
model: "glm",
|
|
245
|
+
usage: {
|
|
246
|
+
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
|
|
247
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
248
|
+
},
|
|
249
|
+
stopReason: "stop",
|
|
250
|
+
timestamp: 0,
|
|
251
|
+
},
|
|
252
|
+
} as SessionEntry;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function makeCompactionEntry(
|
|
256
|
+
id: string,
|
|
257
|
+
parentId: string | null,
|
|
258
|
+
firstKeptEntryId: string,
|
|
259
|
+
details?: { readFiles: string[]; modifiedFiles: string[] },
|
|
260
|
+
): SessionEntry {
|
|
261
|
+
return {
|
|
262
|
+
type: "compaction",
|
|
263
|
+
id,
|
|
264
|
+
parentId,
|
|
265
|
+
timestamp: new Date(0).toISOString(),
|
|
266
|
+
summary: "PREV SUMMARY",
|
|
267
|
+
firstKeptEntryId,
|
|
268
|
+
tokensBefore: 123,
|
|
269
|
+
...(details ? { details } : {}),
|
|
270
|
+
} as SessionEntry;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 样例 A(无 compaction):3 轮对话,第 1 轮含 read 工具调用;切点预期落在第 3 轮 user(e3)。 */
|
|
274
|
+
function sampleEntriesNoCompaction(): SessionEntry[] {
|
|
275
|
+
const e1 = makeUserEntry("e1", null, BIG);
|
|
276
|
+
const e2 = makeAssistantEntry("e2", "e1", BIG, { name: "read", path: "/a.ts" });
|
|
277
|
+
const e3 = makeUserEntry("e3", "e2", BIG);
|
|
278
|
+
const e4 = makeAssistantEntry("e4", "e3", BIG);
|
|
279
|
+
const e5 = makeUserEntry("e5", "e4", BIG);
|
|
280
|
+
return [e1, e2, e3, e4, e5];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 样例 B(既有 compaction entry):PREV(c0) → k1..k4;切点预期落在 k3。 */
|
|
284
|
+
function sampleEntriesWithCompaction(): SessionEntry[] {
|
|
285
|
+
const c0 = makeCompactionEntry("c0", null, "k1", { readFiles: ["/c.ts"], modifiedFiles: [] });
|
|
286
|
+
const k1 = makeUserEntry("k1", "c0", BIG);
|
|
287
|
+
const k2 = makeAssistantEntry("k2", "k1", BIG, { name: "edit", path: "/b.ts" });
|
|
288
|
+
const k3 = makeUserEntry("k3", "k2", "y".repeat(60_000));
|
|
289
|
+
const k4 = makeAssistantEntry("k4", "k3", "y".repeat(60_000));
|
|
290
|
+
return [c0, k1, k2, k3, k4];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 投影边界对照(验收①核心断言):组装切分(摘要头 + 被压段 + turnPrefix + 保留段)与
|
|
295
|
+
* buildSessionProjection 全量投影消息逐条一致(无 context_edit 场景应等价,AS4)。
|
|
296
|
+
*/
|
|
297
|
+
function expectMatchesProjection(entries: SessionEntry[], prep: NonNullable<ReturnType<typeof buildBackgroundPreparation>>): void {
|
|
298
|
+
const projection = buildSessionProjection(entries);
|
|
299
|
+
const contextEntries = buildContextEntries(entries);
|
|
300
|
+
const keptIndex = contextEntries.findIndex((e) => e.id === prep.firstKeptEntryId);
|
|
301
|
+
expect(keptIndex).toBeGreaterThan(0);
|
|
302
|
+
const kept = contextEntries
|
|
303
|
+
.slice(keptIndex)
|
|
304
|
+
.filter((e) => e.type !== "compaction")
|
|
305
|
+
.flatMap(sessionEntryToContextMessages);
|
|
306
|
+
const head = contextEntries[0];
|
|
307
|
+
const summaryHead = head?.type === "compaction" ? sessionEntryToContextMessages(head) : [];
|
|
308
|
+
expect([...summaryHead, ...prep.messagesToSummarize, ...prep.turnPrefixMessages, ...kept])
|
|
309
|
+
.toEqual(projection.messages);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** U2 组装/管理器共用的 ctx:entries + usage + sessionId(appendCompaction mock 供 U3 落盘链路)。 */
|
|
313
|
+
function makeBgCtx(entries: SessionEntry[], usage: { tokens: number | null; contextWindow: number } | null): ExtensionContext {
|
|
314
|
+
return {
|
|
315
|
+
model: { provider: "zai", id: "glm" },
|
|
316
|
+
getSystemPrompt: () => "sys",
|
|
317
|
+
getContextUsage: () => (usage ? { ...usage, percent: null } : undefined),
|
|
318
|
+
sessionManager: {
|
|
319
|
+
getSessionId: () => "s1",
|
|
320
|
+
getSessionFile: () => "/tmp/s1.jsonl",
|
|
321
|
+
getEntries: () => entries,
|
|
322
|
+
appendCompaction: vi.fn(() => "entry-persisted"),
|
|
323
|
+
},
|
|
324
|
+
modelRegistry: {},
|
|
325
|
+
} as unknown as ExtensionContext;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 取 ctx.sessionManager 上的 appendCompaction mock(运行时形状,类型声明层不可见——AS1)。 */
|
|
329
|
+
function getAppendMock(ctx: ExtensionContext): Mock {
|
|
330
|
+
return (ctx.sessionManager as unknown as { appendCompaction: Mock }).appendCompaction;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function makeBgManager(
|
|
334
|
+
bgState: BackgroundCompactionState,
|
|
335
|
+
config: SmartContextConfig,
|
|
336
|
+
afterGeneration?: AfterGenerationHook,
|
|
337
|
+
getInflatedSegments?: () => ReadonlySet<string>,
|
|
338
|
+
onSettled?: (settlement: BackgroundTaskSettlement) => void,
|
|
339
|
+
isCtxStale?: () => boolean,
|
|
340
|
+
) {
|
|
341
|
+
const manager = createBackgroundCompactionManager(
|
|
342
|
+
makePi(),
|
|
343
|
+
() => bgState,
|
|
344
|
+
() => config,
|
|
345
|
+
afterGeneration,
|
|
346
|
+
getInflatedSegments,
|
|
347
|
+
onSettled,
|
|
348
|
+
isCtxStale,
|
|
349
|
+
);
|
|
350
|
+
return manager;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 默认触发行情(200k 窗口 95% > 0.9 线)。 */
|
|
354
|
+
const TRIGGER_USAGE = { tokens: 190_000, contextWindow: 200_000 };
|
|
355
|
+
const BELOW_USAGE = { tokens: 100_000, contextWindow: 200_000 };
|
|
356
|
+
|
|
357
|
+
/** 生成期挂起控制器:mock callSameModelCompaction 挂起直到 release。返回同一对象引用(release 后续赋值可见)。 */
|
|
358
|
+
function holdGeneration(): { release: (ok: boolean) => void } {
|
|
359
|
+
const handle = {
|
|
360
|
+
release: (_ok: boolean) => {
|
|
361
|
+
throw new Error("release called before generation started");
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
mockedCall.mockImplementation(
|
|
365
|
+
() =>
|
|
366
|
+
new Promise<{ ok: boolean; text: string; usage: { input: number; output: number } }>((resolve) => {
|
|
367
|
+
handle.release = (ok) => resolve({ ok, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
return handle;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
describe("后台压缩准备数据组装(U2 验收①:buildContextEntries 投影边界对照)", () => {
|
|
374
|
+
it("样例 A(无 compaction):切点=第 3 轮 user、非 split-turn、被压段=[e1,e2]、fileOps 收 read、settings=默认常量", () => {
|
|
375
|
+
const entries = sampleEntriesNoCompaction();
|
|
376
|
+
const ctx = makeBgCtx(entries, TRIGGER_USAGE);
|
|
377
|
+
const prep = buildBackgroundPreparation(ctx);
|
|
378
|
+
expect(prep).toBeDefined();
|
|
379
|
+
// findCutPoint:倒序累计到 e2 越过 20000 预算 → 切在 e3(user,轮首)→ 非分轮
|
|
380
|
+
expect(prep!.firstKeptEntryId).toBe("e3");
|
|
381
|
+
expect(prep!.isSplitTurn).toBe(false);
|
|
382
|
+
expect(prep!.turnPrefixMessages).toEqual([]);
|
|
383
|
+
expect(prep!.messagesToSummarize.map((m) => m.role)).toEqual(["user", "assistant"]);
|
|
384
|
+
// fileOps:e2 的 toolCall read /a.ts
|
|
385
|
+
expect([...prep!.fileOps.read]).toEqual(["/a.ts"]);
|
|
386
|
+
// tokensBefore = ctx.getContextUsage() 口径(AS4 登记差异)
|
|
387
|
+
expect(prep!.tokensBefore).toBe(TRIGGER_USAGE.tokens);
|
|
388
|
+
// settings = DEFAULT_COMPACTION_SETTINGS 快照(AS10:无设置读取通道)
|
|
389
|
+
expect(prep!.settings).toEqual({ enabled: true, reserveTokens: 16_384, keepRecentTokens: 20_000 });
|
|
390
|
+
expectMatchesProjection(entries, prep!);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("样例 B(既有 compaction entry):previousSummary 取最新一份、被压段不混入旧摘要、fileOps 合并 prev details", () => {
|
|
394
|
+
const entries = sampleEntriesWithCompaction();
|
|
395
|
+
const ctx = makeBgCtx(entries, TRIGGER_USAGE);
|
|
396
|
+
const prep = buildBackgroundPreparation(ctx);
|
|
397
|
+
expect(prep).toBeDefined();
|
|
398
|
+
expect(prep!.previousSummary).toBe("PREV SUMMARY");
|
|
399
|
+
expect(prep!.firstKeptEntryId).toBe("k3");
|
|
400
|
+
// 被压段 = 保留段之前的活动条目(k1,k2)——旧 compaction 摘要不混入
|
|
401
|
+
expect(prep!.messagesToSummarize.map((m) => m.role)).toEqual(["user", "assistant"]);
|
|
402
|
+
// fileOps:prev details(pi 原生,非 fromHook)read /c.ts + k2 edit /b.ts
|
|
403
|
+
expect([...prep!.fileOps.read]).toContain("/c.ts");
|
|
404
|
+
expect([...prep!.fileOps.edited]).toContain("/b.ts");
|
|
405
|
+
expectMatchesProjection(entries, prep!);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("不可压场景:全量在保留预算内 / 末条是 compaction → undefined(不触发)", () => {
|
|
409
|
+
const small = [
|
|
410
|
+
makeUserEntry("s1", null, "hi"),
|
|
411
|
+
makeAssistantEntry("s2", "s1", "hello"),
|
|
412
|
+
];
|
|
413
|
+
expect(buildBackgroundPreparation(makeBgCtx(small, TRIGGER_USAGE))).toBeUndefined();
|
|
414
|
+
|
|
415
|
+
const justCompacted = [makeCompactionEntry("c9", null, "c9")];
|
|
416
|
+
expect(buildBackgroundPreparation(makeBgCtx(justCompacted, TRIGGER_USAGE))).toBeUndefined();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("system message 剔除(pi compaction.js:50-53 同款):被压段混入 system 不进摘要输入", () => {
|
|
420
|
+
// system message 条目真实产生(agent-session system prompt sections 变化时落盘);
|
|
421
|
+
// pi 原生 getMessagesFromProjectedEntryForCompaction 剔除 system role(prompt 状态非
|
|
422
|
+
// 对话内容,compaction entry 的 systemMessage 承载其回放)。本用例锁定扩展组装同款
|
|
423
|
+
// 剔除:无 context_edit 场景被压消息与 pi prepareCompaction 逐条一致(不混入 system)
|
|
424
|
+
const sysEntry: SessionEntry = {
|
|
425
|
+
type: "message",
|
|
426
|
+
id: "sys1",
|
|
427
|
+
parentId: null,
|
|
428
|
+
timestamp: new Date(0).toISOString(),
|
|
429
|
+
message: { role: "system", content: "SYS PROMPT STATE", timestamp: 0 },
|
|
430
|
+
} as SessionEntry;
|
|
431
|
+
const [e1, ...rest] = sampleEntriesNoCompaction();
|
|
432
|
+
const entries = [sysEntry, { ...e1, parentId: "sys1" }, ...rest];
|
|
433
|
+
|
|
434
|
+
const prep = buildBackgroundPreparation(makeBgCtx(entries, TRIGGER_USAGE));
|
|
435
|
+
expect(prep).toBeDefined();
|
|
436
|
+
// 切点不因前置 system 条目移动(保留段倒序累计阈值不变)
|
|
437
|
+
expect(prep!.firstKeptEntryId).toBe("e3");
|
|
438
|
+
// 被压段 = [sys1, e1, e2] → 剔除 system 后 [e1, e2](与 pi 原生组装等价)
|
|
439
|
+
expect(prep!.messagesToSummarize.map((m) => m.role)).toEqual(["user", "assistant"]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("被压段仅 system 消息 → undefined(与 pi 原生 prepareCompaction 同判无可压)", () => {
|
|
443
|
+
// e2 加大(60K chars)驱动切点回退到 e1:被压段 = [sys1] 仅 system,过滤后空 →
|
|
444
|
+
// 无可压返回 undefined;pi 原生 prepareCompaction 对同条目流同判(探针对照实跑)
|
|
445
|
+
const sysEntry: SessionEntry = {
|
|
446
|
+
type: "message",
|
|
447
|
+
id: "sys1",
|
|
448
|
+
parentId: null,
|
|
449
|
+
timestamp: new Date(0).toISOString(),
|
|
450
|
+
message: { role: "system", content: "SYS PROMPT STATE", timestamp: 0 },
|
|
451
|
+
} as SessionEntry;
|
|
452
|
+
const entries = [
|
|
453
|
+
sysEntry,
|
|
454
|
+
makeUserEntry("e1", "sys1", BIG),
|
|
455
|
+
makeAssistantEntry("e2", "e1", "y".repeat(60_000)),
|
|
456
|
+
makeUserEntry("e3", "e2", "tail"),
|
|
457
|
+
makeAssistantEntry("e4", "e3", "tail"),
|
|
458
|
+
makeUserEntry("e5", "e4", "tail"),
|
|
459
|
+
];
|
|
460
|
+
expect(buildBackgroundPreparation(makeBgCtx(entries, TRIGGER_USAGE))).toBeUndefined();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("后台触发线判定(U2 验收③)", () => {
|
|
465
|
+
it("0.9 默认 × 窗口百分比:压线即触发、线下一档不触发、非法入参不触发", () => {
|
|
466
|
+
const config = normalizeSmartContextConfig({});
|
|
467
|
+
expect(config.autoCompactThresholdRatio).toBe(0.9);
|
|
468
|
+
expect(isAutoCompactTriggered(180_000, 200_000, config.autoCompactThresholdRatio)).toBe(true);
|
|
469
|
+
expect(isAutoCompactTriggered(179_999, 200_000, config.autoCompactThresholdRatio)).toBe(false);
|
|
470
|
+
expect(isAutoCompactTriggered(Number.NaN, 200_000, 0.9)).toBe(false);
|
|
471
|
+
expect(isAutoCompactTriggered(180_000, 0, 0.9)).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("manager 门控前置:disabled / 排除模型命中 → 不启动", async () => {
|
|
475
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
476
|
+
const disabled = makeBgManager(createBackgroundCompactionState(), normalizeSmartContextConfig({ enabled: false }));
|
|
477
|
+
expect(disabled.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(false);
|
|
478
|
+
|
|
479
|
+
const excluded = makeBgManager(
|
|
480
|
+
createBackgroundCompactionState(),
|
|
481
|
+
normalizeSmartContextConfig({ excludedModels: ["zai/glm"] }),
|
|
482
|
+
);
|
|
483
|
+
expect(excluded.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(false);
|
|
484
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("manager 触发线:越过启动并进入「进行中」;未越过 / 用量未知不启动;收尾回空闲", async () => {
|
|
488
|
+
const gen = holdGeneration();
|
|
489
|
+
const bgState = createBackgroundCompactionState();
|
|
490
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
491
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
492
|
+
|
|
493
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
494
|
+
expect(bgState.active).not.toBeNull();
|
|
495
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
496
|
+
// 互斥(RL1):进行中再触发不启动
|
|
497
|
+
expect(manager.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(false);
|
|
498
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
499
|
+
|
|
500
|
+
gen.release(true);
|
|
501
|
+
await bgState.active?.done;
|
|
502
|
+
expect(bgState.active).toBeNull();
|
|
503
|
+
|
|
504
|
+
// 未越过触发线 / 用量未知(R7 null 窗口期)→ 不启动
|
|
505
|
+
expect(manager.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), BELOW_USAGE))).toBe(false);
|
|
506
|
+
expect(manager.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), { tokens: null, contextWindow: 200_000 }))).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe("后台事件取消与独立熔断(U2 验收②:RL1)", () => {
|
|
511
|
+
it("事件取消各分支(index.ts 接线):new/resume/fork 取消、startup/reload 不取消、shutdown/compact 取消、tree 仅告警", async () => {
|
|
512
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
513
|
+
const hookPi = {
|
|
514
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
515
|
+
handlers.set(name, handler);
|
|
516
|
+
},
|
|
517
|
+
getAllTools: () => [],
|
|
518
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
519
|
+
sendMessage: vi.fn(),
|
|
520
|
+
appendEntry: vi.fn(),
|
|
521
|
+
} as unknown as ExtensionAPI;
|
|
522
|
+
smartContextExtension(hookPi);
|
|
523
|
+
|
|
524
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
525
|
+
const launched: Array<{ release: (ok: boolean) => void; signal: AbortSignal }> = [];
|
|
526
|
+
// launch:agent_settled 启动一个挂起的任务(不 release——保持「进行中」供事件取消)
|
|
527
|
+
const launch = (): void => {
|
|
528
|
+
const gen = holdGeneration();
|
|
529
|
+
handlers.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
530
|
+
const signal = mockedCall.mock.calls[mockedCall.mock.calls.length - 1][1].signal;
|
|
531
|
+
expect(signal.aborted).toBe(false);
|
|
532
|
+
launched.push({ release: gen.release, signal });
|
|
533
|
+
};
|
|
534
|
+
// drain:释放全部挂起任务并等微任务链收尾(被取消任务的 resolve 结果被放弃)
|
|
535
|
+
const drain = async (): Promise<void> => {
|
|
536
|
+
for (const t of launched) t.release(true);
|
|
537
|
+
launched.length = 0;
|
|
538
|
+
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// 段 1:startup / reload 不取消(D3/SI4——生产中 reload 会重跑工厂使旧任务随旧闭包
|
|
542
|
+
// 消亡,因此只断言「事件本身不触发 abort」);session_tree 仅观测告警,不取消
|
|
543
|
+
launch();
|
|
544
|
+
handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
545
|
+
handlers.get("session_start")!({ type: "session_start", reason: "reload" }, ctx);
|
|
546
|
+
handlers.get("session_tree")!({ type: "session_tree", newLeafId: "e1", oldLeafId: "e5" }, ctx);
|
|
547
|
+
expect(launched[0].signal.aborted).toBe(false);
|
|
548
|
+
|
|
549
|
+
// 段 2:session_start(new) 取消(startup/reload 重建状态容器后,agent_settled 在新
|
|
550
|
+
// 容器上再启动是合法序列——此处 new 的取消作用于当前容器内的任务)
|
|
551
|
+
launch();
|
|
552
|
+
handlers.get("session_start")!({ type: "session_start", reason: "new" }, ctx);
|
|
553
|
+
expect(launched[1].signal.aborted).toBe(true);
|
|
554
|
+
|
|
555
|
+
// 段 3:session_start(resume) 取消
|
|
556
|
+
launch();
|
|
557
|
+
handlers.get("session_start")!({ type: "session_start", reason: "resume" }, ctx);
|
|
558
|
+
expect(launched[2].signal.aborted).toBe(true);
|
|
559
|
+
|
|
560
|
+
// 段 4:session_shutdown 取消
|
|
561
|
+
launch();
|
|
562
|
+
handlers.get("session_shutdown")!({ type: "session_shutdown", reason: "quit" }, ctx);
|
|
563
|
+
expect(launched[3].signal.aborted).toBe(true);
|
|
564
|
+
|
|
565
|
+
// 段 5:session_compact 取消(其他压缩抢先落盘;后台路径自身落盘不发该事件)
|
|
566
|
+
launch();
|
|
567
|
+
handlers.get("session_compact")!({ type: "session_compact" }, ctx);
|
|
568
|
+
expect(launched[4].signal.aborted).toBe(true);
|
|
569
|
+
|
|
570
|
+
// 段 6:session_start(fork) 取消
|
|
571
|
+
launch();
|
|
572
|
+
handlers.get("session_start")!({ type: "session_start", reason: "fork" }, ctx);
|
|
573
|
+
expect(launched[5].signal.aborted).toBe(true);
|
|
574
|
+
|
|
575
|
+
await drain();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("被抢先取消不计入熔断、结果被放弃(取消非失败,SI5)", async () => {
|
|
579
|
+
const gen = holdGeneration();
|
|
580
|
+
const bgState = createBackgroundCompactionState();
|
|
581
|
+
const afterGen = vi.fn().mockResolvedValue("validated");
|
|
582
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), afterGen);
|
|
583
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
584
|
+
|
|
585
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
586
|
+
const done = bgState.active?.done;
|
|
587
|
+
manager.cancelActive("session_compact");
|
|
588
|
+
expect(bgState.active).toBeNull();
|
|
589
|
+
gen.release(true);
|
|
590
|
+
await done;
|
|
591
|
+
expect(bgState.failureStreak).toBe(0);
|
|
592
|
+
expect(afterGen).not.toHaveBeenCalled(); // 生成结果随取消放弃,续段不执行
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("独立熔断:后台连续失败 3 次停止自动触发;与同步 failStreak 隔离;成功清零", async () => {
|
|
596
|
+
const bgState = createBackgroundCompactionState();
|
|
597
|
+
const takeover = createTakeoverState();
|
|
598
|
+
takeover.failStreak = 3; // 同步路径已熔断——后台独立计数不受影响
|
|
599
|
+
const manager = createBackgroundCompactionManager(makePi(), () => bgState, () => normalizeSmartContextConfig({}));
|
|
600
|
+
|
|
601
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
602
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
603
|
+
for (let i = 0; i < 3; i++) {
|
|
604
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
605
|
+
await bgState.active?.done;
|
|
606
|
+
}
|
|
607
|
+
expect(bgState.failureStreak).toBe(3);
|
|
608
|
+
expect(mockedCall).toHaveBeenCalledTimes(3);
|
|
609
|
+
// 第 4 次起熔断挡住(尽管同步 failStreak=3 与后台互不干扰)
|
|
610
|
+
expect(manager.maybeStart(ctx)).toBe(false);
|
|
611
|
+
expect(mockedCall).toHaveBeenCalledTimes(3);
|
|
612
|
+
expect(takeover.failStreak).toBe(3);
|
|
613
|
+
|
|
614
|
+
// 计数归零后恢复触发(session_start 重建闭包同口径)
|
|
615
|
+
bgState.failureStreak = 0;
|
|
616
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
617
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
618
|
+
await bgState.active?.done;
|
|
619
|
+
expect(bgState.failureStreak).toBe(0);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("续段回报驱动计数(U3 接口契约):validated 清零 / inflated 递增 / dropped 不动", async () => {
|
|
623
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
624
|
+
const afterGen = vi.fn();
|
|
625
|
+
const bgState = createBackgroundCompactionState();
|
|
626
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), afterGen as unknown as AfterGenerationHook);
|
|
627
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
628
|
+
|
|
629
|
+
bgState.failureStreak = 2;
|
|
630
|
+
afterGen.mockResolvedValue("validated");
|
|
631
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
632
|
+
await bgState.active?.done;
|
|
633
|
+
expect(bgState.failureStreak).toBe(0); // 成功清零
|
|
634
|
+
|
|
635
|
+
afterGen.mockResolvedValue("inflated");
|
|
636
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
637
|
+
await bgState.active?.done;
|
|
638
|
+
expect(bgState.failureStreak).toBe(1); // 递增点②:收缩校验失败(U3 回报)
|
|
639
|
+
|
|
640
|
+
afterGen.mockResolvedValue({ outcome: "dropped", reason: "preempted" });
|
|
641
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
642
|
+
await bgState.active?.done;
|
|
643
|
+
expect(bgState.failureStreak).toBe(1); // 会话态失效类放弃:计数不动
|
|
644
|
+
|
|
645
|
+
// 续段收到的快照含 U3 落盘校验所需字段(sessionId / lastEntryId)
|
|
646
|
+
const snapshot = afterGen.mock.calls[0][1];
|
|
647
|
+
expect(snapshot.sessionId).toBe("s1");
|
|
648
|
+
expect(snapshot.lastEntryId).toBe("e5");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("异常递增(递增点③):生成抛错 → failureStreak+1,任务回空闲", async () => {
|
|
652
|
+
mockedCall.mockRejectedValue(new Error("boom"));
|
|
653
|
+
const bgState = createBackgroundCompactionState();
|
|
654
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
655
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
656
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
657
|
+
await bgState.active?.done;
|
|
658
|
+
expect(bgState.failureStreak).toBe(1);
|
|
659
|
+
expect(bgState.active).toBeNull();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ──────────────────────── U3:落盘兜底校验 + appendCompaction 直调 + 档位重置补偿 ────────────────────────
|
|
664
|
+
|
|
665
|
+
/** 校验器/落盘直调共用的 CompactionResult 底座(短摘要 = 不膨胀;details 为 same-model 标记)。 */
|
|
666
|
+
function makeCompactionResult(overrides?: Partial<CompactionResult>): CompactionResult {
|
|
667
|
+
return {
|
|
668
|
+
summary: "bg summary",
|
|
669
|
+
firstKeptEntryId: "e3",
|
|
670
|
+
tokensBefore: TRIGGER_USAGE.tokens,
|
|
671
|
+
usage: { input: 1, output: 2 },
|
|
672
|
+
details: { engine: "smart-context", mode: "same-model", model: "zai/glm" },
|
|
673
|
+
...overrides,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** 校验器直测共用的快照底座(shadowedTokens=4000:摘要 ≥4000 tokens 即膨胀)。 */
|
|
678
|
+
function makeSnapshot(overrides?: Partial<BackgroundTaskSnapshot>): BackgroundTaskSnapshot {
|
|
679
|
+
return {
|
|
680
|
+
sessionId: "s1",
|
|
681
|
+
lastEntryId: "e5",
|
|
682
|
+
tokens: TRIGGER_USAGE.tokens,
|
|
683
|
+
compactionIds: [],
|
|
684
|
+
shadowedTokens: 4_000,
|
|
685
|
+
...overrides,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
describe("落盘兜底校验器(U3 验收①:RL2 逐项反例 + 通过用例)", () => {
|
|
690
|
+
it("通过用例:sessionId 一致 + 无未知 compaction + 摘要不膨胀 → ok", () => {
|
|
691
|
+
expect(validateBackgroundPersist(makeSnapshot(), sampleEntriesNoCompaction(), "s1", makeCompactionResult()))
|
|
692
|
+
.toEqual({ ok: true });
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("反例① sessionId 变更(会话已替换)→ session-mismatch", () => {
|
|
696
|
+
expect(validateBackgroundPersist(makeSnapshot(), sampleEntriesNoCompaction(), "s2", makeCompactionResult()))
|
|
697
|
+
.toEqual({ ok: false, reason: "session-mismatch" });
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("反例② 快照末端之后出现新 compaction entry(未知 id = 被抢先)→ preempted", () => {
|
|
701
|
+
const entries = [...sampleEntriesNoCompaction(), makeCompactionEntry("c-rival", "e5", "e5")];
|
|
702
|
+
expect(validateBackgroundPersist(makeSnapshot(), entries, "s1", makeCompactionResult()))
|
|
703
|
+
.toEqual({ ok: false, reason: "preempted" });
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("通过② 既有 compaction(快照已知 id 集合内)不误判被抢先", () => {
|
|
707
|
+
const entries = sampleEntriesWithCompaction(); // 含 c0(触发时刻已在 branch 上)
|
|
708
|
+
const snapshot = makeSnapshot({ lastEntryId: "k4", compactionIds: ["c0"] });
|
|
709
|
+
expect(validateBackgroundPersist(snapshot, entries, "s1", makeCompactionResult()))
|
|
710
|
+
.toEqual({ ok: true });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("通过②b 回退丢弃锚点(session_tree 落盘侧断言):锚点不在当前 branch、无新 compaction → 落盘放行(V4:回退不取消)", () => {
|
|
714
|
+
// 回退后 branch = [e1, n1]:锚点 e5 已被丢弃(不在 entries 中),且无抢先 compaction
|
|
715
|
+
const entriesAfterRollback = [makeUserEntry("e1", null, BIG), makeUserEntry("n1", "e1", "continue")];
|
|
716
|
+
expect(validateBackgroundPersist(makeSnapshot(), entriesAfterRollback, "s1", makeCompactionResult()))
|
|
717
|
+
.toEqual({ ok: true });
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("反例②b 回退后新分支被抢先压缩(未知 id)→ preempted(防错位 entry 落盘,AX3)", () => {
|
|
721
|
+
const entriesAfterRollback = [
|
|
722
|
+
makeUserEntry("e1", null, BIG),
|
|
723
|
+
makeCompactionEntry("c-other", "e1", "e1"),
|
|
724
|
+
];
|
|
725
|
+
expect(validateBackgroundPersist(makeSnapshot(), entriesAfterRollback, "s1", makeCompactionResult()))
|
|
726
|
+
.toEqual({ ok: false, reason: "preempted" });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("反例③ 摘要膨胀(摘要 token ≥ 被压段,SI6)→ summary-inflated", () => {
|
|
730
|
+
const inflated = makeCompactionResult({ summary: "z".repeat(20_000) }); // 20000 chars → 5000 tokens ≥ 4000
|
|
731
|
+
expect(validateBackgroundPersist(makeSnapshot(), sampleEntriesNoCompaction(), "s1", inflated))
|
|
732
|
+
.toEqual({ ok: false, reason: "summary-inflated" });
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("通过③ 摘要略小于被压段 → ok(收缩校验放行)", () => {
|
|
736
|
+
const compactEnough = makeCompactionResult({ summary: "y".repeat(8_000) }); // 2000 tokens < 4000
|
|
737
|
+
expect(validateBackgroundPersist(makeSnapshot(), sampleEntriesNoCompaction(), "s1", compactEnough))
|
|
738
|
+
.toEqual({ ok: true });
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe("appendCompaction 直调链路(U3 验收②③:RL3 存在性探测 + D6 档位重置)", () => {
|
|
743
|
+
it("落盘成功:参数口径与 pi 原生一致(fromHook=true、usage/details 透传)、onPersisted 回调、validated 清零", async () => {
|
|
744
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 3, output: 4 } });
|
|
745
|
+
const bgState = createBackgroundCompactionState();
|
|
746
|
+
const onPersisted = vi.fn();
|
|
747
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx) =>
|
|
748
|
+
persistBackgroundCompaction(ctx, result, snapshot, { onPersisted });
|
|
749
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), hook);
|
|
750
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
751
|
+
|
|
752
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
753
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled(); // 生成完成前不落盘
|
|
754
|
+
await bgState.active?.done;
|
|
755
|
+
|
|
756
|
+
expect(getAppendMock(ctx)).toHaveBeenCalledTimes(1);
|
|
757
|
+
const [summary, firstKept, tokensBefore, details, fromHook, usage] = getAppendMock(ctx).mock.calls[0];
|
|
758
|
+
expect(firstKept).toBe("e3"); // 样例 A 切点(快照范围落 entry)
|
|
759
|
+
expect(summary).toContain("bg summary");
|
|
760
|
+
expect(tokensBefore).toBe(TRIGGER_USAGE.tokens); // preparation.tokensBefore = getContextUsage() 口径
|
|
761
|
+
expect(details).toEqual({ engine: "smart-context", mode: "same-model", model: "zai/glm" });
|
|
762
|
+
expect(fromHook).toBe(true); // 与同步接管路径落盘口径一致
|
|
763
|
+
expect(usage).toEqual({ input: 3, output: 4 }); // usage 随 entry 写入(D6 用量归因消费侧照读)
|
|
764
|
+
expect(onPersisted).toHaveBeenCalledTimes(1); // D6 档位重置补偿回调
|
|
765
|
+
// C1:onPersisted 携带落盘 entry id(接线层组装落盘信号的 compactionId 数据源)
|
|
766
|
+
expect(onPersisted).toHaveBeenCalledWith("entry-persisted");
|
|
767
|
+
expect(bgState.failureStreak).toBe(0); // validated 清零
|
|
768
|
+
expect(bgState.active).toBeNull();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("RL3 前置探测:appendCompaction 缺失 → 不启动任务(生成费用零浪费)", () => {
|
|
772
|
+
const bgState = createBackgroundCompactionState();
|
|
773
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
774
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
775
|
+
delete (ctx.sessionManager as { appendCompaction?: unknown }).appendCompaction;
|
|
776
|
+
expect(manager.maybeStart(ctx)).toBe(false);
|
|
777
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
778
|
+
expect(bgState.active).toBeNull();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("RL3 落盘侧探测:appendCompaction 缺失 → 放弃落盘、不抛错(dropped/persist-unavailable),补偿回调不触发", async () => {
|
|
782
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
783
|
+
delete (ctx.sessionManager as { appendCompaction?: unknown }).appendCompaction;
|
|
784
|
+
const onPersisted = vi.fn();
|
|
785
|
+
const outcome = await persistBackgroundCompaction(ctx, makeCompactionResult(), makeSnapshot(), { onPersisted });
|
|
786
|
+
expect(outcome).toEqual({ outcome: "dropped", reason: "persist-unavailable" });
|
|
787
|
+
expect(onPersisted).not.toHaveBeenCalled();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("校验① session-mismatch(落盘侧链路):触发后 sessionId 已换 → dropped 且 reason 原样透传", async () => {
|
|
791
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
792
|
+
const onPersisted = vi.fn();
|
|
793
|
+
const outcome = await persistBackgroundCompaction(
|
|
794
|
+
ctx,
|
|
795
|
+
makeCompactionResult(),
|
|
796
|
+
// 触发时刻快照属旧会话(s-old),落盘时 ctx 已是 s1 → 校验① 放弃
|
|
797
|
+
makeSnapshot({ sessionId: "s-old" }),
|
|
798
|
+
{ onPersisted },
|
|
799
|
+
);
|
|
800
|
+
expect(outcome).toEqual({ outcome: "dropped", reason: "session-mismatch" });
|
|
801
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled();
|
|
802
|
+
expect(onPersisted).not.toHaveBeenCalled();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("被抢先(校验②链路):落盘前出现新 compaction → 放弃,appendCompaction 不调、档位不重置、计数不动;settlement 透传 preempted", async () => {
|
|
806
|
+
const entries = sampleEntriesNoCompaction();
|
|
807
|
+
const bgState = createBackgroundCompactionState();
|
|
808
|
+
const onPersisted = vi.fn();
|
|
809
|
+
const onSettled = vi.fn();
|
|
810
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx) =>
|
|
811
|
+
persistBackgroundCompaction(ctx, result, snapshot, { onPersisted });
|
|
812
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), hook, undefined, onSettled);
|
|
813
|
+
const ctx = makeBgCtx(entries, TRIGGER_USAGE);
|
|
814
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
815
|
+
|
|
816
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
817
|
+
// 生成窗口内被抢先:同步接管路径压了一条新 compaction entry(未知 id)
|
|
818
|
+
entries.push(makeCompactionEntry("c-rival", "e5", "e5"));
|
|
819
|
+
await bgState.active?.done;
|
|
820
|
+
|
|
821
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled();
|
|
822
|
+
expect(onPersisted).not.toHaveBeenCalled();
|
|
823
|
+
expect(bgState.failureStreak).toBe(0); // dropped:会话态失效类放弃,计数不动
|
|
824
|
+
expect(bgState.active).toBeNull();
|
|
825
|
+
// verdict.reason 原样传出(通知文案分派依据——preempted 下上下文已被较新压缩结果改变)
|
|
826
|
+
expect(onSettled.mock.calls[0]![0]).toMatchObject({ outcome: "dropped", droppedReason: "preempted" });
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("摘要膨胀(校验③链路):放弃落盘 + 膨胀段记录回调 + 同段不再触发(共享读取)", async () => {
|
|
830
|
+
const entries = sampleEntriesNoCompaction();
|
|
831
|
+
const bgState = createBackgroundCompactionState();
|
|
832
|
+
const onInflated = vi.fn();
|
|
833
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx) =>
|
|
834
|
+
persistBackgroundCompaction(ctx, result, snapshot, { onInflated });
|
|
835
|
+
const manager = makeBgManager(
|
|
836
|
+
bgState,
|
|
837
|
+
normalizeSmartContextConfig({}),
|
|
838
|
+
hook,
|
|
839
|
+
// 膨胀段共享读取(模拟 index.ts 接线:读 state.takeover.inflatedSegments 同一集合)
|
|
840
|
+
() => new Set(onInflated.mock.calls.map((call) => call[0] as string)),
|
|
841
|
+
);
|
|
842
|
+
const ctx = makeBgCtx(entries, TRIGGER_USAGE);
|
|
843
|
+
// 摘要 80000 chars → 20000 tokens ≥ 被压段(两条 BIG ≈ 15000+ tokens)
|
|
844
|
+
mockedCall.mockResolvedValue({ ok: true, text: "z".repeat(80_000), usage: { input: 1, output: 2 } });
|
|
845
|
+
|
|
846
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
847
|
+
await bgState.active?.done;
|
|
848
|
+
|
|
849
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled();
|
|
850
|
+
expect(onInflated).toHaveBeenCalledWith("e3"); // 样例 A 切点段被记录(与同步路径共享集合语义)
|
|
851
|
+
expect(bgState.failureStreak).toBe(1); // inflated:递增点②
|
|
852
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
853
|
+
|
|
854
|
+
// 同段防重:膨胀段未消 → 不再触发防反复烧钱
|
|
855
|
+
expect(manager.maybeStart(makeBgCtx(entries, TRIGGER_USAGE))).toBe(false);
|
|
856
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("档位重置补偿(U3 验收③,D6):落盘成功后 firedThresholds 清空,同档重新提醒(index.ts 全接线)", async () => {
|
|
860
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
861
|
+
const sendMessage = vi.fn();
|
|
862
|
+
const hookPi = {
|
|
863
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
864
|
+
handlers.set(name, handler);
|
|
865
|
+
},
|
|
866
|
+
getAllTools: () => [],
|
|
867
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
868
|
+
sendMessage,
|
|
869
|
+
appendEntry: vi.fn(),
|
|
870
|
+
} as unknown as ExtensionAPI;
|
|
871
|
+
smartContextExtension(hookPi);
|
|
872
|
+
|
|
873
|
+
// 行情:450K/500K 窗口 → 450_000 = 0.9×500_000 压线触发后台任务,且 ≥ 400K 第一档提醒
|
|
874
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), { tokens: 450_000, contextWindow: 500_000 });
|
|
875
|
+
handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
876
|
+
|
|
877
|
+
// 第一次 settle:越档提醒(fired 记 400K 档)+ 后台任务启动(挂起)
|
|
878
|
+
const gen = holdGeneration();
|
|
879
|
+
handlers.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
880
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
881
|
+
expect(sendMessage).toHaveBeenCalledTimes(1); // 第一档提醒投递
|
|
882
|
+
gen.release(true);
|
|
883
|
+
// 落盘链路全微任务:drain 到 appendCompaction 被调,再 drain 续段尾部(onPersisted 回流
|
|
884
|
+
// 与 U5 终局通知)
|
|
885
|
+
for (let i = 0; i < 50 && getAppendMock(ctx).mock.calls.length === 0; i++) await Promise.resolve();
|
|
886
|
+
expect(getAppendMock(ctx)).toHaveBeenCalledTimes(1); // 落盘成功(补偿的前置事实)
|
|
887
|
+
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
888
|
+
|
|
889
|
+
// U5/D6 完成通知:落盘成功 → compact-complete 经 nextTurn 投递(不自起回合),
|
|
890
|
+
// 含压缩前后 token 数与模式(D6)。C1 落盘信号在 onPersisted 事实点先行投递
|
|
891
|
+
//(display:false 机器信号,分界行数据源),完成通知随后,两条互不替代
|
|
892
|
+
expect(sendMessage).toHaveBeenCalledTimes(3);
|
|
893
|
+
const [signalMessage, signalOptions] = sendMessage.mock.calls[1]!;
|
|
894
|
+
expect(signalMessage).toMatchObject({
|
|
895
|
+
customType: "smart-context",
|
|
896
|
+
display: false,
|
|
897
|
+
details: { source: "compaction-entry-appended", compactionId: "entry-persisted" },
|
|
898
|
+
});
|
|
899
|
+
expect(signalOptions).toEqual({ triggerTurn: false }); // 空闲投递:无 deliverAs,pi 立即 append
|
|
900
|
+
const [completeMessage, completeOptions] = sendMessage.mock.calls[2]!;
|
|
901
|
+
expect(completeMessage).toMatchObject({
|
|
902
|
+
customType: "smart-context",
|
|
903
|
+
display: true,
|
|
904
|
+
details: { source: "compact-complete" },
|
|
905
|
+
});
|
|
906
|
+
expect(String(completeMessage.content)).toContain("后台压缩完成");
|
|
907
|
+
expect(String(completeMessage.content)).toContain("same-model");
|
|
908
|
+
expect(String(completeMessage.content)).toContain("450K"); // tokensBefore = 触发行情
|
|
909
|
+
expect(completeOptions).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
910
|
+
|
|
911
|
+
// 第二次 settle:420K 越第一档但低于触发线(450K,不启动新任务)→ fired 已清 → 同档重新提醒
|
|
912
|
+
handlers.get("agent_settled")!(
|
|
913
|
+
{ type: "agent_settled" },
|
|
914
|
+
makeBgCtx(sampleEntriesNoCompaction(), { tokens: 420_000, contextWindow: 500_000 }),
|
|
915
|
+
);
|
|
916
|
+
expect(sendMessage).toHaveBeenCalledTimes(4); // +1 提醒(firedThresholds 清空的直接行为证据)
|
|
917
|
+
expect(sendMessage.mock.calls[3]![0]).toMatchObject({ details: { source: "threshold-reminder" } });
|
|
918
|
+
expect(mockedCall).toHaveBeenCalledTimes(1); // 未越触发线,不重复启动任务
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// ──────────────────────── U5:工具路径启动(接线残项:startForTool 实装 BackgroundCompactionControls) ────────────────────────
|
|
923
|
+
|
|
924
|
+
describe("工具路径启动(U5:互斥 + 门控前置,跳过触发线/熔断/膨胀段)", () => {
|
|
925
|
+
it("门控前置:disabled → 抛错(不触 LLM 不启动);探测失败 → 抛错", () => {
|
|
926
|
+
const manager = makeBgManager(createBackgroundCompactionState(), normalizeSmartContextConfig({ enabled: false }));
|
|
927
|
+
expect(() => manager.startForTool(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toThrow(/门控拒绝/);
|
|
928
|
+
|
|
929
|
+
const noProbeState = createBackgroundCompactionState();
|
|
930
|
+
const noProbeManager = makeBgManager(noProbeState, normalizeSmartContextConfig({}));
|
|
931
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
932
|
+
delete (ctx.sessionManager as { appendCompaction?: unknown }).appendCompaction;
|
|
933
|
+
expect(() => noProbeManager.startForTool(ctx)).toThrow(/appendCompaction 不可用/);
|
|
934
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
935
|
+
expect(noProbeManager.isActive()).toBe(false);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("manual 来源跳过门控:enabled=false 时手动启动成功(用户意志,与旧 pi 原生手动压缩对齐)", async () => {
|
|
939
|
+
const bgState = createBackgroundCompactionState();
|
|
940
|
+
// enabled=false:tool 来源抛「门控拒绝」(上一用例锁定),manual 来源照常启动
|
|
941
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({ enabled: false }));
|
|
942
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
943
|
+
expect(manager.startForTool(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE), undefined, "manual")).toBe(true);
|
|
944
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
945
|
+
await bgState.active?.done;
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it("互斥(RL1):任务进行中 startForTool 返回 false(U4 契约幂等语义)且不触 LLM;收尾回空闲", async () => {
|
|
949
|
+
const gen = holdGeneration();
|
|
950
|
+
const bgState = createBackgroundCompactionState();
|
|
951
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
952
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
953
|
+
|
|
954
|
+
// 自动入口占位:进行中 → isActive true、工具路径 false
|
|
955
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
956
|
+
expect(manager.isActive()).toBe(true);
|
|
957
|
+
expect(manager.startForTool(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(false);
|
|
958
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
959
|
+
|
|
960
|
+
gen.release(true);
|
|
961
|
+
await bgState.active?.done;
|
|
962
|
+
expect(manager.isActive()).toBe(false);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("跳过触发线(工具显式请求不受 D4 约束):未越线行情 maybeStart 拒绝、startForTool 照常启动", async () => {
|
|
966
|
+
const bgState = createBackgroundCompactionState();
|
|
967
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
968
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
969
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), BELOW_USAGE); // 100K/200K = 50% < 0.9 线
|
|
970
|
+
|
|
971
|
+
expect(manager.maybeStart(ctx)).toBe(false); // 自动入口:触发线挡住(对照)
|
|
972
|
+
expect(manager.startForTool(ctx)).toBe(true); // 工具入口:显式请求放行
|
|
973
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
974
|
+
await bgState.active?.done;
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("跳过独立熔断(SI5:熔断仅停自动触发):连续失败 3 次后 maybeStart 拒绝、startForTool 照常启动", async () => {
|
|
978
|
+
const bgState = createBackgroundCompactionState();
|
|
979
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
980
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
981
|
+
|
|
982
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
983
|
+
for (let i = 0; i < 3; i++) {
|
|
984
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
985
|
+
await bgState.active?.done;
|
|
986
|
+
}
|
|
987
|
+
expect(bgState.failureStreak).toBe(3);
|
|
988
|
+
expect(manager.maybeStart(ctx)).toBe(false); // 自动入口:熔断挡住(对照)
|
|
989
|
+
|
|
990
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
991
|
+
expect(manager.startForTool(ctx)).toBe(true); // 工具入口:不受熔断影响
|
|
992
|
+
await bgState.active?.done;
|
|
993
|
+
expect(bgState.failureStreak).toBe(0); // 成功清零
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("跳过膨胀段拦截(显式请求不拦,落盘侧校验仍生效):同段已 inflated,maybeStart 拒绝、startForTool 照常启动", async () => {
|
|
997
|
+
const bgState = createBackgroundCompactionState();
|
|
998
|
+
const inflated = new Set(["e3"]); // 样例 A 切点段已记录膨胀
|
|
999
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), undefined, () => inflated);
|
|
1000
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
1001
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1002
|
+
|
|
1003
|
+
expect(manager.maybeStart(ctx)).toBe(false); // 自动入口:膨胀段挡住(对照)
|
|
1004
|
+
expect(manager.startForTool(ctx)).toBe(true);
|
|
1005
|
+
await bgState.active?.done;
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("custom_instructions 透传到生成输入(same-model 指令的 Additional focus 段)", async () => {
|
|
1009
|
+
const bgState = createBackgroundCompactionState();
|
|
1010
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
1011
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
1012
|
+
expect(manager.startForTool(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE), "保留验证结果")).toBe(true);
|
|
1013
|
+
const instruction = mockedCall.mock.calls[0]![1].messages.at(-1) as { content: Array<{ text: string }> };
|
|
1014
|
+
expect(instruction.content[0]!.text).toContain("保留验证结果");
|
|
1015
|
+
await bgState.active?.done;
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("无可压内容(刚压缩完/预算内)→ 抛错(诚实失败,不谎报互斥幂等)", () => {
|
|
1019
|
+
const bgState = createBackgroundCompactionState();
|
|
1020
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}));
|
|
1021
|
+
const compacted = [makeCompactionEntry("c9", null, "c9")];
|
|
1022
|
+
expect(() => manager.startForTool(makeBgCtx(compacted, TRIGGER_USAGE))).toThrow(/没有可压缩内容/);
|
|
1023
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("工具触发端到端(index.ts 注入形态):compact_context execute 真实走通到 U2 任务启动", async () => {
|
|
1027
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
1028
|
+
const registerTool = vi.fn();
|
|
1029
|
+
const hookPi = {
|
|
1030
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
1031
|
+
handlers.set(name, handler);
|
|
1032
|
+
},
|
|
1033
|
+
getAllTools: () => [],
|
|
1034
|
+
registerTool,
|
|
1035
|
+
registerCommand: vi.fn(),
|
|
1036
|
+
sendMessage: vi.fn(),
|
|
1037
|
+
appendEntry: vi.fn(),
|
|
1038
|
+
} as unknown as ExtensionAPI;
|
|
1039
|
+
smartContextExtension(hookPi);
|
|
1040
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
1041
|
+
const tool = registerTool.mock.calls[0]![0] as {
|
|
1042
|
+
execute: (
|
|
1043
|
+
id: string,
|
|
1044
|
+
params: Record<string, unknown>,
|
|
1045
|
+
signal: undefined,
|
|
1046
|
+
onUpdate: undefined,
|
|
1047
|
+
ctx: ExtensionContext,
|
|
1048
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
// 行情:420K ≥ 最低提醒档 400K(A9 护栏放行)、42% < 0.9 触发线——工具显式请求不受触发线约束
|
|
1052
|
+
mockedCall.mockResolvedValue({ ok: true, text: "s", usage: { input: 1, output: 2 } });
|
|
1053
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), { tokens: 420_000, contextWindow: 1_000_000 });
|
|
1054
|
+
const result = await tool.execute("t1", {}, undefined, undefined, ctx);
|
|
1055
|
+
expect(result.content[0]!.text).toContain("压缩已启动"); // GL1 立即回执
|
|
1056
|
+
expect(mockedCall).toHaveBeenCalledTimes(1); // 真实走通:U2 任务已启动并进入生成
|
|
1057
|
+
expect(mockedCall.mock.calls[0]![1].signal.aborted).toBe(false);
|
|
1058
|
+
for (let i = 0; i < 50; i++) await Promise.resolve(); // 任务收尾 drain
|
|
1059
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// ──────────────────────── U5:终局通知回调(D6 呈现半边:每个终局恰一次,cancelled 除外) ────────────────────────
|
|
1064
|
+
|
|
1065
|
+
describe("终局通知回调 onSettled(U5:D6 失败不静默、不重复)", () => {
|
|
1066
|
+
it("validated:恰一次回调,载荷含 result/shadowedTokens/mode/sessionId", async () => {
|
|
1067
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
1068
|
+
const onSettled = vi.fn();
|
|
1069
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx) => persistBackgroundCompaction(ctx, result, snapshot);
|
|
1070
|
+
const bgState = createBackgroundCompactionState();
|
|
1071
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), hook, undefined, onSettled);
|
|
1072
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1073
|
+
|
|
1074
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1075
|
+
await bgState.active?.done;
|
|
1076
|
+
|
|
1077
|
+
expect(onSettled).toHaveBeenCalledTimes(1); // 每任务恰一条通知(不重复)
|
|
1078
|
+
const settlement = onSettled.mock.calls[0]![0] as BackgroundTaskSettlement;
|
|
1079
|
+
expect(settlement.outcome).toBe("validated");
|
|
1080
|
+
expect(settlement.mode).toBe("same-model");
|
|
1081
|
+
expect(settlement.sessionId).toBe("s1");
|
|
1082
|
+
expect(settlement.result?.tokensBefore).toBe(TRIGGER_USAGE.tokens);
|
|
1083
|
+
expect(settlement.shadowedTokens).toBeGreaterThan(0);
|
|
1084
|
+
expect(settlement.failureStreak).toBeUndefined();
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it("inflated:回调带递增计数;dropped(校验放弃/探测失败同分支):回调无计数字段", async () => {
|
|
1088
|
+
const onSettled = vi.fn();
|
|
1089
|
+
const bgState = createBackgroundCompactionState();
|
|
1090
|
+
const afterGen = vi.fn();
|
|
1091
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), afterGen as unknown as AfterGenerationHook, undefined, onSettled);
|
|
1092
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1093
|
+
|
|
1094
|
+
// inflated(递增点②):载荷带 failureStreak 与 result
|
|
1095
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
1096
|
+
afterGen.mockResolvedValue("inflated");
|
|
1097
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1098
|
+
await bgState.active?.done;
|
|
1099
|
+
expect(onSettled.mock.calls[0]![0]).toMatchObject({ outcome: "inflated", failureStreak: 1, circuitBreakerOpen: false });
|
|
1100
|
+
|
|
1101
|
+
// dropped(校验放弃与探测失败在 settlement 层同一 "dropped" 分支——通知文案按 droppedReason 分派):
|
|
1102
|
+
// 计数不动、无 failureStreak 字段,droppedReason 原样透传
|
|
1103
|
+
afterGen.mockResolvedValue({ outcome: "dropped", reason: "session-mismatch" });
|
|
1104
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1105
|
+
await bgState.active?.done;
|
|
1106
|
+
const dropped = onSettled.mock.calls[1]![0] as BackgroundTaskSettlement;
|
|
1107
|
+
expect(dropped.outcome).toBe("dropped");
|
|
1108
|
+
expect(dropped.droppedReason).toBe("session-mismatch");
|
|
1109
|
+
expect(dropped.failureStreak).toBeUndefined();
|
|
1110
|
+
expect(dropped.circuitBreakerOpen).toBeUndefined();
|
|
1111
|
+
expect(bgState.failureStreak).toBe(1); // dropped 不递增
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it("empty(递增点①)与 error(递增点③):各回调一次,载荷带错误/计数", async () => {
|
|
1115
|
+
const onSettled = vi.fn();
|
|
1116
|
+
const bgState = createBackgroundCompactionState();
|
|
1117
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), undefined, undefined, onSettled);
|
|
1118
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1119
|
+
|
|
1120
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
1121
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1122
|
+
await bgState.active?.done;
|
|
1123
|
+
expect(onSettled.mock.calls[0]![0]).toMatchObject({ outcome: "empty", failureStreak: 1, circuitBreakerOpen: false });
|
|
1124
|
+
|
|
1125
|
+
mockedCall.mockRejectedValue(new Error("boom"));
|
|
1126
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1127
|
+
await bgState.active?.done;
|
|
1128
|
+
const errorSettlement = onSettled.mock.calls[1]![0] as BackgroundTaskSettlement;
|
|
1129
|
+
expect(errorSettlement.outcome).toBe("error");
|
|
1130
|
+
expect(errorSettlement.error).toContain("boom");
|
|
1131
|
+
expect(errorSettlement.failureStreak).toBe(2);
|
|
1132
|
+
expect(onSettled).toHaveBeenCalledTimes(2);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it("熔断标记:第 3 次失败 circuitBreakerOpen=true(自动触发将暂停,SI5)", async () => {
|
|
1136
|
+
const onSettled = vi.fn();
|
|
1137
|
+
const bgState = createBackgroundCompactionState();
|
|
1138
|
+
bgState.failureStreak = 2;
|
|
1139
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), undefined, undefined, onSettled);
|
|
1140
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
1141
|
+
expect(manager.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(true);
|
|
1142
|
+
await bgState.active?.done;
|
|
1143
|
+
expect(onSettled.mock.calls[0]![0]).toMatchObject({ outcome: "empty", failureStreak: 3, circuitBreakerOpen: true });
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("cancelled(事件取消):不回调通知(取消非失败,D3;session_compact 抢先场景不弹噪音)", async () => {
|
|
1147
|
+
const onSettled = vi.fn();
|
|
1148
|
+
const gen = holdGeneration();
|
|
1149
|
+
const bgState = createBackgroundCompactionState();
|
|
1150
|
+
const afterGen = vi.fn().mockResolvedValue("validated");
|
|
1151
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), afterGen as unknown as AfterGenerationHook, undefined, onSettled);
|
|
1152
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1153
|
+
|
|
1154
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1155
|
+
const done = bgState.active?.done;
|
|
1156
|
+
manager.cancelActive("session_compact");
|
|
1157
|
+
gen.release(true);
|
|
1158
|
+
await done;
|
|
1159
|
+
expect(onSettled).not.toHaveBeenCalled();
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it("index.ts 全接线:生成失败 → compact-failed 通知经 nextTurn 投递(不自起回合)", async () => {
|
|
1163
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
1164
|
+
const sendMessage = vi.fn();
|
|
1165
|
+
const hookPi = {
|
|
1166
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
1167
|
+
handlers.set(name, handler);
|
|
1168
|
+
},
|
|
1169
|
+
getAllTools: () => [],
|
|
1170
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
1171
|
+
sendMessage,
|
|
1172
|
+
appendEntry: vi.fn(),
|
|
1173
|
+
} as unknown as ExtensionAPI;
|
|
1174
|
+
smartContextExtension(hookPi);
|
|
1175
|
+
|
|
1176
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1177
|
+
handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
1178
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
1179
|
+
handlers.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
1180
|
+
// 任务收尾全微任务链(生成 → 续段 → settlement → 通知)
|
|
1181
|
+
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
1182
|
+
|
|
1183
|
+
expect(sendMessage).toHaveBeenCalledTimes(1); // 失败通知(不静默),无提醒(行情未越档不叠加)
|
|
1184
|
+
const [message, options] = sendMessage.mock.calls[0]!;
|
|
1185
|
+
expect(message).toMatchObject({
|
|
1186
|
+
customType: "smart-context",
|
|
1187
|
+
display: true,
|
|
1188
|
+
details: { source: "compact-failed" },
|
|
1189
|
+
});
|
|
1190
|
+
expect(String(message.content)).toContain("后台压缩失败");
|
|
1191
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("index.ts 全接线:连续失败 3 次 → 第 3 条通知含熔断语义(各失败恰一条不重复)", async () => {
|
|
1195
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
1196
|
+
const sendMessage = vi.fn();
|
|
1197
|
+
const hookPi = {
|
|
1198
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
1199
|
+
handlers.set(name, handler);
|
|
1200
|
+
},
|
|
1201
|
+
getAllTools: () => [],
|
|
1202
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
1203
|
+
sendMessage,
|
|
1204
|
+
appendEntry: vi.fn(),
|
|
1205
|
+
} as unknown as ExtensionAPI;
|
|
1206
|
+
smartContextExtension(hookPi);
|
|
1207
|
+
|
|
1208
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1209
|
+
handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
1210
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
1211
|
+
for (let i = 0; i < 3; i++) {
|
|
1212
|
+
handlers.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
1213
|
+
for (let j = 0; j < 50; j++) await Promise.resolve();
|
|
1214
|
+
}
|
|
1215
|
+
expect(sendMessage).toHaveBeenCalledTimes(3); // 每次失败一条,不重复不静默
|
|
1216
|
+
expect(String(sendMessage.mock.calls[0]![0].content)).not.toContain("自动触发压缩已暂停");
|
|
1217
|
+
expect(String(sendMessage.mock.calls[1]![0].content)).not.toContain("自动触发压缩已暂停");
|
|
1218
|
+
expect(String(sendMessage.mock.calls[2]![0].content)).toContain("自动触发压缩已暂停"); // 熔断并入第 3 条
|
|
1219
|
+
expect(String(sendMessage.mock.calls[2]![0].content)).toContain("compact_context 工具仍可手动调用");
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
// ──────────────────────── C-pi-17:后台压缩落盘段 stale ctx 防线(dmg-r1-1)────────────────────────
|
|
1224
|
+
|
|
1225
|
+
describe("落盘段 stale ctx 防线(C-pi-17:guardStaleCtx 接入,dmg-r1-1)", () => {
|
|
1226
|
+
it("isCtxStale 命中 → 放弃落盘回报 dropped(session-mismatch:代际防线语义同会话已切换):appendCompaction 不调、补偿回调不触发", async () => {
|
|
1227
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1228
|
+
const onPersisted = vi.fn();
|
|
1229
|
+
const onInflated = vi.fn();
|
|
1230
|
+
const outcome = await persistBackgroundCompaction(
|
|
1231
|
+
ctx,
|
|
1232
|
+
makeCompactionResult(),
|
|
1233
|
+
makeSnapshot(),
|
|
1234
|
+
{ onPersisted, onInflated },
|
|
1235
|
+
() => true,
|
|
1236
|
+
);
|
|
1237
|
+
expect(outcome).toEqual({ outcome: "dropped", reason: "session-mismatch" });
|
|
1238
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled();
|
|
1239
|
+
expect(onPersisted).not.toHaveBeenCalled();
|
|
1240
|
+
expect(onInflated).not.toHaveBeenCalled();
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("isCtxStale=false(缺省同语义)→ 正常路径零行为变化:照常落盘回报 validated", async () => {
|
|
1244
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1245
|
+
const onPersisted = vi.fn();
|
|
1246
|
+
const outcome = await persistBackgroundCompaction(
|
|
1247
|
+
ctx,
|
|
1248
|
+
makeCompactionResult(),
|
|
1249
|
+
makeSnapshot(),
|
|
1250
|
+
{ onPersisted },
|
|
1251
|
+
() => false,
|
|
1252
|
+
);
|
|
1253
|
+
expect(outcome).toBe("validated");
|
|
1254
|
+
expect(getAppendMock(ctx)).toHaveBeenCalledTimes(1);
|
|
1255
|
+
expect(onPersisted).toHaveBeenCalledTimes(1);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("非 stale 落盘异常仍原样上抛(检查不吞真实 bug)→ 交由 runBackgroundTask 递增点③", async () => {
|
|
1259
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1260
|
+
getAppendMock(ctx).mockImplementation(() => {
|
|
1261
|
+
throw new Error("append boom");
|
|
1262
|
+
});
|
|
1263
|
+
await expect(
|
|
1264
|
+
persistBackgroundCompaction(ctx, makeCompactionResult(), makeSnapshot(), undefined, () => false),
|
|
1265
|
+
).rejects.toThrow(/append boom/);
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it("manager 注入位把 isCtxStale 转交续段钩子(第四参);续段判 stale → 放弃落盘、计数不动", async () => {
|
|
1269
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
1270
|
+
const seen: Array<(() => boolean) | undefined> = [];
|
|
1271
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx, isCtxStale) => {
|
|
1272
|
+
seen.push(isCtxStale);
|
|
1273
|
+
return persistBackgroundCompaction(ctx, result, snapshot, undefined, isCtxStale);
|
|
1274
|
+
};
|
|
1275
|
+
const bgState = createBackgroundCompactionState();
|
|
1276
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), hook, undefined, undefined, () => true);
|
|
1277
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1278
|
+
|
|
1279
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1280
|
+
await bgState.active?.done;
|
|
1281
|
+
|
|
1282
|
+
expect(seen).toHaveLength(1);
|
|
1283
|
+
expect(seen[0]).toBeTypeOf("function");
|
|
1284
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled(); // stale → 放弃落盘
|
|
1285
|
+
expect(bgState.failureStreak).toBe(0); // dropped:会话态失效类放弃,计数不动
|
|
1286
|
+
expect(bgState.active).toBeNull();
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it("index.ts 全接线:session 替换后旧代任务的落盘段判 stale → 放弃落盘、不发通知", async () => {
|
|
1290
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>();
|
|
1291
|
+
const sendMessage = vi.fn();
|
|
1292
|
+
const makeHookPi = () =>
|
|
1293
|
+
({
|
|
1294
|
+
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
|
|
1295
|
+
handlers.set(name, handler);
|
|
1296
|
+
},
|
|
1297
|
+
getAllTools: () => [],
|
|
1298
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
1299
|
+
sendMessage,
|
|
1300
|
+
appendEntry: vi.fn(),
|
|
1301
|
+
}) as unknown as ExtensionAPI;
|
|
1302
|
+
|
|
1303
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1304
|
+
smartContextExtension(makeHookPi()); // 第 1 代
|
|
1305
|
+
handlers.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
1306
|
+
const gen = holdGeneration();
|
|
1307
|
+
handlers.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
1308
|
+
expect(mockedCall).toHaveBeenCalledTimes(1);
|
|
1309
|
+
|
|
1310
|
+
// session 替换:pi 重跑 extension factory(模块级 sessionGeneration 递增 → 第 1 代的
|
|
1311
|
+
// isCtxStale 从此判 stale;handler map 被第 2 代覆写,故第 1 代的 agent_settled 已先行取用)
|
|
1312
|
+
smartContextExtension(makeHookPi());
|
|
1313
|
+
handlers
|
|
1314
|
+
.get("session_start")!({ type: "session_start", reason: "startup" }, makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE));
|
|
1315
|
+
|
|
1316
|
+
gen.release(true);
|
|
1317
|
+
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
1318
|
+
|
|
1319
|
+
expect(getAppendMock(ctx)).not.toHaveBeenCalled(); // 第 1 代落盘段判 stale → 放弃
|
|
1320
|
+
expect(sendMessage).not.toHaveBeenCalled(); // 通知链路同一 stale 窗口降级,不谎报成败
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
// ──────────────────────── settle 错误边界(dmg-r1-2)────────────────────────
|
|
1325
|
+
|
|
1326
|
+
describe("settle 错误边界(dmg-r1-2:通知失败不改写压缩成败事实)", () => {
|
|
1327
|
+
it("validated 终局的通知抛错(非 stale 上抛)→ 落盘事实保留、熔断计数不动、不二次投递", async () => {
|
|
1328
|
+
mockedCall.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
1329
|
+
const onSettled = vi.fn(() => {
|
|
1330
|
+
throw new Error("notice delivery boom");
|
|
1331
|
+
});
|
|
1332
|
+
const hook: AfterGenerationHook = (result, snapshot, ctx) => persistBackgroundCompaction(ctx, result, snapshot);
|
|
1333
|
+
const bgState = createBackgroundCompactionState();
|
|
1334
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), hook, undefined, onSettled);
|
|
1335
|
+
const ctx = makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE);
|
|
1336
|
+
|
|
1337
|
+
expect(manager.maybeStart(ctx)).toBe(true);
|
|
1338
|
+
await expect(bgState.active?.done).resolves.toBeUndefined();
|
|
1339
|
+
|
|
1340
|
+
expect(getAppendMock(ctx)).toHaveBeenCalledTimes(1); // 落盘成功(事实未被通知失败篡改)
|
|
1341
|
+
expect(bgState.failureStreak).toBe(0); // 不递增熔断
|
|
1342
|
+
expect(onSettled).toHaveBeenCalledTimes(1); // 恰一次通知(不改走 error 终局重复投递)
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("error 终局的通知也抛错 → done 仍 resolve(防 unhandled rejection),递增点③照常", async () => {
|
|
1346
|
+
// 契约锁:settle 内吞是主防线(通知失败不改写成败事实),task.done 的 .catch 是末梢
|
|
1347
|
+
// 兜底(防未预见抛漏泄漏为无人 await 的 rejection)——两者同时失守时本用例转红。
|
|
1348
|
+
mockedCall.mockRejectedValue(new Error("boom"));
|
|
1349
|
+
const onSettled = vi.fn(() => {
|
|
1350
|
+
throw new Error("notice boom");
|
|
1351
|
+
});
|
|
1352
|
+
const bgState = createBackgroundCompactionState();
|
|
1353
|
+
const manager = makeBgManager(bgState, normalizeSmartContextConfig({}), undefined, undefined, onSettled);
|
|
1354
|
+
expect(manager.maybeStart(makeBgCtx(sampleEntriesNoCompaction(), TRIGGER_USAGE))).toBe(true);
|
|
1355
|
+
|
|
1356
|
+
await expect(bgState.active?.done).resolves.toBeUndefined();
|
|
1357
|
+
expect(bgState.failureStreak).toBe(1); // 递增点③照常(通知失败不影响失败计数本身)
|
|
1358
|
+
expect(bgState.active).toBeNull();
|
|
1359
|
+
});
|
|
1360
|
+
});
|