@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.
@@ -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
- // G1 装配级测试(crash-resilience D1,参照 scheduler __tests__/index-generation.test.ts 形态):
4
- // index.ts 装配点向 registerCompactContextTool 注入的 deps.isCtxStale 必须是 live 绑定
5
- // wrapper(() => isCtxStale())——registerCompactContextTool 在 factory 体同步执行,简写
6
- // 属性 { isCtxStale } 会把该行时刻的初始 () => false 快照进 deps 对象,session_start
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
- // 可区分快照 vs live 的唯一拓扑是 factory 重跑(生产主路径:pi 每次 session 替换
11
- // newSession/fork/switchSession 都重跑 factory 函数体,loader.ts extensionCache 只缓存
12
- // factory 函数对象):同闭包内 fire session_start 后本闭包恒是当前代(isCtxStale 读到
13
- // false,与快照实现值不可区分);只有第一代 deps 在第二代 session_start 递增模块级
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?: { isCtxStale?: () => boolean } }>,
31
+ registerCompactContextToolCalls: [] as Array<{ deps?: CapturedDeps }>,
32
32
  }));
33
33
  vi.mock("../tool.js", () => ({
34
- registerCompactContextTool: (_pi: unknown, deps?: { isCtxStale?: () => boolean }) => {
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 只更新模块级代数与闭包状态,ctx 不被读取)。 */
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("G1: compact 工具 deps.isCtxStale live 绑定(crash-resilience D1 装配回归)", () => {
62
+ describe("U5: 工具注册 background 契约注入(接线残项落地)", () => {
63
63
  beforeEach(() => {
64
64
  registerCompactContextToolCalls.length = 0;
65
65
  });
66
66
 
67
- it("factory 同步构造 deps:注入的是函数,首个 session_start 前恒 false(安全默认),fire 后本闭包为当前代", () => {
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 isCtxStale = registerCompactContextToolCalls[0]!.deps?.isCtxStale;
72
- expect(isCtxStale).toBeTypeOf("function");
73
-
74
- // 首个 session_start 前:无 session,恒 false(安全默认)
75
- expect(isCtxStale!()).toBe(false);
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 后本闭包被装配为当前代 → false;同闭包重复 fire(rpc-mode bindExtensions
78
- // 重调)后仍为当前代。注意:单闭包拓扑内 live wrapper 与快照实现的返回值同为
79
- // false、不可区分——快照 vs live 的回归断言在下方 factory 重跑用例。
78
+ // 互斥查询可用:装配后(未 fire session_start 亦有初始状态容器)无进行中任务
79
+ expect(background!.isActive()).toBe(false);
80
+ // fire 后 controls 仍可用(闭包经 getState 读当前代容器,非快照)
80
81
  fireSessionStart(events);
81
- expect(isCtxStale!()).toBe(false);
82
- fireSessionStart(events);
83
- expect(isCtxStale!()).toBe(false);
82
+ expect(background!.isActive()).toBe(false);
84
83
  });
85
84
 
86
- it("factory 重跑:第二次 factory + session_start 后,第一代 deps.isCtxStale 翻 true、第二代 false", () => {
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
- // 核心回归断言:第一代 deps 读到第一代闭包重新赋值后的 isCtxStale,感知换代翻
101
- // true(快照实现 = 简写属性 { isCtxStale },在此恒 false——本用例的回归失败点)
102
- expect(firstIsCtxStale()).toBe(true);
103
- expect(registerCompactContextToolCalls[1]!.deps!.isCtxStale!()).toBe(false); // 第二代是当前代
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,