@zhushanwen/pi-plan 0.4.8 → 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.
@@ -12,6 +12,7 @@ vi.mock("node:fs", () => ({
12
12
  }));
13
13
 
14
14
  import { buildPlanSuccessCriteria, handlePlanComplete } from "../compact.js";
15
+ import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
15
16
 
16
17
  const fsMock = vi.mocked(await import("node:fs"));
17
18
 
@@ -19,7 +20,7 @@ function makePi() {
19
20
  return {
20
21
  on: vi.fn(),
21
22
  appendEntry: vi.fn(),
22
- sendUserMessage: vi.fn(),
23
+ sendMessage: vi.fn(),
23
24
  };
24
25
  }
25
26
 
@@ -160,7 +161,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
160
161
  it("direct isolation: 3 步 plan → 总述 + 3 条 preview", () => {
161
162
  fsMock.readFileSync.mockReturnValue(makePlanContent(["Step A", "Step B", "Step C"]));
162
163
 
163
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
164
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
164
165
 
165
166
  expect(getCriteriaArg()).toEqual([
166
167
  "All 3 steps of plan executed and verified",
@@ -173,7 +174,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
173
174
  it("compact isolation: onComplete 后 goalInit 收到同形态数组", () => {
174
175
  fsMock.readFileSync.mockReturnValue(makePlanContent(["Step one", "Step two"]));
175
176
 
176
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
177
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
177
178
  ctx._onCompleteFns[0]();
178
179
 
179
180
  expect(getCriteriaArg()).toEqual([
@@ -187,7 +188,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
187
188
  const steps = Array.from({ length: 12 }, (_, i) => `Step ${i + 1}`);
188
189
  fsMock.readFileSync.mockReturnValue(makePlanContent(steps));
189
190
 
190
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
191
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
191
192
 
192
193
  const items = getCriteriaArg();
193
194
  expect(items).toHaveLength(4);
@@ -198,7 +199,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
198
199
  it("CRLF plan 文件 → 每条 criteria 仍单行不含 \\r \\n", () => {
199
200
  fsMock.readFileSync.mockReturnValue("## 实现步骤\r\n1. Step one\r\n2. Step two\r\n3. Step three");
200
201
 
201
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
202
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
202
203
 
203
204
  expect(getCriteriaArg()).toEqual([
204
205
  "All 3 steps of plan executed and verified",
@@ -211,10 +212,13 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
211
212
  it("0 步 plan → tryGoalInit 提前退出,goalInit 不被调用", () => {
212
213
  (fsMock.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue("## Overview\nNo numbered steps here.");
213
214
 
214
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
215
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
215
216
 
216
217
  expect(goalInitMock).not.toHaveBeenCalled();
217
- // steer 仍发出(执行流程不因 goal 缺席中断)
218
- expect(pi.sendUserMessage).toHaveBeenCalled();
218
+ // steer 仍发出(执行流程不因 goal 缺席中断)——custom message 三要素 + steer options(A6)
219
+ expect(pi.sendMessage).toHaveBeenCalledWith(
220
+ { customType: PLAN_CONTEXT_CUSTOM_TYPE, content: expect.any(String), display: false },
221
+ { deliverAs: "steer", triggerTurn: true },
222
+ );
219
223
  });
220
224
  });
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import type { PlanState } from "../state.js";
4
+ import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
4
5
 
5
6
  // Mock fs before importing compact.ts (ESM namespace is not configurable)
6
7
  vi.mock("node:fs", () => ({
@@ -18,7 +19,7 @@ function makePi() {
18
19
  return {
19
20
  on: vi.fn(),
20
21
  appendEntry: vi.fn(),
21
- sendUserMessage: vi.fn(),
22
+ sendMessage: vi.fn(),
22
23
  };
23
24
  }
24
25
 
@@ -70,10 +71,19 @@ afterEach(() => {
70
71
  Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
71
72
  });
72
73
 
73
- /** 最近一次 steer 消息正文。 */
74
+ /** 最近一次 sendMessage 的正文(P8 custom message 的 content 字段)。 */
74
75
  function lastSteer(pi: ReturnType<typeof makePi>): string {
75
- const calls = (pi.sendUserMessage as ReturnType<typeof vi.fn>).mock.calls;
76
- return String(calls[calls.length - 1]?.[0]);
76
+ const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
77
+ const last = calls[calls.length - 1]?.[0] as { content?: string } | undefined;
78
+ return String(last?.content ?? "");
79
+ }
80
+
81
+ /** P8 执行通知断言形态:custom message 三要素 + streaming steer options(A6)。 */
82
+ function executionNotice(content: unknown) {
83
+ return [
84
+ { customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
85
+ { deliverAs: "steer", triggerTurn: true },
86
+ ];
77
87
  }
78
88
 
79
89
  // --- handlePlanComplete tests ---
@@ -90,10 +100,10 @@ describe("handlePlanComplete", () => {
90
100
  });
91
101
 
92
102
  describe("compact isolation", () => {
93
- it("goal mode: goalInit deferred to onComplete, then goal steer on success (D2 时序——goal entry 在压缩后世界创建)", () => {
103
+ it("execute mode: goalInit deferred to onComplete, then goal steer on success (D2 时序——goal entry 在压缩后世界创建)", () => {
94
104
  const goalInit = attachGoalInit(() => true);
95
105
 
96
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
106
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
97
107
 
98
108
  expect(outcome).toBeUndefined(); // compact 档 outcome 在回调内产生,不进同步返回值
99
109
  expect(ctx.compact).toHaveBeenCalledOnce();
@@ -112,24 +122,39 @@ describe("handlePlanComplete", () => {
112
122
  "2. Step two",
113
123
  ],
114
124
  );
115
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
125
+ expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
116
126
  expect(ctx.ui.notify).not.toHaveBeenCalled();
117
127
  });
118
128
 
119
- it("non-goal mode: onComplete sends mode steer without calling goalInit", () => {
129
+ it("execute mode (compact, bridge unmounted): onComplete sends degraded direct-execution steer, no goalInit", () => {
130
+ // execute 档整合 goal 桥:桥未挂载(goal-unavailable)→ 降级为直接执行指引
131
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
132
+ ctx._onCompleteFns[0]();
133
+
134
+ const steer = lastSteer(pi);
135
+ expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
136
+ expect(steer).toContain("subagents"); // 降级后仍指到 auto-parallel 直接执行
137
+ expect(steer).toContain("current session");
138
+ });
139
+
140
+ it("skill mode (compact): onComplete steer carries the skill entry path (D10 skillEntryPath 通路)", () => {
120
141
  const goalInit = attachGoalInit(() => true);
142
+ const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
121
143
 
122
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "subagent");
144
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "skill:dev-flow", skillEntryPath);
123
145
  ctx._onCompleteFns[0]();
124
146
 
125
147
  expect(goalInit).not.toHaveBeenCalled();
126
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("subagent-driven"), { deliverAs: "steer" });
148
+ const steer = lastSteer(pi);
149
+ expect(steer).toContain("skill:dev-flow"); // Execution mode 行透传 execMode
150
+ expect(steer).toContain("dev-flow"); // 指引按名加载
151
+ expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md"); // 路径读取指引(skillEntryPath = 入口文件路径,直接透传)
127
152
  });
128
153
 
129
- it("onError (goal mode, init-refused): compact-failure notify + degraded steer + warning notify, no /goal promise", () => {
154
+ it("onError (execute mode, init-refused): compact-failure notify + degraded steer + warning notify, no /goal promise", () => {
130
155
  attachGoalInit(() => false); // goalInit 返回 false:已有 active goal
131
156
 
132
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
157
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
133
158
  ctx._onErrorFns[0](new Error("compact failed"));
134
159
 
135
160
  const notifyTexts = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
@@ -137,55 +162,79 @@ describe("handlePlanComplete", () => {
137
162
  expect(notifyTexts.some((t) => t.includes("init-refused"))).toBe(true);
138
163
 
139
164
  const steer = lastSteer(pi);
140
- expect(steer).toContain("Goal execution was not started (init-refused)");
165
+ expect(steer).toContain("Goal tracking was not started (init-refused)");
141
166
  expect(steer).toContain("/goal clear"); // 恢复动作
142
- expect(steer).not.toContain("Execute via /goal"); // 承诺不再无凭据发出
167
+ expect(steer).not.toContain("Goal tracking is active via /goal"); // 承诺不再无凭据发出
143
168
  });
144
169
  });
145
170
 
146
171
  describe("direct isolation", () => {
147
- it("goal mode success: returns outcome synchronously, goal steer, no notify", () => {
172
+ it("execute mode success: returns outcome synchronously, goal steer, no notify", () => {
148
173
  attachGoalInit(() => true);
149
174
 
150
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
175
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
151
176
 
152
177
  expect(outcome).toEqual({ started: true });
153
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
178
+ expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
154
179
  expect(ctx.compact).not.toHaveBeenCalled();
155
180
  expect(ctx.ui.notify).not.toHaveBeenCalled();
156
181
  });
157
182
 
158
- it("non-goal mode: returns undefined, mode steer, no goalInit call", () => {
159
- const goalInit = attachGoalInit(() => true);
183
+ it("skill mode (direct): returns undefined, steer with skill entry path (steer 文案含 skillEntryPath,D10)", () => {
184
+ attachGoalInit(() => true);
185
+ const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
160
186
 
161
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "single-agent");
187
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow", skillEntryPath);
162
188
 
163
189
  expect(outcome).toBeUndefined();
164
- expect(goalInit).not.toHaveBeenCalled();
165
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("step by step"), { deliverAs: "steer" });
166
- expect(ctx.compact).not.toHaveBeenCalled();
190
+ const steer = lastSteer(pi);
191
+ expect(steer).toContain("Execution mode: skill:dev-flow");
192
+ expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md");
193
+ expect(steer).toContain("follow its workflow");
194
+ });
195
+
196
+ it("skill mode (散 .md 形态): steer carries the file path itself, not a dangling joined SKILL.md", () => {
197
+ attachGoalInit(() => true);
198
+ const looseEntryPath = "/tmp/fixtures/skills/loose-tool.md";
199
+
200
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:loose-tool", looseEntryPath);
201
+
202
+ const steer = lastSteer(pi);
203
+ expect(steer).toContain("/tmp/fixtures/skills/loose-tool.md"); // 文件路径本身
204
+ expect(steer).not.toContain("loose-tool.md/SKILL.md"); // 不再拼接 SKILL.md(悬空指引根修)
205
+ expect(steer).not.toContain("/tmp/fixtures/skills/SKILL.md"); // 旧缺陷形态:散 .md 的 dirname + SKILL.md
206
+ });
207
+
208
+ it("skill mode without skillEntryPath falls back to name-only guidance (防御形态)", () => {
209
+ attachGoalInit(() => true);
210
+
211
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow");
212
+
213
+ const steer = lastSteer(pi);
214
+ expect(steer).toContain("load the dev-flow skill");
215
+ expect(steer).not.toContain("SKILL.md");
167
216
  });
168
217
 
169
218
  it("unknown isolation value falls through to direct delivery instead of silently dropping the choice (D1 防御形态)", () => {
170
219
  attachGoalInit(() => true);
171
220
 
172
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "goal");
221
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "execute");
173
222
 
174
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
223
+ expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
175
224
  expect(ctx.compact).not.toHaveBeenCalled();
176
225
  expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("/tree"), "info");
177
226
  });
178
227
  });
179
228
 
180
229
  // goal 桥五个失败出口 → GoalBridgeOutcome 五值(D2)
181
- describe("tryGoalInit failure exits (goal mode, direct isolation)", () => {
230
+ describe("tryGoalInit failure exits (execute mode, direct isolation)", () => {
182
231
  it("goal-unavailable: bridge not mounted → degraded steer + warning notify", () => {
183
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
232
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
184
233
 
185
234
  expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
186
235
  const steer = lastSteer(pi);
187
- expect(steer).toContain("Goal execution was not started (goal-unavailable)");
188
- expect(steer).not.toContain("Execute via /goal");
236
+ expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
237
+ expect(steer).not.toContain("Goal tracking is active via /goal");
189
238
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
190
239
  });
191
240
 
@@ -193,7 +242,7 @@ describe("handlePlanComplete", () => {
193
242
  attachGoalInit(() => true);
194
243
  fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
195
244
 
196
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
245
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
197
246
 
198
247
  expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
199
248
  expect(lastSteer(pi)).toContain("Check that the plan file exists");
@@ -204,7 +253,7 @@ describe("handlePlanComplete", () => {
204
253
  attachGoalInit(() => true);
205
254
  setupFsMock("# Plan\n\nProse without any numbered list.");
206
255
 
207
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
256
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
208
257
 
209
258
  expect(outcome).toEqual({ started: false, reason: "no-steps" });
210
259
  expect(lastSteer(pi)).toContain("Implementation Steps");
@@ -214,7 +263,7 @@ describe("handlePlanComplete", () => {
214
263
  it("init-refused: goalInit returns false (active goal exists)", () => {
215
264
  attachGoalInit(() => false);
216
265
 
217
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
266
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
218
267
 
219
268
  expect(outcome).toEqual({ started: false, reason: "init-refused" });
220
269
  expect(lastSteer(pi)).toContain("/goal clear");
@@ -224,11 +273,11 @@ describe("handlePlanComplete", () => {
224
273
  it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
225
274
  attachGoalInit(() => { throw new Error("goalInit exploded"); });
226
275
 
227
- const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
276
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
228
277
 
229
278
  expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
230
- expect(lastSteer(pi)).toContain("Goal execution was not started (internal-error)");
231
- expect(lastSteer(pi)).not.toContain("Execute via /goal");
279
+ expect(lastSteer(pi)).toContain("Goal tracking was not started (internal-error)");
280
+ expect(lastSteer(pi)).not.toContain("Goal tracking is active via /goal");
232
281
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
233
282
  });
234
283
  });
@@ -0,0 +1,172 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock typebox before importing tool(形态照 tool.test.ts;Type.Array 需在列——enter 的 skills 参数)
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
+ // Mock node:fs(plan 目录 mkdirSync 不触真实盘——fs-guard 只白名单 tmpdir;command.test.ts 同形态)
19
+ vi.mock("node:fs", () => ({
20
+ mkdirSync: vi.fn(),
21
+ readdirSync: vi.fn(() => []),
22
+ statSync: vi.fn(),
23
+ existsSync: vi.fn(() => false),
24
+ readFileSync: vi.fn(() => ""),
25
+ }));
26
+
27
+ // Mock templates(enter 的 buildPlanModePrompt 走 listTemplates;单测不扫真实模板目录)
28
+ vi.mock("../templates.js", () => ({
29
+ listTemplates: vi.fn(() => []),
30
+ loadTemplate: vi.fn(() => null),
31
+ formatAvailablePlans: vi.fn(() => ""),
32
+ }));
33
+
34
+ vi.mock("../compact.js", () => ({
35
+ handlePlanComplete: vi.fn(),
36
+ detectGoalCapability: vi.fn(() => false),
37
+ GOAL_FAILURE_RECOVERY: {},
38
+ }));
39
+
40
+ vi.mock("../exec-skills.js", () => ({
41
+ detectExecSkills: vi.fn(() => []),
42
+ }));
43
+
44
+ vi.mock("../widget.js", () => ({
45
+ updatePlanWidget: vi.fn(),
46
+ }));
47
+
48
+ import * as fs from "node:fs";
49
+
50
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
51
+
52
+ import { registerPlanTool } from "../tool.js";
53
+ import { type PlanState, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
54
+
55
+ const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
56
+
57
+ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
58
+ const sessions = new Map();
59
+ const controllers = new Map<string, AbortController>();
60
+ let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<{
61
+ content: Array<{ type: string; text: string }>;
62
+ details: { action: string; requirement?: string; skills?: string[] };
63
+ }>;
64
+ const pi = {
65
+ registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
66
+ appendEntry: vi.fn(),
67
+ setActiveTools: vi.fn(),
68
+ sendMessage: vi.fn(),
69
+ getCommands: vi.fn(() => skillCommands.map((c) => ({ name: c.name, source: "skill", sourceInfo: { path: c.path } }))),
70
+ getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
71
+ } as unknown as ExtensionAPI;
72
+ registerPlanTool(pi, sessions, controllers);
73
+
74
+ const ctx = {
75
+ sessionId: "test-session",
76
+ cwd: "/tmp/test-project",
77
+ hasUI: true,
78
+ mode: "tui" as const,
79
+ isProjectTrusted: () => true,
80
+ sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
81
+ ui: { select: vi.fn(), notify: vi.fn() },
82
+ } as unknown as ExtensionContext;
83
+
84
+ const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
85
+ executeFn!("tc0", params, signal, undefined, ctx);
86
+ return { pi, sessions, ctx, exec };
87
+ }
88
+
89
+ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1)", () => {
90
+ beforeEach(() => {
91
+ vi.clearAllMocks();
92
+ });
93
+
94
+ it("idle 进入:收工具到白名单 + 持久化 isActive=true + tool result 携带只读纪律提示词", async () => {
95
+ const { exec, pi, sessions } = setup();
96
+ const res = await exec({ action: "enter", requirement: "重构 auth 模块" });
97
+
98
+ // 工具收拢到 plan 白名单
99
+ expect(pi.setActiveTools).toHaveBeenCalledWith(PLAN_MODE_TOOLS);
100
+ // 状态持久化 isActive=true(投影链驱动 GUI 显形的锚点)
101
+ expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: true }));
102
+ // plan 目录创建(.tmp/plans/<slug>,slug 由 requirement 派生)
103
+ expect(fs.mkdirSync).toHaveBeenCalledWith(
104
+ expect.stringMatching(/\.tmp[/\\]plans[/\\]/),
105
+ expect.objectContaining({ recursive: true }),
106
+ );
107
+ // 提示词经 tool result 直返(不经对话流消息注入——对本次调用的直接响应)
108
+ expect(pi.sendMessage).not.toHaveBeenCalled();
109
+ expect(res.details.action).toBe("enter");
110
+ expect(res.content[0].text).toContain("[PLAN MODE] Entered plan mode");
111
+ expect(res.content[0].text).toContain("READ-ONLY");
112
+ // 状态对象已是激活态
113
+ const state = sessions.get("test-session") as { isActive?: boolean; requirement?: string };
114
+ expect(state.isActive).toBe(true);
115
+ });
116
+
117
+ it("已在 plan 模式:幂等返回,不重复收工具 / 不重复注入", async () => {
118
+ const { exec, pi, sessions } = setup();
119
+ await exec({ action: "enter", requirement: "first" });
120
+ vi.clearAllMocks();
121
+ // state 缓存已在(sessions map 同一对象 isActive=true)
122
+ expect((sessions.get("test-session") as { isActive?: boolean }).isActive).toBe(true);
123
+
124
+ const res = await exec({ action: "enter", requirement: "second" });
125
+ expect(res.content[0].text).toContain("Already in plan mode");
126
+ // 幂等:不再 setActiveTools / 不再 appendEntry
127
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
128
+ expect(pi.appendEntry).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("携带有效 skills:解析挂载,state.skills 记录技能名", async () => {
132
+ const { exec, sessions } = setup([{ name: "skill:tech-design", path: "/skills/tech-design/SKILL.md" }]);
133
+ const res = await exec({ action: "enter", requirement: "r", skills: ["tech-design"] });
134
+ expect(res.details.action).toBe("enter");
135
+ expect(res.details.skills).toEqual(["tech-design"]);
136
+ const state = sessions.get("test-session") as { skills?: string[] };
137
+ expect(state.skills).toEqual(["tech-design"]);
138
+ });
139
+
140
+ it("未知技能名:throw 带可用清单自愈(同 select-template 错名先例)", async () => {
141
+ const { exec, pi } = setup([{ name: "skill:tech-design", path: "/x/SKILL.md" }]);
142
+ await expect(exec({ action: "enter", requirement: "r", skills: ["nope"] })).rejects.toThrow(
143
+ /Unknown skill\(s\): nope\. Available skills: .*tech-design/,
144
+ );
145
+ // 校验失败不进入:未收工具、未持久化
146
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
147
+ expect(pi.appendEntry).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it("省略 requirement:提示词回落 (from conversation context),不阻断进入", async () => {
151
+ const { exec } = setup();
152
+ const res = await exec({ action: "enter" });
153
+ expect(res.details.action).toBe("enter");
154
+ expect(res.content[0].text).toContain("(from conversation context)");
155
+ });
156
+
157
+ it("新轮次进入:上轮残留的 reviewStateSource 随进入失效(跨 plan run 残留防护,§3.4 清除点)", async () => {
158
+ const { exec, pi, sessions } = setup();
159
+ // 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState 的路径留下的来源标记
160
+ sessions.set("test-session", { ...DEFAULT_PLAN_STATE, isActive: false, reviewStateSource: "explain" });
161
+
162
+ const res = await exec({ action: "enter", requirement: "new round" });
163
+
164
+ 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)
169
+ const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
170
+ expect(lastEntry.reviewStateSource).toBeUndefined();
171
+ });
172
+ });