@zhushanwen/pi-plan 0.5.1 → 1.0.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.
- package/README.md +9 -9
- package/package.json +7 -5
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +55 -32
- package/src/__tests__/enter.test.ts +58 -20
- package/src/__tests__/exec-options-alignment.test.ts +56 -0
- package/src/__tests__/execution-notice.test.ts +197 -0
- package/src/__tests__/{compact.test.ts → extract-plan-steps.test.ts} +24 -15
- package/src/__tests__/fsm-consumption.test.ts +388 -0
- package/src/__tests__/index.test.ts +175 -75
- package/src/__tests__/prompts.test.ts +66 -0
- package/src/__tests__/review-contract-boundary.test.ts +123 -0
- package/src/__tests__/review.test.ts +264 -97
- package/src/__tests__/skills.test.ts +14 -5
- package/src/__tests__/state.test.ts +254 -52
- package/src/__tests__/{compact-criteria-array.test.ts → success-criteria-array.test.ts} +29 -70
- package/src/__tests__/templates.test.ts +8 -25
- package/src/__tests__/tool.test.ts +269 -121
- package/src/command.ts +26 -29
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +29 -117
- package/src/index.ts +100 -88
- package/src/prompts.ts +42 -12
- package/src/state.ts +290 -87
- package/src/templates.ts +0 -15
- package/src/tool.ts +664 -256
- package/src/__tests__/compact-handler.test.ts +0 -348
- package/src/__tests__/exec-skills.test.ts +0 -397
- package/src/exec-skills.ts +0 -407
|
@@ -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("../
|
|
19
|
-
const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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 "../
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
65
|
-
const
|
|
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,
|
|
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)
|
|
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),
|
|
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
|
-
// 挂起前落
|
|
233
|
+
// 挂起前落 reviewing(transition 'submit',E3 崩溃恢复按 state 查表依赖此持久态)+ selfReview
|
|
194
234
|
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
195
235
|
"plan-state",
|
|
196
|
-
expect.objectContaining({
|
|
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 →
|
|
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
|
-
|
|
270
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
385
|
+
it("未 register-doc 重提交:result 末行追加警告,details 不携带信号字段", async () => {
|
|
311
386
|
const { exec, pi } = setupActive();
|
|
312
|
-
await exec({ action: "submit-review" });
|
|
313
|
-
|
|
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
|
|
397
|
+
expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
|
|
322
398
|
// 重提交不改写快照基线:指纹仍是上次值(docs 确实没变)
|
|
323
|
-
const lastEntry = (pi
|
|
399
|
+
const lastEntry = persistedEntries(pi).at(-1)!;
|
|
324
400
|
expect(lastEntry.lastSubmitReviewDocsFingerprint).toBe("design.md:1");
|
|
325
401
|
});
|
|
326
402
|
|
|
327
|
-
it("gui 分支(revise
|
|
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
|
|
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 →
|
|
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
|
-
//
|
|
367
|
-
//
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
493
|
+
// revise 边落盘(reviewing --revise--> revising)
|
|
399
494
|
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
400
495
|
"plan-state",
|
|
401
|
-
expect.objectContaining({
|
|
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("
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
expect(res.content[0].text).toContain("
|
|
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("
|
|
459
|
-
|
|
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("
|
|
467
|
-
it("
|
|
468
|
-
// 上一轮 E3 重挂残留
|
|
469
|
-
const { exec, pi } = setup({ ...activeStateWithDocs(),
|
|
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
|
-
// 挂起点落盘的
|
|
474
|
-
//
|
|
475
|
-
const hangEntry = (pi.
|
|
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!.
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 不在列)——此处不再重复守护。
|