@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,34 +15,45 @@ vi.mock("@earendil-works/pi-ai", () => ({
15
15
  StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
16
16
  }));
17
17
 
18
- vi.mock("../compact.js", async () => {
19
- const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../compact.js")>("../compact.js");
18
+ vi.mock("../execution-notice.js", async () => {
19
+ const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../execution-notice.js")>("../execution-notice.js");
20
20
  return {
21
21
  handlePlanComplete: vi.fn(),
22
- detectGoalCapability: vi.fn(() => false),
23
22
  GOAL_FAILURE_RECOVERY,
24
23
  };
25
24
  });
26
25
 
26
+ // 条目 7 降级锚:unknown-decision 的版本错配 warn 留痕经 extension-logger,捕获断言要点
27
+ const { loggerWarn } = vi.hoisted(() => ({ loggerWarn: vi.fn() }));
28
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
29
+ getLogger: () => ({ warn: loggerWarn, error: vi.fn() }),
30
+ }));
31
+
27
32
  vi.mock("../widget.js", () => ({
28
33
  updatePlanWidget: vi.fn(),
29
34
  }));
30
35
 
31
- // Mock exec-skills(D10 检测):审批用例不扫真实目录,skill 选项恒空
32
- vi.mock("../exec-skills.js", () => ({
33
- detectExecSkills: vi.fn(() => []),
34
- }));
36
+ // Mock 执行方式检测(D10):审批用例不扫真实目录,skill 选项恒空。
37
+ // importActual 展开:只覆写 detectExecSkills,其余导出(含 enter.ts re-export 的
38
+ // resolveSkills 执行门禁)走真实现——mock 罩全模块会把它一并变 undefined
39
+ vi.mock("@zhushanwen/pi-exec-skills", async () => {
40
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
41
+ "@zhushanwen/pi-exec-skills",
42
+ );
43
+ return { ...actual, detectExecSkills: vi.fn(() => []) };
44
+ });
35
45
 
36
- import { handlePlanComplete } from "../compact.js";
46
+ import { handlePlanComplete } from "../execution-notice.js";
37
47
  import { PLAN_REVIEW_MARKER } from "@zhushanwen/extension-protocol";
48
+ import { detectExecSkills } from "@zhushanwen/pi-exec-skills";
38
49
  import type { PlanDocMeta } from "@zhushanwen/extension-protocol";
39
50
  import type { PlanState } from "../state.js";
40
- import { DEFAULT_PLAN_STATE, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
41
- import { PLAN_ACTIONS, registerPlanTool } from "../tool.js";
51
+ import { createPlanCtx, DEFAULT_PLAN_STATE, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
52
+ import { registerPlanTool } from "../tool.js";
42
53
 
43
54
  const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
44
55
 
45
- /** 已激活且带一份已登记文档的状态(submit-review 的合法前置) */
56
+ /** 已激活、规划中且带一份已登记文档的状态(submit-review 的合法前置:planning --submit--> reviewing) */
46
57
  function activeStateWithDocs(): PlanState {
47
58
  const doc: PlanDocMeta = {
48
59
  fileName: "design.md",
@@ -57,12 +68,13 @@ function activeStateWithDocs(): PlanState {
57
68
  requirement: "refactor auth",
58
69
  skills: ["tech-design"],
59
70
  docs: [doc],
71
+ state: "planning",
60
72
  };
61
73
  }
62
74
 
63
75
  function setup(state?: PlanState) {
64
- const sessions = new Map<string, PlanState>();
65
- const controllers = new Map<string, AbortController>();
76
+ // 单 ctx 对象(D-B4-3):sessions/controllers 别名 = planCtx 两表(同对象),用例断言不变
77
+ const planCtx = createPlanCtx();
66
78
  let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
67
79
  const pi = {
68
80
  registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
@@ -71,7 +83,7 @@ function setup(state?: PlanState) {
71
83
  sendMessage: vi.fn(),
72
84
  getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
73
85
  } as unknown as Parameters<typeof registerPlanTool>[0];
74
- registerPlanTool(pi, sessions, controllers);
86
+ registerPlanTool(pi, planCtx);
75
87
 
76
88
  const ctx = {
77
89
  sessionId: "test-session",
@@ -80,15 +92,15 @@ function setup(state?: PlanState) {
80
92
  hasUI: true,
81
93
  mode: "rpc" as const,
82
94
  isProjectTrusted: () => true,
83
- sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
95
+ sessionManager: { getSessionId: () => "test-session", getLeafId: () => null, getEntries: () => [] },
84
96
  ui: { select: vi.fn(), notify: vi.fn() },
85
97
  };
86
98
 
87
- if (state) sessions.set("test-session", state);
99
+ if (state) planCtx.states.set("test-session", state);
88
100
 
89
101
  const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
90
102
  executeFn!("tc0", params, signal, undefined, ctx);
91
- return { pi, sessions, ctx, controllers, exec };
103
+ return { pi, sessions: planCtx.states, ctx, controllers: planCtx.controllers, exec };
92
104
  }
93
105
 
94
106
  /** 构造 docs 非空的激活态并放入 sessions(多数用例的前置) */
@@ -97,6 +109,14 @@ function setupActive() {
97
109
  return harness;
98
110
  }
99
111
 
112
+ /** submit-review 的自审参数(D9① 硬门:每次必带) */
113
+ const SELF_REVIEW = "3 requirements covered; 2 assumptions verified; chapters complete.";
114
+
115
+ /** 落盘 entry 序列辅助:全部 plan-state entry 的 data */
116
+ function persistedEntries(pi: { appendEntry: unknown }): PlanState[] {
117
+ return (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as PlanState);
118
+ }
119
+
100
120
  beforeEach(() => {
101
121
  // 默认按独立 pi 形态跑(无宿主信号);taiji 分支用例内 stubEnv 覆盖
102
122
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "");
@@ -120,6 +140,8 @@ describe("register-doc(D10)", () => {
120
140
  ],
121
141
  }),
122
142
  );
143
+ // 无生命周期事件(9 事件表无 register-doc 员):persist 携带 state 现值,无转移写
144
+ expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ state: "planning" }));
123
145
  });
124
146
 
125
147
  it("re-registering the same fileName bumps version in place (version+1 覆盖)", async () => {
@@ -152,10 +174,10 @@ describe("register-doc(D10)", () => {
152
174
  });
153
175
  });
154
176
 
155
- describe("submit-review E6 双守卫", () => {
177
+ describe("submit-review E6 双守卫 + D9① 自审硬门", () => {
156
178
  it("guard 1: inactive plan mode → error result, no select is hung", async () => {
157
179
  const { exec, ctx } = setup();
158
- const res = await exec({ action: "submit-review" });
180
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
159
181
  expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
160
182
  // A9 收紧语义:未激活 ≠ 批准——禁止实施、等用户指示(禁「wrap up」类歧义表述)
161
183
  expect(res.content[0].text).toContain("not active");
@@ -170,18 +192,36 @@ describe("submit-review E6 双守卫", () => {
170
192
  ...DEFAULT_PLAN_STATE,
171
193
  isActive: true,
172
194
  planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
195
+ state: "planning",
173
196
  });
174
- const res = await exec({ action: "submit-review" });
197
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
175
198
  expect(res.details).toEqual({ action: "review-error", reason: "no-docs" });
176
199
  expect(res.content[0].text).toContain("register-doc");
177
200
  expect(ctx.ui.select).not.toHaveBeenCalled();
178
201
  });
202
+
203
+ it("guard 3(D9① 硬门,无豁免): selfReview 缺失/空 → tool result 纠偏,不挂 select、零状态写入", async () => {
204
+ for (const bad of [undefined, "", " "]) {
205
+ const { exec, ctx, pi } = setupActive();
206
+ const res = await exec({ action: "submit-review", ...(bad === undefined ? {} : { selfReview: bad }) });
207
+
208
+ expect(res.details).toEqual({ action: "review-error", reason: "no-self-review" });
209
+ // 纠偏指令即自审清单(对照需求核覆盖 / 假设审计 / 章节完整性 / 验收可执行)+ 重调形态
210
+ expect(res.content[0].text).toContain("self-review");
211
+ expect(res.content[0].text).toContain("selfReview");
212
+ expect(res.content[0].text).toContain("requirement");
213
+ expect(res.content[0].text).toContain("[UNVERIFIED]");
214
+ expect(ctx.ui.select).not.toHaveBeenCalled();
215
+ // gate 判定先于一切写入:拒收不更新任何快照
216
+ expect(pi.appendEntry).not.toHaveBeenCalled();
217
+ }
218
+ });
179
219
  });
180
220
 
181
221
  describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
182
- it("standalone pi (no signal): no select, text soft-gate result (E8), reviewState=awaiting persisted", async () => {
222
+ it("standalone pi (no signal): no select, text soft-gate result (E8), state=reviewing + selfReview persisted", async () => {
183
223
  const { exec, ctx, pi } = setupActive();
184
- const res = await exec({ action: "submit-review" });
224
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
185
225
 
186
226
  expect(ctx.ui.select).not.toHaveBeenCalled();
187
227
  expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
@@ -190,14 +230,14 @@ describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
190
230
  expect(res.content[0].text).toContain("rewrite the file");
191
231
  expect(res.content[0].text).toContain("register-doc");
192
232
  expect(res.content[0].text).toContain("plan(action='submit-review') again");
193
- // 挂起前落 awaiting(E3 崩溃恢复依赖此持久态)
233
+ // 挂起前落 reviewing(transition 'submit',E3 崩溃恢复按 state 查表依赖此持久态)+ selfReview
194
234
  expect(pi.appendEntry).toHaveBeenCalledWith(
195
235
  "plan-state",
196
- expect.objectContaining({ reviewState: "awaiting" }),
236
+ expect.objectContaining({ state: "reviewing", selfReview: SELF_REVIEW }),
197
237
  );
198
238
  });
199
239
 
200
- it("taiji host (signal=1): hangs PLAN_REVIEW_MARKER select with PlanReviewRequest payload and a fresh signal", async () => {
240
+ it("taiji host (signal=1): hangs PLAN_REVIEW_MARKER select with PlanReviewRequest payload (docs+selfReview) and a fresh signal", async () => {
201
241
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
202
242
  const { exec, ctx, controllers } = setupActive();
203
243
  // 在挂起窗口内(select 尚未 resolve)断言注册表登记——abort 联动必须能找到 controller
@@ -205,19 +245,21 @@ describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
205
245
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
206
246
  async (_title: string, _options: string[], opts: { signal?: AbortSignal }) => {
207
247
  hungSignal = opts.signal;
208
- expect(controllers.get("test-session")?.signal).toBe(opts.signal);
248
+ expect(controllers.get("test-session")?.controller.signal).toBe(opts.signal);
209
249
  return undefined;
210
250
  },
211
251
  );
212
252
 
213
- await exec({ action: "submit-review" });
253
+ await exec({ action: "submit-review", selfReview: SELF_REVIEW });
214
254
 
215
255
  expect(ctx.ui.select).toHaveBeenCalledOnce();
216
256
  const [title, options, opts] = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0] as [string, string[], { signal?: AbortSignal }];
217
257
  expect(title).toBe(PLAN_REVIEW_MARKER);
218
258
  expect(options).toHaveLength(1);
259
+ // D9③:payload 携带 selfReview(写侧截断单点)
219
260
  expect(JSON.parse(options[0])).toEqual({
220
261
  docs: [expect.objectContaining({ fileName: "design.md", version: 1 })],
262
+ selfReview: SELF_REVIEW,
221
263
  });
222
264
  // E10:挂起 select 必须携带 AbortSignal
223
265
  expect(opts.signal).toBeInstanceOf(AbortSignal);
@@ -233,7 +275,7 @@ describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
233
275
  const { exec, ctx } = setupActive();
234
276
  (ctx as { mode?: string }).mode = "tui";
235
277
 
236
- const res = await exec({ action: "submit-review" });
278
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
237
279
 
238
280
  expect(ctx.ui.select).not.toHaveBeenCalled();
239
281
  expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
@@ -241,6 +283,34 @@ describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
241
283
  });
242
284
  });
243
285
 
286
+ describe("E3 降级态用户输入重提(放行语义,F-W3-1 另一半)", () => {
287
+ it("state=reviewing + resumeHint='resubmit'(E3 降级残留)时重调 submit-review → 照常重挂 select + resumeHint 清除(reviewing 自环降级,恢复触发权在用户的路径必须放行)", async () => {
288
+ vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
289
+ const { exec, ctx, pi } = setup({
290
+ ...activeStateWithDocs(),
291
+ state: "reviewing",
292
+ resumeHint: "resubmit",
293
+ selfReview: SELF_REVIEW,
294
+ lastSubmitReviewDocsFingerprint: "design.md:1",
295
+ });
296
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({ decision: "dismiss" }));
297
+
298
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
299
+
300
+ // 放行:不因降级态残留而拒绝重挂(用户点「重新提交审批」按钮 = 显式重提意图)
301
+ expect(ctx.ui.select).toHaveBeenCalledOnce();
302
+ // select 应答被正常消费(链路完整走通到 decision 消费段)
303
+ expect(res.details).toMatchObject({ action: "review-dismissed" });
304
+ // 重挂落盘(倒数第二条;最后一条是 dismiss 应答的转移落盘):reviewing 自环
305
+ //(状态值不变)+ resumeHint 清除(降级等待解除,clearRoundFields 单函数出口的
306
+ // 重挂起点调用点;persistPlanState 显式携带键,undefined 经 JSON 序列化自然消失——
307
+ // 按值语义断言)
308
+ const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as Record<string, unknown>);
309
+ expect(entries.at(-2)).toMatchObject({ state: "reviewing" });
310
+ expect(entries.at(-2)?.resumeHint).toBeUndefined();
311
+ });
312
+ });
313
+
244
314
  describe("turn abort 级联(execute signal → 挂起 select 解散,MF-1-8)", () => {
245
315
  /** select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):signal 已 abort 首行短路 resolve undefined;挂起中 abort → resolve undefined */
246
316
  function selectHonoringSignal(ctx: { ui: { select: unknown } }): void {
@@ -256,19 +326,24 @@ describe("turn abort 级联(execute signal → 挂起 select 解散,MF-1-8
256
326
  );
257
327
  }
258
328
 
259
- it("submit-review 挂起窗口内 turn abort → PLAN_REVIEW_MARKER select 解散 → cancelled result(非批准)", async () => {
329
+ it("submit-review 挂起窗口内 turn abort → select 解散 → 归口①外部解散(review_aborted → planning 落盘,非批准)", async () => {
260
330
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
261
- const { exec, ctx } = setupActive();
331
+ const { exec, ctx, pi } = setupActive();
262
332
  selectHonoringSignal(ctx);
263
333
 
264
334
  const turn = new AbortController();
265
- const pending = exec({ action: "submit-review" }, turn.signal);
335
+ const pending = exec({ action: "submit-review", selfReview: SELF_REVIEW }, turn.signal);
266
336
  turn.abort();
267
337
  const res = await pending;
268
338
 
269
- expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
270
- // A9 语义:取消 ≠ 批准,result 文本显式禁止实施
339
+ // 归口① 非入口解散(turn abort 级联不打标,dissolvedBy 缺省)→ 外部解散:reviewing --review_aborted--> planning
340
+ expect(res.details).toEqual({ action: "review-error", reason: "review-interrupted" });
341
+ // A9 语义:中断 ≠ 批准,result 文本显式禁止实施
271
342
  expect(res.content[0].text).toContain("NOT an approval");
343
+ expect(res.content[0].text).toContain("planning");
344
+ // review_aborted 边落盘(D3 连带段):审批条随之收敛、不复活
345
+ const entries = persistedEntries(pi);
346
+ expect(entries.at(-1)).toMatchObject({ isActive: true, state: "planning" });
272
347
  });
273
348
 
274
349
  it("execute 进入时 turn signal 已 abort → controller 即刻置 abort 态,select 首行短路解散", async () => {
@@ -278,17 +353,17 @@ describe("turn abort 级联(execute signal → 挂起 select 解散,MF-1-8
278
353
 
279
354
  const turn = new AbortController();
280
355
  turn.abort();
281
- const res = await exec({ action: "submit-review" }, turn.signal);
356
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW }, turn.signal);
282
357
 
283
- expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
358
+ expect(res.details).toEqual({ action: "review-error", reason: "review-interrupted" });
284
359
  expect(ctx.ui.select).toHaveBeenCalledOnce();
285
360
  });
286
361
  });
287
362
 
288
363
  describe("重提交无变化检测(docs 快照指纹,E8 机制级兜底)", () => {
289
- it("首次 submit-review:无警告行、details 无 changed 字段", async () => {
364
+ it("首次 submit-review:无警告行,details 仅 channel/docsCount", async () => {
290
365
  const { exec } = setupActive();
291
- const res = await exec({ action: "submit-review" });
366
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
292
367
 
293
368
  expect(res.content[0].text).not.toContain("no documents changed");
294
369
  expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
@@ -296,21 +371,22 @@ describe("重提交无变化检测(docs 快照指纹,E8 机制级兜底)",
296
371
 
297
372
  it("register-doc 后重提交:无警告且快照指纹随 version 更新落盘", async () => {
298
373
  const { exec, pi } = setupActive();
299
- await exec({ action: "submit-review" });
300
- // 修订闭环:rewrite 后重登记,version 1 → 2
374
+ await exec({ action: "submit-review", selfReview: SELF_REVIEW });
375
+ // 修订闭环:rewrite 后重登记,version 1 → 2;自审对象是新版本(D9① 无豁免)→ 换新 selfReview
301
376
  await exec({ action: "register-doc", fileName: "design.md" });
302
- const res = await exec({ action: "submit-review" });
377
+ const res = await exec({ action: "submit-review", selfReview: "v2 self-review: all covered." });
303
378
 
304
379
  expect(res.content[0].text).not.toContain("no documents changed");
305
380
  expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
306
- const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
381
+ const lastEntry = persistedEntries(pi).at(-1)!;
307
382
  expect(lastEntry.lastSubmitReviewDocsFingerprint).toBe("design.md:2");
308
383
  });
309
384
 
310
- it("未 register-doc 重提交:result 末行追加警告 + details.changed=false", async () => {
385
+ it("未 register-doc 重提交:result 末行追加警告,details 不携带信号字段", async () => {
311
386
  const { exec, pi } = setupActive();
312
- await exec({ action: "submit-review" });
313
- const res = await exec({ action: "submit-review" });
387
+ await exec({ action: "submit-review", selfReview: SELF_REVIEW });
388
+ // 未改文档的重提交(E3 同值回传形态)不触防照抄门(指纹未变)
389
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
314
390
 
315
391
  expect(res.content[0].text).toContain(
316
392
  "Note: no documents changed since the last submit-review. " +
@@ -318,13 +394,13 @@ describe("重提交无变化检测(docs 快照指纹,E8 机制级兜底)",
318
394
  );
319
395
  // 追加为末行(追加一行契约)
320
396
  expect(res.content[0].text.split("\n").at(-1)).toMatch(/^Note: no documents changed/);
321
- expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1, changed: false });
397
+ expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
322
398
  // 重提交不改写快照基线:指纹仍是上次值(docs 确实没变)
323
- const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
399
+ const lastEntry = persistedEntries(pi).at(-1)!;
324
400
  expect(lastEntry.lastSubmitReviewDocsFingerprint).toBe("design.md:1");
325
401
  });
326
402
 
327
- it("gui 分支(revise 后未改文档重提交)同样追加警告 + changed=false", async () => {
403
+ it("gui 分支(revise 后未改文档重提交)同样追加警告,details 不携带信号字段", async () => {
328
404
  vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
329
405
  const { exec, ctx } = setupActive();
330
406
  const comments = [{ quote: "第二节", comment: "补失败分支" }];
@@ -332,16 +408,16 @@ describe("重提交无变化检测(docs 快照指纹,E8 机制级兜底)",
332
408
  .mockResolvedValueOnce(JSON.stringify({ decision: "revise", comments }))
333
409
  .mockResolvedValueOnce(JSON.stringify({ decision: "revise", comments }));
334
410
 
335
- const first = await exec({ action: "submit-review" });
411
+ const first = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
336
412
  expect(first.content[0].text).not.toContain("no documents changed");
337
413
  expect(first.details).toEqual({ action: "submit-review", channel: "gui", docsCount: 1 });
338
414
 
339
415
  // 未 register-doc 直接重调 submit-review → 警告
340
- const second = await exec({ action: "submit-review" });
416
+ const second = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
341
417
  expect(second.content[0].text).toContain(
342
418
  "Note: no documents changed since the last submit-review",
343
419
  );
344
- expect(second.details).toEqual({ action: "submit-review", channel: "gui", docsCount: 1, changed: false });
420
+ expect(second.details).toEqual({ action: "submit-review", channel: "gui", docsCount: 1 });
345
421
  });
346
422
  });
347
423
 
@@ -351,42 +427,61 @@ describe("decision 消费(taiji 形态)", () => {
351
427
  return setupActive();
352
428
  }
353
429
 
354
- it("approve → clears reviewState before exec-choice, walks the complete flow, resets state keeping docs", async () => {
430
+ it("approve → transition reviewing→dispatching 落盘后再挂 exec-choice,终局 completed(D5 阶段不倒退)", async () => {
355
431
  const { exec, ctx, pi } = setupTaiji();
432
+ // 有 plan-exec 技能 → 挂执行方式表单(D7②:空集直通无表单窗,本用例锁 form 挂起窗的 D5 时序)
433
+ (detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
434
+ { name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
435
+ ]);
356
436
  (ctx.ui.select as ReturnType<typeof vi.fn>)
357
437
  .mockResolvedValueOnce(JSON.stringify({ decision: "approve" }))
358
438
  .mockResolvedValueOnce(JSON.stringify({ "Execution method": "Execute" }));
359
439
 
360
- const res = await exec({ action: "submit-review" });
440
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
361
441
 
362
442
  expect(ctx.ui.select).toHaveBeenCalledTimes(2);
363
443
  expect(res.details.action).toBe("complete");
364
444
  expect(res.details.execMode).toBe("execute");
365
445
  expect(handlePlanComplete).toHaveBeenCalled();
366
- // P2-3:approve 消费审批后、exec-choice 挂起前先清 reviewState 落盘——
367
- // 「awaiting 无挂起」窗口不得误导渲染降级态
368
- const stateEntries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls
369
- .map((c) => c[1] as PlanState)
370
- .filter((e) => e.isActive === true);
371
- expect(stateEntries.at(-1)?.reviewState).toBeUndefined();
372
- // reset 终态矩阵经 resetPlanState 落盘:isActive=false + docs 保留
446
+ // D5:approve 消费后、exec-choice 挂起前落盘 dispatching(取代「先清 reviewState」)——
447
+ // 挂起期间 derivePhase(dispatching)='approved',阶段③不倒退(F5 不复活)
448
+ const activeEntries = persistedEntries(pi).filter((e) => e.isActive === true);
449
+ expect(activeEntries.at(-1)?.state).toBe("dispatching");
450
+ // 终局 reset:terminal='completed'(不被 reset 覆写为 'exited')+ docs 保留
373
451
  expect(pi.appendEntry).toHaveBeenCalledWith(
374
452
  "plan-state",
375
- expect.objectContaining({ isActive: false, docs: expect.any(Array) }),
453
+ expect.objectContaining({ isActive: false, state: "completed", docs: expect.any(Array) }),
376
454
  );
377
- const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
455
+ const lastEntry = persistedEntries(pi).at(-1)!;
378
456
  expect(lastEntry.docs).toHaveLength(1);
379
- expect(lastEntry.reviewState).toBeUndefined();
380
457
  });
381
458
 
382
- it("revise → comments injected with explicit deliverAs:'steer' + reviewState=revising persisted", async () => {
459
+ it("dismiss → reviewing --dismiss--> planning 落盘 + 搁置文案(不实施、询问下一步)", async () => {
460
+ const { exec, ctx, pi } = setupTaiji();
461
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(JSON.stringify({ decision: "dismiss" }));
462
+
463
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
464
+
465
+ // D3 消费面②③:dismiss 分支显式结果 + tool result 语义(搁置 ≠ 批准 ≠ 取消)
466
+ expect(res.details).toEqual({ action: "review-dismissed", docsCount: 1 });
467
+ expect(res.content[0].text).toContain("set this review aside");
468
+ expect(res.content[0].text).toContain("Do not implement any changes");
469
+ // dismiss 边落盘:planning(被搁置的审批不复活——F1/F2/F3 构造性消除)
470
+ const entries = persistedEntries(pi);
471
+ expect(entries.at(-1)).toMatchObject({ isActive: true, state: "planning" });
472
+ // 非破坏:不杀 turn(无 setActiveTools / 无解散文案)
473
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
474
+ expect(res.content[0].text).not.toContain("interrupted");
475
+ });
476
+
477
+ it("revise → comments injected with explicit deliverAs:'steer' + state=revising persisted", async () => {
383
478
  const { exec, ctx, pi } = setupTaiji();
384
479
  const comments = [{ quote: "第二节流程图", comment: "这里应该补一个失败分支" }];
385
480
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
386
481
  JSON.stringify({ decision: "revise", comments }),
387
482
  );
388
483
 
389
- const res = await exec({ action: "submit-review" });
484
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
390
485
 
391
486
  // custom message 三要素 + streaming steer options 断言(A6):deliverAs:'steer' 排队至
392
487
  // 下一次 LLM 调用,triggerTurn:true 覆盖非 streaming 窗口的开轮语义
@@ -395,10 +490,51 @@ describe("decision 消费(taiji 形态)", () => {
395
490
  { deliverAs: "steer", triggerTurn: true },
396
491
  );
397
492
  expect(res.details.action).toBe("submit-review");
398
- // reviewState=revising 落 entry
493
+ // revise 边落盘(reviewing --revise--> revising)
399
494
  expect(pi.appendEntry).toHaveBeenCalledWith(
400
495
  "plan-state",
401
- expect.objectContaining({ reviewState: "revising", isActive: true }),
496
+ expect.objectContaining({ state: "revising", isActive: true }),
497
+ );
498
+ });
499
+
500
+ it("交错窗:dismiss 时审批已被推进(state 非 reviewing)→ out-of-order + 不落盘不报成功", async () => {
501
+ const { exec, ctx, pi, sessions } = setupTaiji();
502
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
503
+ // 交错窗模拟:select settle 后、回执消费前,state 已被其他操作从 reviewing 推走
504
+ (sessions.get("test-session")! as { state: string }).state = "planning";
505
+ return Promise.resolve(JSON.stringify({ decision: "dismiss" }));
506
+ });
507
+
508
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
509
+
510
+ expect(res.details).toEqual({ action: "review-error", reason: "out-of-order" });
511
+ expect(res.content[0].text).toContain("no longer be set aside");
512
+ expect(res.content[0].text).toContain("Do not implement");
513
+ // 零副作用:无 dismiss 边写入(submit 边的 reviewing 落盘不算)、不杀 turn
514
+ expect(pi.appendEntry).not.toHaveBeenCalledWith(
515
+ "plan-state",
516
+ expect.objectContaining({ state: "planning", isActive: true }),
517
+ );
518
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
519
+ });
520
+
521
+ it("交错窗:revise 时审批已被推进 → out-of-order + 评论不注入(副作用收敛到转移成功之后)", async () => {
522
+ const { exec, ctx, pi, sessions } = setupTaiji();
523
+ const comments = [{ quote: "第二节", comment: "补失败分支" }];
524
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
525
+ (sessions.get("test-session")! as { state: string }).state = "planning";
526
+ return Promise.resolve(JSON.stringify({ decision: "revise", comments }));
527
+ });
528
+
529
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
530
+
531
+ expect(res.details).toEqual({ action: "review-error", reason: "out-of-order" });
532
+ expect(res.content[0].text).toContain("revision comments cannot be injected");
533
+ // 评论不进队、无 revise 边落盘——否则 agent 拿着成功文案空跑一轮
534
+ expect(pi.sendMessage).not.toHaveBeenCalled();
535
+ expect(pi.appendEntry).not.toHaveBeenCalledWith(
536
+ "plan-state",
537
+ expect.objectContaining({ state: "revising", isActive: true }),
402
538
  );
403
539
  });
404
540
 
@@ -406,7 +542,7 @@ describe("decision 消费(taiji 形态)", () => {
406
542
  const { exec, ctx, pi } = setupTaiji();
407
543
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce("this is not json");
408
544
 
409
- const res = await exec({ action: "submit-review" });
545
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
410
546
 
411
547
  expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
412
548
  expect(res.content[0].text).toContain("submit-review");
@@ -414,19 +550,43 @@ describe("decision 消费(taiji 形态)", () => {
414
550
  expect(pi.sendMessage).not.toHaveBeenCalled();
415
551
  });
416
552
 
417
- it("E5: shape-invalid response (unknown decision) is treated as parse failure too", async () => {
553
+ it("条目 7 降级:unknown decision(合法 JSON、值域外)→ 落 malformed 同款 bad-response 引导重挂 + warn 留痕版本错配信号", async () => {
418
554
  const { exec, ctx, pi } = setupTaiji();
419
555
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
420
556
  JSON.stringify({ decision: "nonsense" }),
421
557
  );
422
558
 
423
- const res = await exec({ action: "submit-review" });
559
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
560
+
561
+ // 独立 version-mismatch 出口已删(原子发版不可达,唯一窗口 dev-link 版本错开):
562
+ // 应答与 malformed 同款出口(报错返回、引导重挂)
563
+ expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
564
+ expect(res.content[0].text).toContain("re-hang");
565
+ // warn 留痕要点:decision 值域外 + 可能宿主/扩展版本错配 + 对齐动作
566
+ expect(loggerWarn).toHaveBeenCalledWith(
567
+ expect.stringContaining("decision out of domain"),
568
+ expect.objectContaining({ decision: "nonsense" }),
569
+ );
570
+ expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining("version mismatch"), expect.anything());
571
+ expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining("dev-link"), expect.anything());
572
+ // 垃圾/错配数据不进流
573
+ expect(pi.sendMessage).not.toHaveBeenCalled();
574
+ });
575
+
576
+ it("E5: malformed shape (decision ok but comments broken) → bad-response with re-hang guidance", async () => {
577
+ const { exec, ctx, pi } = setupTaiji();
578
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
579
+ JSON.stringify({ decision: "revise", comments: [{ quote: 1, comment: null }] }),
580
+ );
581
+
582
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
424
583
 
425
584
  expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
585
+ expect(res.content[0].text).toContain("re-hang");
426
586
  expect(pi.sendMessage).not.toHaveBeenCalled();
427
587
  });
428
588
 
429
- it("echo 回显(旧 taiji 宿主不识别 PLAN_REVIEW_MARKER)→ 升级指引错误,不引导重挂(MF-1-9)", async () => {
589
+ it("echo 回显(旧 taiji 宿主不识别 PLAN_REVIEW_MARKER)→ 升级指引错误,不引导重挂(MF-1-9,判定先于 parse)", async () => {
430
590
  const { exec, ctx, pi } = setupTaiji();
431
591
  // 宿主把 marker select 降级普通单选项:用户点选回显 payload 自身(合法 JSON,
432
592
  // parse 会成功但形状守卫必败——echo 判定必须先于 parse,否则重挂 → 再回显循环)
@@ -434,7 +594,7 @@ describe("decision 消费(taiji 形态)", () => {
434
594
  async (_title: string, options: string[]) => options[0],
435
595
  );
436
596
 
437
- const res = await exec({ action: "submit-review" });
597
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
438
598
 
439
599
  expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
440
600
  expect(res.content[0].text).toContain("upgrade taiji");
@@ -443,47 +603,54 @@ describe("decision 消费(taiji 形态)", () => {
443
603
  expect(pi.sendMessage).not.toHaveBeenCalled();
444
604
  });
445
605
 
446
- it("dismissed select (undefined) → cancelled result: not an approval, stop the review loop", async () => {
606
+ it("dismissed select (undefined, 无 reset 介入) → 归口①外部解散:中断≠批准,停止审批循环等指示", async () => {
447
607
  const { exec, ctx, pi } = setupTaiji();
448
608
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
449
609
 
450
- const res = await exec({ action: "submit-review" });
610
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
451
611
 
452
- expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
453
- // A9 收紧语义:取消 ≠ 批准——禁止实施、停止审批循环(旧文曾指示 re-hang,
454
- // 被 LLM 误读为批准后开始实施)
455
- expect(res.content[0].text).toContain("cancelled");
612
+ // 非入口解散(无 markDissolved 打标)→ 外部解散,review_aborted → planning 已落盘
613
+ expect(res.details).toEqual({ action: "review-error", reason: "review-interrupted" });
614
+ // A9 收紧语义:中断 ≠ 批准——禁止实施、等用户指示(不再引导自动重挂)
615
+ expect(res.content[0].text).toContain("interrupted");
456
616
  expect(res.content[0].text).toContain("NOT an approval");
457
617
  expect(res.content[0].text).toContain("Do not implement any changes");
458
- expect(res.content[0].text).toContain("Stop the review loop");
459
- expect(res.content[0].text).toContain("wait for further user instructions");
460
- // 未消费 decision、未重置状态
618
+ expect(res.content[0].text).toContain("Wait for the user's instructions");
619
+ // 未消费 decision、未恢复工具集
461
620
  expect(pi.sendMessage).not.toHaveBeenCalled();
462
621
  expect(pi.setActiveTools).not.toHaveBeenCalled();
463
622
  });
464
623
  });
465
624
 
466
- describe("reviewStateSource 重挂起点重置(§3.4 第 3 轮裁决)", () => {
467
- it("submit-review re-hang clears stale source before awaiting is persisted (不变量:source 只描述当前降级等待的原因)", async () => {
468
- // 上一轮 E3 重挂残留 source='resubmit';本用例重提交(重挂起新 pending)
469
- const { exec, pi } = setup({ ...activeStateWithDocs(), reviewStateSource: "resubmit" });
625
+ describe("submit-review 转移落盘(D4 clearRoundFields 重挂起点调用点)", () => {
626
+ it("重挂起点清 resumeHint 并重置 docs 指纹(不变量:resumeHint 只描述当前降级等待的原因,不跨轮残留)", async () => {
627
+ // 上一轮 E3 重挂残留 resumeHint='resubmit';本用例重提交(重挂起新 pending)
628
+ const { exec, pi } = setup({ ...activeStateWithDocs(), state: "reviewing", resumeHint: "resubmit" });
470
629
 
471
- await exec({ action: "submit-review" });
630
+ await exec({ action: "submit-review", selfReview: SELF_REVIEW });
472
631
 
473
- // 挂起点落盘的 awaiting entry 不携带上一轮来源——残留会让崩溃恢复(E3)后的
474
- // 降级态渲染上一轮「已收到你的问题」文案,而本轮无人提问(C-U2 同型残留)
475
- const hangEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls
476
- .map((c) => c[1] as PlanState)
477
- .find((e) => e.reviewState === "awaiting");
632
+ // 挂起点落盘的 entry 不携带上一轮 hint——残留会让降级态渲染「会话已重启」文案,
633
+ // 而本轮已重新挂起(C-U2 同型残留)
634
+ const hangEntry = persistedEntries(pi).find((e) => e.state === "reviewing");
478
635
  expect(hangEntry).toBeDefined();
479
- expect(hangEntry!.reviewStateSource).toBeUndefined();
636
+ expect(hangEntry!.resumeHint).toBeUndefined();
637
+ expect(hangEntry!.selfReview).toBe(SELF_REVIEW);
638
+ // 重挂起点重置 docs 指纹为本轮基线(原「自环重挂」独立用例的独有断言并入)
639
+ expect(hangEntry!.lastSubmitReviewDocsFingerprint).toBe("design.md:1");
480
640
  });
481
- });
482
641
 
483
- describe("submit-review 的 PLAN_ACTIONS 面", () => {
484
- it("action list contains exactly the six actions (enter added; list-template removed, D1)", () => {
485
- expect([...PLAN_ACTIONS].sort()).toEqual(
486
- ["abort", "complete", "enter", "register-doc", "select-template", "submit-review"],
487
- );
642
+ it("submit from approved/dispatching → out-of-order 纠偏(FSM 合法性兜底,不落盘)", async () => {
643
+ for (const st of ["approved", "dispatching"] as const) {
644
+ const { exec, pi, ctx } = setup({ ...activeStateWithDocs(), state: st });
645
+ const res = await exec({ action: "submit-review", selfReview: SELF_REVIEW });
646
+
647
+ expect(res.details).toEqual({ action: "review-error", reason: "out-of-order" });
648
+ expect(res.content[0].text).toContain("plan(action='complete')");
649
+ expect(pi.appendEntry).not.toHaveBeenCalled();
650
+ expect(ctx.ui.select).not.toHaveBeenCalled();
651
+ }
488
652
  });
489
653
  });
654
+
655
+ // PLAN_ACTIONS 六值钉死的保留者在 tool.test.ts「action list contains exactly the six actions」
656
+ // (同断言更强:另钉已删 action 不在列)——此处不再重复守护。