@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.
@@ -15,22 +15,26 @@ vi.mock("@earendil-works/pi-ai", () => ({
15
15
  StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
16
16
  }));
17
17
 
18
- // Mock compact.js (statically imported since 06-u1)
19
- vi.mock("../compact.js", async () => {
18
+ // Mock execution-notice.js (statically imported since 06-u1)
19
+ vi.mock("../execution-notice.js", async () => {
20
20
  // GOAL_FAILURE_RECOVERY 与真实实现同文案——completeResultText 在 failure 断言里消费它
21
- const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../compact.js")>("../compact.js");
21
+ const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../execution-notice.js")>("../execution-notice.js");
22
22
  return {
23
23
  handlePlanComplete: vi.fn(),
24
- detectGoalCapability: vi.fn(() => false),
25
24
  GOAL_FAILURE_RECOVERY,
26
25
  };
27
26
  });
28
27
 
29
- // Mock exec-skills(D10 检测):单测里不扫真实目录(~/.agents/skills 等本机路径),
30
- // skill 选项用例显式注入 fixture;专用 exec-skills.test.ts 覆盖真实扫描
31
- vi.mock("../exec-skills.js", () => ({
32
- detectExecSkills: vi.fn(() => []),
33
- }));
28
+ // Mock 执行方式检测(D10):单测里不扫真实目录(~/.agents/skills 等本机路径),
29
+ // skill 选项用例显式注入 fixture;专用 exec-skills.test.ts 覆盖真实扫描。
30
+ // importActual 展开:只覆写 detectExecSkills,其余导出(含 enter.ts re-export 的
31
+ // resolveSkills 执行门禁)走真实现——mock 罩全模块会把它一并变 undefined
32
+ vi.mock("@zhushanwen/pi-exec-skills", async () => {
33
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
34
+ "@zhushanwen/pi-exec-skills",
35
+ );
36
+ return { ...actual, detectExecSkills: vi.fn(() => []) };
37
+ });
34
38
 
35
39
  // Mock widget (imported by abort)
36
40
  vi.mock("../widget.js", () => ({
@@ -44,11 +48,11 @@ import { join } from "node:path";
44
48
  import type { Theme } from "@earendil-works/pi-coding-agent";
45
49
  import { Text } from "@earendil-works/pi-tui";
46
50
  import { UI_FORM_MARKER } from "@zhushanwen/extension-protocol";
51
+ import { detectExecSkills } from "@zhushanwen/pi-exec-skills";
47
52
 
48
- import { detectGoalCapability, handlePlanComplete } from "../compact.js";
49
- import { detectExecSkills } from "../exec-skills.js";
50
- import { DEFAULT_PLAN_STATE } from "../state.js";
51
- import { loadTemplate } from "../templates.js";
53
+ import { handlePlanComplete } from "../execution-notice.js";
54
+ import { createPlanCtx, DEFAULT_PLAN_STATE } from "../state.js";
55
+ import { listTemplates } from "../templates.js";
52
56
  import { PLAN_ACTIONS, registerPlanTool, validateAction } from "../tool.js";
53
57
  import { updatePlanWidget } from "../widget.js";
54
58
 
@@ -56,8 +60,8 @@ import { updatePlanWidget } from "../widget.js";
56
60
  const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
57
61
 
58
62
  function setup() {
59
- const sessions = new Map();
60
- const controllers = new Map<string, AbortController>();
63
+ // 单 ctx 对象(D-B4-3):sessions/controllers 别名 = planCtx 两表(同对象),用例断言不变
64
+ const planCtx = createPlanCtx();
61
65
  let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
62
66
  const pi = {
63
67
  registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
@@ -65,7 +69,7 @@ function setup() {
65
69
  setActiveTools: vi.fn(),
66
70
  getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
67
71
  } as unknown as Parameters<typeof registerPlanTool>[0];
68
- registerPlanTool(pi, sessions, controllers);
72
+ registerPlanTool(pi, planCtx);
69
73
 
70
74
  const ctx = {
71
75
  sessionId: "test-session",
@@ -74,13 +78,24 @@ function setup() {
74
78
  hasUI: true,
75
79
  mode: "tui" as const,
76
80
  isProjectTrusted: () => true,
77
- sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
81
+ sessionManager: { getSessionId: () => "test-session", getLeafId: () => null, getEntries: () => [] },
78
82
  ui: { select: vi.fn(), notify: vi.fn() },
79
83
  };
80
84
 
85
+ // 默认前置:已批准态(approved --approve--> dispatching 重选执行方式,consumers.md §三B)——
86
+ // complete 的审批闸口(D1 边表)要求先过 review 流程;用例可在返回后覆写 sessions
87
+ planCtx.states.set("test-session", {
88
+ ...DEFAULT_PLAN_STATE,
89
+ isActive: true,
90
+ planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
91
+ requirement: "refactor auth",
92
+ state: "approved",
93
+ docs: [{ fileName: "design.md", absPath: "/tmp/test-project/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
94
+ });
95
+
81
96
  const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
82
97
  executeFn!("tc0", params, signal, undefined, ctx);
83
- return { pi, sessions, controllers, ctx, exec };
98
+ return { pi, planCtx, sessions: planCtx.states, controllers: planCtx.controllers, ctx, exec };
84
99
  }
85
100
 
86
101
  describe("registerPlanTool", () => {
@@ -90,6 +105,12 @@ describe("registerPlanTool", () => {
90
105
  expect((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0].name).toBe("plan");
91
106
  });
92
107
 
108
+ it("declares executionMode 'sequential'(D-B1-4:action 就地突变共享 PlanState,串行声明消除同批并行交错)", () => {
109
+ const { pi } = setup();
110
+ const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as { executionMode?: string };
111
+ expect(tool.executionMode).toBe("sequential");
112
+ });
113
+
93
114
  it("tool description and promptSnippet no longer mention list-template (D1 删链不留兼容通道)", () => {
94
115
  const { pi } = setup();
95
116
  const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
@@ -128,11 +149,13 @@ describe("registerPlanTool", () => {
128
149
  });
129
150
 
130
151
  it("content carries the winner file's full text and details has no content field (D7 全文通道唯一化)", async () => {
131
- const { exec } = setup();
152
+ const { exec, ctx } = setup();
132
153
  const res = await exec({ action: "select-template", templateName: "feature-plan" });
133
154
  const text = res.content[0].text;
134
- // 全文到达模型可见通道(对照 loadTemplate 的胜者内容,运行机用户级遮蔽时同样成立)
135
- const winnerContent = loadTemplate("feature-plan");
155
+ // 全文到达模型可见通道(对照同参数合并视图胜者 path 的直读——与生产侧同一次
156
+ // 扫描语义,运行机用户级遮蔽时两轨拿同一胜者,断言同样成立)
157
+ const winner = listTemplates({ projectRoot: ctx.cwd }).find((t) => t.name === "feature-plan");
158
+ const winnerContent = winner ? fs.readFileSync(winner.path, "utf-8") : null;
136
159
  expect(winnerContent).not.toBeNull();
137
160
  expect(text).toContain(`<template>\n${winnerContent}\n</template>`);
138
161
  // details 收窄:仅 action + templateName,全文不再双份持久化
@@ -164,7 +187,7 @@ describe("registerPlanTool", () => {
164
187
  planFilePath: "/tmp/test-project/.tmp/plans/retro/plan.md",
165
188
  requirement: "retro",
166
189
  templateName: "retro-template",
167
- templateProvidedPath: "/tmp/test-project/docs/retro-template.md",
190
+ templateProvided: true,
168
191
  });
169
192
  const error = await exec({ action: "select-template", templateName: "retro-template" }).then(
170
193
  () => new Error("expected rejection"),
@@ -180,21 +203,17 @@ describe("registerPlanTool", () => {
180
203
 
181
204
  // --- removed actions (D1 / D3) ---
182
205
  describe("removed action rejections", () => {
183
- it("rejects plan(action='list-template') as an unknown action with the 6-action list (D1)", async () => {
184
- const { exec } = setup();
185
- await expect(exec({ action: "list-template" })).rejects.toThrow(
186
- "Unknown plan action: list-template. Valid actions: enter, select-template, complete, abort, register-doc, submit-review",
187
- );
188
- });
189
-
190
- it("rejects plan(action='create-template') as an unknown action (D3 / V4)", async () => {
191
- const { exec } = setup();
192
- await expect(
193
- exec({ action: "create-template", templateName: "my-plan", templateContent: "# hello" }),
194
- ).rejects.toThrow(
195
- "Unknown plan action: create-template. Valid actions: enter, select-template, complete, abort, register-doc, submit-review",
196
- );
197
- });
206
+ it.each(["list-template", "create-template"] as const)(
207
+ "rejects plan(action='%s') as an unknown action with the 6-action list (D1/D3)",
208
+ async (removed) => {
209
+ const { exec } = setup();
210
+ await expect(
211
+ exec(removed === "create-template" ? { action: removed, templateName: "my-plan", templateContent: "# hello" } : { action: removed }),
212
+ ).rejects.toThrow(
213
+ `Unknown plan action: ${removed}. Valid actions: enter, select-template, complete, abort, register-doc, submit-review`,
214
+ );
215
+ },
216
+ );
198
217
  });
199
218
 
200
219
  // --- renderResult 兜底(MF-1-7 旧持久化 details 形态)---
@@ -220,43 +239,32 @@ describe("registerPlanTool", () => {
220
239
  return tool.renderResult;
221
240
  }
222
241
 
223
- it("旧 action=list-template details(已删 action 的历史 entry)渲染不抛、回落 content 文本", () => {
242
+ it.each([
243
+ { action: "list-template", details: { action: "list-template", templates: ["feature-plan", "bugfix"] }, text: "Available templates: feature-plan, bugfix" },
244
+ { action: "create-template", details: { action: "create-template" }, text: "legacy entry" },
245
+ ])("legacy/unknown action details ($action) render without throwing, falling back to the content text", ({ details, text }) => {
224
246
  const { pi } = setup();
225
247
  const render = renderFn(pi);
226
- // git 2ab33c46c 旧版形态:details.action="list-template" 不在现版 PlanDetails 联合内。
227
- // 修复前 switch 落空返回 undefined → pi TUI 渲染循环对 undefined 调 .render() TypeError
248
+ // git 2ab33c46c 旧版形态:details.action 不在现版 PlanDetails 联合内(list-template 为
249
+ // 已删 action 的历史 entry 形态)。修复前 switch 落空返回 undefined → pi TUI 渲染循环
250
+ // 对 undefined 调 .render() TypeError;任意未知 action 同走 default 兜底(防御未来再删)。
228
251
  const result = {
229
- content: [{ type: "text", text: "Available templates: feature-plan, bugfix" }],
230
- details: { action: "list-template", templates: ["feature-plan", "bugfix"] },
252
+ content: [{ type: "text", text }],
253
+ details,
231
254
  };
232
255
 
233
256
  const component = render(result, { expanded: false }, renderTheme);
234
257
 
235
258
  expect(component).toBeInstanceOf(Text);
236
- expect(component.render(400).join("\n")).toContain("Available templates: feature-plan, bugfix");
237
- });
238
-
239
- it("任意未知 action 形态同样回落 content 文本(防御未来再删 action)", () => {
240
- const { pi } = setup();
241
- const render = renderFn(pi);
242
- const result = {
243
- content: [{ type: "text", text: "legacy entry" }],
244
- details: { action: "create-template" },
245
- };
246
-
247
- const component = render(result, { expanded: false }, renderTheme);
248
-
249
- expect(component).toBeInstanceOf(Text);
250
- expect(component.render(400).join("\n")).toContain("legacy entry");
259
+ expect(component.render(400).join("\n")).toContain(text);
251
260
  });
252
261
  });
253
262
 
254
263
  // --- complete ---
255
264
  describe("complete", () => {
256
265
  beforeEach(() => {
257
- // detectGoalCapability 不在 complete 链上消费(execute 档 tryGoalInit 内部自理
258
- // goal-unavailable 降级);goal outcome 用例经 handlePlanComplete mock 构造结果
259
- (detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
266
+ // goal outcome 用例经 handlePlanComplete mock 构造结果(execute 档 tryGoalInit
267
+ // 在 execution-notice.ts 内部自理 goal-unavailable 降级,不在本文件消费面)
260
268
  (handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
261
269
  (detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
262
270
  (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
@@ -271,28 +279,40 @@ describe("registerPlanTool", () => {
271
279
  return ((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0]) as Record<string, unknown>;
272
280
  }
273
281
 
274
- it("rejects isolation='tree' at the schema level: enum is exactly compact|direct (D1 / V3①)", async () => {
282
+ it("schema carries no isolation field (D-B1-6: compact|direct 两档分发砍除,单一直接投递路径)", async () => {
275
283
  const { pi } = setup();
276
284
  const parameters = registeredTool(pi).parameters as {
277
- properties: { isolation: { enum: string[] } };
285
+ properties: Record<string, unknown>;
278
286
  };
279
- expect(parameters.properties.isolation.enum).toEqual(["compact", "direct"]);
280
- expect(parameters.properties.isolation.enum).not.toContain("tree");
287
+ expect(parameters.properties.isolation).toBeUndefined();
281
288
  });
282
289
 
283
- it("does not advance when user picks Not now", async () => {
290
+ it("does not advance when user picks Not now — later 边(dispatching→approved)+ later 文案,不进解散文案桶(A9 反向)", async () => {
284
291
  const { exec, ctx, pi } = setup();
292
+ // 有 plan-exec 技能才挂表单(D7②:空集直通,暂不执行档只存在于表单内)
293
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
294
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
295
+ ]);
285
296
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Not now");
286
297
  const res = await exec({ action: "complete" });
287
- expect(res.details.action).toBe("complete-cancelled");
288
- expect(res.details.reason).toBe("Not now");
289
- // 用户主动暂不执行:留在 plan mode 是用户意图(区别于 abort 联动取消的文案)
290
- expect(res.content[0].text).toContain("Staying in plan mode");
298
+ expect(res.details.action).toBe("complete-later");
299
+ expect(res.details.choice).toBe("Not now");
300
+ // later 档文案(D3 连带段):已批准、未派发、可再调 complete——**不得**是外部解散文案
301
+ expect(res.content[0].text).toContain("NOT dispatched");
302
+ expect(res.content[0].text).toContain("plan(action='complete')");
303
+ expect(res.content[0].text).not.toContain("interrupted");
304
+ expect(res.content[0].text).not.toContain("exited");
305
+ // later 边落盘:approved(批准事实保留,留在 plan mode)
306
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
307
+ expect(entries.at(-1)?.state).toBe("approved");
291
308
  expect(pi.setActiveTools).not.toHaveBeenCalled();
292
309
  });
293
310
 
294
- it("turn abort during the pending execution-method select dissolves the dialog → complete-cancelled (MF-1-8)", async () => {
311
+ it("turn abort during the pending execution-method select dissolves the dialog → 归口②外部解散(review_aborted→approved,MF-1-8)", async () => {
295
312
  const { exec, ctx, pi } = setup();
313
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
314
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
315
+ ]);
296
316
  // select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):
297
317
  // signal 已 abort 首行短路 resolve undefined;挂起中 abort → resolve undefined
298
318
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
@@ -309,12 +329,18 @@ describe("registerPlanTool", () => {
309
329
  const pending = exec({ action: "complete" }, turn.signal);
310
330
  turn.abort();
311
331
  const res = await pending;
332
+ // turn abort 级联(未经入口,不打标)→ 外部解散:dispatching --review_aborted--> approved
312
333
  expect(res.details.action).toBe("complete-cancelled");
313
334
  expect(res.details.reason).toBe("cancelled");
335
+ expect(res.details.source).toBe("external");
336
+ expect(res.content[0].text).toContain("interrupted");
337
+ expect(res.content[0].text).toContain("APPROVED");
314
338
  expect(pi.setActiveTools).not.toHaveBeenCalled();
339
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
340
+ expect(entries.at(-1)?.state).toBe("approved");
315
341
  });
316
342
 
317
- it("resets state and restores tools on execute", async () => {
343
+ it("resets state and restores tools on execute — exec_chosen 边(dispatching→completed 终局)", async () => {
318
344
  const { exec, ctx, pi } = setup();
319
345
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
320
346
  const res = await exec({ action: "complete" });
@@ -322,18 +348,28 @@ describe("registerPlanTool", () => {
322
348
  expect(res.details.execMode).toBe("execute");
323
349
  expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
324
350
  expect(handlePlanComplete).toHaveBeenCalled();
325
- expect(res.details.planFilePath).toBeDefined();
351
+ // 内部路径显示为相对路径(A2 边界判断正向:projectDir 后紧随分隔符才切)
352
+ expect(res.details.planFilePath).toBe(".tmp/plans/auth/plan.md");
353
+ // 终局落盘:terminal='completed'(防 reset 覆写 completed,D3 连带段)
354
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string; isActive?: boolean });
355
+ expect(entries.at(-1)).toMatchObject({ state: "completed", isActive: false });
326
356
  });
327
357
 
328
- it("dialog options = skills (max 2) + Execute + Not now, goal bridge availability irrelevant (选项集重排)", async () => {
329
- const { exec, ctx } = setup();
358
+ it("A2 边界锚:兄弟前缀目录(/tmp/test-project-2)不被误切进 /tmp/test-project 的相对路径", async () => {
359
+ const { exec, ctx, sessions } = setup();
360
+ sessions.set("test-session", {
361
+ ...DEFAULT_PLAN_STATE,
362
+ isActive: true,
363
+ planFilePath: "/tmp/test-project-2/.tmp/plans/auth/plan.md",
364
+ requirement: "refactor auth",
365
+ state: "approved",
366
+ docs: [{ fileName: "design.md", absPath: "/tmp/test-project-2/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
367
+ });
330
368
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
331
- await exec({ action: "complete" });
332
- const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
333
- expect(options).toEqual([
334
- "Execute",
335
- "Not now",
336
- ]);
369
+ const res = await exec({ action: "complete" });
370
+ // 裸 startsWith 曾把 test-project-2 误吃进 test-project 前缀(错切为 2/.tmp/...)——
371
+ // 边界判断(分隔符或全等)后兄弟前缀原样显示
372
+ expect(res.details.planFilePath).toBe("/tmp/test-project-2/.tmp/plans/auth/plan.md");
337
373
  });
338
374
 
339
375
  it("execute choice maps to execMode execute (goal + auto-parallel 整合档)", async () => {
@@ -341,7 +377,7 @@ describe("registerPlanTool", () => {
341
377
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
342
378
  const res = await exec({ action: "complete" });
343
379
  expect(res.details.execMode).toBe("execute");
344
- expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
380
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
345
381
  });
346
382
 
347
383
  it("headless (!hasUI) defaults to execute without any select (D4 三路分流第 1 路)", async () => {
@@ -353,7 +389,7 @@ describe("registerPlanTool", () => {
353
389
  expect(ctx.ui.select).not.toHaveBeenCalled(); // 不进任何 select(noOp 软门修复)
354
390
  expect(detectExecSkills).not.toHaveBeenCalled(); // 选择已预定,跳过 skill 扫描
355
391
  expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
356
- expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
392
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
357
393
  });
358
394
 
359
395
  it("detected plan-exec skills lead the option set (first + second) and map to skill:<name> with skillEntryPath (D10 重排)", async () => {
@@ -379,24 +415,36 @@ describe("registerPlanTool", () => {
379
415
  expect(res.details.action).toBe("complete");
380
416
  expect(res.details.execMode).toBe("skill:dev-flow");
381
417
  // skillEntryPath 数据通路:CompleteChoiceOutcome → handlePlanComplete(steer 文案的路径来源)
382
- expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "skill:dev-flow", skillEntryPath);
418
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "skill:dev-flow", skillEntryPath);
383
419
  });
384
420
 
385
- it("empty detection set leaves no skill options (空集不误伤选项集)", async () => {
386
- const { exec, ctx } = setup();
421
+ it("D7② 空集直通(TUI):无 plan-exec 技能不挂任何选择器,直接走执行派发链 + 文案明示", async () => {
422
+ const { exec, ctx, pi } = setup();
387
423
  (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
388
- (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
389
- await exec({ action: "complete" });
390
- const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
391
- expect(options.some((label) => label.startsWith("Execute via skill:"))).toBe(false);
424
+ const res = await exec({ action: "complete" });
425
+
426
+ // 不挂执行方式选择(恒两项死表单构造性消除,S4 通过标准)
427
+ expect(ctx.ui.select).not.toHaveBeenCalled();
428
+ expect(res.details.action).toBe("complete");
429
+ expect(res.details.execMode).toBe("execute");
430
+ expect(res.details.execModeSource).toBe("no-exec-skills");
431
+ // 仍现扫(直通判定依赖检测结果,不是跳过检测——rpc 形态与本形态同分支,原独立用例已合并)
432
+ expect(detectExecSkills).toHaveBeenCalled();
433
+ // 工具结果文案明示「无 plan-exec 技能,直接执行」(不静默吞掉没弹表单的事实)
434
+ expect(res.content[0].text).toContain("No plan-exec skill was detected");
435
+ expect(res.content[0].text).toContain("executed directly");
436
+ // 复用既有执行派发链(goal 桥/直执 steer)+ 终局 completed
437
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
438
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
439
+ expect(entries.at(-1)?.state).toBe("completed");
392
440
  });
393
441
 
394
442
  it("execute tier carries the goal outcome into result content and details (整合档 D2)", async () => {
395
443
  const { exec, ctx } = setup();
396
444
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
397
445
  (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: false, reason: "no-steps" });
398
- const res = await exec({ action: "complete", isolation: "direct" });
399
- expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
446
+ const res = await exec({ action: "complete" });
447
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
400
448
  expect(res.content[0].text).toContain("Goal tracking was not started (no-steps)");
401
449
  expect(res.content[0].text).toContain("Implementation Steps"); // 恢复动作
402
450
  expect(res.details.goalOutcome).toEqual({ started: false, reason: "no-steps" });
@@ -406,20 +454,20 @@ describe("registerPlanTool", () => {
406
454
  const { exec, ctx } = setup();
407
455
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
408
456
  (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: true });
409
- const res = await exec({ action: "complete", isolation: "direct" });
457
+ const res = await exec({ action: "complete" });
410
458
  expect(res.content[0].text).toContain("Goal tracking started via /goal");
411
459
  expect(res.details.goalOutcome).toEqual({ started: true });
412
460
  });
413
461
 
414
- it("compact tier outcome is deferred (undefined): result keeps the plain approved line", async () => {
462
+ it("no goal-bridge outcome (undefined): result keeps the plain approved line", async () => {
415
463
  const { exec, ctx } = setup();
416
464
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
417
465
  (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
418
- const res = await exec({ action: "complete", isolation: "compact" });
466
+ const res = await exec({ action: "complete" });
419
467
  expect(res.content[0].text).toMatch(/^Plan approved\. File: /);
420
468
  expect(res.content[0].text).not.toContain("Goal tracking");
421
469
  expect(res.details.goalOutcome).toBeUndefined();
422
- expect(res.details.isolation).toBe("compact");
470
+ expect(res.details.isolation).toBeUndefined(); // isolation 字段随 D-B1-6 两档分发砍除退役
423
471
  });
424
472
  });
425
473
 
@@ -427,7 +475,6 @@ describe("registerPlanTool", () => {
427
475
  describe("complete via taiji form channel", () => {
428
476
  beforeEach(() => {
429
477
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
430
- (detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
431
478
  (handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
432
479
  (detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
433
480
  (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
@@ -451,8 +498,11 @@ describe("registerPlanTool", () => {
451
498
  return payload.formQuestions[0].options;
452
499
  }
453
500
 
454
- it("sends a single choice question via UI_FORM_MARKER and maps the execute answer (无 tab 条单视图)", async () => {
501
+ it("sends a single choice question via UI_FORM_MARKER and maps the execute answer (无 tab 条单视图;D7② 有技能才挂)", async () => {
455
502
  const { exec, ctx } = setupGui();
503
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
504
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
505
+ ]);
456
506
  (ctx.ui.select as ReturnType<typeof vi.fn>)
457
507
  .mockResolvedValue(JSON.stringify({ "Execution method": "Execute" }));
458
508
  const res = await exec({ action: "complete" });
@@ -461,6 +511,7 @@ describe("registerPlanTool", () => {
461
511
  expect(title).toBe(UI_FORM_MARKER);
462
512
  const labels = formOptionLabels(ctx.ui.select as ReturnType<typeof vi.fn>).map((o) => o.label);
463
513
  expect(labels).toEqual([
514
+ "Execute via skill: dev-flow",
464
515
  "Execute",
465
516
  "Not now",
466
517
  ]);
@@ -482,66 +533,96 @@ describe("registerPlanTool", () => {
482
533
  const skillOption = options.find((o) => o.label === "Execute via skill: dev-flow");
483
534
  expect(skillOption?.description).toBe("Deliver a plan via dev-flow.");
484
535
  expect(res.details.execMode).toBe("skill:dev-flow");
485
- expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "skill:dev-flow", skillEntryPath);
536
+ // 对话档来源标记 + 表单恰挂一次 + 无直通文案(原 rpc 非空独立用例的增量断言并入)
537
+ expect(res.details.execModeSource).toBe("dialog");
538
+ expect(ctx.ui.select).toHaveBeenCalledOnce();
539
+ expect(res.content[0].text).not.toContain("No plan-exec skill");
540
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "skill:dev-flow", skillEntryPath);
486
541
  });
487
542
 
488
- it("timeout via undefined resolve (signal not aborted) folds to complete-cancelled with exit wording (D4 四态折叠)", async () => {
543
+ it("timeout via undefined resolve (signal not aborted) folds to 归口②外部解散(构造点④,review_aborted→approved)", async () => {
489
544
  // rpc 模式 GUI 用户取消 resolve undefined,与超时不可区分(signal 未 abort 折叠
490
- // timeout,库层 callMarkerRpc 判别),消费层 cancelled‖timeout 同折后 reason='cancelled'。
491
- // choice 空 = abort 联动取消语义(P2-3):文案不得声称「Staying in plan mode」
545
+ // timeout,库层 callMarkerRpc 判别),构造点③④同折 via 'dissolved' reason='cancelled'。
546
+ // 未经入口解散(不打标)→ 外部解散:批准事实保留,文案不得声称已退出
492
547
  const { exec, ctx, pi } = setupGui();
548
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
549
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
550
+ ]);
493
551
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
494
552
  const res = await exec({ action: "complete" });
495
553
  expect(res.details.action).toBe("complete-cancelled");
496
554
  expect(res.details.reason).toBe("cancelled");
497
- expect(res.content[0].text).toContain("Plan mode was exited");
498
- expect(res.content[0].text).not.toContain("Staying in plan mode");
555
+ expect(res.details.source).toBe("external");
556
+ expect(res.content[0].text).toContain("interrupted");
557
+ expect(res.content[0].text).toContain("plan(action='complete')");
558
+ expect(res.content[0].text).not.toContain("has been exited");
499
559
  expect(pi.setActiveTools).not.toHaveBeenCalled();
500
560
  });
501
561
 
502
- it("cancelled (abort via controllers registry during pending select) folds to complete-cancelled with exit wording (D4 四态折叠)", async () => {
503
- // 真实通道注入(command.ts handleAbort 同款 `controllers.get(sessionId)?.abort()`):
504
- // 挂起窗口内 session abort → pi 实装 resolve undefined → 库层以 signal.aborted 判
505
- // reason='cancelled'(区别于上一例的 timeout 折叠源)
562
+ it("命令解散(handleAbort 全序列:markDissolved('self') + controller.abort() + reset 介入)→ 归口② no-op(dissolvedBy 判别,不落盘)", async () => {
563
+ // 真实通道注入 = command.ts exitPlanMode 的因果链:markDissolved('self') 与
564
+ // controller.abort() 后 resetPlanState 同步落盘(同步临界段,无让出点)——归口点
565
+ // 在微任务里运行时来源已置位
506
566
  const { exec, ctx, controllers, pi } = setupGui();
567
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
568
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
569
+ ]);
507
570
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async () => {
508
- controllers.get("test-session")?.abort();
571
+ // 模拟 exitPlanMode 入口动作(真实序列:markDissolved 先于 abort)
572
+ const pending = controllers.get("test-session");
573
+ pending?.markDissolved("self");
574
+ pending?.controller.abort();
509
575
  return undefined;
510
576
  });
511
577
  const res = await exec({ action: "complete" });
512
578
  expect(res.details.action).toBe("complete-cancelled");
513
579
  expect(res.details.reason).toBe("cancelled");
514
- expect(res.content[0].text).toContain("Plan mode was exited");
580
+ expect(res.details.source).toBe("self");
581
+ expect(res.content[0].text).toContain("has been exited");
582
+ expect(res.content[0].text).toContain("full tool set is restored");
583
+ // 归口 no-op:不追加任何落盘(reset 已由命令侧落终态)——最后一条仍是入函数时的 dispatching
584
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
585
+ expect(entries.at(-1)?.state).toBe("dispatching");
515
586
  expect(pi.setActiveTools).not.toHaveBeenCalled();
516
587
  });
517
588
 
518
- it("channel-error (echo payload) folds to complete-cancelled with channel note, no throw (D4 四态折叠)", async () => {
589
+ it("channel-error (echo payload) folds to 归口②外部解散(构造点⑤)with channel note, no throw", async () => {
519
590
  const { exec, ctx, pi } = setupGui();
591
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
592
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
593
+ ]);
520
594
  // echo 检测:宿主不识别 UI_FORM_MARKER 时 band 单选项 = payload 自身,点选即回显
521
595
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async (_t: string, options: string[]) => options[0]);
522
596
  const res = await exec({ action: "complete" });
523
597
  expect(res.details.action).toBe("complete-cancelled");
524
598
  expect(res.details.reason).toBe("channel-error");
525
- expect(res.content[0].text).toContain("Interaction channel failed");
599
+ expect(res.details.source).toBe("external");
600
+ expect(res.content[0].text).toContain("interrupted");
601
+ expect(res.content[0].text).toContain("channel-error"); // 构造点原因透出
526
602
  expect(res.content[0].text).toContain("upgrade taiji"); // echo 升级指引透出
527
603
  expect(pi.setActiveTools).not.toHaveBeenCalled();
528
604
  });
529
605
 
530
- it("non-json response folds to complete-cancelled with channel note, no throw (D4 四态折叠)", async () => {
606
+ it("non-json response folds to 归口②外部解散(构造点⑤)with channel note, no throw", async () => {
531
607
  const { exec, ctx } = setupGui();
608
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
609
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
610
+ ]);
532
611
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
533
612
  const res = await exec({ action: "complete" });
534
613
  expect(res.details.action).toBe("complete-cancelled");
535
614
  expect(res.details.reason).toBe("non-json");
536
- expect(res.content[0].text).toContain("Interaction channel failed");
615
+ expect(res.details.source).toBe("external");
616
+ expect(res.content[0].text).toContain("interrupted");
617
+ expect(res.content[0].text).toContain("non-json");
537
618
  });
538
619
  });
539
620
 
540
621
  // --- abort ---
541
622
  describe("abort", () => {
542
- it("resets state and cleans up session", async () => {
543
- const { exec, pi, sessions } = setup();
544
- // Pre-populate a session(工具层 abort 走 resetPlanState——命令层 abort 联动的顺序断言在 command.test.ts)
623
+ it("resets state and cleans up session — exit 边(活跃族非终态→exited)", async () => {
624
+ const { exec, pi, sessions, ctx } = setup();
625
+ // Pre-populate a session(工具层 abort 走 exitPlanMode 单入口——命令层 abort 联动的顺序断言在 command.test.ts)
545
626
  sessions.set("test-session", {
546
627
  ...DEFAULT_PLAN_STATE,
547
628
  isActive: true,
@@ -550,19 +631,86 @@ describe("registerPlanTool", () => {
550
631
  templateName: "t",
551
632
  skills: ["tech-design"],
552
633
  docs: [{ fileName: "design.md", absPath: "/tmp/design.md", sourceSkill: "tech-design", version: 1 }],
553
- reviewState: "awaiting",
634
+ state: "reviewing",
554
635
  });
555
636
  const res = await exec({ action: "abort" });
556
637
  expect(res.details.action).toBe("abort");
557
638
  expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
558
639
  expect(sessions.has("test-session")).toBe(false);
559
- expect(updatePlanWidget).toHaveBeenCalled();
560
- // 终态矩阵:reset entry 落 isActive=false + skills/reviewState 清空 + docs 保留
561
- expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: false }));
640
+ expect(updatePlanWidget).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ isActive: false, state: "exited" }));
641
+ // 终态矩阵:reset entry 落 isActive=false + state='exited' + skills/selfReview 清空 + docs 保留
642
+ expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: false, state: "exited" }));
562
643
  const entry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as Record<string, unknown>;
563
644
  expect(entry.docs).toHaveLength(1);
564
645
  expect(entry.skills).toEqual([]);
565
- expect(entry.reviewState).toBeUndefined();
646
+ });
647
+
648
+ it("终态上 abort → 幂等退出:warn 纠偏 + 不覆写 completed、不落盘 + ④工具集幂等恢复(D-B1-1 守卫极性)", async () => {
649
+ const { exec, pi, sessions } = setup();
650
+ sessions.set("test-session", {
651
+ ...DEFAULT_PLAN_STATE,
652
+ isActive: false,
653
+ state: "completed",
654
+ docs: [{ fileName: "design.md", absPath: "/tmp/design.md", sourceSkill: "", version: 1 }],
655
+ });
656
+ const res = await exec({ action: "abort" });
657
+ expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
658
+ expect(res.content[0].text).toContain("Plan mode is not active");
659
+ // ok:false 不落盘(completed 终态不被覆写为 exited)
660
+ expect(pi.appendEntry).not.toHaveBeenCalled();
661
+ // ④ 工具集照常幂等恢复(D-B1-1:ok:false → ①③④⑤照常,已恢复则 no-op)
662
+ expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
663
+ });
664
+
665
+ it("idle 常态格 abort → warn 纠偏文案、不落盘(D-B1-1 行为变更:噪音 exited entry 消灭,V1 ⑧ 锚)", async () => {
666
+ const { exec, pi, sessions } = setup();
667
+ // idle 常态格:从未进 plan(isActive=false + state='idle'——删 exit 边后 ok:false)
668
+ sessions.set("test-session", { ...DEFAULT_PLAN_STATE });
669
+ const res = await exec({ action: "abort" });
670
+ expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
671
+ expect(res.content[0].text).toBe("No active plan mode.");
672
+ // 行为变更核心断言:不落 exited 噪音 entry
673
+ expect(pi.appendEntry).not.toHaveBeenCalled();
674
+ // ④ 幂等恢复(idle 格工具集已全量,重设 = 可观察零变化)
675
+ expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
676
+ expect(sessions.has("test-session")).toBe(true); // 无 reset(缓存不动)
677
+ });
678
+
679
+ it("坏数据格·终态残留(isActive=true + state=completed)abort → 清洗 entry(终态值不变 + isActive=false,不覆写为 exited)", async () => {
680
+ const { exec, pi, sessions } = setup();
681
+ sessions.set("test-session", {
682
+ ...DEFAULT_PLAN_STATE,
683
+ isActive: true, // 坏格:伪 active 投影
684
+ planFilePath: "/tmp/plans/auth/plan.md",
685
+ state: "completed",
686
+ docs: [{ fileName: "design.md", absPath: "/tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
687
+ });
688
+ const res = await exec({ action: "abort" });
689
+ expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
690
+ // 清洗例外:唯一保留的落盘——isActive 投影复位 + state 取当前终态值(completed 不被 exited 覆写)
691
+ expect(pi.appendEntry).toHaveBeenCalledWith(
692
+ "plan-state",
693
+ expect.objectContaining({ isActive: false, state: "completed" }),
694
+ );
695
+ expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
696
+ expect(sessions.has("test-session")).toBe(false); // 清洗后缓存清理(重开经 entry 恢复正常态)
697
+ });
698
+
699
+ it("坏数据格·idle 残留(isActive=true + state=idle)abort → 清洗 entry(exited + isActive=false)", async () => {
700
+ const { exec, pi, sessions } = setup();
701
+ sessions.set("test-session", {
702
+ ...DEFAULT_PLAN_STATE,
703
+ isActive: true, // 坏格:'idle' 是值域白名单合法值 + isActive=true → 重开复活伪 active
704
+ planFilePath: "/tmp/plans/auth/plan.md",
705
+ state: "idle",
706
+ });
707
+ const res = await exec({ action: "abort" });
708
+ expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
709
+ // 清洗例外 idle 残留形态:abort 是用户显式退出意图,exited 是本次动作的真实记录
710
+ expect(pi.appendEntry).toHaveBeenCalledWith(
711
+ "plan-state",
712
+ expect.objectContaining({ isActive: false, state: "exited" }),
713
+ );
566
714
  });
567
715
  });
568
716
  });