@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.
@@ -0,0 +1,258 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock dependencies before importing(形态照 command.test.ts)
4
+ vi.mock("node:fs", () => ({
5
+ mkdirSync: vi.fn(),
6
+ readdirSync: vi.fn(() => []),
7
+ statSync: vi.fn(),
8
+ existsSync: vi.fn(() => false),
9
+ }));
10
+
11
+ vi.mock("../widget.js", () => ({
12
+ updatePlanWidget: vi.fn(),
13
+ }));
14
+
15
+ import * as fs from "node:fs";
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+
19
+ import { parsePlanArgs, registerPlanCommand } from "../command.js";
20
+ import { resolveSkills } from "../enter.js";
21
+ import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
22
+
23
+ const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
24
+
25
+ /**
26
+ * fake skill 命令条目(SlashCommandInfo 形态:source === 'skill' + sourceInfo.path)。
27
+ * name 带 pi 命名空间前缀(`skill:<name>`)——真实 pi.getCommands() 枚举 skill 类
28
+ * 命令即此形态(A3② 真机实测),E1 归一化比对的靶子。
29
+ */
30
+ const SKILL_COMMANDS = [
31
+ { name: "skill:tech-design", source: "skill", sourceInfo: { path: "/skills/tech-design/SKILL.md" } },
32
+ { name: "skill:dev-flow", source: "skill", sourceInfo: { path: "/skills/dev-flow/SKILL.md" } },
33
+ { name: "skill:code review", source: "skill", sourceInfo: { path: "/skills/code review/SKILL.md" } },
34
+ { name: "plan", source: "extension", sourceInfo: { path: "/extensions/universal/plan" } },
35
+ ];
36
+
37
+ describe("parsePlanArgs(--skills 解析容错定则)", () => {
38
+ it("no flag: requirement only, skills undefined", () => {
39
+ const parsed = parsePlanArgs("重构 auth 模块");
40
+ expect(parsed.requirement).toBe("重构 auth 模块");
41
+ expect(parsed.skills).toBeUndefined();
42
+ });
43
+
44
+ it("basic comma split", () => {
45
+ const parsed = parsePlanArgs("重构 auth --skills tech-design,dev-flow");
46
+ expect(parsed.requirement).toBe("重构 auth");
47
+ expect(parsed.skills).toEqual(["tech-design", "dev-flow"]);
48
+ });
49
+
50
+ it("chinese and space-containing skill names keep their characters (u0 定则)", () => {
51
+ // 内部空格保留——「按逗号原样切分 + 去首尾空格」,中文名/带空格技能名不丢字符
52
+ const parsed = parsePlanArgs("重构渲染层 --skills tech-design, code review, 中文 技能");
53
+ expect(parsed.requirement).toBe("重构渲染层");
54
+ expect(parsed.skills).toEqual(["tech-design", "code review", "中文 技能"]);
55
+ });
56
+
57
+ it("empty items from trailing/double commas are dropped", () => {
58
+ const parsed = parsePlanArgs("req --skills a,,b,");
59
+ expect(parsed.skills).toEqual(["a", "b"]);
60
+ });
61
+
62
+ it("flag with no value yields an empty list (caller fail-fasts)", () => {
63
+ const parsed = parsePlanArgs("req --skills");
64
+ expect(parsed.requirement).toBe("req");
65
+ expect(parsed.skills).toEqual([]);
66
+ });
67
+
68
+ it("flag at the very start leaves empty requirement", () => {
69
+ const parsed = parsePlanArgs("--skills a,b");
70
+ expect(parsed.requirement).toBe("");
71
+ expect(parsed.skills).toEqual(["a", "b"]);
72
+ });
73
+
74
+ it("'--skillsabc' is not treated as the flag (word boundary)", () => {
75
+ const parsed = parsePlanArgs("--skillsabc is part of requirement");
76
+ expect(parsed.skills).toBeUndefined();
77
+ expect(parsed.requirement).toBe("--skillsabc is part of requirement");
78
+ });
79
+ });
80
+
81
+ describe("resolveSkills(E1 技能枚举比对,双向剥 skill: 前缀归一)", () => {
82
+ const pi = {
83
+ getCommands: vi.fn(() => SKILL_COMMANDS),
84
+ } as unknown as ExtensionAPI;
85
+
86
+ it("resolves prefixed enum entries from bare-name input (short name in resolved)", () => {
87
+ // 无前缀输入命中带前缀枚举(设计面:用户输入自然技能名,A3② 回归靶)
88
+ const resolution = resolveSkills(pi, ["tech-design", "code review"]);
89
+ expect(resolution.ok).toBe(true);
90
+ if (resolution.ok) {
91
+ expect(resolution.resolved).toEqual([
92
+ { name: "tech-design", skillPath: "/skills/tech-design/SKILL.md" },
93
+ { name: "code review", skillPath: "/skills/code review/SKILL.md" },
94
+ ]);
95
+ }
96
+ });
97
+
98
+ it("resolves explicit 'skill:'-prefixed input too (both directions normalize)", () => {
99
+ const resolution = resolveSkills(pi, ["skill:tech-design", "skill:dev-flow"]);
100
+ expect(resolution.ok).toBe(true);
101
+ if (resolution.ok) {
102
+ // resolved 用归一后的短名(无前缀)
103
+ expect(resolution.resolved).toEqual([
104
+ { name: "tech-design", skillPath: "/skills/tech-design/SKILL.md" },
105
+ { name: "dev-flow", skillPath: "/skills/dev-flow/SKILL.md" },
106
+ ]);
107
+ }
108
+ });
109
+
110
+ it("only compares entries with source === 'skill'", () => {
111
+ const resolution = resolveSkills(pi, ["plan"]);
112
+ expect(resolution.ok).toBe(false);
113
+ if (!resolution.ok) {
114
+ expect(resolution.missing).toEqual(["plan"]);
115
+ // available 维持枚举原形态(带前缀,错误信息里可直接复制为 pi 命令)
116
+ expect(resolution.available).toEqual(["skill:tech-design", "skill:dev-flow", "skill:code review"]);
117
+ }
118
+ });
119
+
120
+ it("unknown skill reports missing together with the available list", () => {
121
+ const resolution = resolveSkills(pi, ["tech-design", "tech-desig"]);
122
+ expect(resolution.ok).toBe(false);
123
+ if (!resolution.ok) {
124
+ expect(resolution.missing).toEqual(["tech-desig"]);
125
+ }
126
+ });
127
+
128
+ it("prefixed-but-unknown input reports the normalized short name as missing", () => {
129
+ const resolution = resolveSkills(pi, ["skill:tech-desig"]);
130
+ expect(resolution.ok).toBe(false);
131
+ if (!resolution.ok) {
132
+ expect(resolution.missing).toEqual(["tech-desig"]);
133
+ }
134
+ });
135
+ });
136
+
137
+ describe("E1 fail-fast via /plan handler", () => {
138
+ let pi: ExtensionAPI;
139
+ let ctx: ExtensionContext;
140
+ let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
141
+ let controllers: Map<string, AbortController>;
142
+
143
+ beforeEach(() => {
144
+ vi.clearAllMocks();
145
+ let capturedHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
146
+ pi = {
147
+ registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
148
+ capturedHandler = def.handler;
149
+ }),
150
+ appendEntry: vi.fn(),
151
+ setActiveTools: vi.fn(),
152
+ sendMessage: vi.fn(),
153
+ getCommands: vi.fn(() => SKILL_COMMANDS),
154
+ getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
155
+ } as unknown as ExtensionAPI;
156
+ ctx = {
157
+ cwd: "/tmp/test-project",
158
+ sessionManager: {
159
+ getSessionId: () => "test-session",
160
+ getEntries: () => [] as unknown[],
161
+ },
162
+ ui: {
163
+ notify: vi.fn(),
164
+ setWidget: vi.fn(),
165
+ setStatus: vi.fn(),
166
+ theme: { fg: (_t: string, text: string) => text },
167
+ },
168
+ } as unknown as ExtensionContext;
169
+ controllers = new Map();
170
+ registerPlanCommand(pi, new Map(), controllers);
171
+ handler = capturedHandler!;
172
+ });
173
+
174
+ /** 取第一条 sendMessage 的 content 文本 + 断言 custom message 三要素(P1-P4:triggerTurn:true,无 deliverAs) */
175
+ function sentMessageContent(): string {
176
+ const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
177
+ expect(calls.length).toBeGreaterThan(0);
178
+ const [payload, options] = calls[0] as [{ content: string }, Record<string, unknown>];
179
+ expect(payload.customType).toBe(PLAN_CONTEXT_CUSTOM_TYPE);
180
+ expect(payload.display).toBe(false);
181
+ expect(options).toEqual({ triggerTurn: true });
182
+ return payload.content;
183
+ }
184
+
185
+ it("unknown skill: fail-fast — no entry, no tool restriction, reply lists available skills", async () => {
186
+ await handler("重构 auth --skills tech-desig", ctx);
187
+
188
+ // 不进入计划模式:不落 plan-state entry、不限制工具(E1:PlanModeBar 不出现)
189
+ expect(pi.appendEntry).not.toHaveBeenCalled();
190
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
191
+
192
+ // 回复列出可用技能清单与纠正命令(清单维持枚举原形态,可直接复制为 pi 命令)
193
+ expect(pi.sendMessage).toHaveBeenCalledOnce();
194
+ const message = sentMessageContent();
195
+ expect(message).toContain("tech-desig");
196
+ expect(message).toContain("tech-design");
197
+ expect(message).toContain("dev-flow");
198
+ expect(message).toContain("--skills");
199
+ expect(message).toContain("skill:tech-design");
200
+ expect(message).toContain("skill:dev-flow");
201
+ });
202
+
203
+ it("prefixed skill input ('--skills skill:x') enters plan mode with normalized short names", async () => {
204
+ await handler("重构 auth --skills skill:tech-design", ctx);
205
+
206
+ // 进入计划模式,state 落归一化短名
207
+ expect(pi.appendEntry).toHaveBeenCalledWith(
208
+ "plan-state",
209
+ expect.objectContaining({ isActive: true, skills: ["tech-design"] }),
210
+ );
211
+ const prompt = sentMessageContent();
212
+ expect(prompt).toContain("/skills/tech-design/SKILL.md");
213
+ });
214
+
215
+ it("flag with no value also fail-fasts", async () => {
216
+ await handler("重构 auth --skills", ctx);
217
+ expect(pi.appendEntry).not.toHaveBeenCalled();
218
+ expect(pi.sendMessage).toHaveBeenCalledOnce();
219
+ });
220
+
221
+ it("valid skills: enters plan mode, persists skills and injects skill paths in the prompt", async () => {
222
+ await handler("重构 auth --skills tech-design,code review", ctx);
223
+
224
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
225
+ // slug 只保留 [a-z0-9]:「重构 auth」→ "auth"
226
+ expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth", { recursive: true });
227
+ expect(pi.appendEntry).toHaveBeenCalledWith(
228
+ "plan-state",
229
+ expect.objectContaining({
230
+ isActive: true,
231
+ skills: ["tech-design", "code review"],
232
+ docs: [],
233
+ }),
234
+ );
235
+ const prompt = sentMessageContent();
236
+ // 技能指令段注入 SKILL.md 路径(AI 自行 read——D2①)
237
+ expect(prompt).toContain("/skills/tech-design/SKILL.md");
238
+ expect(prompt).toContain("/skills/code review/SKILL.md");
239
+ // 产物纪律段注入(D2②)
240
+ expect(prompt).toContain("register-doc");
241
+ expect(prompt).toContain("submit-review");
242
+ });
243
+
244
+ it("without --skills: falls back to the template flow prompt (D2④) and persists empty skills", async () => {
245
+ await handler("implement dark mode", ctx);
246
+
247
+ expect(pi.appendEntry).toHaveBeenCalledWith(
248
+ "plan-state",
249
+ expect.objectContaining({ isActive: true, skills: [] }),
250
+ );
251
+ const prompt = sentMessageContent();
252
+ // 无技能指令段,回落模板流程段(v2b 注入形态:mock fs 空发现 → no-plans 分支
253
+ // 自构章节骨架,无任何模板查询 action 指引)
254
+ expect(prompt).not.toContain("Skill Workflow");
255
+ expect(prompt).toContain("Phase C: Writing");
256
+ expect(prompt).toContain("No plan templates were discovered");
257
+ });
258
+ });