@zhushanwen/pi-plan 0.5.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { describe, expect,it } from "vitest";
2
2
 
3
- import { extractPlanSteps } from "../compact.js";
3
+ import { extractPlanSteps } from "../execution-notice.js";
4
4
 
5
5
  describe("extractPlanSteps", () => {
6
6
  it("extracts numbered steps from 实现步骤 section", () => {
@@ -8,6 +8,7 @@ describe("extractPlanSteps", () => {
8
8
 
9
9
  ## 背景
10
10
  Some context
11
+ 1. Background numbered line that must not leak
11
12
 
12
13
  ## 实现步骤
13
14
  1. Create the user model
@@ -27,7 +28,10 @@ Run tests`;
27
28
  it("extracts numbered steps from 实施步骤 section", () => {
28
29
  const plan = `## 实施步骤
29
30
  1. Step one
30
- 2. Step two`;
31
+ 2. Step two
32
+
33
+ ## 验证
34
+ 1. Verification line that must not leak`;
31
35
  const steps = extractPlanSteps(plan);
32
36
  expect(steps).toEqual(["Step one", "Step two"]);
33
37
  });
@@ -35,7 +39,10 @@ Run tests`;
35
39
  it("extracts steps from English Steps section", () => {
36
40
  const plan = `## Steps
37
41
  1. First step
38
- 2. Second step`;
42
+ 2. Second step
43
+
44
+ ## Notes
45
+ 1. Notes line that must not leak`;
39
46
  const steps = extractPlanSteps(plan);
40
47
  expect(steps).toEqual(["First step", "Second step"]);
41
48
  });
@@ -43,7 +50,10 @@ Run tests`;
43
50
  it("extracts steps from Implementation section", () => {
44
51
  const plan = `## Implementation Steps
45
52
  1. Create file
46
- 2. Add exports`;
53
+ 2. Add exports
54
+
55
+ ## Validation
56
+ 1. Validation line that must not leak`;
47
57
  const steps = extractPlanSteps(plan);
48
58
  expect(steps).toEqual(["Create file", "Add exports"]);
49
59
  });
@@ -72,16 +82,6 @@ Run tests`;
72
82
  ]);
73
83
  });
74
84
 
75
- it("returns empty array when no steps section and no numbered items", () => {
76
- const plan = `## 背景
77
- Some context
78
-
79
- ## 方案
80
- Option A is preferred`;
81
- const steps = extractPlanSteps(plan);
82
- expect(steps).toEqual([]);
83
- });
84
-
85
85
  it("fallback: collects numbered items when no steps section header", () => {
86
86
  const plan = `# Plan
87
87
  1. First thing
@@ -95,8 +95,17 @@ Option A is preferred`;
95
95
  const plan = `## 实现步骤
96
96
  1. Valid step
97
97
 
98
- 2. Another valid step`;
98
+ 2. Another valid step
99
+ 3.
100
+ 4. ${" "}`;
99
101
  const steps = extractPlanSteps(plan);
100
102
  expect(steps).toEqual(["Valid step", "Another valid step"]);
101
103
  });
104
+
105
+ it("fallback cap: collects at most the first 10 numbered items when no steps section header", () => {
106
+ const plan = Array.from({ length: 12 }, (_, i) => `${i + 1}. step ${i + 1}`).join("\n");
107
+ const steps = extractPlanSteps(plan);
108
+ expect(steps).toHaveLength(10);
109
+ expect(steps[9]).toBe("step 10");
110
+ });
102
111
  });
@@ -0,0 +1,388 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock node:fs 破坏性写(形态照 command.test.ts 的思路,读透传真实实现):enter/abort 会碰
4
+ // mkdir/rmdir/readdir——vitest fs-guard 红线,测试不得写非白名单目录;模板发现等读路径保留真实
5
+ vi.mock("node:fs", async () => {
6
+ const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
7
+ return {
8
+ ...actual,
9
+ mkdirSync: vi.fn(),
10
+ rmdirSync: vi.fn(),
11
+ };
12
+ });
13
+
14
+ // Mock typebox / pi-ai before importing tool(形态照 review.test.ts)
15
+ vi.mock("typebox", () => ({
16
+ Type: {
17
+ Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
18
+ String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
19
+ Optional: (schema: unknown) => schema,
20
+ Array: (item: unknown, opts?: Record<string, unknown>) => ({ type: "array", items: item, ...opts }),
21
+ },
22
+ Static: class {},
23
+ }));
24
+
25
+ vi.mock("@earendil-works/pi-ai", () => ({
26
+ StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
27
+ }));
28
+
29
+ vi.mock("../execution-notice.js", async () => {
30
+ const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../execution-notice.js")>("../execution-notice.js");
31
+ return {
32
+ handlePlanComplete: vi.fn(),
33
+ GOAL_FAILURE_RECOVERY,
34
+ };
35
+ });
36
+
37
+ vi.mock("../widget.js", () => ({ updatePlanWidget: vi.fn() }));
38
+ // importActual 展开:只覆写 detectExecSkills(不扫真实目录),其余导出(含 enter.ts
39
+ // re-export 的 resolveSkills 执行门禁)走真实现——mock 罩全模块会把它一并变 undefined
40
+ vi.mock("@zhushanwen/pi-exec-skills", async () => {
41
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
42
+ "@zhushanwen/pi-exec-skills",
43
+ );
44
+ return { ...actual, detectExecSkills: vi.fn(() => []) };
45
+ });
46
+
47
+ import { PLAN_REVIEW_MARKER } from "@zhushanwen/extension-protocol";
48
+ import { detectExecSkills } from "@zhushanwen/pi-exec-skills";
49
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
50
+
51
+ import { registerPlanCommand } from "../command.js";
52
+ import type { PlanState } from "../state.js";
53
+ import { createPlanCtx, DEFAULT_PLAN_STATE } from "../state.js";
54
+ import { registerPlanTool } from "../tool.js";
55
+
56
+ const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "ask_user", "write", "edit"];
57
+ const SR = "3 requirements covered; 2 assumptions verified.";
58
+
59
+ /** 规划中 + 一份文档(submit-review 合法前置) */
60
+ function planningState(): PlanState {
61
+ return {
62
+ ...DEFAULT_PLAN_STATE,
63
+ isActive: true,
64
+ planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
65
+ requirement: "refactor auth",
66
+ docs: [{ fileName: "design.md", absPath: "/tmp/test-project/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
67
+ state: "planning",
68
+ selfReview: SR,
69
+ lastSubmitReviewDocsFingerprint: "design.md:1",
70
+ };
71
+ }
72
+
73
+ /**
74
+ * 组合 harness:tool + command 共享单 ctx 对象两注册表
75
+ * (跨入口解散判别需要真实共享——exitPlanMode 与等待归口经同一 controllers 表传递
76
+ * dissolvedBy 直传来源,D-B1-2;sessions/controllers 别名 = planCtx 两表,D-B4-3)。
77
+ */
78
+ function setup(initialState?: PlanState) {
79
+ const planCtx = createPlanCtx();
80
+ let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
81
+ let commandHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
82
+
83
+ const pi = {
84
+ registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
85
+ registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
86
+ commandHandler = def.handler;
87
+ }),
88
+ appendEntry: vi.fn(),
89
+ setActiveTools: vi.fn(),
90
+ sendMessage: vi.fn(),
91
+ notify: vi.fn(),
92
+ getCommands: vi.fn(() => []),
93
+ getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
94
+ } as unknown as Parameters<typeof registerPlanTool>[0];
95
+ registerPlanTool(pi, planCtx);
96
+ registerPlanCommand(pi as unknown as Parameters<typeof registerPlanCommand>[0], planCtx);
97
+
98
+ const ctx = {
99
+ sessionId: "test-session",
100
+ cwd: "/tmp/test-project",
101
+ hasUI: true,
102
+ mode: "tui" as const,
103
+ isProjectTrusted: () => true,
104
+ sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
105
+ ui: { select: vi.fn(), notify: vi.fn(), setWidget: vi.fn(), setStatus: vi.fn() },
106
+ };
107
+
108
+ if (initialState) planCtx.states.set("test-session", initialState);
109
+
110
+ const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
111
+ executeFn!("tc0", params, signal, undefined, ctx);
112
+ return {
113
+ pi, planCtx, sessions: planCtx.states, controllers: planCtx.controllers, ctx, exec,
114
+ handleCommand: (args: string) => commandHandler(args, ctx as unknown as ExtensionContext),
115
+ };
116
+ }
117
+
118
+ /** select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):signal 生效即 resolve undefined */
119
+ function selectHonoringSignal(ctx: { ui: { select: unknown } }): void {
120
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
121
+ (_title: string, _options: string[], opts: { signal?: AbortSignal }) =>
122
+ new Promise<string | undefined>((resolve) => {
123
+ if (opts.signal?.aborted) {
124
+ resolve(undefined);
125
+ return;
126
+ }
127
+ opts.signal?.addEventListener("abort", () => resolve(undefined), { once: true });
128
+ }),
129
+ );
130
+ }
131
+
132
+ function entries(pi: { appendEntry: unknown }): PlanState[] {
133
+ return (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as PlanState);
134
+ }
135
+
136
+ beforeEach(() => {
137
+ vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1"); // taiji 形态(marker select 链路)
138
+ });
139
+
140
+ afterEach(() => {
141
+ vi.unstubAllEnvs();
142
+ // exec-skills 模块 mock 归位空集(D7②:空集直通是缺省形态,用例内显式注入技能的覆写不跨用例泄漏)
143
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
144
+ });
145
+
146
+ // ── dissolvedBy 直传断言组(D-B1-2,单测锚定,零 token)──
147
+
148
+ describe("dissolvedBy① 入口解散同步临界段(markDissolved 先于 abort、无让出点——归口点必读到 'self')", () => {
149
+ it("挂起审批中 /plan abort:归口①判命令解散 no-op(不落 review_aborted),reset 终态是唯一新落盘", async () => {
150
+ const h = setup(planningState());
151
+ h.ctx.mode = "rpc";
152
+ selectHonoringSignal(h.ctx);
153
+
154
+ // exec() 同步推进到 ctx.ui.select 挂起(首个 await 即 select)
155
+ const pending = h.exec({ action: "submit-review", selfReview: SR });
156
+ // 挂起已登记(PendingSelect 在表内且未打标——外部解散前的缺省形态)
157
+ const hang = h.controllers.get("test-session");
158
+ expect(hang?.dissolvedBy).toBeUndefined();
159
+ // exitPlanMode 体内无 await:整段(markDissolved('self') → controller.abort() →
160
+ // resetPlanState)同步执行完毕,abort 触发的 promise 解析是微任务——归口点必在其后
161
+ // 运行、dissolvedBy 已置位(同步临界段不变量)
162
+ void h.handleCommand("abort");
163
+ // 打标来源随挂起闭包直达等待处(原独立中途断言用例内联)
164
+ expect(hang?.dissolvedBy).toBe("self");
165
+ const res = await pending;
166
+
167
+ // 归口①:dissolvedBy === 'self'(入口直传)→ 命令解散 no-op
168
+ expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
169
+ expect((res as { content: Array<{ text: string }> }).content[0].text).toContain("has been exited");
170
+ // 归口 no-op:无 review_aborted 落盘(不出现把 exited 打回 planning 的 entry)
171
+ const all = entries(h.pi);
172
+ expect(all.map((e) => e.state)).toEqual(["reviewing", "exited"]);
173
+ });
174
+
175
+ it("反向对照:同形态若仅 controller.abort()(不打标)则判外部解散(断言有效性的负向探针对照)", async () => {
176
+ const h = setup(planningState());
177
+ h.ctx.mode = "rpc";
178
+ selectHonoringSignal(h.ctx);
179
+
180
+ const pending = h.exec({ action: "submit-review", selfReview: SR });
181
+ // 只 abort controller 不经 exitPlanMode(turn abort 级联形态)——不打标,缺省即外部
182
+ h.controllers.get("test-session")?.controller.abort();
183
+ const res = await pending;
184
+
185
+ expect(res.details).toEqual({ action: "review-error", reason: "review-interrupted" });
186
+ const all = entries(h.pi);
187
+ expect(all.at(-1)?.state).toBe("planning"); // review_aborted 边落盘
188
+ });
189
+ });
190
+
191
+ describe("S15② 新鲜度门拒收后重试仍触发(gate 判定先于一切写入、拒收不更新任何快照)", () => {
192
+ it("文档已变 + selfReview 照抄 → 连续两次拒收 stale-self-review,零状态写入", async () => {
193
+ // 前置:上次提交后又登记了新版本(指纹 design.md:1 → design.md:2),自审对象已变——
194
+ // incoming selfReview 与 state.selfReview 逐字节相同 → 防照抄门命中
195
+ const h = setup({
196
+ ...planningState(),
197
+ docs: [{ fileName: "design.md", absPath: "/tmp/test-project/.tmp/plans/auth/design.md", sourceSkill: "", version: 2 }],
198
+ lastSubmitReviewDocsFingerprint: "design.md:1",
199
+ selfReview: SR,
200
+ });
201
+ const first = await h.exec({ action: "submit-review", selfReview: SR });
202
+ expect(first.details).toEqual({ action: "review-error", reason: "stale-self-review" });
203
+ expect((first as { content: Array<{ text: string }> }).content[0].text).toContain("redo the self-review");
204
+ expect(h.pi.appendEntry).not.toHaveBeenCalled();
205
+
206
+ // 重试(agent 未改自审直接重调)→ 仍触发(拒收不更新任何快照:指纹/selfReview 均未被写)
207
+ const second = await h.exec({ action: "submit-review", selfReview: SR });
208
+ expect(second.details).toEqual({ action: "review-error", reason: "stale-self-review" });
209
+ expect(h.pi.appendEntry).not.toHaveBeenCalled();
210
+
211
+ // 换新 selfReview → 过门(对照)
212
+ const third = await h.exec({ action: "submit-review", selfReview: "v2 self-review redone." });
213
+ expect(third.details).toMatchObject({ action: "submit-review" });
214
+ expect(h.pi.appendEntry).toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ describe("dissolvedBy② 跨轮无挂起退出 → 新一轮外部解散正常转移(闭包身份天然隔离,无残留回归)", () => {
219
+ it("上一轮 abort(无挂起 select)不污染本轮归口:新一轮外部解散仍走 review_aborted", async () => {
220
+ const h = setup(planningState());
221
+ // 第一轮:无挂起 select 的退出(规划期退出——旧世代标记机制会在此形态下残留的病根)
222
+ await h.exec({ action: "abort" });
223
+
224
+ // 第二轮:同 turn enter → 登记文档 → 挂新 select → 外部解散(新 PendingSelect,
225
+ // 闭包来源变量独立于上一轮)
226
+ await h.exec({ action: "enter", requirement: "round two" });
227
+ await h.exec({ action: "register-doc", fileName: "design.md" });
228
+ h.ctx.mode = "rpc";
229
+ selectHonoringSignal(h.ctx);
230
+ const turn = new AbortController();
231
+ const pending = h.exec({ action: "submit-review", selfReview: SR }, turn.signal);
232
+ // execute 的 turn signal 级联解散(外部解散源,cascadeTurnAbort——只 abort 不打标)
233
+ turn.abort();
234
+ const res = await pending;
235
+
236
+ // 新挂起的来源变量未被污染(undefined)→ 外部解散正常转移,不被误判为命令解散
237
+ expect(res.details).toEqual({ action: "review-error", reason: "review-interrupted" });
238
+ const all = entries(h.pi);
239
+ expect(all.at(-1)).toMatchObject({ state: "planning", isActive: true });
240
+ });
241
+ });
242
+
243
+ describe("resumeHint 清除断言(不变量:只描述当前降级等待的原因,不跨轮残留)", () => {
244
+ it("① reset 清除 / ② enter 进入重置组清除(③ submit-review 重挂起点清除归 review.test「重挂起点清 resumeHint」保留者)", async () => {
245
+ // ① reset
246
+ const a = setup({ ...planningState(), state: "reviewing", resumeHint: "resubmit" });
247
+ await a.exec({ action: "abort" });
248
+ expect(entries(a.pi).at(-1)?.resumeHint).toBeUndefined();
249
+
250
+ // ② enter 新轮次重置组
251
+ const b = setup({ ...DEFAULT_PLAN_STATE, isActive: false, state: "exited", resumeHint: "resubmit", selfReview: "stale" });
252
+ await b.exec({ action: "enter", requirement: "fresh" });
253
+ expect(entries(b.pi).at(-1)?.resumeHint).toBeUndefined();
254
+ expect(entries(b.pi).at(-1)?.selfReview).toBeUndefined();
255
+ });
256
+ });
257
+
258
+ // ── 状态写全走 transition()(consumers.md 三A/三B 接线断言)──
259
+
260
+ describe("六 action 状态写走 transition()(D1 边表接线)", () => {
261
+ it("enter:idle|completed|exited --enter--> planning 三源全落 planning", async () => {
262
+ for (const from of ["idle", "completed", "exited"] as const) {
263
+ const h = setup({ ...DEFAULT_PLAN_STATE, isActive: false, state: from });
264
+ await h.exec({ action: "enter", requirement: `from ${from}` });
265
+ expect(entries(h.pi).at(-1)?.state).toBe("planning");
266
+ }
267
+ });
268
+
269
+ it("submit-review:planning|revising --submit--> reviewing 两源;approve 决策 --approve--> dispatching", async () => {
270
+ for (const from of ["planning", "revising"] as const) {
271
+ const h = setup({ ...planningState(), state: from });
272
+ h.ctx.mode = "rpc";
273
+ (h.ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({ decision: "dismiss" }));
274
+ await h.exec({ action: "submit-review", selfReview: "fresh review." });
275
+ const states = entries(h.pi).map((e) => e.state);
276
+ expect(states).toContain("reviewing"); // submit 边
277
+ expect(states.at(-1)).toBe("planning"); // dismiss 边
278
+ }
279
+
280
+ const g = setup(planningState());
281
+ g.ctx.mode = "rpc";
282
+ (g.ctx.ui.select as ReturnType<typeof vi.fn>)
283
+ .mockResolvedValueOnce(JSON.stringify({ decision: "approve" }))
284
+ .mockResolvedValueOnce(JSON.stringify({ "Execution method": "Execute" }));
285
+ await g.exec({ action: "submit-review", selfReview: "fresh review." });
286
+ const states = entries(g.pi).map((e) => e.state);
287
+ expect(states).toContain("dispatching"); // approve 边(reviewing → dispatching)
288
+ expect(states.at(-1)).toBe("completed"); // exec_chosen 边 + terminal='completed'
289
+ });
290
+
291
+ it("complete:reviewing --approve--> dispatching(文本流转直调 complete 同边,§三B)→ later --> approved", async () => {
292
+ const h = setup(planningState());
293
+ // 有技能才挂表单(D7②)——later 边只存在于表单的「暂不执行」档
294
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
295
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
296
+ ]);
297
+ // 文本流:submit-review 软门后用户口头确认 → agent 直调 complete
298
+ await h.exec({ action: "submit-review", selfReview: SR });
299
+ (h.ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Not now");
300
+ const res = await h.exec({ action: "complete" });
301
+ expect(res.details).toMatchObject({ action: "complete-later" });
302
+ const states = entries(h.pi).map((e) => e.state);
303
+ expect(states).toContain("dispatching");
304
+ expect(states.at(-1)).toBe("approved"); // later 边
305
+ });
306
+
307
+ it("complete later 档转移被拒(挂起窗口内 exit 交错已落终态)→ 中性 state-changed 出口,不落 approved", async () => {
308
+ const h = setup({ ...planningState(), state: "approved" });
309
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
310
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
311
+ ]);
312
+ // select 应答受控(忽略 signal,解散不代答):挂起窗口内先让 /plan abort 介入
313
+ //(fresh-read 后已非 dispatching),再以「Not now」落定——later 选择与交错退出
314
+ // 同窗到达的真实竞态形态
315
+ let settleSelect: (label: string | undefined) => void = () => {};
316
+ (h.ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
317
+ () => new Promise<string | undefined>((resolve) => { settleSelect = resolve; }),
318
+ );
319
+ const pending = h.exec({ action: "complete" });
320
+ void h.handleCommand("abort");
321
+ settleSelect("Not now");
322
+ const res = await pending;
323
+
324
+ // later 边被 FSM 拒(现值已落 exited):中性 state-changed 出口(与 exec_chosen
325
+ // ok:false 同型),不复用 APPROVED 成功文案——与真实退出态相反会误导 agent 下一步
326
+ expect(res.details).toEqual({ action: "review-error", reason: "out-of-order" });
327
+ const text = (res as { content: Array<{ text: string }> }).content[0].text;
328
+ expect(text).toContain("state changed");
329
+ expect(text).not.toContain("APPROVED");
330
+ // 落盘终态:dispatching(complete 入口 approve 边)→ exited(abort 介入)——无 approved 新落盘
331
+ const states = entries(h.pi).map((e) => e.state);
332
+ expect(states).toEqual(["dispatching", "exited"]);
333
+ });
334
+
335
+ it("complete 未经审批闸口(planning 直调)→ out-of-order 纠偏,不落盘(执行前用户审批结构性保证)", async () => {
336
+ const h = setup(planningState());
337
+ const res = await h.exec({ action: "complete" });
338
+ expect(res.details).toEqual({ action: "review-error", reason: "out-of-order" });
339
+ expect((res as { content: Array<{ text: string }> }).content[0].text).toContain("submit-review");
340
+ expect(h.pi.appendEntry).not.toHaveBeenCalled();
341
+ });
342
+
343
+ it("abort:活跃族非终态 --exit--> exited;select-template/register-doc 无生命周期事件(persist 携带现值)", async () => {
344
+ const h = setup(planningState());
345
+ await h.exec({ action: "select-template", templateName: "feature-plan" });
346
+ await h.exec({ action: "register-doc", fileName: "design.md" });
347
+ // 两 action 均无转移写:state 现值原样落盘
348
+ for (const e of entries(h.pi)) expect(e.state).toBe("planning");
349
+
350
+ await h.exec({ action: "abort" });
351
+ expect(entries(h.pi).at(-1)?.state).toBe("exited");
352
+ });
353
+
354
+ it("reviewing 重挂自环(D-B1-8:reviewing --submit--> reviewing):重提交照常落盘回 reviewing,前后等价,接续 approve 正常", async () => {
355
+ const h = setup({ ...planningState(), state: "reviewing" });
356
+ h.ctx.mode = "rpc";
357
+ // reviewing 态重提交(E3 恢复 steer / D8 重新提交按钮的 agent 侧形态)
358
+ (h.ctx.ui.select as ReturnType<typeof vi.fn>)
359
+ .mockResolvedValueOnce(JSON.stringify({ decision: "approve" }))
360
+ .mockResolvedValueOnce(JSON.stringify({ "Execution method": "Execute" }));
361
+ const res = await h.exec({ action: "submit-review", selfReview: SR });
362
+ // 自环锚:旧特判形态(ok:false 且现值 reviewing 放行)与自环形态落盘产物等价——
363
+ // state 值不变、重挂回 reviewing(单一记录点照常落盘)
364
+ const states = entries(h.pi).map((e) => e.state);
365
+ expect(states[0]).toBe("reviewing"); // 重挂落盘(自环 ok:true)
366
+ expect(states).toContain("dispatching"); // 接续 approve 边正常
367
+ expect(states.at(-1)).toBe("completed"); // exec_chosen 终局
368
+ expect(res.details.action).toBe("complete");
369
+ });
370
+ });
371
+
372
+ // ── CompleteChoiceOutcome 显式 via 五构造点补齐(①later/⑤channel 已在 tool.test 锚定)──
373
+
374
+ describe("CompleteChoiceOutcome via 构造点(D3 连带段:显式枚举,归口直读)", () => {
375
+ // 构造点③(rpc cancel:select 解散 + signal aborted)已并入 tool.test.ts「turn abort
376
+ // during the pending execution-method select」(controller-abort→select-解散接线保留者);
377
+ // 「signal aborted → reason cancelled」的库层判别由 extension-protocol ui-form 测试归属。
378
+ it("构造点④(rpc timeout:undefined resolve 且 signal 未 abort)→ 折叠 reason 'cancelled' 外部解散(review_aborted→approved)", async () => {
379
+ const h = setup({ ...planningState(), state: "approved" });
380
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
381
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
382
+ ]);
383
+ (h.ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
384
+ const res = await h.exec({ action: "complete" });
385
+ expect(res.details).toMatchObject({ action: "complete-cancelled", source: "external" });
386
+ expect(entries(h.pi).at(-1)?.state).toBe("approved"); // review_aborted:dispatching → approved
387
+ });
388
+ });