@zhushanwen/pi-plan 0.5.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/package.json +6 -4
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +55 -32
- package/src/__tests__/enter.test.ts +58 -20
- package/src/__tests__/exec-options-alignment.test.ts +56 -0
- package/src/__tests__/execution-notice.test.ts +197 -0
- package/src/__tests__/{compact.test.ts → extract-plan-steps.test.ts} +24 -15
- package/src/__tests__/fsm-consumption.test.ts +388 -0
- package/src/__tests__/index.test.ts +175 -75
- package/src/__tests__/prompts.test.ts +66 -0
- package/src/__tests__/review-contract-boundary.test.ts +123 -0
- package/src/__tests__/review.test.ts +264 -97
- package/src/__tests__/skills.test.ts +14 -5
- package/src/__tests__/state.test.ts +254 -52
- package/src/__tests__/{compact-criteria-array.test.ts → success-criteria-array.test.ts} +29 -70
- package/src/__tests__/templates.test.ts +8 -25
- package/src/__tests__/tool.test.ts +269 -121
- package/src/command.ts +26 -29
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +29 -117
- package/src/index.ts +100 -88
- package/src/prompts.ts +42 -12
- package/src/state.ts +290 -87
- package/src/templates.ts +0 -15
- package/src/tool.ts +663 -255
- 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
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect,it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { extractPlanSteps } from "../
|
|
3
|
+
import { extractPlanSteps } from "../execution-notice.js";
|
|
4
4
|
|
|
5
5
|
describe("extractPlanSteps", () => {
|
|
6
6
|
it("extracts numbered steps from 实现步骤 section", () => {
|
|
@@ -8,6 +8,7 @@ describe("extractPlanSteps", () => {
|
|
|
8
8
|
|
|
9
9
|
## 背景
|
|
10
10
|
Some context
|
|
11
|
+
1. Background numbered line that must not leak
|
|
11
12
|
|
|
12
13
|
## 实现步骤
|
|
13
14
|
1. Create the user model
|
|
@@ -27,7 +28,10 @@ Run tests`;
|
|
|
27
28
|
it("extracts numbered steps from 实施步骤 section", () => {
|
|
28
29
|
const plan = `## 实施步骤
|
|
29
30
|
1. Step one
|
|
30
|
-
2. Step two
|
|
31
|
+
2. Step two
|
|
32
|
+
|
|
33
|
+
## 验证
|
|
34
|
+
1. Verification line that must not leak`;
|
|
31
35
|
const steps = extractPlanSteps(plan);
|
|
32
36
|
expect(steps).toEqual(["Step one", "Step two"]);
|
|
33
37
|
});
|
|
@@ -35,7 +39,10 @@ Run tests`;
|
|
|
35
39
|
it("extracts steps from English Steps section", () => {
|
|
36
40
|
const plan = `## Steps
|
|
37
41
|
1. First step
|
|
38
|
-
2. Second step
|
|
42
|
+
2. Second step
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
1. Notes line that must not leak`;
|
|
39
46
|
const steps = extractPlanSteps(plan);
|
|
40
47
|
expect(steps).toEqual(["First step", "Second step"]);
|
|
41
48
|
});
|
|
@@ -43,7 +50,10 @@ Run tests`;
|
|
|
43
50
|
it("extracts steps from Implementation section", () => {
|
|
44
51
|
const plan = `## Implementation Steps
|
|
45
52
|
1. Create file
|
|
46
|
-
2. Add exports
|
|
53
|
+
2. Add exports
|
|
54
|
+
|
|
55
|
+
## Validation
|
|
56
|
+
1. Validation line that must not leak`;
|
|
47
57
|
const steps = extractPlanSteps(plan);
|
|
48
58
|
expect(steps).toEqual(["Create file", "Add exports"]);
|
|
49
59
|
});
|
|
@@ -72,16 +82,6 @@ Run tests`;
|
|
|
72
82
|
]);
|
|
73
83
|
});
|
|
74
84
|
|
|
75
|
-
it("returns empty array when no steps section and no numbered items", () => {
|
|
76
|
-
const plan = `## 背景
|
|
77
|
-
Some context
|
|
78
|
-
|
|
79
|
-
## 方案
|
|
80
|
-
Option A is preferred`;
|
|
81
|
-
const steps = extractPlanSteps(plan);
|
|
82
|
-
expect(steps).toEqual([]);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
85
|
it("fallback: collects numbered items when no steps section header", () => {
|
|
86
86
|
const plan = `# Plan
|
|
87
87
|
1. First thing
|
|
@@ -95,8 +95,17 @@ Option A is preferred`;
|
|
|
95
95
|
const plan = `## 实现步骤
|
|
96
96
|
1. Valid step
|
|
97
97
|
|
|
98
|
-
2. Another valid step
|
|
98
|
+
2. Another valid step
|
|
99
|
+
3.
|
|
100
|
+
4. ${" "}`;
|
|
99
101
|
const steps = extractPlanSteps(plan);
|
|
100
102
|
expect(steps).toEqual(["Valid step", "Another valid step"]);
|
|
101
103
|
});
|
|
104
|
+
|
|
105
|
+
it("fallback cap: collects at most the first 10 numbered items when no steps section header", () => {
|
|
106
|
+
const plan = Array.from({ length: 12 }, (_, i) => `${i + 1}. step ${i + 1}`).join("\n");
|
|
107
|
+
const steps = extractPlanSteps(plan);
|
|
108
|
+
expect(steps).toHaveLength(10);
|
|
109
|
+
expect(steps[9]).toBe("step 10");
|
|
110
|
+
});
|
|
102
111
|
});
|