@zhushanwen/pi-plan 0.4.9 → 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 +15 -5
- 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
|
@@ -12,6 +12,7 @@ vi.mock("node:fs", () => ({
|
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
14
|
import { buildPlanSuccessCriteria, handlePlanComplete } from "../compact.js";
|
|
15
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
15
16
|
|
|
16
17
|
const fsMock = vi.mocked(await import("node:fs"));
|
|
17
18
|
|
|
@@ -19,7 +20,7 @@ function makePi() {
|
|
|
19
20
|
return {
|
|
20
21
|
on: vi.fn(),
|
|
21
22
|
appendEntry: vi.fn(),
|
|
22
|
-
|
|
23
|
+
sendMessage: vi.fn(),
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -160,7 +161,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
|
|
|
160
161
|
it("direct isolation: 3 步 plan → 总述 + 3 条 preview", () => {
|
|
161
162
|
fsMock.readFileSync.mockReturnValue(makePlanContent(["Step A", "Step B", "Step C"]));
|
|
162
163
|
|
|
163
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
164
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
164
165
|
|
|
165
166
|
expect(getCriteriaArg()).toEqual([
|
|
166
167
|
"All 3 steps of plan executed and verified",
|
|
@@ -173,7 +174,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
|
|
|
173
174
|
it("compact isolation: onComplete 后 goalInit 收到同形态数组", () => {
|
|
174
175
|
fsMock.readFileSync.mockReturnValue(makePlanContent(["Step one", "Step two"]));
|
|
175
176
|
|
|
176
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "
|
|
177
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
177
178
|
ctx._onCompleteFns[0]();
|
|
178
179
|
|
|
179
180
|
expect(getCriteriaArg()).toEqual([
|
|
@@ -187,7 +188,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
|
|
|
187
188
|
const steps = Array.from({ length: 12 }, (_, i) => `Step ${i + 1}`);
|
|
188
189
|
fsMock.readFileSync.mockReturnValue(makePlanContent(steps));
|
|
189
190
|
|
|
190
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
191
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
191
192
|
|
|
192
193
|
const items = getCriteriaArg();
|
|
193
194
|
expect(items).toHaveLength(4);
|
|
@@ -198,7 +199,7 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
|
|
|
198
199
|
it("CRLF plan 文件 → 每条 criteria 仍单行不含 \\r \\n", () => {
|
|
199
200
|
fsMock.readFileSync.mockReturnValue("## 实现步骤\r\n1. Step one\r\n2. Step two\r\n3. Step three");
|
|
200
201
|
|
|
201
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
202
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
202
203
|
|
|
203
204
|
expect(getCriteriaArg()).toEqual([
|
|
204
205
|
"All 3 steps of plan executed and verified",
|
|
@@ -211,10 +212,13 @@ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]
|
|
|
211
212
|
it("0 步 plan → tryGoalInit 提前退出,goalInit 不被调用", () => {
|
|
212
213
|
(fsMock.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue("## Overview\nNo numbered steps here.");
|
|
213
214
|
|
|
214
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
215
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
215
216
|
|
|
216
217
|
expect(goalInitMock).not.toHaveBeenCalled();
|
|
217
|
-
// steer 仍发出(执行流程不因 goal
|
|
218
|
-
expect(pi.
|
|
218
|
+
// steer 仍发出(执行流程不因 goal 缺席中断)——custom message 三要素 + steer options(A6)
|
|
219
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
220
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content: expect.any(String), display: false },
|
|
221
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
222
|
+
);
|
|
219
223
|
});
|
|
220
224
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import type { PlanState } from "../state.js";
|
|
4
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
4
5
|
|
|
5
6
|
// Mock fs before importing compact.ts (ESM namespace is not configurable)
|
|
6
7
|
vi.mock("node:fs", () => ({
|
|
@@ -18,7 +19,7 @@ function makePi() {
|
|
|
18
19
|
return {
|
|
19
20
|
on: vi.fn(),
|
|
20
21
|
appendEntry: vi.fn(),
|
|
21
|
-
|
|
22
|
+
sendMessage: vi.fn(),
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -70,10 +71,19 @@ afterEach(() => {
|
|
|
70
71
|
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
/** 最近一次
|
|
74
|
+
/** 最近一次 sendMessage 的正文(P8 custom message 的 content 字段)。 */
|
|
74
75
|
function lastSteer(pi: ReturnType<typeof makePi>): string {
|
|
75
|
-
const calls = (pi.
|
|
76
|
-
|
|
76
|
+
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
77
|
+
const last = calls[calls.length - 1]?.[0] as { content?: string } | undefined;
|
|
78
|
+
return String(last?.content ?? "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** P8 执行通知断言形态:custom message 三要素 + streaming steer options(A6)。 */
|
|
82
|
+
function executionNotice(content: unknown) {
|
|
83
|
+
return [
|
|
84
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
|
|
85
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
86
|
+
];
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
// --- handlePlanComplete tests ---
|
|
@@ -90,10 +100,10 @@ describe("handlePlanComplete", () => {
|
|
|
90
100
|
});
|
|
91
101
|
|
|
92
102
|
describe("compact isolation", () => {
|
|
93
|
-
it("
|
|
103
|
+
it("execute mode: goalInit deferred to onComplete, then goal steer on success (D2 时序——goal entry 在压缩后世界创建)", () => {
|
|
94
104
|
const goalInit = attachGoalInit(() => true);
|
|
95
105
|
|
|
96
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "
|
|
106
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
97
107
|
|
|
98
108
|
expect(outcome).toBeUndefined(); // compact 档 outcome 在回调内产生,不进同步返回值
|
|
99
109
|
expect(ctx.compact).toHaveBeenCalledOnce();
|
|
@@ -112,24 +122,39 @@ describe("handlePlanComplete", () => {
|
|
|
112
122
|
"2. Step two",
|
|
113
123
|
],
|
|
114
124
|
);
|
|
115
|
-
expect(pi.
|
|
125
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
116
126
|
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
117
127
|
});
|
|
118
128
|
|
|
119
|
-
it("
|
|
129
|
+
it("execute mode (compact, bridge unmounted): onComplete sends degraded direct-execution steer, no goalInit", () => {
|
|
130
|
+
// execute 档整合 goal 桥:桥未挂载(goal-unavailable)→ 降级为直接执行指引
|
|
131
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
132
|
+
ctx._onCompleteFns[0]();
|
|
133
|
+
|
|
134
|
+
const steer = lastSteer(pi);
|
|
135
|
+
expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
|
|
136
|
+
expect(steer).toContain("subagents"); // 降级后仍指到 auto-parallel 直接执行
|
|
137
|
+
expect(steer).toContain("current session");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("skill mode (compact): onComplete steer carries the skill entry path (D10 skillEntryPath 通路)", () => {
|
|
120
141
|
const goalInit = attachGoalInit(() => true);
|
|
142
|
+
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
121
143
|
|
|
122
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "
|
|
144
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "skill:dev-flow", skillEntryPath);
|
|
123
145
|
ctx._onCompleteFns[0]();
|
|
124
146
|
|
|
125
147
|
expect(goalInit).not.toHaveBeenCalled();
|
|
126
|
-
|
|
148
|
+
const steer = lastSteer(pi);
|
|
149
|
+
expect(steer).toContain("skill:dev-flow"); // Execution mode 行透传 execMode
|
|
150
|
+
expect(steer).toContain("dev-flow"); // 指引按名加载
|
|
151
|
+
expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md"); // 路径读取指引(skillEntryPath = 入口文件路径,直接透传)
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
it("onError (
|
|
154
|
+
it("onError (execute mode, init-refused): compact-failure notify + degraded steer + warning notify, no /goal promise", () => {
|
|
130
155
|
attachGoalInit(() => false); // goalInit 返回 false:已有 active goal
|
|
131
156
|
|
|
132
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "
|
|
157
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
133
158
|
ctx._onErrorFns[0](new Error("compact failed"));
|
|
134
159
|
|
|
135
160
|
const notifyTexts = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
|
|
@@ -137,55 +162,79 @@ describe("handlePlanComplete", () => {
|
|
|
137
162
|
expect(notifyTexts.some((t) => t.includes("init-refused"))).toBe(true);
|
|
138
163
|
|
|
139
164
|
const steer = lastSteer(pi);
|
|
140
|
-
expect(steer).toContain("Goal
|
|
165
|
+
expect(steer).toContain("Goal tracking was not started (init-refused)");
|
|
141
166
|
expect(steer).toContain("/goal clear"); // 恢复动作
|
|
142
|
-
expect(steer).not.toContain("
|
|
167
|
+
expect(steer).not.toContain("Goal tracking is active via /goal"); // 承诺不再无凭据发出
|
|
143
168
|
});
|
|
144
169
|
});
|
|
145
170
|
|
|
146
171
|
describe("direct isolation", () => {
|
|
147
|
-
it("
|
|
172
|
+
it("execute mode success: returns outcome synchronously, goal steer, no notify", () => {
|
|
148
173
|
attachGoalInit(() => true);
|
|
149
174
|
|
|
150
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
175
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
151
176
|
|
|
152
177
|
expect(outcome).toEqual({ started: true });
|
|
153
|
-
expect(pi.
|
|
178
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
154
179
|
expect(ctx.compact).not.toHaveBeenCalled();
|
|
155
180
|
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
156
181
|
});
|
|
157
182
|
|
|
158
|
-
it("
|
|
159
|
-
|
|
183
|
+
it("skill mode (direct): returns undefined, steer with skill entry path (steer 文案含 skillEntryPath,D10)", () => {
|
|
184
|
+
attachGoalInit(() => true);
|
|
185
|
+
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
160
186
|
|
|
161
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
187
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow", skillEntryPath);
|
|
162
188
|
|
|
163
189
|
expect(outcome).toBeUndefined();
|
|
164
|
-
|
|
165
|
-
expect(
|
|
166
|
-
expect(
|
|
190
|
+
const steer = lastSteer(pi);
|
|
191
|
+
expect(steer).toContain("Execution mode: skill:dev-flow");
|
|
192
|
+
expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md");
|
|
193
|
+
expect(steer).toContain("follow its workflow");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("skill mode (散 .md 形态): steer carries the file path itself, not a dangling joined SKILL.md", () => {
|
|
197
|
+
attachGoalInit(() => true);
|
|
198
|
+
const looseEntryPath = "/tmp/fixtures/skills/loose-tool.md";
|
|
199
|
+
|
|
200
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:loose-tool", looseEntryPath);
|
|
201
|
+
|
|
202
|
+
const steer = lastSteer(pi);
|
|
203
|
+
expect(steer).toContain("/tmp/fixtures/skills/loose-tool.md"); // 文件路径本身
|
|
204
|
+
expect(steer).not.toContain("loose-tool.md/SKILL.md"); // 不再拼接 SKILL.md(悬空指引根修)
|
|
205
|
+
expect(steer).not.toContain("/tmp/fixtures/skills/SKILL.md"); // 旧缺陷形态:散 .md 的 dirname + SKILL.md
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("skill mode without skillEntryPath falls back to name-only guidance (防御形态)", () => {
|
|
209
|
+
attachGoalInit(() => true);
|
|
210
|
+
|
|
211
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow");
|
|
212
|
+
|
|
213
|
+
const steer = lastSteer(pi);
|
|
214
|
+
expect(steer).toContain("load the dev-flow skill");
|
|
215
|
+
expect(steer).not.toContain("SKILL.md");
|
|
167
216
|
});
|
|
168
217
|
|
|
169
218
|
it("unknown isolation value falls through to direct delivery instead of silently dropping the choice (D1 防御形态)", () => {
|
|
170
219
|
attachGoalInit(() => true);
|
|
171
220
|
|
|
172
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "
|
|
221
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "execute");
|
|
173
222
|
|
|
174
|
-
expect(pi.
|
|
223
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
175
224
|
expect(ctx.compact).not.toHaveBeenCalled();
|
|
176
225
|
expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("/tree"), "info");
|
|
177
226
|
});
|
|
178
227
|
});
|
|
179
228
|
|
|
180
229
|
// goal 桥五个失败出口 → GoalBridgeOutcome 五值(D2)
|
|
181
|
-
describe("tryGoalInit failure exits (
|
|
230
|
+
describe("tryGoalInit failure exits (execute mode, direct isolation)", () => {
|
|
182
231
|
it("goal-unavailable: bridge not mounted → degraded steer + warning notify", () => {
|
|
183
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
232
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
184
233
|
|
|
185
234
|
expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
|
|
186
235
|
const steer = lastSteer(pi);
|
|
187
|
-
expect(steer).toContain("Goal
|
|
188
|
-
expect(steer).not.toContain("
|
|
236
|
+
expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
|
|
237
|
+
expect(steer).not.toContain("Goal tracking is active via /goal");
|
|
189
238
|
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
|
|
190
239
|
});
|
|
191
240
|
|
|
@@ -193,7 +242,7 @@ describe("handlePlanComplete", () => {
|
|
|
193
242
|
attachGoalInit(() => true);
|
|
194
243
|
fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
|
|
195
244
|
|
|
196
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
245
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
197
246
|
|
|
198
247
|
expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
|
|
199
248
|
expect(lastSteer(pi)).toContain("Check that the plan file exists");
|
|
@@ -204,7 +253,7 @@ describe("handlePlanComplete", () => {
|
|
|
204
253
|
attachGoalInit(() => true);
|
|
205
254
|
setupFsMock("# Plan\n\nProse without any numbered list.");
|
|
206
255
|
|
|
207
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
256
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
208
257
|
|
|
209
258
|
expect(outcome).toEqual({ started: false, reason: "no-steps" });
|
|
210
259
|
expect(lastSteer(pi)).toContain("Implementation Steps");
|
|
@@ -214,7 +263,7 @@ describe("handlePlanComplete", () => {
|
|
|
214
263
|
it("init-refused: goalInit returns false (active goal exists)", () => {
|
|
215
264
|
attachGoalInit(() => false);
|
|
216
265
|
|
|
217
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
266
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
218
267
|
|
|
219
268
|
expect(outcome).toEqual({ started: false, reason: "init-refused" });
|
|
220
269
|
expect(lastSteer(pi)).toContain("/goal clear");
|
|
@@ -224,11 +273,11 @@ describe("handlePlanComplete", () => {
|
|
|
224
273
|
it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
|
|
225
274
|
attachGoalInit(() => { throw new Error("goalInit exploded"); });
|
|
226
275
|
|
|
227
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "
|
|
276
|
+
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
228
277
|
|
|
229
278
|
expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
|
|
230
|
-
expect(lastSteer(pi)).toContain("Goal
|
|
231
|
-
expect(lastSteer(pi)).not.toContain("
|
|
279
|
+
expect(lastSteer(pi)).toContain("Goal tracking was not started (internal-error)");
|
|
280
|
+
expect(lastSteer(pi)).not.toContain("Goal tracking is active via /goal");
|
|
232
281
|
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
|
|
233
282
|
});
|
|
234
283
|
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock typebox before importing tool(形态照 tool.test.ts;Type.Array 需在列——enter 的 skills 参数)
|
|
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
|
+
// Mock node:fs(plan 目录 mkdirSync 不触真实盘——fs-guard 只白名单 tmpdir;command.test.ts 同形态)
|
|
19
|
+
vi.mock("node:fs", () => ({
|
|
20
|
+
mkdirSync: vi.fn(),
|
|
21
|
+
readdirSync: vi.fn(() => []),
|
|
22
|
+
statSync: vi.fn(),
|
|
23
|
+
existsSync: vi.fn(() => false),
|
|
24
|
+
readFileSync: vi.fn(() => ""),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Mock templates(enter 的 buildPlanModePrompt 走 listTemplates;单测不扫真实模板目录)
|
|
28
|
+
vi.mock("../templates.js", () => ({
|
|
29
|
+
listTemplates: vi.fn(() => []),
|
|
30
|
+
loadTemplate: vi.fn(() => null),
|
|
31
|
+
formatAvailablePlans: vi.fn(() => ""),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("../compact.js", () => ({
|
|
35
|
+
handlePlanComplete: vi.fn(),
|
|
36
|
+
detectGoalCapability: vi.fn(() => false),
|
|
37
|
+
GOAL_FAILURE_RECOVERY: {},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("../exec-skills.js", () => ({
|
|
41
|
+
detectExecSkills: vi.fn(() => []),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("../widget.js", () => ({
|
|
45
|
+
updatePlanWidget: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import * as fs from "node:fs";
|
|
49
|
+
|
|
50
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
51
|
+
|
|
52
|
+
import { registerPlanTool } from "../tool.js";
|
|
53
|
+
import { type PlanState, DEFAULT_PLAN_STATE, PLAN_MODE_TOOLS } from "../state.js";
|
|
54
|
+
|
|
55
|
+
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
56
|
+
|
|
57
|
+
function setup(skillCommands: Array<{ name: string; path: string }> = []) {
|
|
58
|
+
const sessions = new Map();
|
|
59
|
+
const controllers = new Map<string, AbortController>();
|
|
60
|
+
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<{
|
|
61
|
+
content: Array<{ type: string; text: string }>;
|
|
62
|
+
details: { action: string; requirement?: string; skills?: string[] };
|
|
63
|
+
}>;
|
|
64
|
+
const pi = {
|
|
65
|
+
registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
|
|
66
|
+
appendEntry: vi.fn(),
|
|
67
|
+
setActiveTools: vi.fn(),
|
|
68
|
+
sendMessage: vi.fn(),
|
|
69
|
+
getCommands: vi.fn(() => skillCommands.map((c) => ({ name: c.name, source: "skill", sourceInfo: { path: c.path } }))),
|
|
70
|
+
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
71
|
+
} as unknown as ExtensionAPI;
|
|
72
|
+
registerPlanTool(pi, sessions, controllers);
|
|
73
|
+
|
|
74
|
+
const ctx = {
|
|
75
|
+
sessionId: "test-session",
|
|
76
|
+
cwd: "/tmp/test-project",
|
|
77
|
+
hasUI: true,
|
|
78
|
+
mode: "tui" as const,
|
|
79
|
+
isProjectTrusted: () => true,
|
|
80
|
+
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
81
|
+
ui: { select: vi.fn(), notify: vi.fn() },
|
|
82
|
+
} as unknown as ExtensionContext;
|
|
83
|
+
|
|
84
|
+
const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
|
|
85
|
+
executeFn!("tc0", params, signal, undefined, ctx);
|
|
86
|
+
return { pi, sessions, ctx, exec };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1)", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.clearAllMocks();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("idle 进入:收工具到白名单 + 持久化 isActive=true + tool result 携带只读纪律提示词", async () => {
|
|
95
|
+
const { exec, pi, sessions } = setup();
|
|
96
|
+
const res = await exec({ action: "enter", requirement: "重构 auth 模块" });
|
|
97
|
+
|
|
98
|
+
// 工具收拢到 plan 白名单
|
|
99
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(PLAN_MODE_TOOLS);
|
|
100
|
+
// 状态持久化 isActive=true(投影链驱动 GUI 显形的锚点)
|
|
101
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: true }));
|
|
102
|
+
// plan 目录创建(.tmp/plans/<slug>,slug 由 requirement 派生)
|
|
103
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringMatching(/\.tmp[/\\]plans[/\\]/),
|
|
105
|
+
expect.objectContaining({ recursive: true }),
|
|
106
|
+
);
|
|
107
|
+
// 提示词经 tool result 直返(不经对话流消息注入——对本次调用的直接响应)
|
|
108
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
109
|
+
expect(res.details.action).toBe("enter");
|
|
110
|
+
expect(res.content[0].text).toContain("[PLAN MODE] Entered plan mode");
|
|
111
|
+
expect(res.content[0].text).toContain("READ-ONLY");
|
|
112
|
+
// 状态对象已是激活态
|
|
113
|
+
const state = sessions.get("test-session") as { isActive?: boolean; requirement?: string };
|
|
114
|
+
expect(state.isActive).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("已在 plan 模式:幂等返回,不重复收工具 / 不重复注入", async () => {
|
|
118
|
+
const { exec, pi, sessions } = setup();
|
|
119
|
+
await exec({ action: "enter", requirement: "first" });
|
|
120
|
+
vi.clearAllMocks();
|
|
121
|
+
// state 缓存已在(sessions map 同一对象 isActive=true)
|
|
122
|
+
expect((sessions.get("test-session") as { isActive?: boolean }).isActive).toBe(true);
|
|
123
|
+
|
|
124
|
+
const res = await exec({ action: "enter", requirement: "second" });
|
|
125
|
+
expect(res.content[0].text).toContain("Already in plan mode");
|
|
126
|
+
// 幂等:不再 setActiveTools / 不再 appendEntry
|
|
127
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
128
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("携带有效 skills:解析挂载,state.skills 记录技能名", async () => {
|
|
132
|
+
const { exec, sessions } = setup([{ name: "skill:tech-design", path: "/skills/tech-design/SKILL.md" }]);
|
|
133
|
+
const res = await exec({ action: "enter", requirement: "r", skills: ["tech-design"] });
|
|
134
|
+
expect(res.details.action).toBe("enter");
|
|
135
|
+
expect(res.details.skills).toEqual(["tech-design"]);
|
|
136
|
+
const state = sessions.get("test-session") as { skills?: string[] };
|
|
137
|
+
expect(state.skills).toEqual(["tech-design"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("未知技能名:throw 带可用清单自愈(同 select-template 错名先例)", async () => {
|
|
141
|
+
const { exec, pi } = setup([{ name: "skill:tech-design", path: "/x/SKILL.md" }]);
|
|
142
|
+
await expect(exec({ action: "enter", requirement: "r", skills: ["nope"] })).rejects.toThrow(
|
|
143
|
+
/Unknown skill\(s\): nope\. Available skills: .*tech-design/,
|
|
144
|
+
);
|
|
145
|
+
// 校验失败不进入:未收工具、未持久化
|
|
146
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
147
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("省略 requirement:提示词回落 (from conversation context),不阻断进入", async () => {
|
|
151
|
+
const { exec } = setup();
|
|
152
|
+
const res = await exec({ action: "enter" });
|
|
153
|
+
expect(res.details.action).toBe("enter");
|
|
154
|
+
expect(res.content[0].text).toContain("(from conversation context)");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("新轮次进入:上轮残留的 reviewStateSource 随进入失效(跨 plan run 残留防护,§3.4 清除点)", async () => {
|
|
158
|
+
const { exec, pi, sessions } = setup();
|
|
159
|
+
// 模拟上轮残留:崩溃/bad-response 等绕过 resetPlanState 的路径留下的来源标记
|
|
160
|
+
sessions.set("test-session", { ...DEFAULT_PLAN_STATE, isActive: false, reviewStateSource: "explain" });
|
|
161
|
+
|
|
162
|
+
const res = await exec({ action: "enter", requirement: "new round" });
|
|
163
|
+
|
|
164
|
+
expect(res.details.action).toBe("enter");
|
|
165
|
+
// activatePlanMode 新轮次重置组清来源标记(与 resetPlanState 对齐)
|
|
166
|
+
const state = sessions.get("test-session") as { reviewStateSource?: string };
|
|
167
|
+
expect(state.reviewStateSource).toBeUndefined();
|
|
168
|
+
// 新轮 entry 无来源标记(undefined 序列化自然消失——D4)
|
|
169
|
+
const lastEntry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
170
|
+
expect(lastEntry.reviewStateSource).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|