@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.
@@ -24,13 +24,14 @@ import * as path from "node:path";
24
24
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
25
 
26
26
  import { parsePlanArgs, registerPlanCommand, resolveTemplateFile } from "../command.js";
27
- import { MAX_PLAN_REQUIREMENT_LENGTH, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
27
+ import type { PlanAbortControllers } from "../state.js";
28
+ import { createPlanCtx, freshPendingSelect, MAX_PLAN_REQUIREMENT_LENGTH, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
28
29
 
29
30
  const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
30
31
 
31
32
  function createMocks() {
32
33
  let capturedHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
33
- const controllers = new Map<string, AbortController>();
34
+ const planCtx = createPlanCtx();
34
35
 
35
36
  const pi = {
36
37
  registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
@@ -47,6 +48,7 @@ function createMocks() {
47
48
  cwd: "/tmp/test-project",
48
49
  sessionManager: {
49
50
  getSessionId: () => "test-session",
51
+ getLeafId: () => null,
50
52
  getEntries: () => [] as unknown[],
51
53
  },
52
54
  ui: {
@@ -60,7 +62,8 @@ function createMocks() {
60
62
  return {
61
63
  pi,
62
64
  ctx,
63
- controllers,
65
+ planCtx,
66
+ controllers: planCtx.controllers,
64
67
  getHandler: () => capturedHandler!,
65
68
  };
66
69
  }
@@ -74,7 +77,7 @@ describe("registerPlanCommand", () => {
74
77
  let pi: ExtensionAPI;
75
78
  let ctx: ExtensionContext;
76
79
  let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
77
- let controllers: Map<string, AbortController>;
80
+ let controllers: PlanAbortControllers;
78
81
 
79
82
  beforeEach(() => {
80
83
  vi.clearAllMocks();
@@ -82,8 +85,7 @@ describe("registerPlanCommand", () => {
82
85
  pi = mocks.pi;
83
86
  ctx = mocks.ctx;
84
87
  controllers = mocks.controllers;
85
- const sessions = new Map();
86
- registerPlanCommand(pi, sessions, controllers);
88
+ registerPlanCommand(pi, mocks.planCtx);
87
89
  handler = mocks.getHandler();
88
90
  });
89
91
 
@@ -93,38 +95,54 @@ describe("registerPlanCommand", () => {
93
95
 
94
96
  // --- abort subcommand ---
95
97
 
96
- it("abort: notifies 'No active plan mode' when idle", async () => {
98
+ it("abort(idle 常态格): warn 纠偏文案 + 幂等退出——不落盘、工具集幂等恢复(D-B1-1 单入口守卫极性,原 info no-op 取消)", async () => {
97
99
  await handler("abort", ctx);
98
- expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "info");
99
- expect(pi.setActiveTools).not.toHaveBeenCalled();
100
+ expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "warning");
101
+ // ④ 工具集照常幂等恢复(idle 格已是全量,重设全量 = 可观察零变化)
102
+ expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
103
+ // ② 跳过:idle 删 exit 边后 ok:false 不落盘(无噪音 exited entry)
104
+ expect(pi.appendEntry).not.toHaveBeenCalled();
100
105
  });
101
106
 
102
- it("abort: resets state and restores tools when active", async () => {
103
- // Enter plan mode first — handler uses the sessions map from registerPlanCommand closure
104
- await handler("implement user auth", ctx);
105
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
106
- vi.clearAllMocks();
107
-
108
- // Now abort — state is active in the sessions map
109
- await handler("abort", ctx);
110
-
111
- expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
112
- expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("Plan mode aborted.", "info");
107
+ it("abort(终态格 isActive=false): warn 纠偏指令文案 + 不落盘(V1 ⑤ 幂等退出锚)", async () => {
108
+ // 注入终态格:上一轮已 complete,缓存重建自 entries(空)→ 直接预置 sessions 难以
109
+ // 穿透 handler 闭包,改走 entries 注入 completed 残留形态
110
+ const terminalCtx = {
111
+ ...ctx,
112
+ sessionManager: {
113
+ getSessionId: () => "test-session",
114
+ getEntries: () => [
115
+ {
116
+ type: "custom",
117
+ customType: "plan-state",
118
+ data: { isActive: false, state: "completed", planFilePath: "", requirement: "", templateName: "" },
119
+ },
120
+ ],
121
+ },
122
+ } as unknown as ExtensionContext;
123
+ await handler("abort", terminalCtx);
124
+ expect(terminalCtx.ui.notify).toHaveBeenCalledWith(
125
+ expect.stringContaining("Plan mode is not active"),
126
+ "warning",
127
+ );
128
+ // completed 终态记录不被 exited 覆写(ok:false 跳过状态值写入,isActive=false 非坏格)
129
+ expect(pi.appendEntry).not.toHaveBeenCalled();
113
130
  });
114
131
 
115
- it("abort 联动(E10):controller.abort() 先于 resetPlanState 的 entry 落盘", async () => {
132
+ it("abort 联动(E10):controller.abort() 先于 resetPlanState 的 entry 落盘,markDissolved('self') 先行打标", async () => {
116
133
  await handler("implement user auth", ctx);
117
134
  vi.clearAllMocks();
118
135
 
119
- // 模拟一个挂起 select 的 controller(submit-review / complete 执行方式两处共用注册表)
120
- const controller = new AbortController();
121
- const abortSpy = vi.spyOn(controller, "abort");
122
- controllers.set("test-session", controller);
136
+ // 模拟一个挂起 select 的 PendingSelect(submit-review / complete 执行方式两处共用注册表)
137
+ const pending = freshPendingSelect(controllers, "test-session");
138
+ const abortSpy = vi.spyOn(pending.controller, "abort");
123
139
 
124
140
  await handler("abort", ctx);
125
141
 
126
- // 挂起 select 被 abort(→ resolve undefined → tool execute 已取消分支 → turn 结束 → settled → busy defer 恢复)
127
- expect(controller.signal.aborted).toBe(true);
142
+ // 挂起 select 被 abort(→ resolve undefined → tool execute 归口分支 → turn 结束 → settled → busy defer 恢复)
143
+ expect(pending.controller.signal.aborted).toBe(true);
144
+ // D-B1-2:入口解散先打 'self' 标(同步先于 abort,等待处归口按来源直传判别)
145
+ expect(pending.dissolvedBy).toBe("self");
128
146
  // 顺序断言:abort 必须先于 reset entry 落盘(反序 = 挂起 select 无人 resolve → session 卡死)
129
147
  const resetCallIndex = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.findIndex(
130
148
  (c) => (c[1] as { isActive?: boolean }).isActive === false,
@@ -168,7 +186,7 @@ describe("registerPlanCommand", () => {
168
186
  "/tmp/test-project/.tmp/plans/implement-user-auth",
169
187
  { recursive: true },
170
188
  );
171
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
189
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
172
190
  expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("[PLAN MODE]")));
173
191
  expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("Implement User Auth")));
174
192
  expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
@@ -176,9 +194,13 @@ describe("registerPlanCommand", () => {
176
194
  planFilePath: "/tmp/test-project/.tmp/plans/implement-user-auth/plan.md",
177
195
  requirement: "Implement User Auth",
178
196
  templateName: "",
197
+ templateProvided: undefined,
179
198
  skills: [],
180
199
  docs: [],
181
- reviewState: undefined,
200
+ state: "planning",
201
+ selfReview: undefined,
202
+ resumeHint: undefined,
203
+ lastSubmitReviewDocsFingerprint: undefined,
182
204
  });
183
205
  });
184
206
 
@@ -221,6 +243,7 @@ describe("registerPlanCommand", () => {
221
243
  ...ctx,
222
244
  sessionManager: {
223
245
  getSessionId: () => "test-session",
246
+ getLeafId: () => null,
224
247
  getEntries: () => [
225
248
  {
226
249
  type: "custom",
@@ -272,6 +295,53 @@ describe("registerPlanCommand", () => {
272
295
  return payload.content;
273
296
  }
274
297
 
298
+ // --- D10②:命令反馈 notify(display:false 注入仅 LLM 可见,用户可见性由 notify 承担) ---
299
+
300
+ it("重入分支(存在既有 plan 文件):notify 提示 + 选项清单仍走 display:false 注入", async () => {
301
+ vi.mocked(fs.readdirSync).mockReturnValue(["old-plan"] as unknown as ReturnType<typeof fs.readdirSync>);
302
+ vi.mocked(fs.statSync).mockImplementation(() => ({ isDirectory: () => true }) as unknown as fs.Stats);
303
+ vi.mocked(fs.existsSync).mockImplementation((p) => String(p).endsWith("/.tmp/plans/old-plan/plan.md"));
304
+ try {
305
+ await handler("", ctx);
306
+ } finally {
307
+ vi.mocked(fs.existsSync).mockImplementation(() => false);
308
+ }
309
+
310
+ const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
311
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 existing plan file(s)"), "info");
312
+ // LLM 注入通路不变:custom message display:false + 选项清单全文
313
+ const message = sentMessage();
314
+ expect(message).toContain("[PLAN MODE] Found existing plan files:");
315
+ const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
316
+ const payload = calls[0][0] as { display: boolean };
317
+ expect(payload.display).toBe(false);
318
+ });
319
+
320
+ it("--skills 未知技能 fail-fast:notify error + 注入通路保留", async () => {
321
+ await handler("复盘 --skills nope", ctx);
322
+
323
+ const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
324
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("unknown skill(s): nope"), "error");
325
+ expect(sentMessage()).toContain("Failed to enter: unknown skill(s): nope");
326
+ });
327
+
328
+ it("--template 文件缺失 fail-fast:notify error", async () => {
329
+ await handler("复盘 --template tpl.md", ctx);
330
+
331
+ const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
332
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("Template file not found"), "error");
333
+ });
334
+
335
+ it("进入成功:notify info 携带 planFilePath(命令反馈通道,widget 之外补可达)", async () => {
336
+ await handler("复盘需求", ctx);
337
+
338
+ const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
339
+ expect(notify).toHaveBeenCalledWith(
340
+ expect.stringContaining("Plan mode entered: /tmp/test-project/.tmp/plans/"),
341
+ "info",
342
+ );
343
+ });
344
+
275
345
  describe("--template via /plan handler", () => {
276
346
  afterEach(() => {
277
347
  // 还原默认 existsSync=false,避免路径实现泄漏到后续用例
@@ -327,11 +397,11 @@ describe("registerPlanCommand", () => {
327
397
  isActive: true,
328
398
  requirement: "retro meeting",
329
399
  templateName: "retro-template",
330
- templateProvidedPath: "/tmp/test-project/docs/retro-template.md",
400
+ templateProvided: true,
331
401
  skills: [],
332
402
  }),
333
403
  );
334
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
404
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
335
405
  const prompt = sentMessage();
336
406
  // 直传声明 + 全文内嵌(文件内容直达模型,不赌自发 read——D5)
337
407
  expect(prompt).toContain("template was provided via --template");
@@ -349,7 +419,7 @@ describe("registerPlanCommand", () => {
349
419
 
350
420
  expect(pi.appendEntry).toHaveBeenCalledWith(
351
421
  "plan-state",
352
- expect.objectContaining({ isActive: true, templateName: "retro template", templateProvidedPath: spacedAbs }),
422
+ expect.objectContaining({ isActive: true, templateName: "retro template", templateProvided: true }),
353
423
  );
354
424
  const prompt = sentMessage();
355
425
  expect(prompt).toContain(spacedAbs);
@@ -27,19 +27,23 @@ vi.mock("node:fs", () => ({
27
27
  // Mock templates(enter 的 buildPlanModePrompt 走 listTemplates;单测不扫真实模板目录)
28
28
  vi.mock("../templates.js", () => ({
29
29
  listTemplates: vi.fn(() => []),
30
- loadTemplate: vi.fn(() => null),
31
30
  formatAvailablePlans: vi.fn(() => ""),
32
31
  }));
33
32
 
34
- vi.mock("../compact.js", () => ({
33
+ vi.mock("../execution-notice.js", () => ({
35
34
  handlePlanComplete: vi.fn(),
36
- detectGoalCapability: vi.fn(() => false),
37
35
  GOAL_FAILURE_RECOVERY: {},
38
36
  }));
39
37
 
40
- vi.mock("../exec-skills.js", () => ({
41
- detectExecSkills: vi.fn(() => []),
42
- }));
38
+ // Mock 执行方式检测(D10):单测不扫真实目录。importActual 展开:只覆写
39
+ // detectExecSkills,其余导出(含 enter.ts re-export 的 resolveSkills 执行门禁)走
40
+ // 真实现——mock 罩全模块会把它一并变 undefined
41
+ vi.mock("@zhushanwen/pi-exec-skills", async () => {
42
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
43
+ "@zhushanwen/pi-exec-skills",
44
+ );
45
+ return { ...actual, detectExecSkills: vi.fn(() => []) };
46
+ });
43
47
 
44
48
  vi.mock("../widget.js", () => ({
45
49
  updatePlanWidget: vi.fn(),
@@ -50,13 +54,12 @@ import * as fs from "node:fs";
50
54
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
51
55
 
52
56
  import { registerPlanTool } from "../tool.js";
53
- import { type PlanState, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
57
+ import { type PlanState, createPlanCtx, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
54
58
 
55
59
  const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
56
60
 
57
61
  function setup(skillCommands: Array<{ name: string; path: string }> = []) {
58
- const sessions = new Map();
59
- const controllers = new Map<string, AbortController>();
62
+ const planCtx = createPlanCtx();
60
63
  let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<{
61
64
  content: Array<{ type: string; text: string }>;
62
65
  details: { action: string; requirement?: string; skills?: string[] };
@@ -69,7 +72,7 @@ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
69
72
  getCommands: vi.fn(() => skillCommands.map((c) => ({ name: c.name, source: "skill", sourceInfo: { path: c.path } }))),
70
73
  getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
71
74
  } as unknown as ExtensionAPI;
72
- registerPlanTool(pi, sessions, controllers);
75
+ registerPlanTool(pi, planCtx);
73
76
 
74
77
  const ctx = {
75
78
  sessionId: "test-session",
@@ -77,13 +80,13 @@ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
77
80
  hasUI: true,
78
81
  mode: "tui" as const,
79
82
  isProjectTrusted: () => true,
80
- sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
83
+ sessionManager: { getSessionId: () => "test-session", getLeafId: () => null, getEntries: () => [] },
81
84
  ui: { select: vi.fn(), notify: vi.fn() },
82
85
  } as unknown as ExtensionContext;
83
86
 
84
87
  const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
85
88
  executeFn!("tc0", params, signal, undefined, ctx);
86
- return { pi, sessions, ctx, exec };
89
+ return { pi, sessions: planCtx.states, ctx, exec };
87
90
  }
88
91
 
89
92
  describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1)", () => {
@@ -112,6 +115,10 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
112
115
  // 状态对象已是激活态
113
116
  const state = sessions.get("test-session") as { isActive?: boolean; requirement?: string };
114
117
  expect(state.isActive).toBe(true);
118
+ // 状态写走 transition():idle --enter--> planning 落盘(新会话缺省态进入;原独立
119
+ // 用例的转移断言并入——同通道同断言字段,仅转移源字面量与下方坏格用例不同)
120
+ const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
121
+ expect(lastEntry.state).toBe("planning");
115
122
  });
116
123
 
117
124
  it("已在 plan 模式:幂等返回,不重复收工具 / 不重复注入", async () => {
@@ -154,19 +161,50 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
154
161
  expect(res.content[0].text).toContain("(from conversation context)");
155
162
  });
156
163
 
157
- it("新轮次进入:上轮残留的 reviewStateSource 随进入失效(跨 plan run 残留防护,§3.4 清除点)", async () => {
164
+ it("新轮次进入:上轮残留的 selfReview/resumeHint/指纹随进入失效(clearRoundFields 单函数出口的 enter 调用点,D9①防跨轮误触新鲜度门)", async () => {
158
165
  const { exec, pi, sessions } = setup();
159
- // 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState 的路径留下的来源标记
160
- sessions.set("test-session", { ...DEFAULT_PLAN_STATE, isActive: false, reviewStateSource: "explain" });
166
+ // 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState 的路径留下的降级标记与基线
167
+ sessions.set("test-session", {
168
+ ...DEFAULT_PLAN_STATE,
169
+ isActive: false,
170
+ state: "exited",
171
+ selfReview: "stale self-review",
172
+ resumeHint: "resubmit",
173
+ lastSubmitReviewDocsFingerprint: "old.md:3",
174
+ });
161
175
 
162
176
  const res = await exec({ action: "enter", requirement: "new round" });
163
177
 
164
178
  expect(res.details.action).toBe("enter");
165
- // activatePlanMode 新轮次重置组清来源标记(与 resetPlanState 对齐)
166
- const state = sessions.get("test-session") as { reviewStateSource?: string };
167
- expect(state.reviewStateSource).toBeUndefined();
168
- // 新轮 entry 无来源标记(undefined 序列化自然消失——D4)
179
+ // activatePlanMode 经 clearRoundFields 清三字段(D4 单函数出口,与 resetPlanState 同源)
180
+ const state = sessions.get("test-session") as PlanState;
181
+ expect(state.selfReview).toBeUndefined();
182
+ expect(state.resumeHint).toBeUndefined();
183
+ expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
184
+ // enter 边落盘(exited --enter--> planning 新一轮);entry 无残留字段
185
+ const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
186
+ expect(lastEntry.state).toBe("planning");
187
+ expect(lastEntry.resumeHint).toBeUndefined();
188
+ });
189
+
190
+ it("坏数据格(isActive=false 且 state 落活跃族):enter 按转移 ok:false 落穿,不归一不重试(坏格自愈分支已删,C1 行为变更组——仅坏格可达,行为差锚)", async () => {
191
+ const { exec, pi, sessions } = setup();
192
+ // 手改 session 文件可达的坏格:isActive=false 却残留活跃族 state(reviewing)
193
+ sessions.set("test-session", {
194
+ ...DEFAULT_PLAN_STATE,
195
+ isActive: false,
196
+ state: "reviewing",
197
+ });
198
+
199
+ await exec({ action: "enter", requirement: "on a corrupt cell" });
200
+
201
+ // 落穿:不归一 idle 再进——state 保持坏值(reviewing --enter--> 非 ok),进入副作用
202
+ // 照常(isActive=true 已置、persist 携带现值)
203
+ const state = sessions.get("test-session") as PlanState;
204
+ expect(state.isActive).toBe(true);
205
+ expect(state.state).toBe("reviewing");
169
206
  const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
170
- expect(lastEntry.reviewStateSource).toBeUndefined();
207
+ expect(lastEntry.state).toBe("reviewing");
208
+ expect(lastEntry.isActive).toBe(true);
171
209
  });
172
210
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * PHASE_D 散文 ↔ buildExecOptions 选项集对齐防线(plan-phase-d-prompt-options-alignment
3
+ * 定案:2026-09-26 登记二选一,2026-09-27 裁决 = 单源化 + 对齐测试双管)。
4
+ *
5
+ * 能单源的已单源:技能档上限 MAX_SKILL_OPTIONS(prompts.ts SSOT,散文插值 + 截断同源)、
6
+ * 选项 label(i18n exec.* 词典单源)。散文无法整体生成的结构性原因:面向模型的是英文
7
+ * 静态散文,选项是运行时按检测结果动态构造 + 用户侧 label 随 locale 本地化——两种语言、
8
+ * 两个受众;散文还描述「无技能直通」分支行为(无选项对象对应物)。故散文锚点靠本测试
9
+ * 机器锁定:数量锚、label 字面锚、两分支行为句锚,漂移即红。
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { t } from "../i18n.js";
14
+ import { MAX_SKILL_OPTIONS, PHASE_D_PROSE } from "../prompts.js";
15
+ import { buildExecOptions } from "../tool.js";
16
+ import type { ExecSkill } from "@zhushanwen/pi-exec-skills";
17
+
18
+ function fixtureSkill(name: string): ExecSkill {
19
+ return { name, description: `${name} plan-exec skill`, skillEntryPath: `/tmp/skills/${name}/SKILL.md` } as ExecSkill;
20
+ }
21
+
22
+ describe("PHASE_D 散文 ↔ buildExecOptions 对齐", () => {
23
+ it("数量锚:散文技能档上限 = 截断常量(MAX_SKILL_OPTIONS 单源,改一处散文自动跟随)", () => {
24
+ expect(PHASE_D_PROSE).toContain(`up to ${MAX_SKILL_OPTIONS} detected plan-exec skills`);
25
+ });
26
+
27
+ it("截断行为锚:检测超过上限的技能只取前 MAX_SKILL_OPTIONS 个 + 固定尾部两项", () => {
28
+ const skills = Array.from({ length: MAX_SKILL_OPTIONS + 2 }, (_, i) => fixtureSkill(`s${i + 1}`));
29
+ const options = buildExecOptions(skills);
30
+
31
+ expect(options).toHaveLength(MAX_SKILL_OPTIONS + 2);
32
+ expect(options.slice(0, MAX_SKILL_OPTIONS).map((o) => o.mode)).toEqual(["skill:s1", "skill:s2"]);
33
+ expect(options.slice(MAX_SKILL_OPTIONS).map((o) => o.mode)).toEqual(["execute", "later"]);
34
+ });
35
+
36
+ it("label 锚:选项 label 出自 i18n 词典(en-US 基准),散文含对应英文字面", () => {
37
+ const en = (key: string, params?: Record<string, string | number>) => t(key, params, "en-US");
38
+
39
+ const options = buildExecOptions([fixtureSkill("dev-flow")]);
40
+ const [skillOption, executeOption, laterOption] = options;
41
+
42
+ // 选项 label 单源 = 词典;散文锚定词典渲染产物(技能档取 name 占位渲染形态)
43
+ expect(skillOption.label).toBe(en("exec.viaSkill", { name: "dev-flow" }));
44
+ expect(executeOption.label).toBe(en("exec.execute"));
45
+ expect(laterOption.label).toBe(en("exec.later"));
46
+
47
+ expect(PHASE_D_PROSE).toContain("Execute via skill: <name>");
48
+ // Execute 作为完整选项词(非 "Execute via" 前缀的一部分——用后随括号边界锚定)
49
+ expect(PHASE_D_PROSE).toContain(`${en("exec.execute")} (goal tracking integrated when available), or ${en("exec.later")}`);
50
+ });
51
+
52
+ it("两分支行为句锚:有技能弹表单 / 无技能直通(D7②),散文与行为分支共存", () => {
53
+ expect(PHASE_D_PROSE).toContain("the user picks an execution method in the completion dialog");
54
+ expect(PHASE_D_PROSE).toContain("When NO plan-exec skill is detected, no dialog appears and the plan executes directly");
55
+ });
56
+ });
@@ -0,0 +1,197 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { PlanState } from "../state.js";
4
+ import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
5
+
6
+ // Mock fs before importing execution-notice.ts (ESM namespace is not configurable)
7
+ vi.mock("node:fs", () => ({
8
+ readFileSync: vi.fn(),
9
+ }));
10
+
11
+ // Import after mock setup
12
+ import { handlePlanComplete } from "../execution-notice.js";
13
+
14
+ const fsMock = vi.mocked(await import("node:fs"));
15
+
16
+ // --- Shared mock factories ---
17
+
18
+ function makePi() {
19
+ return {
20
+ on: vi.fn(),
21
+ appendEntry: vi.fn(),
22
+ sendMessage: vi.fn(),
23
+ };
24
+ }
25
+
26
+ type CtxMock = ReturnType<typeof makeCtx>;
27
+ function makeCtx() {
28
+ return {
29
+ sessionManager: { getSessionId: () => "test-session", getEntries: () => [] as unknown[] },
30
+ ui: { notify: vi.fn() },
31
+ };
32
+ }
33
+
34
+ function makeActiveState(): PlanState {
35
+ return {
36
+ isActive: true,
37
+ // 生产真实形态(enter.ts:<project>/.tmp/plans/<slug>/plan.md)——合成单层路径会让
38
+ // slug 派生类消费面(buildPlanSlug 取 basename)在测试数据上失真
39
+ planFilePath: "/tmp/test-project/.tmp/plans/login-page/plan.md",
40
+ requirement: "Add login page",
41
+ templateName: "default",
42
+ };
43
+ }
44
+
45
+ function setupFsMock(content: string) {
46
+ fsMock.readFileSync.mockReturnValue(content);
47
+ }
48
+
49
+ /**
50
+ * goal 桥 slot key——与 execution-notice.ts / goal 侧 index.ts 的字符串一致(本地声明,
51
+ * 不 import 对方包:pi-goal 是 optional peer,两侧靠同一字符串共享 slot)。
52
+ */
53
+ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
54
+
55
+ /** 在 globalThis slot 上挂 goal 桥——mock 形态与真实通道同构(goal-bridge-cross-extension.md §3.4:goal 侧 Reflect.set 挂 slot,本测试同款挂载)。 */
56
+ function attachGoalInit(impl: () => boolean) {
57
+ const fn = vi.fn(impl);
58
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, fn);
59
+ return fn;
60
+ }
61
+
62
+ /** 清 slot 防跨用例 globalThis 泄漏(设计 §3.4:teardown 两侧通用)。 */
63
+ afterEach(() => {
64
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
65
+ });
66
+
67
+ /** 最近一次 sendMessage 的正文(P8 custom message 的 content 字段)。 */
68
+ function lastSteer(pi: ReturnType<typeof makePi>): string {
69
+ const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
70
+ const last = calls[calls.length - 1]?.[0] as { content?: string } | undefined;
71
+ return String(last?.content ?? "");
72
+ }
73
+
74
+ /** P8 执行通知断言形态:custom message 三要素 + streaming steer options(A6)。 */
75
+ function executionNotice(content: unknown) {
76
+ return [
77
+ { customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
78
+ { deliverAs: "steer", triggerTurn: true },
79
+ ];
80
+ }
81
+
82
+ // --- handlePlanComplete tests ---
83
+
84
+ describe("handlePlanComplete", () => {
85
+ let pi: ReturnType<typeof makePi>;
86
+ let ctx: CtxMock;
87
+
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ pi = makePi();
91
+ ctx = makeCtx();
92
+ setupFsMock("## 实现步骤\n1. Step one\n2. Step two");
93
+ });
94
+
95
+ describe("direct delivery", () => {
96
+ it("execute mode success: returns outcome synchronously, goal steer, no notify", () => {
97
+ attachGoalInit(() => true);
98
+
99
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
100
+
101
+ expect(outcome).toEqual({ started: true });
102
+ expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
103
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("skill mode: returns undefined, steer with skill entry path (steer 文案含 skillEntryPath,D10)", () => {
107
+ attachGoalInit(() => true);
108
+ const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
109
+
110
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:dev-flow", skillEntryPath);
111
+
112
+ expect(outcome).toBeUndefined();
113
+ const steer = lastSteer(pi);
114
+ expect(steer).toContain("Execution mode: skill:dev-flow");
115
+ expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md");
116
+ expect(steer).toContain("follow its workflow");
117
+ });
118
+
119
+ it("skill mode (散 .md 形态): steer carries the file path itself, not a dangling joined SKILL.md", () => {
120
+ attachGoalInit(() => true);
121
+ const looseEntryPath = "/tmp/fixtures/skills/loose-tool.md";
122
+
123
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:loose-tool", looseEntryPath);
124
+
125
+ const steer = lastSteer(pi);
126
+ expect(steer).toContain("/tmp/fixtures/skills/loose-tool.md"); // 文件路径本身
127
+ expect(steer).not.toContain("loose-tool.md/SKILL.md"); // 不再拼接 SKILL.md(悬空指引根修)
128
+ expect(steer).not.toContain("/tmp/fixtures/skills/SKILL.md"); // 旧缺陷形态:散 .md 的 dirname + SKILL.md
129
+ });
130
+
131
+ it("skill mode without skillEntryPath falls back to name-only guidance (防御形态)", () => {
132
+ attachGoalInit(() => true);
133
+
134
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:dev-flow");
135
+
136
+ const steer = lastSteer(pi);
137
+ expect(steer).toContain("load the dev-flow skill");
138
+ expect(steer).not.toContain("SKILL.md");
139
+ });
140
+ });
141
+
142
+ // goal 桥五个失败出口 → GoalBridgeOutcome 五值(D2)
143
+ describe("tryGoalInit failure exits (execute mode)", () => {
144
+ it("goal-unavailable: bridge not mounted → degraded steer + warning notify", () => {
145
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
146
+
147
+ expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
148
+ const steer = lastSteer(pi);
149
+ expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
150
+ expect(steer).not.toContain("Goal tracking is active via /goal");
151
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
152
+ });
153
+
154
+ it("plan-unreadable: plan file read fails → recovery points at the plan file", () => {
155
+ attachGoalInit(() => true);
156
+ fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
157
+
158
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
159
+
160
+ expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
161
+ expect(lastSteer(pi)).toContain("Check that the plan file exists");
162
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("plan-unreadable"), "warning");
163
+ });
164
+
165
+ it("no-steps: plan content has no extractable steps", () => {
166
+ attachGoalInit(() => true);
167
+ setupFsMock("# Plan\n\nProse without any numbered list.");
168
+
169
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
170
+
171
+ expect(outcome).toEqual({ started: false, reason: "no-steps" });
172
+ expect(lastSteer(pi)).toContain("Implementation Steps");
173
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("no-steps"), "warning");
174
+ });
175
+
176
+ it("init-refused: goalInit returns false (active goal exists)", () => {
177
+ attachGoalInit(() => false);
178
+
179
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
180
+
181
+ expect(outcome).toEqual({ started: false, reason: "init-refused" });
182
+ expect(lastSteer(pi)).toContain("/goal clear");
183
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("init-refused"), "warning");
184
+ });
185
+
186
+ it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
187
+ attachGoalInit(() => { throw new Error("goalInit exploded"); });
188
+
189
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
190
+
191
+ expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
192
+ expect(lastSteer(pi)).toContain("Goal tracking was not started (internal-error)");
193
+ expect(lastSteer(pi)).not.toContain("Goal tracking is active via /goal");
194
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
195
+ });
196
+ });
197
+ });