@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// src/__tests__/index-compaction-signal.test.ts
|
|
2
|
+
//
|
|
3
|
+
// C1 分界行实时化(feat-compaction-closure):后台压缩落盘信号在 index.ts 装配点的端到端
|
|
4
|
+
// 回归。信号挂在「落盘成功」事实点(BackgroundPersistCallbacks.onPersisted,携带 entry id),
|
|
5
|
+
// 不在终局回调点——分界行帧的数据源(compactionId)只有落盘现场有。
|
|
6
|
+
//
|
|
7
|
+
// 本文件锁三件事:
|
|
8
|
+
// ① validated 终局(persist 成功)→ sendMessage 落盘信号恰一条(形态见 notices 单测,
|
|
9
|
+
// 此处锁装配链路真正发出 + 先于完成通知);
|
|
10
|
+
// ② 失败终局(生成空结果)→ 无落盘信号(失败通知照旧,不混入信号帧判据);
|
|
11
|
+
// ③ 信号投递同步抛错 → 不改写压缩成败(完成通知照常投递,dmg-r1-2 同语义)。
|
|
12
|
+
//
|
|
13
|
+
// 信号→分界行帧的后半段(runtime 分诊合成)由 runtime 包单测覆盖,两端以 extension-protocol
|
|
14
|
+
// 词表常量对齐(值漂移结构性不可能)。
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
20
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
23
|
+
getLogger: () => loggerMock,
|
|
24
|
+
createLogger: () => loggerMock,
|
|
25
|
+
setPiHandle: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// 工具注册短路(本文件只测后台压缩信号链路装配;工具装配由 index-generation.test.ts 覆盖)
|
|
29
|
+
vi.mock("../tool.js", () => ({
|
|
30
|
+
registerCompactContextTool: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// same-model 生成替换(不触网;成功/空结果按用例改写)
|
|
34
|
+
const { callSameModelCompactionMock } = vi.hoisted(() => ({
|
|
35
|
+
callSameModelCompactionMock: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("../llm.js", () => ({
|
|
38
|
+
callSameModelCompaction: callSameModelCompactionMock,
|
|
39
|
+
projectTools: (tools: unknown[]) => tools.map((t) => ({ name: (t as { name: string }).name })),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// 配置冻结(不随本机 ~/.taiji 配置漂移):默认基底(enabled + 0.9 触发线)
|
|
43
|
+
vi.mock("../pure.js", async (importOriginal) => {
|
|
44
|
+
const actual = await importOriginal<typeof import("../pure.js")>();
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
loadSmartContextConfig: () => actual.normalizeSmartContextConfig({}),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
import { COMPACTION_ENTRY_APPENDED_SOURCE, SMART_CONTEXT_NOTICE_CUSTOM_TYPE } from "@zhushanwen/extension-protocol";
|
|
52
|
+
|
|
53
|
+
import smartContextExtension from "../index.js";
|
|
54
|
+
|
|
55
|
+
/** 最小 fake pi:本文件消费 on / sendMessage / getAllTools(same-mode 生成投影工具清单)。 */
|
|
56
|
+
function createMockPi(): { pi: ExtensionAPI; events: Map<string, (...args: unknown[]) => void>; sendMessage: ReturnType<typeof vi.fn> } {
|
|
57
|
+
const events = new Map<string, (...args: unknown[]) => void>();
|
|
58
|
+
const sendMessage = vi.fn();
|
|
59
|
+
const pi = {
|
|
60
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
61
|
+
getAllTools: () => [],
|
|
62
|
+
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
63
|
+
sendMessage,
|
|
64
|
+
} as unknown as ExtensionAPI;
|
|
65
|
+
return { pi, events, sendMessage };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BIG = "x".repeat(30_000);
|
|
69
|
+
|
|
70
|
+
function makeUserEntry(id: string, parentId: string | null): SessionEntry {
|
|
71
|
+
return {
|
|
72
|
+
type: "message",
|
|
73
|
+
id,
|
|
74
|
+
parentId,
|
|
75
|
+
timestamp: new Date(0).toISOString(),
|
|
76
|
+
message: { role: "user", content: BIG, timestamp: 0 },
|
|
77
|
+
} as SessionEntry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeAssistantEntry(id: string, parentId: string | null): SessionEntry {
|
|
81
|
+
return {
|
|
82
|
+
type: "message",
|
|
83
|
+
id,
|
|
84
|
+
parentId,
|
|
85
|
+
timestamp: new Date(0).toISOString(),
|
|
86
|
+
message: {
|
|
87
|
+
role: "assistant",
|
|
88
|
+
content: [{ type: "text", text: BIG }],
|
|
89
|
+
api: "openai-completions",
|
|
90
|
+
provider: "zai",
|
|
91
|
+
model: "glm",
|
|
92
|
+
usage: {
|
|
93
|
+
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
|
|
94
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
95
|
+
},
|
|
96
|
+
stopReason: "stop",
|
|
97
|
+
timestamp: 0,
|
|
98
|
+
},
|
|
99
|
+
} as SessionEntry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 触发行情上下文(190k/200k > 0.9 线;entries 3 轮足够切出被压段)。 */
|
|
103
|
+
function makeCtx(entries: SessionEntry[], tokens = 190_000): ExtensionContext {
|
|
104
|
+
return {
|
|
105
|
+
model: { provider: "zai", id: "glm" },
|
|
106
|
+
getSystemPrompt: () => "sys",
|
|
107
|
+
getContextUsage: () => ({ tokens, contextWindow: 200_000, percent: null }),
|
|
108
|
+
sessionManager: {
|
|
109
|
+
getSessionId: () => "s1",
|
|
110
|
+
getSessionFile: () => "/tmp/s1.jsonl",
|
|
111
|
+
getEntries: () => entries,
|
|
112
|
+
appendCompaction: vi.fn(() => "entry-persisted"),
|
|
113
|
+
},
|
|
114
|
+
} as unknown as ExtensionContext;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fireSessionStart(mock: ReturnType<typeof createMockPi>, ctx: ExtensionContext): void {
|
|
118
|
+
mock.events.get("session_start")!({ type: "session_start", reason: "startup" }, ctx);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function settle(mock: ReturnType<typeof createMockPi>, ctx: ExtensionContext): void {
|
|
122
|
+
mock.events.get("agent_settled")!({ type: "agent_settled" }, ctx);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 信号帧判据(details.source 双判据的扩展侧写点)。 */
|
|
126
|
+
function isPersistSignal(call: unknown[]): boolean {
|
|
127
|
+
const [message] = call as [{ customType?: string; details?: { source?: string } }];
|
|
128
|
+
return message?.customType === SMART_CONTEXT_NOTICE_CUSTOM_TYPE
|
|
129
|
+
&& message?.details?.source === COMPACTION_ENTRY_APPENDED_SOURCE;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("C1: 后台压缩落盘信号装配(validated 事实点发出)", () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
callSameModelCompactionMock.mockReset();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("persist 成功 → 信号恰一条(compactionId=appendCompaction 返回值)+ 先于完成通知(nextTurn)", async () => {
|
|
138
|
+
callSameModelCompactionMock.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
139
|
+
const mock = createMockPi();
|
|
140
|
+
const entries = [makeUserEntry("e1", null), makeAssistantEntry("e2", "e1"), makeUserEntry("e3", "e2"), makeAssistantEntry("e4", "e3"), makeUserEntry("e5", "e4")];
|
|
141
|
+
smartContextExtension(mock.pi);
|
|
142
|
+
const ctx = makeCtx(entries);
|
|
143
|
+
fireSessionStart(mock, ctx);
|
|
144
|
+
|
|
145
|
+
settle(mock, ctx);
|
|
146
|
+
// 后台任务落盘段全微任务:等信号帧出现(完成通知随后,nextTurn 投递)
|
|
147
|
+
await vi.waitFor(() => {
|
|
148
|
+
expect(mock.sendMessage.mock.calls.filter(isPersistSignal)).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const signal = mock.sendMessage.mock.calls.find(isPersistSignal)!;
|
|
152
|
+
const [message, options] = signal as [{ customType: string; content: string; display: boolean; details: { compactionId?: string } }, unknown];
|
|
153
|
+
expect(message.customType).toBe(SMART_CONTEXT_NOTICE_CUSTOM_TYPE);
|
|
154
|
+
expect(message.display).toBe(false);
|
|
155
|
+
expect(message.details.compactionId).toBe("entry-persisted");
|
|
156
|
+
// 空闲投递:无 deliverAs(pi 立即 append + emit,runtime 即时收 customStart 帧)
|
|
157
|
+
expect(options).toEqual({ triggerTurn: false });
|
|
158
|
+
// 完成通知(D6 既有语义)在信号之后照常投递,两条互不替代
|
|
159
|
+
await vi.waitFor(() => {
|
|
160
|
+
expect(mock.sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
161
|
+
});
|
|
162
|
+
const notice = mock.sendMessage.mock.calls.find((call) => !isPersistSignal(call));
|
|
163
|
+
expect(notice).toBeDefined();
|
|
164
|
+
expect((notice as [{ details?: { source?: string } }])[0].details?.source).toBe("compact-complete");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("生成空结果(empty 终局)→ 无落盘信号(失败通知照旧)", async () => {
|
|
168
|
+
callSameModelCompactionMock.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
169
|
+
const mock = createMockPi();
|
|
170
|
+
const entries = [makeUserEntry("e1", null), makeAssistantEntry("e2", "e1"), makeUserEntry("e3", "e2"), makeAssistantEntry("e4", "e3"), makeUserEntry("e5", "e4")];
|
|
171
|
+
smartContextExtension(mock.pi);
|
|
172
|
+
const ctx = makeCtx(entries);
|
|
173
|
+
fireSessionStart(mock, ctx);
|
|
174
|
+
|
|
175
|
+
settle(mock, ctx);
|
|
176
|
+
await vi.waitFor(() => {
|
|
177
|
+
expect(mock.sendMessage.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(mock.sendMessage.mock.calls.filter(isPersistSignal)).toHaveLength(0);
|
|
181
|
+
const only = mock.sendMessage.mock.calls[0] as [{ details?: { source?: string } }];
|
|
182
|
+
expect(only[0].details?.source).toBe("compact-failed");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("信号投递同步抛错 → 不改写压缩成败(完成通知照常投递)", async () => {
|
|
186
|
+
callSameModelCompactionMock.mockResolvedValue({ ok: true, text: "bg summary", usage: { input: 1, output: 2 } });
|
|
187
|
+
const mock = createMockPi();
|
|
188
|
+
const entries = [makeUserEntry("e1", null), makeAssistantEntry("e2", "e1"), makeUserEntry("e3", "e2"), makeAssistantEntry("e4", "e3"), makeUserEntry("e5", "e4")];
|
|
189
|
+
smartContextExtension(mock.pi);
|
|
190
|
+
const ctx = makeCtx(entries);
|
|
191
|
+
fireSessionStart(mock, ctx);
|
|
192
|
+
// 信号发送点同步抛错:onPersisted 在 appendCompaction try 块内,上抛会把已落盘
|
|
193
|
+
// 的压缩改记失败(递增点③)——内吞后任务应仍走 validated 终局
|
|
194
|
+
mock.sendMessage.mockImplementation((message: { details?: { source?: string } }) => {
|
|
195
|
+
if (message?.details?.source === COMPACTION_ENTRY_APPENDED_SOURCE) throw new Error("signal boom");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
settle(mock, ctx);
|
|
199
|
+
await vi.waitFor(() => {
|
|
200
|
+
// 信号抛错被内吞后任务仍走 validated 终局:完成通知照常投递,且不产生失败通知
|
|
201
|
+
//(error 终局会经递增点③改投 compact-failed——成败事实被信号失败篡改的回归形态)
|
|
202
|
+
const sources = mock.sendMessage.mock.calls.map((call) => (call[0] as { details?: { source?: string } }).details?.source);
|
|
203
|
+
expect(sources).toContain("compact-complete");
|
|
204
|
+
expect(sources).not.toContain("compact-failed");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
// src/__tests__/index-generation.test.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// handler 内的重新赋值不回写已构造对象 → compact onComplete/onError(E1 实锤崩溃点)
|
|
8
|
-
// 检查的前置代际检查恒不生效,stale 分诊 100% 退化为 PS-30 文案兜底。
|
|
3
|
+
// U5 工具接线装配回归:index.ts 注册点向 registerCompactContextTool 注入 deps.background
|
|
4
|
+
// (BackgroundCompactionControls 契约:isActive/start),工具路径与 agent_settled 自动
|
|
5
|
+
// 触发共享同一 session 级互斥容器(RL1)——未注入时工具 execute 在门控与阈值检查后抛
|
|
6
|
+
// 「后台压缩未接线」(fail-fast,tool.test.ts 覆盖),本文件守住注册点真的注入了。
|
|
9
7
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// sessionGeneration 后必须翻 true——快照实现在此恒 false,正是生产失效路径。
|
|
8
|
+
// 本文件前身是 G1 deps.isCtxStale live 绑定装配回归:compact_context 纯触发化(U4)后
|
|
9
|
+
// 工具不再持有异步回调(旧 ctx.compact onComplete/onError 路径删除),isCtxStale 过渡
|
|
10
|
+
// 字段随 U5 注入 background 一并退役——代际分诊仅剩事件回调内的 guardStaleCtx 消费
|
|
11
|
+
// (闭包内读变量绑定,天然 live),无快照风险点,故该回归对象消亡、本文件改守注入契约。
|
|
15
12
|
|
|
16
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
14
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -27,11 +24,14 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
|
27
24
|
}));
|
|
28
25
|
|
|
29
26
|
// 装配捕获:mock tool.js,逐次登记 index.ts 传入的 deps(同 scheduler 捕获 runtime 构造第三参)
|
|
27
|
+
interface CapturedDeps {
|
|
28
|
+
background?: { isActive: () => boolean; start: (ctx: ExtensionContext, customInstructions?: string) => boolean };
|
|
29
|
+
}
|
|
30
30
|
const { registerCompactContextToolCalls } = vi.hoisted(() => ({
|
|
31
|
-
registerCompactContextToolCalls: [] as Array<{ deps?:
|
|
31
|
+
registerCompactContextToolCalls: [] as Array<{ deps?: CapturedDeps }>,
|
|
32
32
|
}));
|
|
33
33
|
vi.mock("../tool.js", () => ({
|
|
34
|
-
registerCompactContextTool: (_pi: unknown, deps?:
|
|
34
|
+
registerCompactContextTool: (_pi: unknown, deps?: CapturedDeps) => {
|
|
35
35
|
registerCompactContextToolCalls.push({ deps });
|
|
36
36
|
},
|
|
37
37
|
}));
|
|
@@ -45,61 +45,56 @@ function createMockPi(): {
|
|
|
45
45
|
} {
|
|
46
46
|
const events = new Map<string, (...args: unknown[]) => void>();
|
|
47
47
|
const pi = {
|
|
48
|
-
registerTool: vi.fn(),
|
|
48
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
49
49
|
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
50
50
|
sendMessage: vi.fn(),
|
|
51
51
|
} as unknown as ExtensionAPI;
|
|
52
52
|
return { pi, events };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** 触发 session_start(handler
|
|
55
|
+
/** 触发 session_start(handler 重建 session 级闭包状态,ctx 仅 seed 用——空 entries 安全)。 */
|
|
56
56
|
function fireSessionStart(events: Map<string, (...args: unknown[]) => void>): void {
|
|
57
57
|
const sessionStart = events.get("session_start");
|
|
58
58
|
expect(sessionStart).toBeDefined();
|
|
59
59
|
sessionStart!({ type: "session_start" }, {} as ExtensionContext);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
describe("
|
|
62
|
+
describe("U5: 工具注册 background 契约注入(接线残项落地)", () => {
|
|
63
63
|
beforeEach(() => {
|
|
64
64
|
registerCompactContextToolCalls.length = 0;
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
it("
|
|
67
|
+
it("deps.background 注入且 isActive/start 均为函数;isCtxStale 过渡字段不再传", () => {
|
|
68
68
|
const { pi, events } = createMockPi();
|
|
69
69
|
smartContextExtension(pi);
|
|
70
70
|
expect(registerCompactContextToolCalls).toHaveLength(1);
|
|
71
|
-
const
|
|
72
|
-
expect(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const background = registerCompactContextToolCalls[0]!.deps?.background;
|
|
72
|
+
expect(background).toBeDefined();
|
|
73
|
+
expect(background!.isActive).toBeTypeOf("function");
|
|
74
|
+
expect(background!.start).toBeTypeOf("function");
|
|
75
|
+
// 过渡字段退役断言:注册点不得再传 isCtxStale(tool.ts deps 已无该字段)
|
|
76
|
+
expect("isCtxStale" in (registerCompactContextToolCalls[0]!.deps ?? {})).toBe(false);
|
|
76
77
|
|
|
77
|
-
// fire
|
|
78
|
-
|
|
79
|
-
//
|
|
78
|
+
// 互斥查询可用:装配后(未 fire session_start 亦有初始状态容器)无进行中任务
|
|
79
|
+
expect(background!.isActive()).toBe(false);
|
|
80
|
+
// fire 后 controls 仍可用(闭包经 getState 读当前代容器,非快照)
|
|
80
81
|
fireSessionStart(events);
|
|
81
|
-
expect(
|
|
82
|
-
fireSessionStart(events);
|
|
83
|
-
expect(isCtxStale!()).toBe(false);
|
|
82
|
+
expect(background!.isActive()).toBe(false);
|
|
84
83
|
});
|
|
85
84
|
|
|
86
|
-
it("factory
|
|
87
|
-
// 第一代:独立 factory 执行 + session_start 装配
|
|
85
|
+
it("factory 重跑(session 替换拓扑):两代各自拿到独立 controls,均读各自代容器", () => {
|
|
88
86
|
const first = createMockPi();
|
|
89
87
|
smartContextExtension(first.pi);
|
|
90
88
|
fireSessionStart(first.events);
|
|
91
|
-
const firstIsCtxStale = registerCompactContextToolCalls[0]!.deps!.isCtxStale!;
|
|
92
|
-
expect(firstIsCtxStale()).toBe(false); // 第一代是当前代
|
|
93
|
-
|
|
94
|
-
// 第二代:再次独立执行 factory(模拟 newSession/fork/switchSession 的 factory 重跑,
|
|
95
|
-
// 新闭包;模块级 sessionGeneration 跨 factory 重跑保留)+ session_start
|
|
96
89
|
const second = createMockPi();
|
|
97
90
|
smartContextExtension(second.pi);
|
|
98
91
|
fireSessionStart(second.events);
|
|
99
92
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
93
|
+
expect(registerCompactContextToolCalls).toHaveLength(2);
|
|
94
|
+
const firstBackground = registerCompactContextToolCalls[0]!.deps!.background!;
|
|
95
|
+
const secondBackground = registerCompactContextToolCalls[1]!.deps!.background!;
|
|
96
|
+
// 两代 controls 各自可调用且空闲(工厂重跑后旧代任务随旧闭包消亡,isActive 恒 false)
|
|
97
|
+
expect(firstBackground.isActive()).toBe(false);
|
|
98
|
+
expect(secondBackground.isActive()).toBe(false);
|
|
104
99
|
});
|
|
105
100
|
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// src/__tests__/index-manual-compact-command.test.ts
|
|
2
|
+
//
|
|
3
|
+
// C2 /compact 后台化(feat-compaction-closure):`__taiji_compact__` 内部命令(GUI 手动
|
|
4
|
+
// 压缩的扩展侧触发入口)装配与回执契约。runtime 收到 session.compactBackground RPC 后经
|
|
5
|
+
// client.prompt fire-and-forget 发 `/__taiji_compact__ <encoded>`(session-service),本
|
|
6
|
+
// 文件锁命令 handler 的四件事:
|
|
7
|
+
// ① 启动成功:经后台管理器 startForTool(manual 来源)启动,不投通知(GUI 提示由
|
|
8
|
+
// runtime reply → renderer toast 承载,不双投);
|
|
9
|
+
// ② customInstructions percent 编码可逆:args = encodeURIComponent 原文(含空格/引号),
|
|
10
|
+
// 解码后透传给压缩生成(input.customInstructions);
|
|
11
|
+
// ③ busy 幂等:后台任务进行中再触发 → 即时通知通道投递「进行中已并入」回执(口径与
|
|
12
|
+
// compact_context 工具 busy 回执一致),不重复启动;
|
|
13
|
+
// ④ 启动失败(门控拒绝 / args 解码失败):即时通知通道投递失败文案(含原因),不上抛
|
|
14
|
+
// (重抛只进 pi emitError 日志,通知才是用户可达呈现)。③④ 走即时投递形态
|
|
15
|
+
// (sendSmartContextImmediateNotice,无 deliverAs):回执承载用户刚敲的 /compact 的
|
|
16
|
+
// 即时失败事实,nextTurn pending 队列会推迟到下一条用户消息后才可见——失败提示与
|
|
17
|
+
// 触发动作脱钩(renderer 受理 toast 已先行,延迟到达 = 假成功观感)。
|
|
18
|
+
//
|
|
19
|
+
// runtime 侧契约(RPC handler 受理即 reply / session-service fire-and-forget 投递)由
|
|
20
|
+
// runtime 包单测覆盖;两侧以命令名字面量对齐(跨包字符串契约,改名必须同批)。
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
|
|
25
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
26
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
29
|
+
getLogger: () => loggerMock,
|
|
30
|
+
createLogger: () => loggerMock,
|
|
31
|
+
setPiHandle: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// 工具注册短路(本文件只测手动命令入口;工具装配由 index-generation.test.ts 覆盖)
|
|
35
|
+
vi.mock("../tool.js", () => ({
|
|
36
|
+
registerCompactContextTool: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// same-model 生成替换(不触网;用例经 resolve/reject/挂起控制任务终局与 busy 窗口)
|
|
40
|
+
const { callSameModelCompactionMock } = vi.hoisted(() => ({
|
|
41
|
+
callSameModelCompactionMock: vi.fn(),
|
|
42
|
+
}));
|
|
43
|
+
vi.mock("../llm.js", () => ({
|
|
44
|
+
callSameModelCompaction: callSameModelCompactionMock,
|
|
45
|
+
projectTools: (tools: unknown[]) => tools.map((t) => ({ name: (t as { name: string }).name })),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// 配置冻结(不随本机 ~/.taiji 配置漂移):默认基底(enabled + 0.9 触发线);门控拒绝
|
|
49
|
+
// 用例经 enable(false) 局部改写
|
|
50
|
+
vi.mock("../pure.js", async (importOriginal) => {
|
|
51
|
+
const actual = await importOriginal<typeof import("../pure.js")>();
|
|
52
|
+
let enabledOverride: boolean | undefined;
|
|
53
|
+
return {
|
|
54
|
+
...actual,
|
|
55
|
+
loadSmartContextConfig: () => {
|
|
56
|
+
const base = actual.normalizeSmartContextConfig(
|
|
57
|
+
enabledOverride === undefined ? {} : { enabled: enabledOverride },
|
|
58
|
+
);
|
|
59
|
+
enabledOverride = undefined;
|
|
60
|
+
return base;
|
|
61
|
+
},
|
|
62
|
+
// 门控拒绝用例的写入口(测试内 import 本模块后调用;生产代码不消费)
|
|
63
|
+
__testSetEnabledOnce: (v: boolean) => {
|
|
64
|
+
enabledOverride = v;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
import { SMART_CONTEXT_NOTICE_CUSTOM_TYPE } from "@zhushanwen/extension-protocol";
|
|
70
|
+
|
|
71
|
+
import smartContextExtension from "../index.js";
|
|
72
|
+
|
|
73
|
+
type CommandHandler = (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
74
|
+
|
|
75
|
+
/** 最小 fake pi:捕获 registerCommand 的 handler(本文件被测对象)。 */
|
|
76
|
+
function createMockPi(): {
|
|
77
|
+
pi: ExtensionAPI;
|
|
78
|
+
commands: Map<string, CommandHandler>;
|
|
79
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
80
|
+
} {
|
|
81
|
+
const commands = new Map<string, CommandHandler>();
|
|
82
|
+
const sendMessage = vi.fn();
|
|
83
|
+
const pi = {
|
|
84
|
+
registerTool: vi.fn(),
|
|
85
|
+
registerCommand: (name: string, reg: { handler: CommandHandler }) => commands.set(name, reg.handler),
|
|
86
|
+
getAllTools: () => [],
|
|
87
|
+
on: vi.fn(),
|
|
88
|
+
sendMessage,
|
|
89
|
+
} as unknown as ExtensionAPI;
|
|
90
|
+
return { pi, commands, sendMessage };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 单条 120k 字符(≈30k tokens)> keepRecentTokens 保留预算(pi 默认 20k)——保证手动
|
|
94
|
+
// 入口(不受触发线约束,低 tokens 也启动)的被压段非空(buildBackgroundPreparation 判据)。
|
|
95
|
+
const BIG = "x".repeat(120_000);
|
|
96
|
+
|
|
97
|
+
function makeUserEntry(id: string, parentId: string | null): SessionEntry {
|
|
98
|
+
return {
|
|
99
|
+
type: "message",
|
|
100
|
+
id,
|
|
101
|
+
parentId,
|
|
102
|
+
timestamp: new Date(0).toISOString(),
|
|
103
|
+
message: { role: "user", content: BIG, timestamp: 0 },
|
|
104
|
+
} as SessionEntry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeAssistantEntry(id: string, parentId: string | null): SessionEntry {
|
|
108
|
+
return {
|
|
109
|
+
type: "message",
|
|
110
|
+
id,
|
|
111
|
+
parentId,
|
|
112
|
+
timestamp: new Date(0).toISOString(),
|
|
113
|
+
message: {
|
|
114
|
+
role: "assistant",
|
|
115
|
+
content: [{ type: "text", text: BIG }],
|
|
116
|
+
api: "openai-completions",
|
|
117
|
+
provider: "zai",
|
|
118
|
+
model: "glm",
|
|
119
|
+
usage: {
|
|
120
|
+
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
|
|
121
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
122
|
+
},
|
|
123
|
+
stopReason: "stop",
|
|
124
|
+
timestamp: 0,
|
|
125
|
+
} as unknown as SessionEntry["message"],
|
|
126
|
+
} as SessionEntry;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 手动入口不受触发线约束(startForTool 跳过 D4 ratio 判定)——低 tokens 上下文即可启动。 */
|
|
130
|
+
function makeCtx(entries: SessionEntry[], tokens: number | null = 1_000): ExtensionContext {
|
|
131
|
+
return {
|
|
132
|
+
model: { provider: "zai", id: "glm" },
|
|
133
|
+
getSystemPrompt: () => "sys",
|
|
134
|
+
getContextUsage: () => ({ tokens, contextWindow: 200_000, percent: null }),
|
|
135
|
+
sessionManager: {
|
|
136
|
+
getSessionId: () => "s1",
|
|
137
|
+
getSessionFile: () => "/tmp/s1.jsonl",
|
|
138
|
+
getEntries: () => entries,
|
|
139
|
+
appendCompaction: vi.fn(() => "entry-persisted"),
|
|
140
|
+
},
|
|
141
|
+
} as unknown as ExtensionContext;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 三轮可压行情(与 index-compaction-signal 同款最小 entries 集)。 */
|
|
145
|
+
function makeCompressibleEntries(): SessionEntry[] {
|
|
146
|
+
return [
|
|
147
|
+
makeUserEntry("e1", null),
|
|
148
|
+
makeAssistantEntry("e2", "e1"),
|
|
149
|
+
makeUserEntry("e3", "e2"),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function setup(overrides?: { enabled?: boolean }): Promise<{
|
|
154
|
+
pi: ReturnType<typeof createMockPi>["pi"];
|
|
155
|
+
handler: CommandHandler;
|
|
156
|
+
sendMessage: ReturnType<typeof createMockPi>["sendMessage"];
|
|
157
|
+
}> {
|
|
158
|
+
const mock = createMockPi();
|
|
159
|
+
smartContextExtension(mock.pi);
|
|
160
|
+
const handler = mock.commands.get("__taiji_compact__");
|
|
161
|
+
expect(handler).toBeDefined();
|
|
162
|
+
if (overrides?.enabled !== undefined) {
|
|
163
|
+
const pure = await import("../pure.js") as unknown as { __testSetEnabledOnce: (v: boolean) => void };
|
|
164
|
+
pure.__testSetEnabledOnce(overrides.enabled);
|
|
165
|
+
}
|
|
166
|
+
return { pi: mock, handler: handler!, sendMessage: mock.sendMessage };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 通知帧判据(notices 单测锁形态,此处锁装配链路真正发出)。 */
|
|
170
|
+
function noticeCalls(sendMessage: ReturnType<typeof vi.fn>): string[] {
|
|
171
|
+
return sendMessage.mock.calls
|
|
172
|
+
.filter((c) => (c[0] as { customType?: string }).customType === SMART_CONTEXT_NOTICE_CUSTOM_TYPE)
|
|
173
|
+
.map((c) => (c[0] as { content: string }).content);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 通知投递选项判据:锁 smart-context 通知的投递形态(即时回执 vs nextTurn 通道)。 */
|
|
177
|
+
function noticeOptions(sendMessage: ReturnType<typeof vi.fn>): unknown[] {
|
|
178
|
+
return sendMessage.mock.calls
|
|
179
|
+
.filter((c) => (c[0] as { customType?: string }).customType === SMART_CONTEXT_NOTICE_CUSTOM_TYPE)
|
|
180
|
+
.map((c) => c[1]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("__taiji_compact__ 命令(C2 手动触发入口)", () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
callSameModelCompactionMock.mockReset();
|
|
186
|
+
callSameModelCompactionMock.mockResolvedValue({
|
|
187
|
+
summary: "compressed summary",
|
|
188
|
+
firstKeptEntryId: "e3",
|
|
189
|
+
tokensBefore: 30_000,
|
|
190
|
+
estimatedTokensAfter: 1_000,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("注册为内部命令(__ 双下划线前缀,前端 internal-command-filter 过滤)", async () => {
|
|
195
|
+
const { pi } = await setup();
|
|
196
|
+
expect([...pi.commands.keys()]).toEqual(["__taiji_compact__"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("空 args 启动成功:不经通知回执(GUI 提示由 runtime reply → renderer 承载)", async () => {
|
|
200
|
+
const { handler, sendMessage } = await setup();
|
|
201
|
+
await handler("", makeCtx(makeCompressibleEntries()));
|
|
202
|
+
// 启动成功不投任何 smart-context 通知
|
|
203
|
+
expect(noticeCalls(sendMessage)).toHaveLength(0);
|
|
204
|
+
// 手动入口不受触发线约束(startForTool 语义):低 tokens 上下文照样启动
|
|
205
|
+
expect(callSameModelCompactionMock).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("customInstructions percent 解码透传(含空格/引号可逆,烘进摘要指令消息)", async () => {
|
|
209
|
+
const { handler } = await setup();
|
|
210
|
+
const instructions = '保留 关键决策 与 "引号" 内容';
|
|
211
|
+
await handler(encodeURIComponent(instructions), makeCtx(makeCompressibleEntries()));
|
|
212
|
+
// 生成入参:callSameModelCompaction(ctx, { messages, ... })——customInstructions 经
|
|
213
|
+
// buildSameModelInstruction 烘进末条指令消息(Additional focus 段),原文逐字可检索
|
|
214
|
+
const input = callSameModelCompactionMock.mock.calls[0]?.[1] as {
|
|
215
|
+
messages: Array<{ content: Array<{ text?: string }> }>;
|
|
216
|
+
};
|
|
217
|
+
const lastMessage = input.messages.at(-1);
|
|
218
|
+
const instructionText = lastMessage?.content.map((p) => p.text ?? "").join("");
|
|
219
|
+
expect(instructionText).toContain(instructions);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("busy 幂等:任务进行中再触发 → 通知通道投递「进行中已并入」回执,不重复启动", async () => {
|
|
223
|
+
const { handler, sendMessage } = await setup();
|
|
224
|
+
// 第一发:生成挂起(任务 active)
|
|
225
|
+
let release!: (v: unknown) => void;
|
|
226
|
+
callSameModelCompactionMock.mockImplementationOnce(
|
|
227
|
+
() => new Promise((resolve) => { release = resolve }),
|
|
228
|
+
);
|
|
229
|
+
await handler("", makeCtx(makeCompressibleEntries()));
|
|
230
|
+
expect(callSameModelCompactionMock).toHaveBeenCalledTimes(1);
|
|
231
|
+
// 第二发:busy 回执(口径与 compact_context 工具 busy 回执逐字一致)
|
|
232
|
+
await handler("", makeCtx(makeCompressibleEntries()));
|
|
233
|
+
const notices = noticeCalls(sendMessage);
|
|
234
|
+
expect(notices).toHaveLength(1);
|
|
235
|
+
expect(notices[0]).toContain("后台压缩进行中");
|
|
236
|
+
expect(notices[0]).toContain("已并入该任务");
|
|
237
|
+
// busy 回执走即时投递(无 deliverAs,空闲即达不进 nextTurn pending 队列)
|
|
238
|
+
expect(noticeOptions(sendMessage)[0]).toEqual({ triggerTurn: false });
|
|
239
|
+
expect(callSameModelCompactionMock).toHaveBeenCalledTimes(1); // 未重复启动
|
|
240
|
+
release({ summary: "s", firstKeptEntryId: "e3", tokensBefore: 30_000 });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("门控关闭(enabled=false)不拦 manual:手动显式命令 = 用户意志,照常启动(门控只拦 auto/tool)", async () => {
|
|
244
|
+
const { handler, sendMessage } = await setup({ enabled: false });
|
|
245
|
+
await handler("", makeCtx(makeCompressibleEntries()));
|
|
246
|
+
// manual 来源跳过 isGatingActive(与旧 pi 原生手动压缩对齐:旧链不受 smart-context
|
|
247
|
+
// 开关影响)——enabled=false 时照样启动,无失败通知
|
|
248
|
+
expect(callSameModelCompactionMock).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(noticeCalls(sendMessage)).toHaveLength(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("args 解码失败(非法 percent 编码)→ 即时通知通道失败文案(协议异常可操作呈现)", async () => {
|
|
253
|
+
const { handler, sendMessage } = await setup();
|
|
254
|
+
await handler("%zz-not-percent", makeCtx(makeCompressibleEntries()));
|
|
255
|
+
const notices = noticeCalls(sendMessage);
|
|
256
|
+
expect(notices).toHaveLength(1);
|
|
257
|
+
expect(notices[0]).toContain("后台压缩未启动");
|
|
258
|
+
expect(notices[0]).toContain("解码失败");
|
|
259
|
+
expect(noticeOptions(sendMessage)[0]).toEqual({ triggerTurn: false });
|
|
260
|
+
expect(callSameModelCompactionMock).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -65,7 +65,7 @@ function createMockPi(): MockPi {
|
|
|
65
65
|
const sendUserMessage = vi.fn();
|
|
66
66
|
const appendEntry = vi.fn();
|
|
67
67
|
const pi = {
|
|
68
|
-
registerTool: vi.fn(),
|
|
68
|
+
registerTool: vi.fn(), registerCommand: vi.fn(),
|
|
69
69
|
on: (event: string, handler: (...args: unknown[]) => void) => events.set(event, handler),
|
|
70
70
|
sendMessage,
|
|
71
71
|
sendUserMessage,
|