@zhushanwen/pi-plan 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -13
- package/package.json +16 -6
- package/skills/plan-ext-config/SKILL.md +86 -0
- package/src/__tests__/command.test.ts +290 -10
- package/src/__tests__/compact-criteria-array.test.ts +12 -8
- package/src/__tests__/compact-handler.test.ts +84 -35
- package/src/__tests__/enter.test.ts +172 -0
- package/src/__tests__/exec-skills.test.ts +397 -0
- package/src/__tests__/i18n.test.ts +57 -0
- package/src/__tests__/index.test.ts +228 -0
- package/src/__tests__/prompts.test.ts +74 -0
- package/src/__tests__/review.test.ts +489 -0
- package/src/__tests__/skills.test.ts +258 -0
- package/src/__tests__/state.test.ts +395 -16
- package/src/__tests__/templates.test.ts +203 -15
- package/src/__tests__/tool.test.ts +389 -73
- package/src/command.ts +220 -62
- package/src/compact.ts +43 -27
- package/src/enter.ts +133 -0
- package/src/exec-skills.ts +407 -0
- package/src/i18n.ts +70 -0
- package/src/index.ts +106 -5
- package/src/prompts.ts +151 -0
- package/src/state.ts +230 -8
- package/src/templates.ts +125 -7
- package/src/tool.ts +692 -110
- package/src/widget.ts +6 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock typebox before importing tool(形态照 tool.test.ts)
|
|
4
|
+
vi.mock("typebox", () => ({
|
|
5
|
+
Type: {
|
|
6
|
+
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
7
|
+
String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
|
|
8
|
+
Optional: (schema: unknown) => schema,
|
|
9
|
+
Array: (item: unknown, opts?: Record<string, unknown>) => ({ type: "array", items: item, ...opts }),
|
|
10
|
+
},
|
|
11
|
+
Static: class {},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@earendil-works/pi-ai", () => ({
|
|
15
|
+
StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("../compact.js", async () => {
|
|
19
|
+
const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../compact.js")>("../compact.js");
|
|
20
|
+
return {
|
|
21
|
+
handlePlanComplete: vi.fn(),
|
|
22
|
+
detectGoalCapability: vi.fn(() => false),
|
|
23
|
+
GOAL_FAILURE_RECOVERY,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("../widget.js", () => ({
|
|
28
|
+
updatePlanWidget: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock exec-skills(D10 检测):审批用例不扫真实目录,skill 选项恒空
|
|
32
|
+
vi.mock("../exec-skills.js", () => ({
|
|
33
|
+
detectExecSkills: vi.fn(() => []),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import { handlePlanComplete } from "../compact.js";
|
|
37
|
+
import { PLAN_REVIEW_MARKER } from "@zhushanwen/extension-protocol";
|
|
38
|
+
import type { PlanDocMeta } from "@zhushanwen/extension-protocol";
|
|
39
|
+
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";
|
|
42
|
+
|
|
43
|
+
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
44
|
+
|
|
45
|
+
/** 已激活且带一份已登记文档的状态(submit-review 的合法前置) */
|
|
46
|
+
function activeStateWithDocs(): PlanState {
|
|
47
|
+
const doc: PlanDocMeta = {
|
|
48
|
+
fileName: "design.md",
|
|
49
|
+
absPath: "/tmp/test-project/.tmp/plans/auth/design.md",
|
|
50
|
+
sourceSkill: "tech-design",
|
|
51
|
+
version: 1,
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
...DEFAULT_PLAN_STATE,
|
|
55
|
+
isActive: true,
|
|
56
|
+
planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
|
|
57
|
+
requirement: "refactor auth",
|
|
58
|
+
skills: ["tech-design"],
|
|
59
|
+
docs: [doc],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setup(state?: PlanState) {
|
|
64
|
+
const sessions = new Map<string, PlanState>();
|
|
65
|
+
const controllers = new Map<string, AbortController>();
|
|
66
|
+
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
|
|
67
|
+
const pi = {
|
|
68
|
+
registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
|
|
69
|
+
appendEntry: vi.fn(),
|
|
70
|
+
setActiveTools: vi.fn(),
|
|
71
|
+
sendMessage: vi.fn(),
|
|
72
|
+
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
73
|
+
} as unknown as Parameters<typeof registerPlanTool>[0];
|
|
74
|
+
registerPlanTool(pi, sessions, controllers);
|
|
75
|
+
|
|
76
|
+
const ctx = {
|
|
77
|
+
sessionId: "test-session",
|
|
78
|
+
cwd: "/tmp/test-project",
|
|
79
|
+
// D4 三路分流 ctx 形态字段:审批 approve → complete 在 taiji 形态走 uiFormInteract(rpc)
|
|
80
|
+
hasUI: true,
|
|
81
|
+
mode: "rpc" as const,
|
|
82
|
+
isProjectTrusted: () => true,
|
|
83
|
+
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
84
|
+
ui: { select: vi.fn(), notify: vi.fn() },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (state) sessions.set("test-session", state);
|
|
88
|
+
|
|
89
|
+
const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
|
|
90
|
+
executeFn!("tc0", params, signal, undefined, ctx);
|
|
91
|
+
return { pi, sessions, ctx, controllers, exec };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 构造 docs 非空的激活态并放入 sessions(多数用例的前置) */
|
|
95
|
+
function setupActive() {
|
|
96
|
+
const harness = setup(activeStateWithDocs());
|
|
97
|
+
return harness;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
// 默认按独立 pi 形态跑(无宿主信号);taiji 分支用例内 stubEnv 覆盖
|
|
102
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.unstubAllEnvs();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("register-doc(D10)", () => {
|
|
110
|
+
it("registers a document with derived absPath and persists it", async () => {
|
|
111
|
+
const { exec, pi } = setup(activeStateWithDocs());
|
|
112
|
+
const res = await exec({ action: "register-doc", fileName: "impl-plan.md", sourceSkill: "dev-flow" });
|
|
113
|
+
expect(res.details).toEqual({ action: "register-doc", fileName: "impl-plan.md", version: 1 });
|
|
114
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
115
|
+
"plan-state",
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
docs: [
|
|
118
|
+
expect.objectContaining({ fileName: "design.md", version: 1 }),
|
|
119
|
+
expect.objectContaining({ fileName: "impl-plan.md", absPath: "/tmp/test-project/.tmp/plans/auth/impl-plan.md", version: 1 }),
|
|
120
|
+
],
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("re-registering the same fileName bumps version in place (version+1 覆盖)", async () => {
|
|
126
|
+
const { exec } = setup(activeStateWithDocs());
|
|
127
|
+
await exec({ action: "register-doc", fileName: "design.md" });
|
|
128
|
+
const res = await exec({ action: "register-doc", fileName: "design.md" });
|
|
129
|
+
expect(res.details).toEqual({ action: "register-doc", fileName: "design.md", version: 3 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("throws when fileName is missing (programming error)", async () => {
|
|
133
|
+
const { exec } = setup(activeStateWithDocs());
|
|
134
|
+
await expect(exec({ action: "register-doc" })).rejects.toThrow("fileName is required");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("rejects path traversal in fileName — absPath 不得逃出 plan 目录", async () => {
|
|
138
|
+
const { exec, pi } = setup(activeStateWithDocs());
|
|
139
|
+
for (const bad of ["../secret.md", "sub/dir.md", "back\\slash.md", "..", "."]) {
|
|
140
|
+
await expect(exec({ action: "register-doc", fileName: bad })).rejects.toThrow(
|
|
141
|
+
"fileName must be a plain file name inside the plan directory",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
// 全部拒绝:零状态写入(throw 先于 persistPlanState)
|
|
145
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns an error result when plan mode is not active (前置条件用 result 错误)", async () => {
|
|
149
|
+
const { exec } = setup();
|
|
150
|
+
const res = await exec({ action: "register-doc", fileName: "design.md" });
|
|
151
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("submit-review E6 双守卫", () => {
|
|
156
|
+
it("guard 1: inactive plan mode → error result, no select is hung", async () => {
|
|
157
|
+
const { exec, ctx } = setup();
|
|
158
|
+
const res = await exec({ action: "submit-review" });
|
|
159
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
160
|
+
// A9 收紧语义:未激活 ≠ 批准——禁止实施、等用户指示(禁「wrap up」类歧义表述)
|
|
161
|
+
expect(res.content[0].text).toContain("not active");
|
|
162
|
+
expect(res.content[0].text).toContain("No plan has been approved");
|
|
163
|
+
expect(res.content[0].text).toContain("do not implement any changes");
|
|
164
|
+
expect(res.content[0].text).toContain("wait for further user instructions");
|
|
165
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("guard 2: no registered docs → error result telling the agent to register first", async () => {
|
|
169
|
+
const { exec, ctx } = setup({
|
|
170
|
+
...DEFAULT_PLAN_STATE,
|
|
171
|
+
isActive: true,
|
|
172
|
+
planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
|
|
173
|
+
});
|
|
174
|
+
const res = await exec({ action: "submit-review" });
|
|
175
|
+
expect(res.details).toEqual({ action: "review-error", reason: "no-docs" });
|
|
176
|
+
expect(res.content[0].text).toContain("register-doc");
|
|
177
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("submit-review 宿主分流(TAIJI_AGENT_EXT_LOG)", () => {
|
|
182
|
+
it("standalone pi (no signal): no select, text soft-gate result (E8), reviewState=awaiting persisted", async () => {
|
|
183
|
+
const { exec, ctx, pi } = setupActive();
|
|
184
|
+
const res = await exec({ action: "submit-review" });
|
|
185
|
+
|
|
186
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
187
|
+
expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
|
|
188
|
+
expect(res.content[0].text).toContain("directly in the conversation");
|
|
189
|
+
// E8 软门修订闭环:text result 就近携带与 formatReviewComments revise 对齐的指令
|
|
190
|
+
expect(res.content[0].text).toContain("rewrite the file");
|
|
191
|
+
expect(res.content[0].text).toContain("register-doc");
|
|
192
|
+
expect(res.content[0].text).toContain("plan(action='submit-review') again");
|
|
193
|
+
// 挂起前落 awaiting(E3 崩溃恢复依赖此持久态)
|
|
194
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
195
|
+
"plan-state",
|
|
196
|
+
expect.objectContaining({ reviewState: "awaiting" }),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("taiji host (signal=1): hangs PLAN_REVIEW_MARKER select with PlanReviewRequest payload and a fresh signal", async () => {
|
|
201
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
202
|
+
const { exec, ctx, controllers } = setupActive();
|
|
203
|
+
// 在挂起窗口内(select 尚未 resolve)断言注册表登记——abort 联动必须能找到 controller
|
|
204
|
+
let hungSignal: AbortSignal | undefined;
|
|
205
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
|
|
206
|
+
async (_title: string, _options: string[], opts: { signal?: AbortSignal }) => {
|
|
207
|
+
hungSignal = opts.signal;
|
|
208
|
+
expect(controllers.get("test-session")?.signal).toBe(opts.signal);
|
|
209
|
+
return undefined;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await exec({ action: "submit-review" });
|
|
214
|
+
|
|
215
|
+
expect(ctx.ui.select).toHaveBeenCalledOnce();
|
|
216
|
+
const [title, options, opts] = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0] as [string, string[], { signal?: AbortSignal }];
|
|
217
|
+
expect(title).toBe(PLAN_REVIEW_MARKER);
|
|
218
|
+
expect(options).toHaveLength(1);
|
|
219
|
+
expect(JSON.parse(options[0])).toEqual({
|
|
220
|
+
docs: [expect.objectContaining({ fileName: "design.md", version: 1 })],
|
|
221
|
+
});
|
|
222
|
+
// E10:挂起 select 必须携带 AbortSignal
|
|
223
|
+
expect(opts.signal).toBeInstanceOf(AbortSignal);
|
|
224
|
+
expect(hungSignal).toBe(opts.signal);
|
|
225
|
+
// select settled 后 controller 即弃(注册表不留已 settled 的条目)
|
|
226
|
+
expect(controllers.has("test-session")).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("env 泄漏的非 rpc 形态(signal=1 但 mode='tui'):回落文本软门,不挂 marker select(与 resolveCompleteChoice 分流对齐)", async () => {
|
|
230
|
+
// 独立 pi TUI 继承了 TAIJI_AGENT_EXT_LOG=1(env 泄漏)时,宿主没有 marker 路由——
|
|
231
|
+
// 只判 env 会挂 \x00 marker select,pi TUI 渲染成乱码对话;分流条件必须同查 ctx.mode
|
|
232
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
233
|
+
const { exec, ctx } = setupActive();
|
|
234
|
+
(ctx as { mode?: string }).mode = "tui";
|
|
235
|
+
|
|
236
|
+
const res = await exec({ action: "submit-review" });
|
|
237
|
+
|
|
238
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
239
|
+
expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
|
|
240
|
+
expect(res.content[0].text).toContain("directly in the conversation");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("turn abort 级联(execute signal → 挂起 select 解散,MF-1-8)", () => {
|
|
245
|
+
/** select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):signal 已 abort 首行短路 resolve undefined;挂起中 abort → resolve undefined */
|
|
246
|
+
function selectHonoringSignal(ctx: { ui: { select: unknown } }): void {
|
|
247
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
|
|
248
|
+
(_title: string, _options: string[], opts: { signal?: AbortSignal }) =>
|
|
249
|
+
new Promise<string | undefined>((resolve) => {
|
|
250
|
+
if (opts.signal?.aborted) {
|
|
251
|
+
resolve(undefined);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
opts.signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it("submit-review 挂起窗口内 turn abort → PLAN_REVIEW_MARKER select 解散 → cancelled result(非批准)", async () => {
|
|
260
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
261
|
+
const { exec, ctx } = setupActive();
|
|
262
|
+
selectHonoringSignal(ctx);
|
|
263
|
+
|
|
264
|
+
const turn = new AbortController();
|
|
265
|
+
const pending = exec({ action: "submit-review" }, turn.signal);
|
|
266
|
+
turn.abort();
|
|
267
|
+
const res = await pending;
|
|
268
|
+
|
|
269
|
+
expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
|
|
270
|
+
// A9 语义:取消 ≠ 批准,result 文本显式禁止实施
|
|
271
|
+
expect(res.content[0].text).toContain("NOT an approval");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("execute 进入时 turn signal 已 abort → controller 即刻置 abort 态,select 首行短路解散", async () => {
|
|
275
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
276
|
+
const { exec, ctx } = setupActive();
|
|
277
|
+
selectHonoringSignal(ctx);
|
|
278
|
+
|
|
279
|
+
const turn = new AbortController();
|
|
280
|
+
turn.abort();
|
|
281
|
+
const res = await exec({ action: "submit-review" }, turn.signal);
|
|
282
|
+
|
|
283
|
+
expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
|
|
284
|
+
expect(ctx.ui.select).toHaveBeenCalledOnce();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("重提交无变化检测(docs 快照指纹,E8 机制级兜底)", () => {
|
|
289
|
+
it("首次 submit-review:无警告行、details 无 changed 字段", async () => {
|
|
290
|
+
const { exec } = setupActive();
|
|
291
|
+
const res = await exec({ action: "submit-review" });
|
|
292
|
+
|
|
293
|
+
expect(res.content[0].text).not.toContain("no documents changed");
|
|
294
|
+
expect(res.details).toEqual({ action: "submit-review", channel: "text", docsCount: 1 });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("register-doc 后重提交:无警告且快照指纹随 version 更新落盘", async () => {
|
|
298
|
+
const { exec, pi } = setupActive();
|
|
299
|
+
await exec({ action: "submit-review" });
|
|
300
|
+
// 修订闭环:rewrite 后重登记,version 1 → 2
|
|
301
|
+
await exec({ action: "register-doc", fileName: "design.md" });
|
|
302
|
+
const res = await exec({ action: "submit-review" });
|
|
303
|
+
|
|
304
|
+
expect(res.content[0].text).not.toContain("no documents changed");
|
|
305
|
+
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;
|
|
307
|
+
expect(lastEntry.lastSubmitReviewDocsFingerprint).toBe("design.md:2");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("未 register-doc 重提交:result 末行追加警告 + details.changed=false", async () => {
|
|
311
|
+
const { exec, pi } = setupActive();
|
|
312
|
+
await exec({ action: "submit-review" });
|
|
313
|
+
const res = await exec({ action: "submit-review" });
|
|
314
|
+
|
|
315
|
+
expect(res.content[0].text).toContain(
|
|
316
|
+
"Note: no documents changed since the last submit-review. " +
|
|
317
|
+
"If the user requested changes, you MUST rewrite the file(s) and re-register each via plan(action='register-doc') BEFORE calling submit-review again.",
|
|
318
|
+
);
|
|
319
|
+
// 追加为末行(追加一行契约)
|
|
320
|
+
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 });
|
|
322
|
+
// 重提交不改写快照基线:指纹仍是上次值(docs 确实没变)
|
|
323
|
+
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
324
|
+
expect(lastEntry.lastSubmitReviewDocsFingerprint).toBe("design.md:1");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("gui 分支(revise 后未改文档重提交)同样追加警告 + changed=false", async () => {
|
|
328
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
329
|
+
const { exec, ctx } = setupActive();
|
|
330
|
+
const comments = [{ quote: "第二节", comment: "补失败分支" }];
|
|
331
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>)
|
|
332
|
+
.mockResolvedValueOnce(JSON.stringify({ decision: "revise", comments }))
|
|
333
|
+
.mockResolvedValueOnce(JSON.stringify({ decision: "revise", comments }));
|
|
334
|
+
|
|
335
|
+
const first = await exec({ action: "submit-review" });
|
|
336
|
+
expect(first.content[0].text).not.toContain("no documents changed");
|
|
337
|
+
expect(first.details).toEqual({ action: "submit-review", channel: "gui", docsCount: 1 });
|
|
338
|
+
|
|
339
|
+
// 未 register-doc 直接重调 submit-review → 警告
|
|
340
|
+
const second = await exec({ action: "submit-review" });
|
|
341
|
+
expect(second.content[0].text).toContain(
|
|
342
|
+
"Note: no documents changed since the last submit-review",
|
|
343
|
+
);
|
|
344
|
+
expect(second.details).toEqual({ action: "submit-review", channel: "gui", docsCount: 1, changed: false });
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("decision 消费(taiji 形态)", () => {
|
|
349
|
+
function setupTaiji() {
|
|
350
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
351
|
+
return setupActive();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
it("approve → clears reviewState before exec-choice, walks the complete flow, resets state keeping docs", async () => {
|
|
355
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
356
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>)
|
|
357
|
+
.mockResolvedValueOnce(JSON.stringify({ decision: "approve" }))
|
|
358
|
+
.mockResolvedValueOnce(JSON.stringify({ "Execution method": "Execute" }));
|
|
359
|
+
|
|
360
|
+
const res = await exec({ action: "submit-review" });
|
|
361
|
+
|
|
362
|
+
expect(ctx.ui.select).toHaveBeenCalledTimes(2);
|
|
363
|
+
expect(res.details.action).toBe("complete");
|
|
364
|
+
expect(res.details.execMode).toBe("execute");
|
|
365
|
+
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 保留
|
|
373
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
374
|
+
"plan-state",
|
|
375
|
+
expect.objectContaining({ isActive: false, docs: expect.any(Array) }),
|
|
376
|
+
);
|
|
377
|
+
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
378
|
+
expect(lastEntry.docs).toHaveLength(1);
|
|
379
|
+
expect(lastEntry.reviewState).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("revise → comments injected with explicit deliverAs:'steer' + reviewState=revising persisted", async () => {
|
|
383
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
384
|
+
const comments = [{ quote: "第二节流程图", comment: "这里应该补一个失败分支" }];
|
|
385
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
386
|
+
JSON.stringify({ decision: "revise", comments }),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const res = await exec({ action: "submit-review" });
|
|
390
|
+
|
|
391
|
+
// custom message 三要素 + streaming steer options 断言(A6):deliverAs:'steer' 排队至
|
|
392
|
+
// 下一次 LLM 调用,triggerTurn:true 覆盖非 streaming 窗口的开轮语义
|
|
393
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
394
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content: expect.stringContaining("第二节流程图"), display: false },
|
|
395
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
396
|
+
);
|
|
397
|
+
expect(res.details.action).toBe("submit-review");
|
|
398
|
+
// reviewState=revising 落 entry
|
|
399
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
400
|
+
"plan-state",
|
|
401
|
+
expect.objectContaining({ reviewState: "revising", isActive: true }),
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("E5: unparseable select response → warn + re-hang prompt, nothing injected into the conversation", async () => {
|
|
406
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
407
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce("this is not json");
|
|
408
|
+
|
|
409
|
+
const res = await exec({ action: "submit-review" });
|
|
410
|
+
|
|
411
|
+
expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
|
|
412
|
+
expect(res.content[0].text).toContain("submit-review");
|
|
413
|
+
// 垃圾数据不进流:没有任何 decision 注入
|
|
414
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("E5: shape-invalid response (unknown decision) is treated as parse failure too", async () => {
|
|
418
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
419
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
420
|
+
JSON.stringify({ decision: "nonsense" }),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const res = await exec({ action: "submit-review" });
|
|
424
|
+
|
|
425
|
+
expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
|
|
426
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("echo 回显(旧 taiji 宿主不识别 PLAN_REVIEW_MARKER)→ 升级指引错误,不引导重挂(MF-1-9)", async () => {
|
|
430
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
431
|
+
// 宿主把 marker select 降级普通单选项:用户点选回显 payload 自身(合法 JSON,
|
|
432
|
+
// parse 会成功但形状守卫必败——echo 判定必须先于 parse,否则重挂 → 再回显循环)
|
|
433
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
|
|
434
|
+
async (_title: string, options: string[]) => options[0],
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const res = await exec({ action: "submit-review" });
|
|
438
|
+
|
|
439
|
+
expect(res.details).toEqual({ action: "review-error", reason: "bad-response" });
|
|
440
|
+
expect(res.content[0].text).toContain("upgrade taiji");
|
|
441
|
+
// 不引导重挂:重挂会同样回显,形成重复弹错循环
|
|
442
|
+
expect(res.content[0].text).not.toContain("re-hang");
|
|
443
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("dismissed select (undefined) → cancelled result: not an approval, stop the review loop", async () => {
|
|
447
|
+
const { exec, ctx, pi } = setupTaiji();
|
|
448
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
|
449
|
+
|
|
450
|
+
const res = await exec({ action: "submit-review" });
|
|
451
|
+
|
|
452
|
+
expect(res.details).toEqual({ action: "review-error", reason: "cancelled" });
|
|
453
|
+
// A9 收紧语义:取消 ≠ 批准——禁止实施、停止审批循环(旧文曾指示 re-hang,
|
|
454
|
+
// 被 LLM 误读为批准后开始实施)
|
|
455
|
+
expect(res.content[0].text).toContain("cancelled");
|
|
456
|
+
expect(res.content[0].text).toContain("NOT an approval");
|
|
457
|
+
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、未重置状态
|
|
461
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
462
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
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" });
|
|
470
|
+
|
|
471
|
+
await exec({ action: "submit-review" });
|
|
472
|
+
|
|
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");
|
|
478
|
+
expect(hangEntry).toBeDefined();
|
|
479
|
+
expect(hangEntry!.reviewStateSource).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
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
|
+
);
|
|
488
|
+
});
|
|
489
|
+
});
|