@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// index.ts 只做装配:mock 掉子模块注册函数,只捕获 pi.on 的 hook 与 controllers 注册表
|
|
4
|
+
vi.mock("../tool.js", () => ({
|
|
5
|
+
registerPlanTool: vi.fn(
|
|
6
|
+
(_pi: unknown, _sessions: unknown, controllers: Map<string, AbortController>) => {
|
|
7
|
+
captured.controllers = controllers;
|
|
8
|
+
},
|
|
9
|
+
),
|
|
10
|
+
PLAN_MODE_TOOLS: ["read", "bash", "grep", "find", "ls", "plan"],
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../command.js", () => ({ registerPlanCommand: vi.fn() }));
|
|
13
|
+
vi.mock("../compact.js", () => ({ registerPlanEventHandlers: vi.fn() }));
|
|
14
|
+
vi.mock("../widget.js", () => ({ updatePlanWidget: vi.fn() }));
|
|
15
|
+
|
|
16
|
+
// MF-1-8:注入失败日志必须走 extension-logger(stderr 仅 logger 自身抛错的内层兜底)——
|
|
17
|
+
// 捕获 warn spy 断言落盘通道
|
|
18
|
+
const { loggerWarn } = vi.hoisted(() => ({ loggerWarn: vi.fn() }));
|
|
19
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
20
|
+
getLogger: () => ({ warn: loggerWarn, error: vi.fn() }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
import planExtension from "../index.js";
|
|
26
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
27
|
+
|
|
28
|
+
const captured: { controllers?: Map<string, AbortController> } = {};
|
|
29
|
+
|
|
30
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => Promise<unknown> | unknown;
|
|
31
|
+
|
|
32
|
+
function setup() {
|
|
33
|
+
const handlers = new Map<string, Handler>();
|
|
34
|
+
const pi = {
|
|
35
|
+
on: vi.fn((event: string, handler: Handler) => {
|
|
36
|
+
handlers.set(event, handler);
|
|
37
|
+
}),
|
|
38
|
+
sendMessage: vi.fn(),
|
|
39
|
+
setActiveTools: vi.fn(),
|
|
40
|
+
appendEntry: vi.fn(),
|
|
41
|
+
} as unknown as ExtensionAPI;
|
|
42
|
+
|
|
43
|
+
planExtension(pi);
|
|
44
|
+
return { handlers, pi };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeCtx(entries: unknown[]): ExtensionContext {
|
|
48
|
+
return {
|
|
49
|
+
sessionManager: {
|
|
50
|
+
getSessionId: () => "test-session",
|
|
51
|
+
getEntries: () => entries,
|
|
52
|
+
},
|
|
53
|
+
ui: { setWidget: vi.fn(), setStatus: vi.fn(), theme: { fg: (_t: string, text: string) => text } },
|
|
54
|
+
} as unknown as ExtensionContext;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function planStateEntry(data: Record<string, unknown>) {
|
|
58
|
+
return { type: "custom", customType: "plan-state", data };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
captured.controllers = undefined;
|
|
64
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.unstubAllEnvs();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("session_start hook(E3:awaiting 重挂提醒)", () => {
|
|
72
|
+
it("awaiting + no live select → steers the agent to re-call submit-review (pi 懒重生之后跑)", async () => {
|
|
73
|
+
const { handlers, pi } = setup();
|
|
74
|
+
const ctx = makeCtx([
|
|
75
|
+
planStateEntry({
|
|
76
|
+
isActive: true,
|
|
77
|
+
planFilePath: "/p/plan.md",
|
|
78
|
+
requirement: "r",
|
|
79
|
+
templateName: "",
|
|
80
|
+
skills: ["tech-design"],
|
|
81
|
+
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
|
|
82
|
+
reviewState: "awaiting",
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
|
|
87
|
+
|
|
88
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
89
|
+
// custom message 三要素 + streaming steer options 断言(A6)
|
|
90
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
91
|
+
{
|
|
92
|
+
customType: PLAN_CONTEXT_CUSTOM_TYPE,
|
|
93
|
+
content: expect.stringContaining("submit-review"),
|
|
94
|
+
display: false,
|
|
95
|
+
},
|
|
96
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
97
|
+
);
|
|
98
|
+
// E3 重挂即落盘(§3.4 降级两源:该处曾只发 steer 不落盘——不落盘则 renderer 冷启动
|
|
99
|
+
// 扫描的 View 恒无 source、恒渲染通用文案)
|
|
100
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
101
|
+
"plan-state",
|
|
102
|
+
expect.objectContaining({ isActive: true, reviewState: "awaiting", reviewStateSource: "resubmit" }),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("revising + no live select → steers the agent to continue the revision (P1-1:revising 崩溃恢复缺口)", async () => {
|
|
107
|
+
const { handlers, pi } = setup();
|
|
108
|
+
const ctx = makeCtx([
|
|
109
|
+
planStateEntry({
|
|
110
|
+
isActive: true,
|
|
111
|
+
planFilePath: "/p/plan.md",
|
|
112
|
+
requirement: "r",
|
|
113
|
+
templateName: "",
|
|
114
|
+
skills: ["tech-design"],
|
|
115
|
+
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
|
|
116
|
+
reviewState: "revising",
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
|
|
121
|
+
|
|
122
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
123
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
124
|
+
{
|
|
125
|
+
customType: PLAN_CONTEXT_CUSTOM_TYPE,
|
|
126
|
+
content: expect.stringContaining("revision"),
|
|
127
|
+
display: false,
|
|
128
|
+
},
|
|
129
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
130
|
+
);
|
|
131
|
+
// revising 恢复不落 source 标记('resubmit' 只描述 awaiting 降级等待;revising 由
|
|
132
|
+
// steer triggerTurn 立即开轮接续,恢复期间显示的 revising 是真实进行中)
|
|
133
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("stale controllers are cleared on session rebuild (禁复用已 abort 的 controller)", async () => {
|
|
137
|
+
const { handlers } = setup();
|
|
138
|
+
const ctx = makeCtx([]);
|
|
139
|
+
// 同进程 reload 场景:注册表里残留旧 controller——hook 重建 session 时必须清掉
|
|
140
|
+
// (残留的已 abort controller 禁止复用:发起新挂起 select 会 fresh 新建,此处是防御清理)
|
|
141
|
+
captured.controllers!.set("test-session", new AbortController());
|
|
142
|
+
|
|
143
|
+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
|
|
144
|
+
|
|
145
|
+
expect(captured.controllers!.has("test-session")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("active without awaiting (in progress) → no reminder", async () => {
|
|
149
|
+
const { handlers, pi } = setup();
|
|
150
|
+
const ctx = makeCtx([
|
|
151
|
+
planStateEntry({
|
|
152
|
+
isActive: true,
|
|
153
|
+
planFilePath: "/p/plan.md",
|
|
154
|
+
requirement: "r",
|
|
155
|
+
templateName: "",
|
|
156
|
+
skills: [],
|
|
157
|
+
docs: [],
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
|
|
162
|
+
|
|
163
|
+
expect(pi.setActiveTools).toHaveBeenCalled();
|
|
164
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
165
|
+
// 非 awaiting 不落盘:E3 source 标记只在 awaiting 重挂分支写
|
|
166
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("inactive plan → no reminder, no tool restriction", async () => {
|
|
170
|
+
const { handlers, pi } = setup();
|
|
171
|
+
const ctx = makeCtx([]);
|
|
172
|
+
|
|
173
|
+
await handlers.get("session_start")!({ type: "session_start" }, ctx);
|
|
174
|
+
|
|
175
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
176
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("before_agent_start hook(D9:taiji 形态引导注入)", () => {
|
|
181
|
+
const basePrompt = "You are a helpful coding agent.";
|
|
182
|
+
|
|
183
|
+
it("no host signal (standalone pi) → undefined, prompt untouched", () => {
|
|
184
|
+
const { handlers } = setup();
|
|
185
|
+
const result = handlers.get("before_agent_start")!(
|
|
186
|
+
{ type: "before_agent_start", prompt: "hi", systemPrompt: basePrompt },
|
|
187
|
+
makeCtx([]),
|
|
188
|
+
);
|
|
189
|
+
expect(result).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("TAIJI_AGENT_EXT_LOG=1 → appends the plan-mode suggestion (not TAIJI_RUNTIME_TOKEN — 出站 deny list 剥除)", () => {
|
|
193
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
194
|
+
const { handlers } = setup();
|
|
195
|
+
const result = handlers.get("before_agent_start")!(
|
|
196
|
+
{ type: "before_agent_start", prompt: "hi", systemPrompt: basePrompt },
|
|
197
|
+
makeCtx([]),
|
|
198
|
+
) as { systemPrompt?: string } | undefined;
|
|
199
|
+
|
|
200
|
+
expect(result?.systemPrompt).toContain(basePrompt);
|
|
201
|
+
// agent 自助进入引导在场(enter action + 无需确认)
|
|
202
|
+
expect(result?.systemPrompt).toContain("plan(action='enter'");
|
|
203
|
+
expect(result?.systemPrompt).toContain("Do not ask for permission to enter");
|
|
204
|
+
// 旧的「建议 + 需确认」措辞已移除(enter 是 tool action,无需确认闸门)
|
|
205
|
+
expect(result?.systemPrompt).not.toContain("Do NOT enter plan mode without the user's confirmation");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("注入失败(systemPrompt 读取抛错)→ logger.warn 落盘 + 返回 undefined,不阻塞 agent loop(MF-1-8)", () => {
|
|
209
|
+
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
210
|
+
const { handlers } = setup();
|
|
211
|
+
const evilEvent = {
|
|
212
|
+
type: "before_agent_start",
|
|
213
|
+
get systemPrompt(): string {
|
|
214
|
+
throw new Error("boom");
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = handlers.get("before_agent_start")!(evilEvent, makeCtx([]));
|
|
219
|
+
|
|
220
|
+
// Never block the agent loop:吞错返回 undefined
|
|
221
|
+
expect(result).toBeUndefined();
|
|
222
|
+
// 日志走 extension-logger 文件通道(~/.pi/agent/logs/),不再 stderr 直写
|
|
223
|
+
expect(loggerWarn).toHaveBeenCalledWith(
|
|
224
|
+
"plan: before_agent_start injection failed",
|
|
225
|
+
expect.objectContaining({ error: expect.stringContaining("boom") }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { buildPlanModePrompt } from "../prompts.js";
|
|
9
|
+
|
|
10
|
+
// ── tmp 脚手架(测试红线:写删目标全部 mkdtempSync 自建自删,禁触真实数据目录)──
|
|
11
|
+
|
|
12
|
+
const createdDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
function mkTmpDir(prefix: string): string {
|
|
15
|
+
const dir = fs.mkdtempSync(join(tmpdir(), prefix));
|
|
16
|
+
createdDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const dir of createdDirs.splice(0)) {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 注入段项目级源锚点回归锁(U1):planFilePath = <projectRoot>/.tmp/plans/
|
|
28
|
+
* <slug>/plan.md(slug 目录嵌套)——从 planFilePath 逆推项目级源曾因层级差致
|
|
29
|
+
* 注入段扫浅层 .agents/plans(几乎恒不存在)→ 项目级模板
|
|
30
|
+
* 投放(G2)从注入清单完全消失,而 select-template 侧用 listTemplates({
|
|
31
|
+
* projectRoot: ctx.cwd }) 正确(两轨不同源)。本组用例按真实层级构造
|
|
32
|
+
* planFilePath,锁死「注入段项目级源 = PlanPromptInput.projectRoot 的
|
|
33
|
+
* .agents/plans」——旧签名(无 projectRoot 字段)在类型层即编译失败。
|
|
34
|
+
*/
|
|
35
|
+
describe("注入段项目级模板源锚点 = PlanPromptInput.projectRoot(ctx.cwd,D2/U1)", () => {
|
|
36
|
+
it("projectRoot/.agents/plans 下的模板进 <available-plans>(name + location 绝对路径)", () => {
|
|
37
|
+
const projectRoot = mkTmpDir("plan-prompt-proj-");
|
|
38
|
+
const templateName = `project-tpl-${randomUUID().slice(0, 8)}`;
|
|
39
|
+
const plansDir = join(projectRoot, ".agents", "plans");
|
|
40
|
+
fs.mkdirSync(plansDir, { recursive: true });
|
|
41
|
+
const templatePath = join(plansDir, `${templateName}.md`);
|
|
42
|
+
fs.writeFileSync(templatePath, "# project skeleton\n\n## Implementation Steps\n");
|
|
43
|
+
|
|
44
|
+
const prompt = buildPlanModePrompt({
|
|
45
|
+
requirement: "integrate project-level template source",
|
|
46
|
+
planFilePath: join(projectRoot, ".tmp", "plans", "some-slug", "plan.md"),
|
|
47
|
+
projectRoot,
|
|
48
|
+
skills: [],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// UUID 后缀名不与运行机真实 ~/.agents/plans、内置 5 模板撞名——containment 断言确定
|
|
52
|
+
expect(prompt).toContain("<available-plans>");
|
|
53
|
+
expect(prompt).toContain(`<name>${templateName}</name>`);
|
|
54
|
+
expect(prompt).toContain(`<location>${templatePath}</location>`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("planDir 逆推层级(<projectRoot>/.tmp/plans/.agents/plans)不是项目级源——放在那里的模板不进清单", () => {
|
|
58
|
+
const projectRoot = mkTmpDir("plan-prompt-trap-");
|
|
59
|
+
const trapName = `trap-tpl-${randomUUID().slice(0, 8)}`;
|
|
60
|
+
const trapDir = join(projectRoot, ".tmp", "plans", ".agents", "plans");
|
|
61
|
+
fs.mkdirSync(trapDir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(join(trapDir, `${trapName}.md`), "# wrong anchor\n");
|
|
63
|
+
|
|
64
|
+
const prompt = buildPlanModePrompt({
|
|
65
|
+
requirement: "wrong anchor must not surface",
|
|
66
|
+
planFilePath: join(projectRoot, ".tmp", "plans", "some-slug", "plan.md"),
|
|
67
|
+
projectRoot,
|
|
68
|
+
skills: [],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(prompt).not.toContain(`<name>${trapName}</name>`);
|
|
72
|
+
expect(prompt).not.toContain(`<location>${join(trapDir, `${trapName}.md`)}</location>`);
|
|
73
|
+
});
|
|
74
|
+
});
|