@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.
- package/README.md +9 -9
- package/package.json +13 -11
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +102 -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 +42 -34
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +30 -118
- 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 +665 -257
- 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
|
@@ -24,13 +24,14 @@ import * as path from "node:path";
|
|
|
24
24
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
25
25
|
|
|
26
26
|
import { parsePlanArgs, registerPlanCommand, resolveTemplateFile } from "../command.js";
|
|
27
|
-
import {
|
|
27
|
+
import type { PlanAbortControllers } from "../state.js";
|
|
28
|
+
import { createPlanCtx, freshPendingSelect, MAX_PLAN_REQUIREMENT_LENGTH, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
28
29
|
|
|
29
30
|
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
30
31
|
|
|
31
32
|
function createMocks() {
|
|
32
33
|
let capturedHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
33
|
-
const
|
|
34
|
+
const planCtx = createPlanCtx();
|
|
34
35
|
|
|
35
36
|
const pi = {
|
|
36
37
|
registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
|
|
@@ -47,6 +48,7 @@ function createMocks() {
|
|
|
47
48
|
cwd: "/tmp/test-project",
|
|
48
49
|
sessionManager: {
|
|
49
50
|
getSessionId: () => "test-session",
|
|
51
|
+
getLeafId: () => null,
|
|
50
52
|
getEntries: () => [] as unknown[],
|
|
51
53
|
},
|
|
52
54
|
ui: {
|
|
@@ -60,7 +62,8 @@ function createMocks() {
|
|
|
60
62
|
return {
|
|
61
63
|
pi,
|
|
62
64
|
ctx,
|
|
63
|
-
|
|
65
|
+
planCtx,
|
|
66
|
+
controllers: planCtx.controllers,
|
|
64
67
|
getHandler: () => capturedHandler!,
|
|
65
68
|
};
|
|
66
69
|
}
|
|
@@ -74,7 +77,7 @@ describe("registerPlanCommand", () => {
|
|
|
74
77
|
let pi: ExtensionAPI;
|
|
75
78
|
let ctx: ExtensionContext;
|
|
76
79
|
let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
77
|
-
let controllers:
|
|
80
|
+
let controllers: PlanAbortControllers;
|
|
78
81
|
|
|
79
82
|
beforeEach(() => {
|
|
80
83
|
vi.clearAllMocks();
|
|
@@ -82,8 +85,7 @@ describe("registerPlanCommand", () => {
|
|
|
82
85
|
pi = mocks.pi;
|
|
83
86
|
ctx = mocks.ctx;
|
|
84
87
|
controllers = mocks.controllers;
|
|
85
|
-
|
|
86
|
-
registerPlanCommand(pi, sessions, controllers);
|
|
88
|
+
registerPlanCommand(pi, mocks.planCtx);
|
|
87
89
|
handler = mocks.getHandler();
|
|
88
90
|
});
|
|
89
91
|
|
|
@@ -93,38 +95,54 @@ describe("registerPlanCommand", () => {
|
|
|
93
95
|
|
|
94
96
|
// --- abort subcommand ---
|
|
95
97
|
|
|
96
|
-
it("abort
|
|
98
|
+
it("abort(idle 常态格): warn 纠偏文案 + 幂等退出——不落盘、工具集幂等恢复(D-B1-1 单入口守卫极性,原 info no-op 取消)", async () => {
|
|
97
99
|
await handler("abort", ctx);
|
|
98
|
-
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "
|
|
99
|
-
|
|
100
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "warning");
|
|
101
|
+
// ④ 工具集照常幂等恢复(idle 格已是全量,重设全量 = 可观察零变化)
|
|
102
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
103
|
+
// ② 跳过:idle 删 exit 边后 ok:false 不落盘(无噪音 exited entry)
|
|
104
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
100
105
|
});
|
|
101
106
|
|
|
102
|
-
it("abort
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
it("abort(终态格 isActive=false): warn 纠偏指令文案 + 不落盘(V1 ⑤ 幂等退出锚)", async () => {
|
|
108
|
+
// 注入终态格:上一轮已 complete,缓存重建自 entries(空)→ 直接预置 sessions 难以
|
|
109
|
+
// 穿透 handler 闭包,改走 entries 注入 completed 残留形态
|
|
110
|
+
const terminalCtx = {
|
|
111
|
+
...ctx,
|
|
112
|
+
sessionManager: {
|
|
113
|
+
getSessionId: () => "test-session",
|
|
114
|
+
getEntries: () => [
|
|
115
|
+
{
|
|
116
|
+
type: "custom",
|
|
117
|
+
customType: "plan-state",
|
|
118
|
+
data: { isActive: false, state: "completed", planFilePath: "", requirement: "", templateName: "" },
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
} as unknown as ExtensionContext;
|
|
123
|
+
await handler("abort", terminalCtx);
|
|
124
|
+
expect(terminalCtx.ui.notify).toHaveBeenCalledWith(
|
|
125
|
+
expect.stringContaining("Plan mode is not active"),
|
|
126
|
+
"warning",
|
|
127
|
+
);
|
|
128
|
+
// completed 终态记录不被 exited 覆写(ok:false 跳过状态值写入,isActive=false 非坏格)
|
|
129
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
113
130
|
});
|
|
114
131
|
|
|
115
|
-
it("abort 联动(E10):controller.abort() 先于 resetPlanState 的 entry
|
|
132
|
+
it("abort 联动(E10):controller.abort() 先于 resetPlanState 的 entry 落盘,markDissolved('self') 先行打标", async () => {
|
|
116
133
|
await handler("implement user auth", ctx);
|
|
117
134
|
vi.clearAllMocks();
|
|
118
135
|
|
|
119
|
-
// 模拟一个挂起 select 的
|
|
120
|
-
const
|
|
121
|
-
const abortSpy = vi.spyOn(controller, "abort");
|
|
122
|
-
controllers.set("test-session", controller);
|
|
136
|
+
// 模拟一个挂起 select 的 PendingSelect(submit-review / complete 执行方式两处共用注册表)
|
|
137
|
+
const pending = freshPendingSelect(controllers, "test-session");
|
|
138
|
+
const abortSpy = vi.spyOn(pending.controller, "abort");
|
|
123
139
|
|
|
124
140
|
await handler("abort", ctx);
|
|
125
141
|
|
|
126
|
-
// 挂起 select 被 abort(→ resolve undefined → tool execute
|
|
127
|
-
expect(controller.signal.aborted).toBe(true);
|
|
142
|
+
// 挂起 select 被 abort(→ resolve undefined → tool execute 归口分支 → turn 结束 → settled → busy defer 恢复)
|
|
143
|
+
expect(pending.controller.signal.aborted).toBe(true);
|
|
144
|
+
// D-B1-2:入口解散先打 'self' 标(同步先于 abort,等待处归口按来源直传判别)
|
|
145
|
+
expect(pending.dissolvedBy).toBe("self");
|
|
128
146
|
// 顺序断言:abort 必须先于 reset entry 落盘(反序 = 挂起 select 无人 resolve → session 卡死)
|
|
129
147
|
const resetCallIndex = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.findIndex(
|
|
130
148
|
(c) => (c[1] as { isActive?: boolean }).isActive === false,
|
|
@@ -168,7 +186,7 @@ describe("registerPlanCommand", () => {
|
|
|
168
186
|
"/tmp/test-project/.tmp/plans/implement-user-auth",
|
|
169
187
|
{ recursive: true },
|
|
170
188
|
);
|
|
171
|
-
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
189
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
|
|
172
190
|
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("[PLAN MODE]")));
|
|
173
191
|
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("Implement User Auth")));
|
|
174
192
|
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
@@ -176,9 +194,13 @@ describe("registerPlanCommand", () => {
|
|
|
176
194
|
planFilePath: "/tmp/test-project/.tmp/plans/implement-user-auth/plan.md",
|
|
177
195
|
requirement: "Implement User Auth",
|
|
178
196
|
templateName: "",
|
|
197
|
+
templateProvided: undefined,
|
|
179
198
|
skills: [],
|
|
180
199
|
docs: [],
|
|
181
|
-
|
|
200
|
+
state: "planning",
|
|
201
|
+
selfReview: undefined,
|
|
202
|
+
resumeHint: undefined,
|
|
203
|
+
lastSubmitReviewDocsFingerprint: undefined,
|
|
182
204
|
});
|
|
183
205
|
});
|
|
184
206
|
|
|
@@ -221,6 +243,7 @@ describe("registerPlanCommand", () => {
|
|
|
221
243
|
...ctx,
|
|
222
244
|
sessionManager: {
|
|
223
245
|
getSessionId: () => "test-session",
|
|
246
|
+
getLeafId: () => null,
|
|
224
247
|
getEntries: () => [
|
|
225
248
|
{
|
|
226
249
|
type: "custom",
|
|
@@ -272,6 +295,53 @@ describe("registerPlanCommand", () => {
|
|
|
272
295
|
return payload.content;
|
|
273
296
|
}
|
|
274
297
|
|
|
298
|
+
// --- D10②:命令反馈 notify(display:false 注入仅 LLM 可见,用户可见性由 notify 承担) ---
|
|
299
|
+
|
|
300
|
+
it("重入分支(存在既有 plan 文件):notify 提示 + 选项清单仍走 display:false 注入", async () => {
|
|
301
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["old-plan"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
302
|
+
vi.mocked(fs.statSync).mockImplementation(() => ({ isDirectory: () => true }) as unknown as fs.Stats);
|
|
303
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => String(p).endsWith("/.tmp/plans/old-plan/plan.md"));
|
|
304
|
+
try {
|
|
305
|
+
await handler("", ctx);
|
|
306
|
+
} finally {
|
|
307
|
+
vi.mocked(fs.existsSync).mockImplementation(() => false);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
311
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 existing plan file(s)"), "info");
|
|
312
|
+
// LLM 注入通路不变:custom message display:false + 选项清单全文
|
|
313
|
+
const message = sentMessage();
|
|
314
|
+
expect(message).toContain("[PLAN MODE] Found existing plan files:");
|
|
315
|
+
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
316
|
+
const payload = calls[0][0] as { display: boolean };
|
|
317
|
+
expect(payload.display).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("--skills 未知技能 fail-fast:notify error + 注入通路保留", async () => {
|
|
321
|
+
await handler("复盘 --skills nope", ctx);
|
|
322
|
+
|
|
323
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
324
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("unknown skill(s): nope"), "error");
|
|
325
|
+
expect(sentMessage()).toContain("Failed to enter: unknown skill(s): nope");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("--template 文件缺失 fail-fast:notify error", async () => {
|
|
329
|
+
await handler("复盘 --template tpl.md", ctx);
|
|
330
|
+
|
|
331
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
332
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Template file not found"), "error");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("进入成功:notify info 携带 planFilePath(命令反馈通道,widget 之外补可达)", async () => {
|
|
336
|
+
await handler("复盘需求", ctx);
|
|
337
|
+
|
|
338
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
339
|
+
expect(notify).toHaveBeenCalledWith(
|
|
340
|
+
expect.stringContaining("Plan mode entered: /tmp/test-project/.tmp/plans/"),
|
|
341
|
+
"info",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
275
345
|
describe("--template via /plan handler", () => {
|
|
276
346
|
afterEach(() => {
|
|
277
347
|
// 还原默认 existsSync=false,避免路径实现泄漏到后续用例
|
|
@@ -327,11 +397,11 @@ describe("registerPlanCommand", () => {
|
|
|
327
397
|
isActive: true,
|
|
328
398
|
requirement: "retro meeting",
|
|
329
399
|
templateName: "retro-template",
|
|
330
|
-
|
|
400
|
+
templateProvided: true,
|
|
331
401
|
skills: [],
|
|
332
402
|
}),
|
|
333
403
|
);
|
|
334
|
-
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
404
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
|
|
335
405
|
const prompt = sentMessage();
|
|
336
406
|
// 直传声明 + 全文内嵌(文件内容直达模型,不赌自发 read——D5)
|
|
337
407
|
expect(prompt).toContain("template was provided via --template");
|
|
@@ -349,7 +419,7 @@ describe("registerPlanCommand", () => {
|
|
|
349
419
|
|
|
350
420
|
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
351
421
|
"plan-state",
|
|
352
|
-
expect.objectContaining({ isActive: true, templateName: "retro template",
|
|
422
|
+
expect.objectContaining({ isActive: true, templateName: "retro template", templateProvided: true }),
|
|
353
423
|
);
|
|
354
424
|
const prompt = sentMessage();
|
|
355
425
|
expect(prompt).toContain(spacedAbs);
|
|
@@ -27,19 +27,23 @@ vi.mock("node:fs", () => ({
|
|
|
27
27
|
// Mock templates(enter 的 buildPlanModePrompt 走 listTemplates;单测不扫真实模板目录)
|
|
28
28
|
vi.mock("../templates.js", () => ({
|
|
29
29
|
listTemplates: vi.fn(() => []),
|
|
30
|
-
loadTemplate: vi.fn(() => null),
|
|
31
30
|
formatAvailablePlans: vi.fn(() => ""),
|
|
32
31
|
}));
|
|
33
32
|
|
|
34
|
-
vi.mock("../
|
|
33
|
+
vi.mock("../execution-notice.js", () => ({
|
|
35
34
|
handlePlanComplete: vi.fn(),
|
|
36
|
-
detectGoalCapability: vi.fn(() => false),
|
|
37
35
|
GOAL_FAILURE_RECOVERY: {},
|
|
38
36
|
}));
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// Mock 执行方式检测(D10):单测不扫真实目录。importActual 展开:只覆写
|
|
39
|
+
// detectExecSkills,其余导出(含 enter.ts re-export 的 resolveSkills 执行门禁)走
|
|
40
|
+
// 真实现——mock 罩全模块会把它一并变 undefined
|
|
41
|
+
vi.mock("@zhushanwen/pi-exec-skills", async () => {
|
|
42
|
+
const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
|
|
43
|
+
"@zhushanwen/pi-exec-skills",
|
|
44
|
+
);
|
|
45
|
+
return { ...actual, detectExecSkills: vi.fn(() => []) };
|
|
46
|
+
});
|
|
43
47
|
|
|
44
48
|
vi.mock("../widget.js", () => ({
|
|
45
49
|
updatePlanWidget: vi.fn(),
|
|
@@ -50,13 +54,12 @@ import * as fs from "node:fs";
|
|
|
50
54
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
51
55
|
|
|
52
56
|
import { registerPlanTool } from "../tool.js";
|
|
53
|
-
import { type PlanState, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
|
|
57
|
+
import { type PlanState, createPlanCtx, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
|
|
54
58
|
|
|
55
59
|
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
56
60
|
|
|
57
61
|
function setup(skillCommands: Array<{ name: string; path: string }> = []) {
|
|
58
|
-
const
|
|
59
|
-
const controllers = new Map<string, AbortController>();
|
|
62
|
+
const planCtx = createPlanCtx();
|
|
60
63
|
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<{
|
|
61
64
|
content: Array<{ type: string; text: string }>;
|
|
62
65
|
details: { action: string; requirement?: string; skills?: string[] };
|
|
@@ -69,7 +72,7 @@ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
|
|
|
69
72
|
getCommands: vi.fn(() => skillCommands.map((c) => ({ name: c.name, source: "skill", sourceInfo: { path: c.path } }))),
|
|
70
73
|
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
71
74
|
} as unknown as ExtensionAPI;
|
|
72
|
-
registerPlanTool(pi,
|
|
75
|
+
registerPlanTool(pi, planCtx);
|
|
73
76
|
|
|
74
77
|
const ctx = {
|
|
75
78
|
sessionId: "test-session",
|
|
@@ -77,13 +80,13 @@ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
|
|
|
77
80
|
hasUI: true,
|
|
78
81
|
mode: "tui" as const,
|
|
79
82
|
isProjectTrusted: () => true,
|
|
80
|
-
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
83
|
+
sessionManager: { getSessionId: () => "test-session", getLeafId: () => null, getEntries: () => [] },
|
|
81
84
|
ui: { select: vi.fn(), notify: vi.fn() },
|
|
82
85
|
} as unknown as ExtensionContext;
|
|
83
86
|
|
|
84
87
|
const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
|
|
85
88
|
executeFn!("tc0", params, signal, undefined, ctx);
|
|
86
|
-
return { pi, sessions, ctx, exec };
|
|
89
|
+
return { pi, sessions: planCtx.states, ctx, exec };
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1)", () => {
|
|
@@ -112,6 +115,10 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
|
|
|
112
115
|
// 状态对象已是激活态
|
|
113
116
|
const state = sessions.get("test-session") as { isActive?: boolean; requirement?: string };
|
|
114
117
|
expect(state.isActive).toBe(true);
|
|
118
|
+
// 状态写走 transition():idle --enter--> planning 落盘(新会话缺省态进入;原独立
|
|
119
|
+
// 用例的转移断言并入——同通道同断言字段,仅转移源字面量与下方坏格用例不同)
|
|
120
|
+
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
121
|
+
expect(lastEntry.state).toBe("planning");
|
|
115
122
|
});
|
|
116
123
|
|
|
117
124
|
it("已在 plan 模式:幂等返回,不重复收工具 / 不重复注入", async () => {
|
|
@@ -154,19 +161,50 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
|
|
|
154
161
|
expect(res.content[0].text).toContain("(from conversation context)");
|
|
155
162
|
});
|
|
156
163
|
|
|
157
|
-
it("新轮次进入:上轮残留的
|
|
164
|
+
it("新轮次进入:上轮残留的 selfReview/resumeHint/指纹随进入失效(clearRoundFields 单函数出口的 enter 调用点,D9①防跨轮误触新鲜度门)", async () => {
|
|
158
165
|
const { exec, pi, sessions } = setup();
|
|
159
|
-
// 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState
|
|
160
|
-
sessions.set("test-session", {
|
|
166
|
+
// 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState 的路径留下的降级标记与基线
|
|
167
|
+
sessions.set("test-session", {
|
|
168
|
+
...DEFAULT_PLAN_STATE,
|
|
169
|
+
isActive: false,
|
|
170
|
+
state: "exited",
|
|
171
|
+
selfReview: "stale self-review",
|
|
172
|
+
resumeHint: "resubmit",
|
|
173
|
+
lastSubmitReviewDocsFingerprint: "old.md:3",
|
|
174
|
+
});
|
|
161
175
|
|
|
162
176
|
const res = await exec({ action: "enter", requirement: "new round" });
|
|
163
177
|
|
|
164
178
|
expect(res.details.action).toBe("enter");
|
|
165
|
-
// activatePlanMode
|
|
166
|
-
const state = sessions.get("test-session") as
|
|
167
|
-
expect(state.
|
|
168
|
-
|
|
179
|
+
// activatePlanMode 经 clearRoundFields 清三字段(D4 单函数出口,与 resetPlanState 同源)
|
|
180
|
+
const state = sessions.get("test-session") as PlanState;
|
|
181
|
+
expect(state.selfReview).toBeUndefined();
|
|
182
|
+
expect(state.resumeHint).toBeUndefined();
|
|
183
|
+
expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
|
|
184
|
+
// enter 边落盘(exited --enter--> planning 新一轮);entry 无残留字段
|
|
185
|
+
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
186
|
+
expect(lastEntry.state).toBe("planning");
|
|
187
|
+
expect(lastEntry.resumeHint).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("坏数据格(isActive=false 且 state 落活跃族):enter 按转移 ok:false 落穿,不归一不重试(坏格自愈分支已删,C1 行为变更组——仅坏格可达,行为差锚)", async () => {
|
|
191
|
+
const { exec, pi, sessions } = setup();
|
|
192
|
+
// 手改 session 文件可达的坏格:isActive=false 却残留活跃族 state(reviewing)
|
|
193
|
+
sessions.set("test-session", {
|
|
194
|
+
...DEFAULT_PLAN_STATE,
|
|
195
|
+
isActive: false,
|
|
196
|
+
state: "reviewing",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await exec({ action: "enter", requirement: "on a corrupt cell" });
|
|
200
|
+
|
|
201
|
+
// 落穿:不归一 idle 再进——state 保持坏值(reviewing --enter--> 非 ok),进入副作用
|
|
202
|
+
// 照常(isActive=true 已置、persist 携带现值)
|
|
203
|
+
const state = sessions.get("test-session") as PlanState;
|
|
204
|
+
expect(state.isActive).toBe(true);
|
|
205
|
+
expect(state.state).toBe("reviewing");
|
|
169
206
|
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
170
|
-
expect(lastEntry.
|
|
207
|
+
expect(lastEntry.state).toBe("reviewing");
|
|
208
|
+
expect(lastEntry.isActive).toBe(true);
|
|
171
209
|
});
|
|
172
210
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE_D 散文 ↔ buildExecOptions 选项集对齐防线(plan-phase-d-prompt-options-alignment
|
|
3
|
+
* 定案:2026-09-26 登记二选一,2026-09-27 裁决 = 单源化 + 对齐测试双管)。
|
|
4
|
+
*
|
|
5
|
+
* 能单源的已单源:技能档上限 MAX_SKILL_OPTIONS(prompts.ts SSOT,散文插值 + 截断同源)、
|
|
6
|
+
* 选项 label(i18n exec.* 词典单源)。散文无法整体生成的结构性原因:面向模型的是英文
|
|
7
|
+
* 静态散文,选项是运行时按检测结果动态构造 + 用户侧 label 随 locale 本地化——两种语言、
|
|
8
|
+
* 两个受众;散文还描述「无技能直通」分支行为(无选项对象对应物)。故散文锚点靠本测试
|
|
9
|
+
* 机器锁定:数量锚、label 字面锚、两分支行为句锚,漂移即红。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { t } from "../i18n.js";
|
|
14
|
+
import { MAX_SKILL_OPTIONS, PHASE_D_PROSE } from "../prompts.js";
|
|
15
|
+
import { buildExecOptions } from "../tool.js";
|
|
16
|
+
import type { ExecSkill } from "@zhushanwen/pi-exec-skills";
|
|
17
|
+
|
|
18
|
+
function fixtureSkill(name: string): ExecSkill {
|
|
19
|
+
return { name, description: `${name} plan-exec skill`, skillEntryPath: `/tmp/skills/${name}/SKILL.md` } as ExecSkill;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("PHASE_D 散文 ↔ buildExecOptions 对齐", () => {
|
|
23
|
+
it("数量锚:散文技能档上限 = 截断常量(MAX_SKILL_OPTIONS 单源,改一处散文自动跟随)", () => {
|
|
24
|
+
expect(PHASE_D_PROSE).toContain(`up to ${MAX_SKILL_OPTIONS} detected plan-exec skills`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("截断行为锚:检测超过上限的技能只取前 MAX_SKILL_OPTIONS 个 + 固定尾部两项", () => {
|
|
28
|
+
const skills = Array.from({ length: MAX_SKILL_OPTIONS + 2 }, (_, i) => fixtureSkill(`s${i + 1}`));
|
|
29
|
+
const options = buildExecOptions(skills);
|
|
30
|
+
|
|
31
|
+
expect(options).toHaveLength(MAX_SKILL_OPTIONS + 2);
|
|
32
|
+
expect(options.slice(0, MAX_SKILL_OPTIONS).map((o) => o.mode)).toEqual(["skill:s1", "skill:s2"]);
|
|
33
|
+
expect(options.slice(MAX_SKILL_OPTIONS).map((o) => o.mode)).toEqual(["execute", "later"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("label 锚:选项 label 出自 i18n 词典(en-US 基准),散文含对应英文字面", () => {
|
|
37
|
+
const en = (key: string, params?: Record<string, string | number>) => t(key, params, "en-US");
|
|
38
|
+
|
|
39
|
+
const options = buildExecOptions([fixtureSkill("dev-flow")]);
|
|
40
|
+
const [skillOption, executeOption, laterOption] = options;
|
|
41
|
+
|
|
42
|
+
// 选项 label 单源 = 词典;散文锚定词典渲染产物(技能档取 name 占位渲染形态)
|
|
43
|
+
expect(skillOption.label).toBe(en("exec.viaSkill", { name: "dev-flow" }));
|
|
44
|
+
expect(executeOption.label).toBe(en("exec.execute"));
|
|
45
|
+
expect(laterOption.label).toBe(en("exec.later"));
|
|
46
|
+
|
|
47
|
+
expect(PHASE_D_PROSE).toContain("Execute via skill: <name>");
|
|
48
|
+
// Execute 作为完整选项词(非 "Execute via" 前缀的一部分——用后随括号边界锚定)
|
|
49
|
+
expect(PHASE_D_PROSE).toContain(`${en("exec.execute")} (goal tracking integrated when available), or ${en("exec.later")}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("两分支行为句锚:有技能弹表单 / 无技能直通(D7②),散文与行为分支共存", () => {
|
|
53
|
+
expect(PHASE_D_PROSE).toContain("the user picks an execution method in the completion dialog");
|
|
54
|
+
expect(PHASE_D_PROSE).toContain("When NO plan-exec skill is detected, no dialog appears and the plan executes directly");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PlanState } from "../state.js";
|
|
4
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
5
|
+
|
|
6
|
+
// Mock fs before importing execution-notice.ts (ESM namespace is not configurable)
|
|
7
|
+
vi.mock("node:fs", () => ({
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Import after mock setup
|
|
12
|
+
import { handlePlanComplete } from "../execution-notice.js";
|
|
13
|
+
|
|
14
|
+
const fsMock = vi.mocked(await import("node:fs"));
|
|
15
|
+
|
|
16
|
+
// --- Shared mock factories ---
|
|
17
|
+
|
|
18
|
+
function makePi() {
|
|
19
|
+
return {
|
|
20
|
+
on: vi.fn(),
|
|
21
|
+
appendEntry: vi.fn(),
|
|
22
|
+
sendMessage: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CtxMock = ReturnType<typeof makeCtx>;
|
|
27
|
+
function makeCtx() {
|
|
28
|
+
return {
|
|
29
|
+
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] as unknown[] },
|
|
30
|
+
ui: { notify: vi.fn() },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeActiveState(): PlanState {
|
|
35
|
+
return {
|
|
36
|
+
isActive: true,
|
|
37
|
+
// 生产真实形态(enter.ts:<project>/.tmp/plans/<slug>/plan.md)——合成单层路径会让
|
|
38
|
+
// slug 派生类消费面(buildPlanSlug 取 basename)在测试数据上失真
|
|
39
|
+
planFilePath: "/tmp/test-project/.tmp/plans/login-page/plan.md",
|
|
40
|
+
requirement: "Add login page",
|
|
41
|
+
templateName: "default",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setupFsMock(content: string) {
|
|
46
|
+
fsMock.readFileSync.mockReturnValue(content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* goal 桥 slot key——与 execution-notice.ts / goal 侧 index.ts 的字符串一致(本地声明,
|
|
51
|
+
* 不 import 对方包:pi-goal 是 optional peer,两侧靠同一字符串共享 slot)。
|
|
52
|
+
*/
|
|
53
|
+
const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
|
|
54
|
+
|
|
55
|
+
/** 在 globalThis slot 上挂 goal 桥——mock 形态与真实通道同构(goal-bridge-cross-extension.md §3.4:goal 侧 Reflect.set 挂 slot,本测试同款挂载)。 */
|
|
56
|
+
function attachGoalInit(impl: () => boolean) {
|
|
57
|
+
const fn = vi.fn(impl);
|
|
58
|
+
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, fn);
|
|
59
|
+
return fn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 清 slot 防跨用例 globalThis 泄漏(设计 §3.4:teardown 两侧通用)。 */
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** 最近一次 sendMessage 的正文(P8 custom message 的 content 字段)。 */
|
|
68
|
+
function lastSteer(pi: ReturnType<typeof makePi>): string {
|
|
69
|
+
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
70
|
+
const last = calls[calls.length - 1]?.[0] as { content?: string } | undefined;
|
|
71
|
+
return String(last?.content ?? "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** P8 执行通知断言形态:custom message 三要素 + streaming steer options(A6)。 */
|
|
75
|
+
function executionNotice(content: unknown) {
|
|
76
|
+
return [
|
|
77
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
|
|
78
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- handlePlanComplete tests ---
|
|
83
|
+
|
|
84
|
+
describe("handlePlanComplete", () => {
|
|
85
|
+
let pi: ReturnType<typeof makePi>;
|
|
86
|
+
let ctx: CtxMock;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
pi = makePi();
|
|
91
|
+
ctx = makeCtx();
|
|
92
|
+
setupFsMock("## 实现步骤\n1. Step one\n2. Step two");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("direct delivery", () => {
|
|
96
|
+
it("execute mode success: returns outcome synchronously, goal steer, no notify", () => {
|
|
97
|
+
attachGoalInit(() => true);
|
|
98
|
+
|
|
99
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
100
|
+
|
|
101
|
+
expect(outcome).toEqual({ started: true });
|
|
102
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
103
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skill mode: returns undefined, steer with skill entry path (steer 文案含 skillEntryPath,D10)", () => {
|
|
107
|
+
attachGoalInit(() => true);
|
|
108
|
+
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
109
|
+
|
|
110
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:dev-flow", skillEntryPath);
|
|
111
|
+
|
|
112
|
+
expect(outcome).toBeUndefined();
|
|
113
|
+
const steer = lastSteer(pi);
|
|
114
|
+
expect(steer).toContain("Execution mode: skill:dev-flow");
|
|
115
|
+
expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md");
|
|
116
|
+
expect(steer).toContain("follow its workflow");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("skill mode (散 .md 形态): steer carries the file path itself, not a dangling joined SKILL.md", () => {
|
|
120
|
+
attachGoalInit(() => true);
|
|
121
|
+
const looseEntryPath = "/tmp/fixtures/skills/loose-tool.md";
|
|
122
|
+
|
|
123
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:loose-tool", looseEntryPath);
|
|
124
|
+
|
|
125
|
+
const steer = lastSteer(pi);
|
|
126
|
+
expect(steer).toContain("/tmp/fixtures/skills/loose-tool.md"); // 文件路径本身
|
|
127
|
+
expect(steer).not.toContain("loose-tool.md/SKILL.md"); // 不再拼接 SKILL.md(悬空指引根修)
|
|
128
|
+
expect(steer).not.toContain("/tmp/fixtures/skills/SKILL.md"); // 旧缺陷形态:散 .md 的 dirname + SKILL.md
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("skill mode without skillEntryPath falls back to name-only guidance (防御形态)", () => {
|
|
132
|
+
attachGoalInit(() => true);
|
|
133
|
+
|
|
134
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "skill:dev-flow");
|
|
135
|
+
|
|
136
|
+
const steer = lastSteer(pi);
|
|
137
|
+
expect(steer).toContain("load the dev-flow skill");
|
|
138
|
+
expect(steer).not.toContain("SKILL.md");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// goal 桥五个失败出口 → GoalBridgeOutcome 五值(D2)
|
|
143
|
+
describe("tryGoalInit failure exits (execute mode)", () => {
|
|
144
|
+
it("goal-unavailable: bridge not mounted → degraded steer + warning notify", () => {
|
|
145
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
146
|
+
|
|
147
|
+
expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
|
|
148
|
+
const steer = lastSteer(pi);
|
|
149
|
+
expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
|
|
150
|
+
expect(steer).not.toContain("Goal tracking is active via /goal");
|
|
151
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("plan-unreadable: plan file read fails → recovery points at the plan file", () => {
|
|
155
|
+
attachGoalInit(() => true);
|
|
156
|
+
fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
|
|
157
|
+
|
|
158
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
159
|
+
|
|
160
|
+
expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
|
|
161
|
+
expect(lastSteer(pi)).toContain("Check that the plan file exists");
|
|
162
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("plan-unreadable"), "warning");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("no-steps: plan content has no extractable steps", () => {
|
|
166
|
+
attachGoalInit(() => true);
|
|
167
|
+
setupFsMock("# Plan\n\nProse without any numbered list.");
|
|
168
|
+
|
|
169
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
170
|
+
|
|
171
|
+
expect(outcome).toEqual({ started: false, reason: "no-steps" });
|
|
172
|
+
expect(lastSteer(pi)).toContain("Implementation Steps");
|
|
173
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("no-steps"), "warning");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("init-refused: goalInit returns false (active goal exists)", () => {
|
|
177
|
+
attachGoalInit(() => false);
|
|
178
|
+
|
|
179
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
180
|
+
|
|
181
|
+
expect(outcome).toEqual({ started: false, reason: "init-refused" });
|
|
182
|
+
expect(lastSteer(pi)).toContain("/goal clear");
|
|
183
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("init-refused"), "warning");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
|
|
187
|
+
attachGoalInit(() => { throw new Error("goalInit exploded"); });
|
|
188
|
+
|
|
189
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "execute");
|
|
190
|
+
|
|
191
|
+
expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
|
|
192
|
+
expect(lastSteer(pi)).toContain("Goal tracking was not started (internal-error)");
|
|
193
|
+
expect(lastSteer(pi)).not.toContain("Goal tracking is active via /goal");
|
|
194
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|