@zhushanwen/pi-smart-context 0.4.3 → 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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// src/__tests__/notices.test.ts
|
|
2
|
+
//
|
|
3
|
+
// U5/D6 通知文案构建纯函数(buildCompactCompleteNotice / buildCompactFailureNotice)。
|
|
4
|
+
// 投递形态断言(customType/display/{triggerTurn:false, deliverAs:"nextTurn"})在装配层
|
|
5
|
+
// 覆盖:index-reminder-delivery.test.ts(提醒/模型通知)+ compact-handler.test.ts(压缩
|
|
6
|
+
// 完成/失败通知全接线),本文件只锁文案内容与条件分支。
|
|
7
|
+
//
|
|
8
|
+
// C1 落盘信号(sendCompactionPersistSignal)的形态在本文件直测:它是 runtime 分诊消费的
|
|
9
|
+
// 机器信号(非用户可见通知),字段与投递选项是跨层契约,漂移即分界行链路断。
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { COMPACTION_ENTRY_APPENDED_SOURCE, SMART_CONTEXT_NOTICE_CUSTOM_TYPE } from "@zhushanwen/extension-protocol";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
buildCompactCompleteNotice,
|
|
18
|
+
buildCompactFailureNotice,
|
|
19
|
+
sendCompactionPersistSignal,
|
|
20
|
+
sendSmartContextImmediateNotice,
|
|
21
|
+
sendSmartContextNotice,
|
|
22
|
+
} from "../notices.js";
|
|
23
|
+
|
|
24
|
+
describe("buildCompactCompleteNotice(U5/D6 完成通知:压缩前后 token 数与模式)", () => {
|
|
25
|
+
it("含模式、前后 token(formatK 口径)与下一条消息生效语义", () => {
|
|
26
|
+
const text = buildCompactCompleteNotice({ mode: "same-model", tokensBefore: 190_000, tokensAfter: 45_000 });
|
|
27
|
+
expect(text).toContain("[smart-context]");
|
|
28
|
+
expect(text).toContain("后台压缩完成");
|
|
29
|
+
expect(text).toContain("same-model");
|
|
30
|
+
expect(text).toContain("190K");
|
|
31
|
+
expect(text).toContain("45K");
|
|
32
|
+
expect(text).toContain("下一条消息");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("cross-model 模式名照原样展示", () => {
|
|
36
|
+
const text = buildCompactCompleteNotice({ mode: "cross-model", tokensBefore: 1_234_500, tokensAfter: 200_000 });
|
|
37
|
+
expect(text).toContain("cross-model");
|
|
38
|
+
expect(text).toContain("1234.5K");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("buildCompactFailureNotice(U5/D6 失败通知:四终局原因行,不静默)", () => {
|
|
43
|
+
it("inflated(收缩校验放弃):说明无净收益与未落盘", () => {
|
|
44
|
+
const text = buildCompactFailureNotice({ outcome: "inflated" });
|
|
45
|
+
expect(text).toContain("未缩小上下文");
|
|
46
|
+
expect(text).toContain("未落盘");
|
|
47
|
+
expect(text).toContain("[smart-context]");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("dropped(被抢先):说明另一轮压缩已生效,不声称「上下文保持原样」", () => {
|
|
51
|
+
const text = buildCompactFailureNotice({ outcome: "dropped", droppedReason: "preempted" });
|
|
52
|
+
expect(text).toContain("另一轮压缩已先行落盘");
|
|
53
|
+
expect(text).toContain("未再写入");
|
|
54
|
+
expect(text).not.toContain("上下文保持原样"); // 上下文已被较新压缩结果改变
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("dropped(会话已切换):说明切换事实,不声称「上下文保持原样」", () => {
|
|
58
|
+
const text = buildCompactFailureNotice({ outcome: "dropped", droppedReason: "session-mismatch" });
|
|
59
|
+
expect(text).toContain("会话已切换");
|
|
60
|
+
expect(text).toContain("未落盘");
|
|
61
|
+
expect(text).toContain("不受本次压缩影响");
|
|
62
|
+
expect(text).not.toContain("上下文保持原样");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("dropped(写入接口不可用):上下文确实未被触碰,如实声明保持原样", () => {
|
|
66
|
+
const text = buildCompactFailureNotice({ outcome: "dropped", droppedReason: "persist-unavailable" });
|
|
67
|
+
expect(text).toContain("写入接口不可用");
|
|
68
|
+
expect(text).toContain("上下文保持原样");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("dropped(未细分缺省):只说会话状态变化与未落盘,不声称保持原样", () => {
|
|
72
|
+
const text = buildCompactFailureNotice({ outcome: "dropped" });
|
|
73
|
+
expect(text).toContain("会话状态已变化");
|
|
74
|
+
expect(text).toContain("未落盘");
|
|
75
|
+
expect(text).not.toContain("上下文保持原样");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("empty(生成空结果):带重试指引", () => {
|
|
79
|
+
const text = buildCompactFailureNotice({ outcome: "empty" });
|
|
80
|
+
expect(text).toContain("未返回结果");
|
|
81
|
+
expect(text).toContain("compact_context");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("error(异常):错误消息嵌入文案", () => {
|
|
85
|
+
const text = buildCompactFailureNotice({ outcome: "error", error: "connect ECONNREFUSED" });
|
|
86
|
+
expect(text).toContain("connect ECONNREFUSED");
|
|
87
|
+
expect(text).toContain("compact_context");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("熔断达到阈值:附加「自动触发已暂停」行(并入本条,不另发通知)", () => {
|
|
91
|
+
const text = buildCompactFailureNotice({ outcome: "empty", failureStreak: 3, circuitBreakerOpen: true });
|
|
92
|
+
expect(text).toContain("连续失败 3 次");
|
|
93
|
+
expect(text).toContain("自动触发压缩已暂停");
|
|
94
|
+
expect(text).toContain("compact_context 工具仍可手动调用"); // SI5:工具路径不受熔断影响
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("未达熔断:empty/error 带重试行;inflated/dropped(含各细分原因)不带(放弃类无需重试指引)", () => {
|
|
98
|
+
expect(buildCompactFailureNotice({ outcome: "empty", failureStreak: 1 })).toContain("重试");
|
|
99
|
+
expect(buildCompactFailureNotice({ outcome: "inflated" })).not.toContain("重试");
|
|
100
|
+
for (const droppedReason of ["preempted", "session-mismatch", "persist-unavailable"] as const) {
|
|
101
|
+
expect(buildCompactFailureNotice({ outcome: "dropped", droppedReason })).not.toContain("重试");
|
|
102
|
+
}
|
|
103
|
+
expect(buildCompactFailureNotice({ outcome: "dropped" })).not.toContain("重试");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("落盘信号词表值锚定(extension-protocol SSOT,contract.notify-custom-types 先例同款)", () => {
|
|
108
|
+
it("SMART_CONTEXT_NOTICE_CUSTOM_TYPE === 'smart-context'(历史 session JSONL 已有该 customType entry,改值 = 跨侧破坏性变更)", () => {
|
|
109
|
+
expect(SMART_CONTEXT_NOTICE_CUSTOM_TYPE).toBe("smart-context");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("COMPACTION_ENTRY_APPENDED_SOURCE === 'compaction-entry-appended'(runtime 分诊 details.source 字节契约)", () => {
|
|
113
|
+
expect(COMPACTION_ENTRY_APPENDED_SOURCE).toBe("compaction-entry-appended");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("sendSmartContextImmediateNotice(C2 手动触发失败回执:即时可见投递)", () => {
|
|
118
|
+
function makePi(): { pi: ExtensionAPI; sendMessage: ReturnType<typeof vi.fn> } {
|
|
119
|
+
const sendMessage = vi.fn();
|
|
120
|
+
return { pi: { sendMessage } as unknown as ExtensionAPI, sendMessage };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it("投递选项无 deliverAs({triggerTurn:false})——空闲即达,不进 nextTurn pending 队列", () => {
|
|
124
|
+
const { pi, sendMessage } = makePi();
|
|
125
|
+
sendSmartContextImmediateNotice(pi, "后台压缩未启动:原因", "compact-start-failed");
|
|
126
|
+
const [message, options] = sendMessage.mock.calls[0]!;
|
|
127
|
+
// toEqual 全量锁投递选项:无 deliverAs = pi sendCustomMessage 分支序不走
|
|
128
|
+
// _pendingNextTurnMessages(nextTurn 通道),空闲分支直接 _appendCustomMessage
|
|
129
|
+
// (立即 append + emit,用户当下可见——失败回执与触发动作不脱钩)
|
|
130
|
+
expect(options).toEqual({ triggerTurn: false });
|
|
131
|
+
// 消息载荷与 nextTurn 形态一致:customType 单源常量 + display:true 可见 + source 定位
|
|
132
|
+
expect(message).toEqual({
|
|
133
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
134
|
+
content: "后台压缩未启动:原因",
|
|
135
|
+
display: true,
|
|
136
|
+
details: { source: "compact-start-failed" },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("busy 回执同一即时形态(source=compact-busy)", () => {
|
|
141
|
+
const { pi, sendMessage } = makePi();
|
|
142
|
+
sendSmartContextImmediateNotice(pi, "后台压缩进行中", "compact-busy");
|
|
143
|
+
const [, options] = sendMessage.mock.calls[0]!;
|
|
144
|
+
expect(options).toEqual({ triggerTurn: false });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("对照:sendSmartContextNotice(其余通知)保持 nextTurn 不动", () => {
|
|
148
|
+
const { pi, sendMessage } = makePi();
|
|
149
|
+
sendSmartContextNotice(pi, "后台压缩完成", "compact-complete");
|
|
150
|
+
const [, options] = sendMessage.mock.calls[0]!;
|
|
151
|
+
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("sendCompactionPersistSignal(C1 落盘信号:runtime 分诊消费的机器信号)", () => {
|
|
156
|
+
function makePi(impl?: () => void): { pi: ExtensionAPI; sendMessage: ReturnType<typeof vi.fn> } {
|
|
157
|
+
const sendMessage = vi.fn(impl);
|
|
158
|
+
return { pi: { sendMessage } as unknown as ExtensionAPI, sendMessage };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it("形态契约:customType 单源常量 + display:false + content 空串 + details{source, compactionId} + {triggerTurn:false} 无 deliverAs", () => {
|
|
162
|
+
const { pi, sendMessage } = makePi();
|
|
163
|
+
sendCompactionPersistSignal(pi, "entry-1");
|
|
164
|
+
|
|
165
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
166
|
+
const [message, options] = sendMessage.mock.calls[0]!;
|
|
167
|
+
expect(message).toEqual({
|
|
168
|
+
customType: SMART_CONTEXT_NOTICE_CUSTOM_TYPE,
|
|
169
|
+
content: "",
|
|
170
|
+
display: false,
|
|
171
|
+
details: { source: COMPACTION_ENTRY_APPENDED_SOURCE, compactionId: "entry-1" },
|
|
172
|
+
});
|
|
173
|
+
// toEqual 全量锁投递选项:无 deliverAs(空闲时 pi 立即 append 并 emit,秒级直达)
|
|
174
|
+
expect(options).toEqual({ triggerTurn: false });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("compactionId undefined 透传(形状声明保守允许;runtime 侧降级尾部兜底)", () => {
|
|
178
|
+
const { pi, sendMessage } = makePi();
|
|
179
|
+
sendCompactionPersistSignal(pi, undefined);
|
|
180
|
+
|
|
181
|
+
expect(sendMessage.mock.calls[0]![0].details).toEqual({
|
|
182
|
+
source: COMPACTION_ENTRY_APPENDED_SOURCE,
|
|
183
|
+
compactionId: undefined,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("sendMessage 同步抛错 → 内吞不上抛(上抛会把已落盘的压缩改记失败,dmg-r1-2 同语义)", () => {
|
|
188
|
+
const { pi, sendMessage } = makePi(() => {
|
|
189
|
+
throw new Error("boom");
|
|
190
|
+
});
|
|
191
|
+
expect(() => sendCompactionPersistSignal(pi, "entry-1")).not.toThrow();
|
|
192
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -46,6 +46,23 @@ describe("normalizeSmartContextConfig", () => {
|
|
|
46
46
|
const c = normalizeSmartContextConfig({ compactModel: { type: "regex", pattern: "x" } });
|
|
47
47
|
expect(c.compactModel).toEqual({ type: "ref", ref: "" });
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
it("autoCompactThresholdRatio 缺省/非法类型回默认 0.9(D4 用户裁决)", () => {
|
|
51
|
+
expect(DEFAULT_SMART_CONTEXT_CONFIG.autoCompactThresholdRatio).toBe(0.9);
|
|
52
|
+
expect(normalizeSmartContextConfig({}).autoCompactThresholdRatio).toBe(0.9);
|
|
53
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: "0.8" }).autoCompactThresholdRatio).toBe(0.9);
|
|
54
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: null }).autoCompactThresholdRatio).toBe(0.9);
|
|
55
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: Number.NaN }).autoCompactThresholdRatio).toBe(0.9);
|
|
56
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: Number.POSITIVE_INFINITY }).autoCompactThresholdRatio).toBe(0.9);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("autoCompactThresholdRatio 有限数越界钳制到 [0,1],区间内直通", () => {
|
|
60
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: 1.5 }).autoCompactThresholdRatio).toBe(1);
|
|
61
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: -0.2 }).autoCompactThresholdRatio).toBe(0);
|
|
62
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: 0.95 }).autoCompactThresholdRatio).toBe(0.95);
|
|
63
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: 0 }).autoCompactThresholdRatio).toBe(0);
|
|
64
|
+
expect(normalizeSmartContextConfig({ autoCompactThresholdRatio: 1 }).autoCompactThresholdRatio).toBe(1);
|
|
65
|
+
});
|
|
49
66
|
});
|
|
50
67
|
|
|
51
68
|
describe("门控与模式判定(D5/D12)", () => {
|
|
@@ -78,7 +95,7 @@ describe("门控与模式判定(D5/D12)", () => {
|
|
|
78
95
|
});
|
|
79
96
|
|
|
80
97
|
describe("阈值检查(D3/D6)", () => {
|
|
81
|
-
it("findCrossedThresholds:null tokens 容错为空(R7
|
|
98
|
+
it("findCrossedThresholds:null tokens 容错为空(R7 旧编号,见 compact-handler.ts 头注消歧);已 fired 排除", () => {
|
|
82
99
|
const tiers = [5_000, 10_000, 15_000];
|
|
83
100
|
expect(findCrossedThresholds(tiers, null, new Set())).toEqual([]);
|
|
84
101
|
expect(findCrossedThresholds(tiers, 11_000, new Set([5_000]))).toEqual([10_000]);
|
|
@@ -185,7 +202,8 @@ describe("deriveFiredThresholds(D15:session entries → 已提醒档位)",
|
|
|
185
202
|
|
|
186
203
|
describe("estimateShadowedTokens 口径契约(D2:与 pi estimateTokens 同源)", () => {
|
|
187
204
|
// 合法 AssistantMessage 形状的 fixture(block 数组 content,禁止 as 硬塞 string content——
|
|
188
|
-
// estimateTokens 对 string content 计 0
|
|
205
|
+
// estimateTokens 对 string content 计 0,口径用例会静默失去判别力;上一代接管设计 §8.3,
|
|
206
|
+
// 旧编号,文档已不在可达域,见 compact-handler.ts 头注消歧)。
|
|
189
207
|
// 不 import 名义类型:const 泛型保留 block 字面量形状,由 estimateShadowedTokens
|
|
190
208
|
// 参数类型(Parameters<typeof estimateTokens>[0])在调用点结构校验。
|
|
191
209
|
function makeAssistantMessage<const C extends readonly unknown[]>(content: C) {
|
|
@@ -14,7 +14,7 @@ vi.mock("../pure.js", async (importOriginal) => {
|
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
import { DEFAULT_SMART_CONTEXT_CONFIG, loadSmartContextConfig } from "../pure.js";
|
|
17
|
-
import { buildCompactContextSummary, registerCompactContextTool } from "../tool.js";
|
|
17
|
+
import { buildCompactContextSummary, registerCompactContextTool, type BackgroundCompactionControls } from "../tool.js";
|
|
18
18
|
import { renderCompactContextResult } from "../render-result.js";
|
|
19
19
|
|
|
20
20
|
const mockedLoad = vi.mocked(loadSmartContextConfig);
|
|
@@ -23,6 +23,11 @@ beforeEach(() => {
|
|
|
23
23
|
mockedLoad.mockReturnValue({ ...DEFAULT_SMART_CONTEXT_CONFIG });
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
/** 后台任务依赖注入桩(BackgroundCompactionControls 契约的测试替身,形态对齐 tool.test.ts)。 */
|
|
27
|
+
function makeBackground(): BackgroundCompactionControls {
|
|
28
|
+
return { isActive: () => false, start: () => true };
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
interface RegisteredTool { // oe-exempt:20261017:test:测试 harness 契约——断言面引用其字段类型,非抽象接口
|
|
27
32
|
name: string;
|
|
28
33
|
execute: (
|
|
@@ -57,8 +62,8 @@ function makeCtx(): ExtensionContext {
|
|
|
57
62
|
// ── buildCompactContextSummary ───────────────────────
|
|
58
63
|
|
|
59
64
|
describe("buildCompactContextSummary", () => {
|
|
60
|
-
it("
|
|
61
|
-
expect(buildCompactContextSummary()).toBe("
|
|
65
|
+
it("恒为「后台压缩已启动」(后台执行:返回时任务刚启动,完成语义由完成通知承载)", () => {
|
|
66
|
+
expect(buildCompactContextSummary()).toBe("后台压缩已启动");
|
|
62
67
|
});
|
|
63
68
|
});
|
|
64
69
|
|
|
@@ -67,13 +72,11 @@ describe("buildCompactContextSummary", () => {
|
|
|
67
72
|
describe("compact_context execute details.summary", () => {
|
|
68
73
|
it("details 带现有字段 + summary", async () => {
|
|
69
74
|
const { pi, tools } = makePi();
|
|
70
|
-
registerCompactContextTool(pi);
|
|
75
|
+
registerCompactContextTool(pi, { background: makeBackground() });
|
|
71
76
|
const res = await tools[0]!.execute("t1", {}, undefined, undefined, makeCtx());
|
|
72
77
|
expect(res.details).toMatchObject({
|
|
73
78
|
mode: "same-model",
|
|
74
|
-
|
|
75
|
-
fellBack: false,
|
|
76
|
-
summary: "Context compacted",
|
|
79
|
+
summary: "后台压缩已启动",
|
|
77
80
|
});
|
|
78
81
|
});
|
|
79
82
|
});
|
|
@@ -105,7 +108,7 @@ function renderedText(result: unknown, expanded: boolean): string {
|
|
|
105
108
|
describe("renderCompactContextResult", () => {
|
|
106
109
|
const result = {
|
|
107
110
|
content: [{ type: "text", text: "压缩已启动(same-model 模式)..." }],
|
|
108
|
-
details: { mode: "same-model",
|
|
111
|
+
details: { mode: "same-model", summary: "Context compacted" },
|
|
109
112
|
};
|
|
110
113
|
|
|
111
114
|
it("折叠态 → details.summary 一行", () => {
|
|
@@ -10,7 +10,7 @@ vi.mock("../pure.js", async (importOriginal) => {
|
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
import { DEFAULT_SMART_CONTEXT_CONFIG, loadSmartContextConfig } from "../pure.js";
|
|
13
|
-
import { registerCompactContextTool } from "../tool.js";
|
|
13
|
+
import { registerCompactContextTool, type BackgroundCompactionControls } from "../tool.js";
|
|
14
14
|
import { countCompactions } from "../pure.js";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
|
|
@@ -23,38 +23,38 @@ interface RegisteredTool {
|
|
|
23
23
|
execute: (id: string, params: Record<string, unknown>, signal: undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
customInstructions?: string;
|
|
28
|
-
onComplete: (r: unknown) => void;
|
|
29
|
-
onError: (e: Error) => void;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
function makePi(): { pi: ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn>; sendUserMessage: ReturnType<typeof vi.fn> }; tools: RegisteredTool[] } {
|
|
26
|
+
function makePi(): { pi: ExtensionAPI; tools: RegisteredTool[] } {
|
|
33
27
|
const tools: RegisteredTool[] = [];
|
|
34
28
|
const pi = {
|
|
35
29
|
registerTool: (t: RegisteredTool) => tools.push(t),
|
|
36
|
-
|
|
37
|
-
sendUserMessage: vi.fn(),
|
|
38
|
-
} as unknown as ExtensionAPI & { sendMessage: ReturnType<typeof vi.fn>; sendUserMessage: ReturnType<typeof vi.fn> };
|
|
30
|
+
} as unknown as ExtensionAPI;
|
|
39
31
|
return { pi, tools };
|
|
40
32
|
}
|
|
41
33
|
|
|
42
|
-
|
|
34
|
+
/** ctx.compact 恒为 spy:RL5 交互断言——execute 不再触碰压缩执行路径,被调用即红。 */
|
|
35
|
+
function makeCtx(): ExtensionContext {
|
|
43
36
|
return {
|
|
44
37
|
model: { provider: "zai", id: "glm" },
|
|
45
38
|
getContextUsage: () => ({ tokens: 450_000, contextWindow: 1_000_000 }),
|
|
46
39
|
sessionManager: { getEntries: () => [] },
|
|
47
|
-
compact:
|
|
48
|
-
options.onComplete({
|
|
49
|
-
tokensBefore: 500_000,
|
|
50
|
-
estimatedTokensAfter: 24_000,
|
|
51
|
-
usage: { input: 432_000, output: 1_800, cacheRead: 0 },
|
|
52
|
-
details: { engine: "smart-context", mode: "same-model" },
|
|
53
|
-
});
|
|
54
|
-
}),
|
|
40
|
+
compact: vi.fn(),
|
|
55
41
|
} as unknown as ExtensionContext;
|
|
56
42
|
}
|
|
57
43
|
|
|
44
|
+
interface BackgroundSpies {
|
|
45
|
+
isActive: ReturnType<typeof vi.fn<() => boolean>>;
|
|
46
|
+
start: ReturnType<typeof vi.fn<(ctx: ExtensionContext, customInstructions?: string) => boolean>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 后台任务依赖注入桩(BackgroundCompactionControls 契约的测试替身)。 */
|
|
50
|
+
function makeBackground(
|
|
51
|
+
{ active = false, startResult = true }: { active?: boolean; startResult?: boolean } = {},
|
|
52
|
+
): { controls: BackgroundCompactionControls; spies: BackgroundSpies } {
|
|
53
|
+
const isActive = vi.fn((): boolean => active);
|
|
54
|
+
const start = vi.fn((_ctx: ExtensionContext, _customInstructions?: string): boolean => startResult);
|
|
55
|
+
return { controls: { isActive, start }, spies: { isActive, start } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
58
|
const mockedLoad = vi.mocked(loadSmartContextConfig);
|
|
59
59
|
|
|
60
60
|
beforeEach(() => {
|
|
@@ -62,7 +62,7 @@ beforeEach(() => {
|
|
|
62
62
|
mockedLoad.mockReturnValue(DEFAULT_SMART_CONTEXT_CONFIG);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
describe("compact_context 工具(
|
|
65
|
+
describe("compact_context 工具(U4 纯触发化:后台任务触发 + 立即回执)", () => {
|
|
66
66
|
it("parameters 顶层为 Type.Object(OpenAI 兼容红线)", () => {
|
|
67
67
|
const { pi, tools } = makePi();
|
|
68
68
|
registerCompactContextTool(pi);
|
|
@@ -81,13 +81,25 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
81
81
|
expect(tools[0]!.exposure).toBeUndefined();
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
// V7 工具路径回归:门控拒绝回现状文案,且不触达后台任务(无后台任务启动)
|
|
85
|
+
it("门控拒绝(排除命中)→ throw 带原因与恢复指引(D5),不触达后台任务(V7)", async () => {
|
|
85
86
|
mockedLoad.mockReturnValue({ ...DEFAULT_SMART_CONTEXT_CONFIG, excludedModels: ["zai/glm"] });
|
|
86
87
|
const { pi, tools } = makePi();
|
|
87
|
-
|
|
88
|
+
const { controls, spies } = makeBackground();
|
|
89
|
+
registerCompactContextTool(pi, { gatingProbe: () => ({ active: false, modelId: "zai/glm" }), background: controls });
|
|
88
90
|
await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/已配置为排除/);
|
|
91
|
+
expect(spies.isActive).not.toHaveBeenCalled();
|
|
92
|
+
expect(spies.start).not.toHaveBeenCalled();
|
|
89
93
|
});
|
|
90
94
|
|
|
95
|
+
it("门控拒绝(禁用)→ throw 带恢复指引(D5)", async () => {
|
|
96
|
+
mockedLoad.mockReturnValue({ ...DEFAULT_SMART_CONTEXT_CONFIG, enabled: false });
|
|
97
|
+
const { pi, tools } = makePi();
|
|
98
|
+
registerCompactContextTool(pi, { gatingProbe: () => ({ active: false, modelId: "zai/glm" }) });
|
|
99
|
+
await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/已禁用/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A9 成本护栏(UC-12):checkToolThresholdGuard 保留的既有用例
|
|
91
103
|
it("阈值保护:低于最低档 throw 带用量数据(D6)", async () => {
|
|
92
104
|
const { pi, tools } = makePi();
|
|
93
105
|
registerCompactContextTool(pi, { usageProbe: () => ({ tokens: 38_000, contextWindow: 1_000_000 }) });
|
|
@@ -100,79 +112,76 @@ describe("compact_context 工具(R2 降级态:fire-and-forget + 结果注入
|
|
|
100
112
|
await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/用量未知/);
|
|
101
113
|
});
|
|
102
114
|
|
|
103
|
-
|
|
115
|
+
// RL5 单测半边:工具触发路径不再掐断进行中回合——mock 层断言不再调用压缩执行
|
|
116
|
+
// 路径(ctx.compact 的 abort 副作用随之消失)+ 启动后台任务后立即回执(GL1)
|
|
117
|
+
it("RL5:execute 不触碰 ctx.compact 压缩执行路径(交互断言)+ 启动后台任务立即回执", async () => {
|
|
104
118
|
const { pi, tools } = makePi();
|
|
105
|
-
|
|
106
|
-
const
|
|
119
|
+
const { controls, spies } = makeBackground();
|
|
120
|
+
const ctx = makeCtx();
|
|
121
|
+
registerCompactContextTool(pi, { getEntries: () => [{ type: "message" }], background: controls });
|
|
122
|
+
const result = await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
123
|
+
expect(ctx.compact).not.toHaveBeenCalled();
|
|
124
|
+
expect(spies.start).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(spies.start.mock.calls[0]?.[0]).toBe(ctx);
|
|
107
126
|
expect(result.content[0].text).toContain("压缩已启动");
|
|
108
|
-
expect(result.content[0].text).toContain("
|
|
109
|
-
expect(result.
|
|
127
|
+
expect(result.content[0].text).toContain("后台进行");
|
|
128
|
+
expect(result.content[0].text).toContain("可以继续任何工作");
|
|
129
|
+
// 等待指引退役:不再要求「引用早期上下文的操作等结果消息到达」
|
|
130
|
+
expect(result.content[0].text).not.toContain("等结果消息");
|
|
131
|
+
expect(result.details).toMatchObject({ mode: "same-model", compactionCount: 1 });
|
|
110
132
|
});
|
|
111
133
|
|
|
112
|
-
it("
|
|
134
|
+
it("custom_instructions 透传给后台启动;空白串归一为 undefined", async () => {
|
|
113
135
|
const { pi, tools } = makePi();
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Text.render 按宽度 pad,trim 后断言(折叠态 = details.summary 一行)
|
|
121
|
-
expect(lines.length).toBe(1);
|
|
122
|
-
expect(lines[0].trim()).toBe("Context compacted");
|
|
136
|
+
const { controls, spies } = makeBackground();
|
|
137
|
+
registerCompactContextTool(pi, { background: controls });
|
|
138
|
+
await tools[0].execute("t1", { custom_instructions: "保留验证结果" }, undefined, undefined, makeCtx());
|
|
139
|
+
expect(spies.start.mock.calls[0]?.[1]).toBe("保留验证结果");
|
|
140
|
+
await tools[0].execute("t1", { custom_instructions: " " }, undefined, undefined, makeCtx());
|
|
141
|
+
expect(spies.start.mock.calls[1]?.[1]).toBeUndefined();
|
|
123
142
|
});
|
|
124
143
|
|
|
125
|
-
|
|
144
|
+
// RL1 互斥幂等:已有后台任务进行中 → 幂等成功回执(不是错误),不再启动
|
|
145
|
+
it("互斥幂等:后台任务进行中 →「后台压缩进行中」成功回执,不再启动新任务", async () => {
|
|
126
146
|
const { pi, tools } = makePi();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
expect(
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
const text = message.content as string;
|
|
136
|
-
expect(text).toContain("压缩完成");
|
|
137
|
-
expect(text).toContain("same-model");
|
|
138
|
-
expect(text).toContain("500K");
|
|
139
|
-
expect(text).toContain("24K");
|
|
140
|
-
expect(text).toContain("compacted multiple times");
|
|
141
|
-
// 旧投递路径(自起 run 的 user 消息)已退役:不再调用 sendUserMessage(mock 挂 spy,被调用即断言红)
|
|
142
|
-
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
147
|
+
const { controls, spies } = makeBackground({ active: true });
|
|
148
|
+
registerCompactContextTool(pi, { background: controls });
|
|
149
|
+
const result = await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
|
|
150
|
+
expect(result.content[0].text).toContain("后台压缩进行中");
|
|
151
|
+
expect(result.content[0].text).toContain("可以继续任何工作");
|
|
152
|
+
expect(spies.start).not.toHaveBeenCalled();
|
|
153
|
+
// 无新任务启动,压缩序号不变
|
|
154
|
+
expect(result.details).toMatchObject({ compactionCount: 0 });
|
|
143
155
|
});
|
|
144
156
|
|
|
145
|
-
it("
|
|
157
|
+
it("竞态兜底:isActive 空闲但 start 返回 false(互斥撞上)→ 同幂等回执", async () => {
|
|
146
158
|
const { pi, tools } = makePi();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await tools[0].execute("t1", {}, undefined, undefined,
|
|
150
|
-
expect(
|
|
151
|
-
expect(pi.sendMessage.mock.calls[0][0].content).toContain("回退");
|
|
159
|
+
const { controls } = makeBackground({ startResult: false });
|
|
160
|
+
registerCompactContextTool(pi, { background: controls });
|
|
161
|
+
const result = await tools[0].execute("t1", {}, undefined, undefined, makeCtx());
|
|
162
|
+
expect(result.content[0].text).toContain("后台压缩进行中");
|
|
152
163
|
});
|
|
153
164
|
|
|
154
|
-
|
|
165
|
+
// 接线缺失 fail-fast:不撒谎回执、不静默降级(BackgroundCompactionControls 契约)
|
|
166
|
+
it("background 未注入(生产接线缺失)→ 门控与阈值检查后抛「后台压缩未接线」", async () => {
|
|
155
167
|
const { pi, tools } = makePi();
|
|
156
|
-
registerCompactContextTool(pi);
|
|
157
|
-
|
|
158
|
-
await tools[0].execute("t1", {}, undefined, undefined, ctx);
|
|
159
|
-
const [message, options] = pi.sendMessage.mock.calls[0];
|
|
160
|
-
expect(message.content).toContain("压缩失败");
|
|
161
|
-
expect(message.content).toContain("Nothing to compact");
|
|
162
|
-
expect(message.details).toEqual({ source: "compact-failed" });
|
|
163
|
-
expect(options).toEqual({ triggerTurn: false, deliverAs: "nextTurn" });
|
|
168
|
+
registerCompactContextTool(pi, { getEntries: () => [{ type: "message" }] });
|
|
169
|
+
await expect(tools[0].execute("t1", {}, undefined, undefined, makeCtx())).rejects.toThrow(/后台压缩未接线/);
|
|
164
170
|
});
|
|
165
171
|
|
|
166
|
-
it("
|
|
167
|
-
let received: string | undefined;
|
|
172
|
+
it("renderResult 接线(C-ext-05):注册定义携带函数成员 + 折叠态输出 details.summary 一行", async () => {
|
|
168
173
|
const { pi, tools } = makePi();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
const { controls } = makeBackground();
|
|
175
|
+
registerCompactContextTool(pi, { background: controls });
|
|
176
|
+
const tool = tools[0];
|
|
177
|
+
expect(typeof tool.renderResult).toBe("function");
|
|
178
|
+
const result = await tool.execute("t1", {}, undefined, undefined, makeCtx());
|
|
179
|
+
const renderTheme = { fg: (_token: string, text: string) => text } as unknown as Theme;
|
|
180
|
+
const lines = tool.renderResult!(result, { expanded: false }, renderTheme).render(400);
|
|
181
|
+
// Text.render 按宽度 pad,trim 后断言(折叠态 = details.summary 一行;U5 文案:
|
|
182
|
+
// 工具返回时任务刚启动,「已完成」语义由完成通知承载)
|
|
183
|
+
expect(lines.length).toBe(1);
|
|
184
|
+
expect(lines[0].trim()).toBe("后台压缩已启动");
|
|
176
185
|
});
|
|
177
186
|
|
|
178
187
|
it("countCompactions 只数 compaction entries", () => {
|