@zhushanwen/pi-plan 0.4.9 → 0.5.1
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
// Mock typebox before importing tool
|
|
4
4
|
vi.mock("typebox", () => ({
|
|
@@ -6,6 +6,7 @@ vi.mock("typebox", () => ({
|
|
|
6
6
|
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
7
7
|
String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
|
|
8
8
|
Optional: (schema: unknown) => schema,
|
|
9
|
+
Array: (item: unknown, opts?: Record<string, unknown>) => ({ type: "array", items: item, ...opts }),
|
|
9
10
|
},
|
|
10
11
|
Static: class {},
|
|
11
12
|
}));
|
|
@@ -25,12 +26,29 @@ vi.mock("../compact.js", async () => {
|
|
|
25
26
|
};
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
// Mock exec-skills(D10 检测):单测里不扫真实目录(~/.agents/skills 等本机路径),
|
|
30
|
+
// skill 选项用例显式注入 fixture;专用 exec-skills.test.ts 覆盖真实扫描
|
|
31
|
+
vi.mock("../exec-skills.js", () => ({
|
|
32
|
+
detectExecSkills: vi.fn(() => []),
|
|
33
|
+
}));
|
|
34
|
+
|
|
28
35
|
// Mock widget (imported by abort)
|
|
29
36
|
vi.mock("../widget.js", () => ({
|
|
30
37
|
updatePlanWidget: vi.fn(),
|
|
31
38
|
}));
|
|
32
39
|
|
|
40
|
+
import * as fs from "node:fs";
|
|
41
|
+
import { tmpdir } from "node:os";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
|
|
44
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
45
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
46
|
+
import { UI_FORM_MARKER } from "@zhushanwen/extension-protocol";
|
|
47
|
+
|
|
33
48
|
import { detectGoalCapability, handlePlanComplete } from "../compact.js";
|
|
49
|
+
import { detectExecSkills } from "../exec-skills.js";
|
|
50
|
+
import { DEFAULT_PLAN_STATE } from "../state.js";
|
|
51
|
+
import { loadTemplate } from "../templates.js";
|
|
34
52
|
import { PLAN_ACTIONS, registerPlanTool, validateAction } from "../tool.js";
|
|
35
53
|
import { updatePlanWidget } from "../widget.js";
|
|
36
54
|
|
|
@@ -39,6 +57,7 @@ const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "
|
|
|
39
57
|
|
|
40
58
|
function setup() {
|
|
41
59
|
const sessions = new Map();
|
|
60
|
+
const controllers = new Map<string, AbortController>();
|
|
42
61
|
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
|
|
43
62
|
const pi = {
|
|
44
63
|
registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
|
|
@@ -46,17 +65,22 @@ function setup() {
|
|
|
46
65
|
setActiveTools: vi.fn(),
|
|
47
66
|
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
48
67
|
} as unknown as Parameters<typeof registerPlanTool>[0];
|
|
49
|
-
registerPlanTool(pi, sessions);
|
|
68
|
+
registerPlanTool(pi, sessions, controllers);
|
|
50
69
|
|
|
51
70
|
const ctx = {
|
|
52
71
|
sessionId: "test-session",
|
|
53
72
|
cwd: "/tmp/test-project",
|
|
73
|
+
// D4 三路分流的 ctx 形态字段:默认 TUI(原生 select 路径),GUI 用例覆写 mode
|
|
74
|
+
hasUI: true,
|
|
75
|
+
mode: "tui" as const,
|
|
76
|
+
isProjectTrusted: () => true,
|
|
54
77
|
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
55
78
|
ui: { select: vi.fn(), notify: vi.fn() },
|
|
56
79
|
};
|
|
57
80
|
|
|
58
|
-
const exec = (params: Record<string, unknown
|
|
59
|
-
|
|
81
|
+
const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
|
|
82
|
+
executeFn!("tc0", params, signal, undefined, ctx);
|
|
83
|
+
return { pi, sessions, controllers, ctx, exec };
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
describe("registerPlanTool", () => {
|
|
@@ -66,28 +90,14 @@ describe("registerPlanTool", () => {
|
|
|
66
90
|
expect((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0].name).toBe("plan");
|
|
67
91
|
});
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns exactly the 5 builtin templates with no source field (D3 / V4)", async () => {
|
|
80
|
-
const { exec } = setup();
|
|
81
|
-
const res = await exec({ action: "list-template" });
|
|
82
|
-
const templates = res.details.templates as Array<{ name: string; source?: string; path: string }>;
|
|
83
|
-
expect(templates.map((t) => t.name).sort()).toEqual(
|
|
84
|
-
["feature-plan", "bugfix-plan", "refactor-plan", "research-plan", "implementation-plan"].sort(),
|
|
85
|
-
);
|
|
86
|
-
for (const t of templates) {
|
|
87
|
-
expect(t.source).toBeUndefined();
|
|
88
|
-
expect(t).toEqual({ name: t.name, path: t.path });
|
|
89
|
-
}
|
|
90
|
-
});
|
|
93
|
+
it("tool description and promptSnippet no longer mention list-template (D1 删链不留兼容通道)", () => {
|
|
94
|
+
const { pi } = setup();
|
|
95
|
+
const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
|
|
96
|
+
description: string;
|
|
97
|
+
promptSnippet: string;
|
|
98
|
+
};
|
|
99
|
+
expect(tool.description).not.toContain("list-template");
|
|
100
|
+
expect(tool.promptSnippet).not.toContain("list-template");
|
|
91
101
|
});
|
|
92
102
|
|
|
93
103
|
// --- select-template ---
|
|
@@ -97,19 +107,18 @@ describe("registerPlanTool", () => {
|
|
|
97
107
|
await expect(exec({ action: "select-template" })).rejects.toThrow("templateName is required");
|
|
98
108
|
});
|
|
99
109
|
|
|
100
|
-
it("throws when template does not exist", async () => {
|
|
110
|
+
it("throws when template does not exist — error carries the available name list (D7 自愈闭环)", async () => {
|
|
101
111
|
const { exec } = setup();
|
|
102
|
-
|
|
112
|
+
// 内置 5 名恒在清单(外部源只增名不删内置名),断言清单形态而非全集
|
|
113
|
+
await expect(exec({ action: "select-template", templateName: "nonexistent" })).rejects.toThrow(
|
|
114
|
+
/Template not found: nonexistent\. Available: .*feature-plan/,
|
|
115
|
+
);
|
|
103
116
|
});
|
|
104
117
|
|
|
105
118
|
it("sets templateName and persists (D6:无 phase 写入)", async () => {
|
|
106
119
|
const { exec, pi, sessions } = setup();
|
|
107
|
-
//
|
|
108
|
-
const
|
|
109
|
-
const templates = listRes.details.templates as { name: string }[];
|
|
110
|
-
if (templates.length === 0) return; // no builtin templates available
|
|
111
|
-
|
|
112
|
-
const name = templates[0].name;
|
|
120
|
+
// 内置模板名直接断言(templates.test.ts 覆盖清单内容,这里只测选中与持久化)
|
|
121
|
+
const name = "feature-plan";
|
|
113
122
|
const res = await exec({ action: "select-template", templateName: name });
|
|
114
123
|
expect(res.details.templateName).toBe(name);
|
|
115
124
|
expect(res.details.action).toBe("select-template");
|
|
@@ -117,26 +126,144 @@ describe("registerPlanTool", () => {
|
|
|
117
126
|
const state = sessions.get("test-session") as { templateName?: string; isActive?: boolean };
|
|
118
127
|
expect(state?.templateName).toBe(name);
|
|
119
128
|
});
|
|
129
|
+
|
|
130
|
+
it("content carries the winner file's full text and details has no content field (D7 全文通道唯一化)", async () => {
|
|
131
|
+
const { exec } = setup();
|
|
132
|
+
const res = await exec({ action: "select-template", templateName: "feature-plan" });
|
|
133
|
+
const text = res.content[0].text;
|
|
134
|
+
// 全文到达模型可见通道(对照 loadTemplate 的胜者内容,运行机用户级遮蔽时同样成立)
|
|
135
|
+
const winnerContent = loadTemplate("feature-plan");
|
|
136
|
+
expect(winnerContent).not.toBeNull();
|
|
137
|
+
expect(text).toContain(`<template>\n${winnerContent}\n</template>`);
|
|
138
|
+
// details 收窄:仅 action + templateName,全文不再双份持久化
|
|
139
|
+
expect(res.details).toEqual({ action: "select-template", templateName: "feature-plan" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("resolves from the merged view: project-level .agents/plans template is selectable (D7 合并视图)", async () => {
|
|
143
|
+
// tmp 自建项目根 + 项目级模板(fs-guard 红线:写删目标 mkdtempSync 自建自删)
|
|
144
|
+
const projectRoot = fs.mkdtempSync(join(tmpdir(), "plan-tool-v2b-"));
|
|
145
|
+
const projectTemplateDir = join(projectRoot, ".agents", "plans");
|
|
146
|
+
fs.mkdirSync(projectTemplateDir, { recursive: true });
|
|
147
|
+
fs.writeFileSync(join(projectTemplateDir, "v2b-project-only.md"), "# project-owned skeleton\n");
|
|
148
|
+
try {
|
|
149
|
+
const { exec, ctx } = setup();
|
|
150
|
+
ctx.cwd = projectRoot;
|
|
151
|
+
const res = await exec({ action: "select-template", templateName: "v2b-project-only" });
|
|
152
|
+
expect(res.content[0].text).toContain("# project-owned skeleton");
|
|
153
|
+
expect(res.details).toEqual({ action: "select-template", templateName: "v2b-project-only" });
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(projectRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("--template 直传防御:报错指向已注入全文,不带三源清单 (D7)", async () => {
|
|
160
|
+
const { exec, pi, sessions } = setup();
|
|
161
|
+
sessions.set("test-session", {
|
|
162
|
+
...DEFAULT_PLAN_STATE,
|
|
163
|
+
isActive: true,
|
|
164
|
+
planFilePath: "/tmp/test-project/.tmp/plans/retro/plan.md",
|
|
165
|
+
requirement: "retro",
|
|
166
|
+
templateName: "retro-template",
|
|
167
|
+
templateProvidedPath: "/tmp/test-project/docs/retro-template.md",
|
|
168
|
+
});
|
|
169
|
+
const error = await exec({ action: "select-template", templateName: "retro-template" }).then(
|
|
170
|
+
() => new Error("expected rejection"),
|
|
171
|
+
(e: Error) => e,
|
|
172
|
+
);
|
|
173
|
+
expect(error.message).toContain("template was provided via --template");
|
|
174
|
+
// 不带三源清单:直传文件不在清单里,清单会误导模型改选内置模板
|
|
175
|
+
expect(error.message).not.toContain("Available:");
|
|
176
|
+
// 防御分支不产生任何状态写入(throw 先于 persistPlanState)
|
|
177
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
120
179
|
});
|
|
121
180
|
|
|
122
|
-
// --- removed
|
|
123
|
-
describe("
|
|
181
|
+
// --- removed actions (D1 / D3) ---
|
|
182
|
+
describe("removed action rejections", () => {
|
|
183
|
+
it("rejects plan(action='list-template') as an unknown action with the 6-action list (D1)", async () => {
|
|
184
|
+
const { exec } = setup();
|
|
185
|
+
await expect(exec({ action: "list-template" })).rejects.toThrow(
|
|
186
|
+
"Unknown plan action: list-template. Valid actions: enter, select-template, complete, abort, register-doc, submit-review",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
124
190
|
it("rejects plan(action='create-template') as an unknown action (D3 / V4)", async () => {
|
|
125
191
|
const { exec } = setup();
|
|
126
192
|
await expect(
|
|
127
193
|
exec({ action: "create-template", templateName: "my-plan", templateContent: "# hello" }),
|
|
128
194
|
).rejects.toThrow(
|
|
129
|
-
"Unknown plan action: create-template. Valid actions:
|
|
195
|
+
"Unknown plan action: create-template. Valid actions: enter, select-template, complete, abort, register-doc, submit-review",
|
|
130
196
|
);
|
|
131
197
|
});
|
|
132
198
|
});
|
|
133
199
|
|
|
200
|
+
// --- renderResult 兜底(MF-1-7 旧持久化 details 形态)---
|
|
201
|
+
describe("renderResult fallback for legacy details forms", () => {
|
|
202
|
+
/** mock theme:renderPlanResult 只消费 fg,直通便于断言纯文本 */
|
|
203
|
+
const renderTheme = {
|
|
204
|
+
fg: (_token: string, text: string) => text,
|
|
205
|
+
} as unknown as Theme;
|
|
206
|
+
|
|
207
|
+
/** 注册时捕获的 renderResult(真实签名 (result, options, theme)) */
|
|
208
|
+
function renderFn(pi: { registerTool: unknown }): (
|
|
209
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
210
|
+
options: unknown,
|
|
211
|
+
theme: Theme,
|
|
212
|
+
) => { render: (width: number) => string[] } {
|
|
213
|
+
const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
|
|
214
|
+
renderResult: (
|
|
215
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
216
|
+
options: unknown,
|
|
217
|
+
theme: Theme,
|
|
218
|
+
) => { render: (width: number) => string[] };
|
|
219
|
+
};
|
|
220
|
+
return tool.renderResult;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it("旧 action=list-template details(已删 action 的历史 entry)渲染不抛、回落 content 文本", () => {
|
|
224
|
+
const { pi } = setup();
|
|
225
|
+
const render = renderFn(pi);
|
|
226
|
+
// git 2ab33c46c 旧版形态:details.action="list-template" 不在现版 PlanDetails 联合内。
|
|
227
|
+
// 修复前 switch 落空返回 undefined → pi TUI 渲染循环对 undefined 调 .render() TypeError
|
|
228
|
+
const result = {
|
|
229
|
+
content: [{ type: "text", text: "Available templates: feature-plan, bugfix" }],
|
|
230
|
+
details: { action: "list-template", templates: ["feature-plan", "bugfix"] },
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const component = render(result, { expanded: false }, renderTheme);
|
|
234
|
+
|
|
235
|
+
expect(component).toBeInstanceOf(Text);
|
|
236
|
+
expect(component.render(400).join("\n")).toContain("Available templates: feature-plan, bugfix");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("任意未知 action 形态同样回落 content 文本(防御未来再删 action)", () => {
|
|
240
|
+
const { pi } = setup();
|
|
241
|
+
const render = renderFn(pi);
|
|
242
|
+
const result = {
|
|
243
|
+
content: [{ type: "text", text: "legacy entry" }],
|
|
244
|
+
details: { action: "create-template" },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const component = render(result, { expanded: false }, renderTheme);
|
|
248
|
+
|
|
249
|
+
expect(component).toBeInstanceOf(Text);
|
|
250
|
+
expect(component.render(400).join("\n")).toContain("legacy entry");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
134
254
|
// --- complete ---
|
|
135
255
|
describe("complete", () => {
|
|
136
256
|
beforeEach(() => {
|
|
137
|
-
//
|
|
257
|
+
// detectGoalCapability 不在 complete 链上消费(execute 档 tryGoalInit 内部自理
|
|
258
|
+
// goal-unavailable 降级);goal outcome 用例经 handlePlanComplete mock 构造结果
|
|
138
259
|
(detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
139
260
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
|
|
261
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
|
|
262
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
vi.unstubAllEnvs();
|
|
140
267
|
});
|
|
141
268
|
|
|
142
269
|
/** 注册时捕获的工具定义(schema 检查用)。 */
|
|
@@ -153,105 +280,289 @@ describe("registerPlanTool", () => {
|
|
|
153
280
|
expect(parameters.properties.isolation.enum).not.toContain("tree");
|
|
154
281
|
});
|
|
155
282
|
|
|
156
|
-
it("does not advance when user
|
|
283
|
+
it("does not advance when user picks Not now", async () => {
|
|
157
284
|
const { exec, ctx, pi } = setup();
|
|
158
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
285
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Not now");
|
|
159
286
|
const res = await exec({ action: "complete" });
|
|
160
287
|
expect(res.details.action).toBe("complete-cancelled");
|
|
288
|
+
expect(res.details.reason).toBe("Not now");
|
|
289
|
+
// 用户主动暂不执行:留在 plan mode 是用户意图(区别于 abort 联动取消的文案)
|
|
290
|
+
expect(res.content[0].text).toContain("Staying in plan mode");
|
|
291
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("turn abort during the pending execution-method select dissolves the dialog → complete-cancelled (MF-1-8)", async () => {
|
|
295
|
+
const { exec, ctx, pi } = setup();
|
|
296
|
+
// select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):
|
|
297
|
+
// signal 已 abort 首行短路 resolve undefined;挂起中 abort → resolve undefined
|
|
298
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
|
|
299
|
+
(_title: string, _labels: string[], opts: { signal?: AbortSignal }) =>
|
|
300
|
+
new Promise<string | undefined>((resolve) => {
|
|
301
|
+
if (opts.signal?.aborted) {
|
|
302
|
+
resolve(undefined);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
opts.signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
const turn = new AbortController();
|
|
309
|
+
const pending = exec({ action: "complete" }, turn.signal);
|
|
310
|
+
turn.abort();
|
|
311
|
+
const res = await pending;
|
|
312
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
313
|
+
expect(res.details.reason).toBe("cancelled");
|
|
161
314
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
162
315
|
});
|
|
163
316
|
|
|
164
317
|
it("resets state and restores tools on execute", async () => {
|
|
165
318
|
const { exec, ctx, pi } = setup();
|
|
166
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
319
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
167
320
|
const res = await exec({ action: "complete" });
|
|
168
321
|
expect(res.details.action).toBe("complete");
|
|
169
|
-
expect(res.details.execMode).toBe("
|
|
322
|
+
expect(res.details.execMode).toBe("execute");
|
|
170
323
|
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
171
324
|
expect(handlePlanComplete).toHaveBeenCalled();
|
|
172
325
|
expect(res.details.planFilePath).toBeDefined();
|
|
173
326
|
});
|
|
174
327
|
|
|
175
|
-
it("dialog options
|
|
328
|
+
it("dialog options = skills (max 2) + Execute + Not now, goal bridge availability irrelevant (选项集重排)", async () => {
|
|
176
329
|
const { exec, ctx } = setup();
|
|
177
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
330
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
178
331
|
await exec({ action: "complete" });
|
|
179
332
|
const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
|
|
180
|
-
expect(options).not.toContain("Goal-driven execution (/goal)");
|
|
181
333
|
expect(options).toEqual([
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
"Modify the plan first",
|
|
185
|
-
"Save for later",
|
|
334
|
+
"Execute",
|
|
335
|
+
"Not now",
|
|
186
336
|
]);
|
|
187
337
|
});
|
|
188
338
|
|
|
189
|
-
it("
|
|
339
|
+
it("execute choice maps to execMode execute (goal + auto-parallel 整合档)", async () => {
|
|
340
|
+
const { exec, ctx } = setup();
|
|
341
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
342
|
+
const res = await exec({ action: "complete" });
|
|
343
|
+
expect(res.details.execMode).toBe("execute");
|
|
344
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("headless (!hasUI) defaults to execute without any select (D4 三路分流第 1 路)", async () => {
|
|
348
|
+
const { exec, ctx, pi } = setup();
|
|
349
|
+
ctx.hasUI = false;
|
|
350
|
+
const res = await exec({ action: "complete" });
|
|
351
|
+
expect(res.details.action).toBe("complete");
|
|
352
|
+
expect(res.details.execMode).toBe("execute");
|
|
353
|
+
expect(ctx.ui.select).not.toHaveBeenCalled(); // 不进任何 select(noOp 软门修复)
|
|
354
|
+
expect(detectExecSkills).not.toHaveBeenCalled(); // 选择已预定,跳过 skill 扫描
|
|
355
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
356
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("detected plan-exec skills lead the option set (first + second) and map to skill:<name> with skillEntryPath (D10 重排)", async () => {
|
|
190
360
|
const { exec, ctx } = setup();
|
|
191
|
-
|
|
192
|
-
|
|
361
|
+
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
362
|
+
const secondPath = "/tmp/fixtures/skills/pr-cr-fix/SKILL.md";
|
|
363
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
364
|
+
{ name: "dev-flow", description: "Deliver a plan via dev-flow.", skillEntryPath },
|
|
365
|
+
{ name: "pr-cr-fix", description: "PR lifecycle.", skillEntryPath: secondPath },
|
|
366
|
+
{ name: "third", description: "Beyond the cap.", skillEntryPath: "/tmp/x" },
|
|
367
|
+
]);
|
|
368
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute via skill: dev-flow");
|
|
193
369
|
const res = await exec({ action: "complete" });
|
|
370
|
+
|
|
194
371
|
const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
|
|
372
|
+
// skill 前置(用户裁决:第一/第二排位),第 3 个起截断;Execute/Not now 固定收尾
|
|
195
373
|
expect(options).toEqual([
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"Save for later",
|
|
374
|
+
"Execute via skill: dev-flow",
|
|
375
|
+
"Execute via skill: pr-cr-fix",
|
|
376
|
+
"Execute",
|
|
377
|
+
"Not now",
|
|
201
378
|
]);
|
|
202
|
-
expect(res.details.
|
|
379
|
+
expect(res.details.action).toBe("complete");
|
|
380
|
+
expect(res.details.execMode).toBe("skill:dev-flow");
|
|
381
|
+
// skillEntryPath 数据通路:CompleteChoiceOutcome → handlePlanComplete(steer 文案的路径来源)
|
|
382
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "skill:dev-flow", skillEntryPath);
|
|
203
383
|
});
|
|
204
384
|
|
|
205
|
-
it("
|
|
385
|
+
it("empty detection set leaves no skill options (空集不误伤选项集)", async () => {
|
|
206
386
|
const { exec, ctx } = setup();
|
|
207
|
-
(
|
|
208
|
-
|
|
209
|
-
|
|
387
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
388
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
389
|
+
await exec({ action: "complete" });
|
|
390
|
+
const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
|
|
391
|
+
expect(options.some((label) => label.startsWith("Execute via skill:"))).toBe(false);
|
|
210
392
|
});
|
|
211
393
|
|
|
212
|
-
it("
|
|
394
|
+
it("execute tier carries the goal outcome into result content and details (整合档 D2)", async () => {
|
|
213
395
|
const { exec, ctx } = setup();
|
|
214
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
396
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
215
397
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: false, reason: "no-steps" });
|
|
216
398
|
const res = await exec({ action: "complete", isolation: "direct" });
|
|
217
|
-
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "
|
|
218
|
-
expect(res.content[0].text).toContain("Goal
|
|
399
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "execute", undefined);
|
|
400
|
+
expect(res.content[0].text).toContain("Goal tracking was not started (no-steps)");
|
|
219
401
|
expect(res.content[0].text).toContain("Implementation Steps"); // 恢复动作
|
|
220
402
|
expect(res.details.goalOutcome).toEqual({ started: false, reason: "no-steps" });
|
|
221
403
|
});
|
|
222
404
|
|
|
223
405
|
it("successful goal outcome appends the started line", async () => {
|
|
224
406
|
const { exec, ctx } = setup();
|
|
225
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
407
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
226
408
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: true });
|
|
227
409
|
const res = await exec({ action: "complete", isolation: "direct" });
|
|
228
|
-
expect(res.content[0].text).toContain("Goal
|
|
410
|
+
expect(res.content[0].text).toContain("Goal tracking started via /goal");
|
|
229
411
|
expect(res.details.goalOutcome).toEqual({ started: true });
|
|
230
412
|
});
|
|
231
413
|
|
|
232
414
|
it("compact tier outcome is deferred (undefined): result keeps the plain approved line", async () => {
|
|
233
415
|
const { exec, ctx } = setup();
|
|
234
|
-
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("
|
|
416
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
235
417
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
|
236
418
|
const res = await exec({ action: "complete", isolation: "compact" });
|
|
237
419
|
expect(res.content[0].text).toMatch(/^Plan approved\. File: /);
|
|
238
|
-
expect(res.content[0].text).not.toContain("Goal
|
|
420
|
+
expect(res.content[0].text).not.toContain("Goal tracking");
|
|
239
421
|
expect(res.details.goalOutcome).toBeUndefined();
|
|
240
422
|
expect(res.details.isolation).toBe("compact");
|
|
241
423
|
});
|
|
242
424
|
});
|
|
243
425
|
|
|
426
|
+
// --- complete · GUI 路径(taiji rpc 宿主 → uiFormInteract,D4 第 2 路)---
|
|
427
|
+
describe("complete via taiji form channel", () => {
|
|
428
|
+
beforeEach(() => {
|
|
429
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
430
|
+
(detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
431
|
+
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
|
|
432
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
|
|
433
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
afterEach(() => {
|
|
437
|
+
vi.unstubAllEnvs();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
function setupGui() {
|
|
441
|
+
const harness = setup();
|
|
442
|
+
(harness.ctx as { mode?: string }).mode = "rpc";
|
|
443
|
+
return harness;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** form 帧断言辅助:单 choice 问题的 options label 清单 */
|
|
447
|
+
function formOptionLabels(selectMock: ReturnType<typeof vi.fn>): Array<{ label: string; description?: string }> {
|
|
448
|
+
const payload = JSON.parse(selectMock.mock.calls[0][1][0] as string) as {
|
|
449
|
+
formQuestions: Array<{ options: Array<{ label: string; description?: string }> }>;
|
|
450
|
+
};
|
|
451
|
+
return payload.formQuestions[0].options;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
it("sends a single choice question via UI_FORM_MARKER and maps the execute answer (无 tab 条单视图)", async () => {
|
|
455
|
+
const { exec, ctx } = setupGui();
|
|
456
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>)
|
|
457
|
+
.mockResolvedValue(JSON.stringify({ "Execution method": "Execute" }));
|
|
458
|
+
const res = await exec({ action: "complete" });
|
|
459
|
+
|
|
460
|
+
const title = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
461
|
+
expect(title).toBe(UI_FORM_MARKER);
|
|
462
|
+
const labels = formOptionLabels(ctx.ui.select as ReturnType<typeof vi.fn>).map((o) => o.label);
|
|
463
|
+
expect(labels).toEqual([
|
|
464
|
+
"Execute",
|
|
465
|
+
"Not now",
|
|
466
|
+
]);
|
|
467
|
+
expect(res.details.action).toBe("complete");
|
|
468
|
+
expect(res.details.execMode).toBe("execute");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("skill option carries its description into the form and maps to skill:<name>", async () => {
|
|
472
|
+
const { exec, ctx } = setupGui();
|
|
473
|
+
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
474
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
475
|
+
{ name: "dev-flow", description: "Deliver a plan via dev-flow.", skillEntryPath },
|
|
476
|
+
]);
|
|
477
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>)
|
|
478
|
+
.mockResolvedValue(JSON.stringify({ "Execution method": "Execute via skill: dev-flow" }));
|
|
479
|
+
const res = await exec({ action: "complete" });
|
|
480
|
+
|
|
481
|
+
const options = formOptionLabels(ctx.ui.select as ReturnType<typeof vi.fn>);
|
|
482
|
+
const skillOption = options.find((o) => o.label === "Execute via skill: dev-flow");
|
|
483
|
+
expect(skillOption?.description).toBe("Deliver a plan via dev-flow.");
|
|
484
|
+
expect(res.details.execMode).toBe("skill:dev-flow");
|
|
485
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "skill:dev-flow", skillEntryPath);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("timeout via undefined resolve (signal not aborted) folds to complete-cancelled with exit wording (D4 四态折叠)", async () => {
|
|
489
|
+
// rpc 模式 GUI 用户取消 resolve undefined,与超时不可区分(signal 未 abort 折叠
|
|
490
|
+
// timeout,库层 callMarkerRpc 判别),消费层 cancelled‖timeout 同折后 reason='cancelled'。
|
|
491
|
+
// choice 空 = abort 联动取消语义(P2-3):文案不得声称「Staying in plan mode」
|
|
492
|
+
const { exec, ctx, pi } = setupGui();
|
|
493
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
494
|
+
const res = await exec({ action: "complete" });
|
|
495
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
496
|
+
expect(res.details.reason).toBe("cancelled");
|
|
497
|
+
expect(res.content[0].text).toContain("Plan mode was exited");
|
|
498
|
+
expect(res.content[0].text).not.toContain("Staying in plan mode");
|
|
499
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("cancelled (abort via controllers registry during pending select) folds to complete-cancelled with exit wording (D4 四态折叠)", async () => {
|
|
503
|
+
// 真实通道注入(command.ts handleAbort 同款 `controllers.get(sessionId)?.abort()`):
|
|
504
|
+
// 挂起窗口内 session abort → pi 实装 resolve undefined → 库层以 signal.aborted 判
|
|
505
|
+
// reason='cancelled'(区别于上一例的 timeout 折叠源)
|
|
506
|
+
const { exec, ctx, controllers, pi } = setupGui();
|
|
507
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
508
|
+
controllers.get("test-session")?.abort();
|
|
509
|
+
return undefined;
|
|
510
|
+
});
|
|
511
|
+
const res = await exec({ action: "complete" });
|
|
512
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
513
|
+
expect(res.details.reason).toBe("cancelled");
|
|
514
|
+
expect(res.content[0].text).toContain("Plan mode was exited");
|
|
515
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("channel-error (echo payload) folds to complete-cancelled with channel note, no throw (D4 四态折叠)", async () => {
|
|
519
|
+
const { exec, ctx, pi } = setupGui();
|
|
520
|
+
// echo 检测:宿主不识别 UI_FORM_MARKER 时 band 单选项 = payload 自身,点选即回显
|
|
521
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async (_t: string, options: string[]) => options[0]);
|
|
522
|
+
const res = await exec({ action: "complete" });
|
|
523
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
524
|
+
expect(res.details.reason).toBe("channel-error");
|
|
525
|
+
expect(res.content[0].text).toContain("Interaction channel failed");
|
|
526
|
+
expect(res.content[0].text).toContain("upgrade taiji"); // echo 升级指引透出
|
|
527
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("non-json response folds to complete-cancelled with channel note, no throw (D4 四态折叠)", async () => {
|
|
531
|
+
const { exec, ctx } = setupGui();
|
|
532
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
|
|
533
|
+
const res = await exec({ action: "complete" });
|
|
534
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
535
|
+
expect(res.details.reason).toBe("non-json");
|
|
536
|
+
expect(res.content[0].text).toContain("Interaction channel failed");
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
244
540
|
// --- abort ---
|
|
245
541
|
describe("abort", () => {
|
|
246
542
|
it("resets state and cleans up session", async () => {
|
|
247
543
|
const { exec, pi, sessions } = setup();
|
|
248
|
-
// Pre-populate a session
|
|
249
|
-
sessions.set("test-session", {
|
|
544
|
+
// Pre-populate a session(工具层 abort 走 resetPlanState——命令层 abort 联动的顺序断言在 command.test.ts)
|
|
545
|
+
sessions.set("test-session", {
|
|
546
|
+
...DEFAULT_PLAN_STATE,
|
|
547
|
+
isActive: true,
|
|
548
|
+
planFilePath: "/tmp/plan.md",
|
|
549
|
+
requirement: "test",
|
|
550
|
+
templateName: "t",
|
|
551
|
+
skills: ["tech-design"],
|
|
552
|
+
docs: [{ fileName: "design.md", absPath: "/tmp/design.md", sourceSkill: "tech-design", version: 1 }],
|
|
553
|
+
reviewState: "awaiting",
|
|
554
|
+
});
|
|
250
555
|
const res = await exec({ action: "abort" });
|
|
251
556
|
expect(res.details.action).toBe("abort");
|
|
252
557
|
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
253
558
|
expect(sessions.has("test-session")).toBe(false);
|
|
254
559
|
expect(updatePlanWidget).toHaveBeenCalled();
|
|
560
|
+
// 终态矩阵:reset entry 落 isActive=false + skills/reviewState 清空 + docs 保留
|
|
561
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: false }));
|
|
562
|
+
const entry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
|
563
|
+
expect(entry.docs).toHaveLength(1);
|
|
564
|
+
expect(entry.skills).toEqual([]);
|
|
565
|
+
expect(entry.reviewState).toBeUndefined();
|
|
255
566
|
});
|
|
256
567
|
});
|
|
257
568
|
});
|
|
@@ -262,8 +573,13 @@ describe("validateAction", () => {
|
|
|
262
573
|
});
|
|
263
574
|
it("rejects invalid", () => {
|
|
264
575
|
expect(validateAction("bogus")).toBe(false);
|
|
576
|
+
expect(validateAction("list-template")).toBe(false);
|
|
265
577
|
});
|
|
266
|
-
it("action list
|
|
578
|
+
it("action list contains exactly the six actions (enter added for agent self-entry; list-template removed, D1)", () => {
|
|
579
|
+
expect([...PLAN_ACTIONS].sort()).toEqual(
|
|
580
|
+
["abort", "complete", "enter", "register-doc", "select-template", "submit-review"],
|
|
581
|
+
);
|
|
267
582
|
expect(PLAN_ACTIONS).not.toContain("create-template");
|
|
583
|
+
expect(PLAN_ACTIONS).not.toContain("list-template");
|
|
268
584
|
});
|
|
269
585
|
});
|