@zhushanwen/pi-plan 0.5.2 → 1.1.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.
@@ -1,31 +1,25 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- // index.ts 只做装配:mock 掉子模块注册函数,只捕获 pi.on 的 hook 与 controllers 注册表
3
+ // index.ts 只做装配:mock 掉子模块注册函数,只捕获 pi.on 的 hook 与 planCtx 注册表
4
4
  vi.mock("../tool.js", () => ({
5
5
  registerPlanTool: vi.fn(
6
- (_pi: unknown, _sessions: unknown, controllers: Map<string, AbortController>) => {
7
- captured.controllers = controllers;
6
+ (_pi: unknown, planCtx: { controllers: Map<string, AbortController> }) => {
7
+ captured.planCtx = planCtx;
8
8
  },
9
9
  ),
10
- PLAN_MODE_TOOLS: ["read", "bash", "grep", "find", "ls", "plan"],
10
+ PLAN_MODE_TOOLS: ["read", "bash", "grep", "find", "ls", "plan", "ask_user"],
11
+ // E3 宿主分流(F-W3-1)依赖:与 tool.ts 实装同源的一行 env 读(受 vi.stubEnv 控制)
12
+ isTaijiHost: () => process.env.TAIJI_AGENT_EXT_LOG === "1",
11
13
  }));
12
14
  vi.mock("../command.js", () => ({ registerPlanCommand: vi.fn() }));
13
- vi.mock("../compact.js", () => ({ registerPlanEventHandlers: vi.fn() }));
14
15
  vi.mock("../widget.js", () => ({ updatePlanWidget: vi.fn() }));
15
16
 
16
- // MF-1-8:注入失败日志必须走 extension-logger(stderr 仅 logger 自身抛错的内层兜底)——
17
- // 捕获 warn spy 断言落盘通道
18
- const { loggerWarn } = vi.hoisted(() => ({ loggerWarn: vi.fn() }));
19
- vi.mock("@zhushanwen/pi-extension-logger", () => ({
20
- getLogger: () => ({ warn: loggerWarn, error: vi.fn() }),
21
- }));
22
-
23
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
18
 
25
19
  import planExtension from "../index.js";
26
20
  import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
27
21
 
28
- const captured: { controllers?: Map<string, AbortController> } = {};
22
+ const captured: { planCtx?: { controllers: Map<string, AbortController> } } = {};
29
23
 
30
24
  type Handler = (event: unknown, ctx: ExtensionContext) => Promise<unknown> | unknown;
31
25
 
@@ -44,10 +38,12 @@ function setup() {
44
38
  return { handlers, pi };
45
39
  }
46
40
 
47
- function makeCtx(entries: unknown[]): ExtensionContext {
41
+ function makeCtx(entries: unknown[], mode?: string): ExtensionContext {
48
42
  return {
43
+ mode,
49
44
  sessionManager: {
50
45
  getSessionId: () => "test-session",
46
+ getLeafId: () => null,
51
47
  getEntries: () => entries,
52
48
  },
53
49
  ui: { setWidget: vi.fn(), setStatus: vi.fn(), theme: { fg: (_t: string, text: string) => text } },
@@ -60,7 +56,7 @@ function planStateEntry(data: Record<string, unknown>) {
60
56
 
61
57
  beforeEach(() => {
62
58
  vi.clearAllMocks();
63
- captured.controllers = undefined;
59
+ captured.planCtx = undefined;
64
60
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "");
65
61
  });
66
62
 
@@ -68,8 +64,8 @@ afterEach(() => {
68
64
  vi.unstubAllEnvs();
69
65
  });
70
66
 
71
- describe("session_start hook(E3:awaiting 重挂提醒)", () => {
72
- it("awaiting + no live select → steers the agent to re-call submit-review (pi 懒重生之后跑)", async () => {
67
+ describe("session_start hook(E3:按 state 查表恢复)", () => {
68
+ it("reviewing + no live select → steers the agent to re-call submit-review with 上轮 selfReview 全文(D9④)", async () => {
73
69
  const { handlers, pi } = setup();
74
70
  const ctx = makeCtx([
75
71
  planStateEntry({
@@ -79,13 +75,14 @@ describe("session_start hook(E3:awaiting 重挂提醒)", () => {
79
75
  templateName: "",
80
76
  skills: ["tech-design"],
81
77
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
82
- reviewState: "awaiting",
78
+ state: "reviewing",
79
+ selfReview: "3 requirements covered; 2 assumptions verified.",
83
80
  }),
84
81
  ]);
85
82
 
86
83
  await handlers.get("session_start")!({ type: "session_start" }, ctx);
87
84
 
88
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
85
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
89
86
  // custom message 三要素 + streaming steer options 断言(A6)
90
87
  expect(pi.sendMessage).toHaveBeenCalledWith(
91
88
  {
@@ -95,11 +92,104 @@ describe("session_start hook(E3:awaiting 重挂提醒)", () => {
95
92
  },
96
93
  { deliverAs: "steer", triggerTurn: true },
97
94
  );
98
- // E3 重挂即落盘(§3.4 降级两源:该处曾只发 steer 不落盘——不落盘则 renderer 冷启动
99
- // 扫描的 View 恒无 source、恒渲染通用文案)
95
+ // D9④:steer 携带上轮 selfReview 全文 + 原样回传指令(豁免只在「自审内容」,过门义务不豁免)
96
+ const steerContent = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content as string;
97
+ expect(steerContent).toContain("3 requirements covered; 2 assumptions verified.");
98
+ expect(steerContent).toContain("VERBATIM");
99
+ expect(steerContent).toContain("selfReview");
100
+ // E3 重挂即落盘(§3.4 降级两源):resumeHint='resubmit'(旧 reviewStateSource 语义取代)
101
+ expect(pi.appendEntry).toHaveBeenCalledWith(
102
+ "plan-state",
103
+ expect.objectContaining({ isActive: true, state: "reviewing", resumeHint: "resubmit" }),
104
+ );
105
+ });
106
+
107
+ it("旧 entry(reviewState='awaiting' 无 state 字段)经映射同样落 reviewing 重挂分支(D2 读方①)", async () => {
108
+ const { handlers, pi } = setup();
109
+ const ctx = makeCtx([
110
+ planStateEntry({
111
+ isActive: true,
112
+ planFilePath: "/p/plan.md",
113
+ requirement: "r",
114
+ templateName: "",
115
+ skills: [],
116
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
117
+ reviewState: "awaiting",
118
+ }),
119
+ ]);
120
+
121
+ await handlers.get("session_start")!({ type: "session_start" }, ctx);
122
+
123
+ expect(pi.sendMessage).toHaveBeenCalledWith(
124
+ expect.objectContaining({ content: expect.stringContaining("submit-review") }),
125
+ expect.anything(),
126
+ );
127
+ expect(pi.appendEntry).toHaveBeenCalledWith(
128
+ "plan-state",
129
+ expect.objectContaining({ state: "reviewing", resumeHint: "resubmit" }),
130
+ );
131
+ });
132
+
133
+ it("taiji GUI 宿主(signal=1 + mode=rpc)reviewing → 不自动 steer 重挂(F-W3-1 审批条复活拦截),只落 resumeHint 交 degraded 按钮恢复", async () => {
134
+ vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
135
+ const { handlers, pi } = setup();
136
+ const ctx = makeCtx(
137
+ [
138
+ planStateEntry({
139
+ isActive: true,
140
+ planFilePath: "/p/plan.md",
141
+ requirement: "r",
142
+ templateName: "",
143
+ skills: [],
144
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
145
+ state: "reviewing",
146
+ selfReview: "carried conclusions",
147
+ }),
148
+ ],
149
+ "rpc",
150
+ );
151
+
152
+ await handlers.get("session_start")!({ type: "session_start" }, ctx);
153
+
154
+ // 审批条不自动复活:E3 不得无人值守重提审批(steer triggerTurn = 自动重提通道)
155
+ expect(pi.sendMessage).not.toHaveBeenCalled();
156
+ // 工具集收拢照常(E3 的非交互恢复职责保留)
157
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
158
+ // resumeHint 落盘:renderer degraded 分支(reviewing ∧ 无挂起)据此呈现
159
+ // 「审批提问已随会话重启失效 + 重新提交审批」按钮——恢复触发权归用户
160
+ expect(pi.appendEntry).toHaveBeenCalledWith(
161
+ "plan-state",
162
+ expect.objectContaining({ isActive: true, state: "reviewing", resumeHint: "resubmit" }),
163
+ );
164
+ });
165
+
166
+ it("taiji 宿主 env 泄漏到非 rpc 形态(signal=1 + mode=tui)→ steer 照旧(与 executeSubmitReview 的 E8 分流对齐)", async () => {
167
+ vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
168
+ const { handlers, pi } = setup();
169
+ const ctx = makeCtx(
170
+ [
171
+ planStateEntry({
172
+ isActive: true,
173
+ planFilePath: "/p/plan.md",
174
+ requirement: "r",
175
+ templateName: "",
176
+ skills: [],
177
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
178
+ state: "reviewing",
179
+ }),
180
+ ],
181
+ "tui",
182
+ );
183
+
184
+ await handlers.get("session_start")!({ type: "session_start" }, ctx);
185
+
186
+ expect(pi.sendMessage).toHaveBeenCalledWith(
187
+ expect.objectContaining({ content: expect.stringContaining("submit-review") }),
188
+ { deliverAs: "steer", triggerTurn: true },
189
+ );
100
190
  expect(pi.appendEntry).toHaveBeenCalledWith(
101
191
  "plan-state",
102
- expect.objectContaining({ isActive: true, reviewState: "awaiting", reviewStateSource: "resubmit" }),
192
+ expect.objectContaining({ state: "reviewing", resumeHint: "resubmit" }),
103
193
  );
104
194
  });
105
195
 
@@ -113,13 +203,13 @@ describe("session_start hook(E3:awaiting 重挂提醒)", () => {
113
203
  templateName: "",
114
204
  skills: ["tech-design"],
115
205
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
116
- reviewState: "revising",
206
+ state: "revising",
117
207
  }),
118
208
  ]);
119
209
 
120
210
  await handlers.get("session_start")!({ type: "session_start" }, ctx);
121
211
 
122
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
212
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
123
213
  expect(pi.sendMessage).toHaveBeenCalledWith(
124
214
  {
125
215
  customType: PLAN_CONTEXT_CUSTOM_TYPE,
@@ -128,24 +218,56 @@ describe("session_start hook(E3:awaiting 重挂提醒)", () => {
128
218
  },
129
219
  { deliverAs: "steer", triggerTurn: true },
130
220
  );
131
- // revising 恢复不落 source 标记('resubmit' 只描述 awaiting 降级等待;revising 由
221
+ // revising 恢复不落 resumeHint('resubmit' 只描述 reviewing 降级等待;revising 由
132
222
  // steer triggerTurn 立即开轮接续,恢复期间显示的 revising 是真实进行中)
133
223
  expect(pi.appendEntry).not.toHaveBeenCalled();
134
224
  });
135
225
 
226
+ it("dispatching + no live select → review_aborted 边落 approved + steer 重调 complete(D5 E3 新支)", async () => {
227
+ const { handlers, pi } = setup();
228
+ const ctx = makeCtx([
229
+ planStateEntry({
230
+ isActive: true,
231
+ planFilePath: "/p/plan.md",
232
+ requirement: "r",
233
+ templateName: "",
234
+ skills: [],
235
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
236
+ state: "dispatching",
237
+ }),
238
+ ]);
239
+
240
+ await handlers.get("session_start")!({ type: "session_start" }, ctx);
241
+
242
+ // 崩溃消散的表单 = 无选择解散极端形态:dispatching --review_aborted--> approved 落盘
243
+ //(批准事实保留,且使重调 complete 的 approve 边合法)
244
+ expect(pi.appendEntry).toHaveBeenCalledWith(
245
+ "plan-state",
246
+ expect.objectContaining({ state: "approved", isActive: true }),
247
+ );
248
+ expect(pi.sendMessage).toHaveBeenCalledWith(
249
+ {
250
+ customType: PLAN_CONTEXT_CUSTOM_TYPE,
251
+ content: expect.stringContaining("plan(action='complete')"),
252
+ display: false,
253
+ },
254
+ { deliverAs: "steer", triggerTurn: true },
255
+ );
256
+ });
257
+
136
258
  it("stale controllers are cleared on session rebuild (禁复用已 abort 的 controller)", async () => {
137
259
  const { handlers } = setup();
138
260
  const ctx = makeCtx([]);
139
261
  // 同进程 reload 场景:注册表里残留旧 controller——hook 重建 session 时必须清掉
140
262
  // (残留的已 abort controller 禁止复用:发起新挂起 select 会 fresh 新建,此处是防御清理)
141
- captured.controllers!.set("test-session", new AbortController());
263
+ captured.planCtx!.controllers.set("test-session", new AbortController());
142
264
 
143
265
  await handlers.get("session_start")!({ type: "session_start" }, ctx);
144
266
 
145
- expect(captured.controllers!.has("test-session")).toBe(false);
267
+ expect(captured.planCtx!.controllers.has("test-session")).toBe(false);
146
268
  });
147
269
 
148
- it("active without awaiting (in progress) → no reminder", async () => {
270
+ it("active without pending review (in progress) → no reminder", async () => {
149
271
  const { handlers, pi } = setup();
150
272
  const ctx = makeCtx([
151
273
  planStateEntry({
@@ -162,67 +284,45 @@ describe("session_start hook(E3:awaiting 重挂提醒)", () => {
162
284
 
163
285
  expect(pi.setActiveTools).toHaveBeenCalled();
164
286
  expect(pi.sendMessage).not.toHaveBeenCalled();
165
- // 非 awaiting 不落盘:E3 source 标记只在 awaiting 重挂分支写
287
+ // 非 reviewing/dispatching 不落盘:E3 resumeHint 只在 reviewing 重挂分支写
166
288
  expect(pi.appendEntry).not.toHaveBeenCalled();
167
289
  });
168
290
 
169
- it("inactive plan → no reminder, no tool restriction", async () => {
291
+ it("approved(later 后重开)不打扰:无 E3 重挂(S16「重开 session 无 E3 重挂打扰」)", async () => {
170
292
  const { handlers, pi } = setup();
171
- const ctx = makeCtx([]);
293
+ const ctx = makeCtx([
294
+ planStateEntry({
295
+ isActive: true,
296
+ planFilePath: "/p/plan.md",
297
+ requirement: "r",
298
+ templateName: "",
299
+ skills: [],
300
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
301
+ state: "approved",
302
+ }),
303
+ ]);
172
304
 
173
305
  await handlers.get("session_start")!({ type: "session_start" }, ctx);
174
306
 
175
- expect(pi.setActiveTools).not.toHaveBeenCalled();
307
+ expect(pi.setActiveTools).toHaveBeenCalled();
176
308
  expect(pi.sendMessage).not.toHaveBeenCalled();
309
+ expect(pi.appendEntry).not.toHaveBeenCalled();
177
310
  });
178
- });
179
311
 
180
- describe("before_agent_start hook(D9:taiji 形态引导注入)", () => {
181
- const basePrompt = "You are a helpful coding agent.";
312
+ it("inactive plan → no reminder, no tool restriction", async () => {
313
+ const { handlers, pi } = setup();
314
+ const ctx = makeCtx([]);
182
315
 
183
- it("no host signal (standalone pi) → undefined, prompt untouched", () => {
184
- const { handlers } = setup();
185
- const result = handlers.get("before_agent_start")!(
186
- { type: "before_agent_start", prompt: "hi", systemPrompt: basePrompt },
187
- makeCtx([]),
188
- );
189
- expect(result).toBeUndefined();
190
- });
316
+ await handlers.get("session_start")!({ type: "session_start" }, ctx);
191
317
 
192
- it("TAIJI_AGENT_EXT_LOG=1 → appends the plan-mode suggestion (not TAIJI_RUNTIME_TOKEN — 出站 deny list 剥除)", () => {
193
- vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
194
- const { handlers } = setup();
195
- const result = handlers.get("before_agent_start")!(
196
- { type: "before_agent_start", prompt: "hi", systemPrompt: basePrompt },
197
- makeCtx([]),
198
- ) as { systemPrompt?: string } | undefined;
199
-
200
- expect(result?.systemPrompt).toContain(basePrompt);
201
- // agent 自助进入引导在场(enter action + 无需确认)
202
- expect(result?.systemPrompt).toContain("plan(action='enter'");
203
- expect(result?.systemPrompt).toContain("Do not ask for permission to enter");
204
- // 旧的「建议 + 需确认」措辞已移除(enter 是 tool action,无需确认闸门)
205
- expect(result?.systemPrompt).not.toContain("Do NOT enter plan mode without the user's confirmation");
318
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
319
+ expect(pi.sendMessage).not.toHaveBeenCalled();
206
320
  });
321
+ });
207
322
 
208
- it("注入失败(systemPrompt 读取抛错)→ logger.warn 落盘 + 返回 undefined,不阻塞 agent loop(MF-1-8)", () => {
209
- vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
323
+ describe("进入引导单源(systemPrompt 注入面已收敛到 tool promptSnippet)", () => {
324
+ it("before_agent_start 钩子不注册:taiji 宿主注入面与 tool 描述面不再同时含进入引导(C1 行为变更组锚)", () => {
210
325
  const { handlers } = setup();
211
- const evilEvent = {
212
- type: "before_agent_start",
213
- get systemPrompt(): string {
214
- throw new Error("boom");
215
- },
216
- };
217
-
218
- const result = handlers.get("before_agent_start")!(evilEvent, makeCtx([]));
219
-
220
- // Never block the agent loop:吞错返回 undefined
221
- expect(result).toBeUndefined();
222
- // 日志走 extension-logger 文件通道(~/.pi/agent/logs/),不再 stderr 直写
223
- expect(loggerWarn).toHaveBeenCalledWith(
224
- "plan: before_agent_start injection failed",
225
- expect.objectContaining({ error: expect.stringContaining("boom") }),
226
- );
326
+ expect(handlers.has("before_agent_start")).toBe(false);
227
327
  });
228
328
  });
@@ -72,3 +72,69 @@ describe("注入段项目级模板源锚点 = PlanPromptInput.projectRoot(ctx.
72
72
  expect(prompt).not.toContain(`<location>${join(trapDir, `${trapName}.md`)}</location>`);
73
73
  });
74
74
  });
75
+
76
+ describe("Phase D 段注入 buildPlanModePrompt(对齐测试的装配面锚)", () => {
77
+ it("Phase D 段的两个注入分支(模板流程 / --template 直传)都携带该段(若装配漏注入,exec-options-alignment 的散文锚测不到)", () => {
78
+ const projectRoot = mkTmpDir("plan-prompt-phased-");
79
+ const base = {
80
+ requirement: "add dark mode",
81
+ planFilePath: join(projectRoot, ".tmp", "plans", "add-dark-mode", "plan.md"),
82
+ projectRoot,
83
+ skills: [],
84
+ };
85
+
86
+ // 分支一:无 --skills 的模板流程(prompts.ts else-if 注入腿)
87
+ const templateFlow = buildPlanModePrompt(base);
88
+ expect(templateFlow).toContain("Phase D: Completion");
89
+ expect(templateFlow).toContain("picks an execution method in the completion dialog");
90
+
91
+ // 分支二:--template 直传变体(prompts.ts if 注入腿;技能流程分支无 Phase D 属设计现状,不锚定)
92
+ const directFlow = buildPlanModePrompt({
93
+ ...base,
94
+ template: { absPath: "/tmp/plans-templates/feature.md", content: "# Feature\n## Implementation Steps\n1. Do it" },
95
+ });
96
+ expect(directFlow).toContain("Phase D: Completion");
97
+ expect(directFlow).toContain("Template (via --template)");
98
+ });
99
+ });
100
+
101
+ describe("Phase C.5 自审清单 + D8 重挂请求纪律 + selfReview 必带(D9②/D8)", () => {
102
+ it("模板流程:C.5 四项清单在产物纪律段之后注入,submit-review 指引必带 selfReview,重挂请求直接重提", () => {
103
+ const projectRoot = mkTmpDir("plan-prompt-c5-");
104
+ const prompt = buildPlanModePrompt({
105
+ requirement: "add dark mode",
106
+ planFilePath: join(projectRoot, ".tmp", "plans", "add-dark-mode", "plan.md"),
107
+ projectRoot,
108
+ skills: [],
109
+ });
110
+
111
+ // Phase C.5 自审清单(D9②):四项 + 先修文档再提交
112
+ expect(prompt).toContain("Phase C.5: Self-Review Before Submission");
113
+ expect(prompt).toContain("Requirement coverage");
114
+ expect(prompt).toContain("Assumption audit");
115
+ expect(prompt).toContain("[UNVERIFIED]");
116
+ expect(prompt).toContain("Chapter completeness");
117
+ expect(prompt).toContain("Acceptance realism");
118
+ // C.5 段落在产物纪律段之后(D9② 注入位钉死)
119
+ expect(prompt.indexOf("## Deliverable Discipline")).toBeLessThan(prompt.indexOf("Phase C.5"));
120
+ // 自审硬门的提示词面:submit-review 必带 selfReview(无豁免)
121
+ expect(prompt).toContain("selfReview is REQUIRED every time");
122
+ expect(prompt).toContain("plan(action='submit-review', selfReview=");
123
+ // D8 提示词纪律:收到重挂请求直接 submit-review(不再让用户猜/让 agent 无指令)
124
+ expect(prompt).toContain("asks to re-submit the plan review");
125
+ expect(prompt).toContain("IMMEDIATELY");
126
+ });
127
+
128
+ it("技能流程同样注入 C.5(自审硬门对每次 submit-review 生效,不随模板/技能流程分叉)", () => {
129
+ const projectRoot = mkTmpDir("plan-prompt-c5s-");
130
+ const prompt = buildPlanModePrompt({
131
+ requirement: "refactor auth",
132
+ planFilePath: join(projectRoot, ".tmp", "plans", "refactor-auth", "plan.md"),
133
+ projectRoot,
134
+ skills: [{ name: "tech-design", skillPath: "/skills/tech-design/SKILL.md" }],
135
+ });
136
+
137
+ expect(prompt).toContain("Phase C.5: Self-Review Before Submission");
138
+ expect(prompt).toContain("selfReview is REQUIRED every time");
139
+ });
140
+ });
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock typebox / pi-ai before importing tool(形态照 review.test.ts)
4
+ vi.mock("typebox", () => ({
5
+ Type: {
6
+ Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
7
+ String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
8
+ Optional: (schema: unknown) => schema,
9
+ Array: (item: unknown, opts?: Record<string, unknown>) => ({ type: "array", items: item, ...opts }),
10
+ },
11
+ Static: class {},
12
+ }));
13
+
14
+ vi.mock("@earendil-works/pi-ai", () => ({
15
+ StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
16
+ }));
17
+
18
+ vi.mock("../execution-notice.js", async () => {
19
+ const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../execution-notice.js")>("../execution-notice.js");
20
+ return {
21
+ handlePlanComplete: vi.fn(),
22
+ GOAL_FAILURE_RECOVERY,
23
+ };
24
+ });
25
+
26
+ vi.mock("../widget.js", () => ({ updatePlanWidget: vi.fn() }));
27
+ // importActual 展开:只覆写 detectExecSkills(不扫真实目录),其余导出(含 enter.ts
28
+ // re-export 的 resolveSkills 执行门禁)走真实现——mock 罩全模块会把它一并变 undefined
29
+ vi.mock("@zhushanwen/pi-exec-skills", async () => {
30
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
31
+ "@zhushanwen/pi-exec-skills",
32
+ );
33
+ return { ...actual, detectExecSkills: vi.fn(() => []) };
34
+ });
35
+
36
+ import {
37
+ isPlanReviewRequest,
38
+ PLAN_REVIEW_MARKER,
39
+ PLAN_SELF_REVIEW_MAX_BYTES,
40
+ } from "@zhushanwen/extension-protocol";
41
+
42
+ import type { PlanState } from "../state.js";
43
+ import { createPlanCtx, DEFAULT_PLAN_STATE } from "../state.js";
44
+ import { registerPlanTool } from "../tool.js";
45
+
46
+ const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "ask_user", "write", "edit"];
47
+
48
+ function planningState(): PlanState {
49
+ return {
50
+ ...DEFAULT_PLAN_STATE,
51
+ isActive: true,
52
+ planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
53
+ requirement: "refactor auth",
54
+ docs: [{ fileName: "design.md", absPath: "/tmp/test-project/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
55
+ state: "planning",
56
+ };
57
+ }
58
+
59
+ function setup() {
60
+ const planCtx = createPlanCtx();
61
+ let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
62
+ const pi = {
63
+ registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
64
+ appendEntry: vi.fn(),
65
+ setActiveTools: vi.fn(),
66
+ sendMessage: vi.fn(),
67
+ getCommands: vi.fn(() => []),
68
+ getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
69
+ } as unknown as Parameters<typeof registerPlanTool>[0];
70
+ registerPlanTool(pi, planCtx);
71
+
72
+ const ctx = {
73
+ sessionId: "test-session",
74
+ cwd: "/tmp/test-project",
75
+ hasUI: true,
76
+ mode: "rpc" as const,
77
+ isProjectTrusted: () => true,
78
+ sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
79
+ ui: { select: vi.fn(), notify: vi.fn() },
80
+ };
81
+ planCtx.states.set("test-session", planningState());
82
+
83
+ const exec = (params: Record<string, unknown>) => executeFn!("tc0", params, undefined, undefined, ctx);
84
+ return { pi, ctx, exec };
85
+ }
86
+
87
+ /** 取 marker payload(submit-review select options[0] 解析结果) */
88
+ function markerPayload(ctx: { ui: { select: unknown } }): unknown {
89
+ const calls = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls;
90
+ const markerCall = calls.find((c) => c[0] === PLAN_REVIEW_MARKER);
91
+ return JSON.parse((markerCall![1] as string[])[0]);
92
+ }
93
+
94
+ beforeEach(() => {
95
+ vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.unstubAllEnvs();
100
+ });
101
+
102
+ // ── 契约纯函数(error envelope / boundary 帧 / truncateSelfReview)的 canonical 测试
103
+ // 在 packages/extension-protocol/src/extensions/plan/review-contract.test.ts(更强版本:
104
+ // 键剥除结构断言 / 4KB 字节界精确锚 / 刁钻样本超集)——本文件只保留经 tool 消费面的
105
+ // 接线契约,不重复测共享逻辑。──
106
+
107
+ // ── 经 tool 消费面的契约落地(consumers.md ①③:payload 构造单点 + 双分源降级)──
108
+
109
+ describe("契约经 executeSubmitReview 消费面落地", () => {
110
+ it("超限 selfReview:payload 构造单点截断(写侧 4KB),且产出 payload 过入站守卫(boundary 帧合法)", async () => {
111
+ const { ctx, exec } = setup();
112
+ const oversized = "自审".repeat(PLAN_SELF_REVIEW_MAX_BYTES); // 远超 4KB
113
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({ decision: "dismiss" }));
114
+
115
+ await exec({ action: "submit-review", selfReview: oversized });
116
+
117
+ const payload = markerPayload(ctx) as { selfReview: string };
118
+ expect(Buffer.byteLength(payload.selfReview, "utf8")).toBeLessThanOrEqual(PLAN_SELF_REVIEW_MAX_BYTES);
119
+ // 截断后仍是合法 boundary 帧(消费侧守卫必过)
120
+ expect(isPlanReviewRequest(payload)).toBe(true);
121
+ });
122
+
123
+ });