@zhushanwen/pi-plan 0.5.2 → 1.1.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 +13 -11
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +102 -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 +42 -34
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +30 -118
- 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 +665 -257
- 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
|
@@ -15,22 +15,26 @@ vi.mock("@earendil-works/pi-ai", () => ({
|
|
|
15
15
|
StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
// Mock
|
|
19
|
-
vi.mock("../
|
|
18
|
+
// Mock execution-notice.js (statically imported since 06-u1)
|
|
19
|
+
vi.mock("../execution-notice.js", async () => {
|
|
20
20
|
// GOAL_FAILURE_RECOVERY 与真实实现同文案——completeResultText 在 failure 断言里消费它
|
|
21
|
-
const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../
|
|
21
|
+
const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../execution-notice.js")>("../execution-notice.js");
|
|
22
22
|
return {
|
|
23
23
|
handlePlanComplete: vi.fn(),
|
|
24
|
-
detectGoalCapability: vi.fn(() => false),
|
|
25
24
|
GOAL_FAILURE_RECOVERY,
|
|
26
25
|
};
|
|
27
26
|
});
|
|
28
27
|
|
|
29
|
-
// Mock
|
|
30
|
-
// skill 选项用例显式注入 fixture;专用 exec-skills.test.ts
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
// Mock 执行方式检测(D10):单测里不扫真实目录(~/.agents/skills 等本机路径),
|
|
29
|
+
// skill 选项用例显式注入 fixture;专用 exec-skills.test.ts 覆盖真实扫描。
|
|
30
|
+
// importActual 展开:只覆写 detectExecSkills,其余导出(含 enter.ts re-export 的
|
|
31
|
+
// resolveSkills 执行门禁)走真实现——mock 罩全模块会把它一并变 undefined
|
|
32
|
+
vi.mock("@zhushanwen/pi-exec-skills", async () => {
|
|
33
|
+
const actual = await vi.importActual<typeof import("@zhushanwen/pi-exec-skills")>(
|
|
34
|
+
"@zhushanwen/pi-exec-skills",
|
|
35
|
+
);
|
|
36
|
+
return { ...actual, detectExecSkills: vi.fn(() => []) };
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
// Mock widget (imported by abort)
|
|
36
40
|
vi.mock("../widget.js", () => ({
|
|
@@ -44,11 +48,11 @@ import { join } from "node:path";
|
|
|
44
48
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
45
49
|
import { Text } from "@earendil-works/pi-tui";
|
|
46
50
|
import { UI_FORM_MARKER } from "@zhushanwen/extension-protocol";
|
|
51
|
+
import { detectExecSkills } from "@zhushanwen/pi-exec-skills";
|
|
47
52
|
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import { loadTemplate } from "../templates.js";
|
|
53
|
+
import { handlePlanComplete } from "../execution-notice.js";
|
|
54
|
+
import { createPlanCtx, DEFAULT_PLAN_STATE } from "../state.js";
|
|
55
|
+
import { listTemplates } from "../templates.js";
|
|
52
56
|
import { PLAN_ACTIONS, registerPlanTool, validateAction } from "../tool.js";
|
|
53
57
|
import { updatePlanWidget } from "../widget.js";
|
|
54
58
|
|
|
@@ -56,8 +60,8 @@ import { updatePlanWidget } from "../widget.js";
|
|
|
56
60
|
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
57
61
|
|
|
58
62
|
function setup() {
|
|
59
|
-
|
|
60
|
-
const
|
|
63
|
+
// 单 ctx 对象(D-B4-3):sessions/controllers 别名 = planCtx 两表(同对象),用例断言不变
|
|
64
|
+
const planCtx = createPlanCtx();
|
|
61
65
|
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
|
|
62
66
|
const pi = {
|
|
63
67
|
registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
|
|
@@ -65,7 +69,7 @@ function setup() {
|
|
|
65
69
|
setActiveTools: vi.fn(),
|
|
66
70
|
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
67
71
|
} as unknown as Parameters<typeof registerPlanTool>[0];
|
|
68
|
-
registerPlanTool(pi,
|
|
72
|
+
registerPlanTool(pi, planCtx);
|
|
69
73
|
|
|
70
74
|
const ctx = {
|
|
71
75
|
sessionId: "test-session",
|
|
@@ -74,13 +78,24 @@ function setup() {
|
|
|
74
78
|
hasUI: true,
|
|
75
79
|
mode: "tui" as const,
|
|
76
80
|
isProjectTrusted: () => true,
|
|
77
|
-
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
81
|
+
sessionManager: { getSessionId: () => "test-session", getLeafId: () => null, getEntries: () => [] },
|
|
78
82
|
ui: { select: vi.fn(), notify: vi.fn() },
|
|
79
83
|
};
|
|
80
84
|
|
|
85
|
+
// 默认前置:已批准态(approved --approve--> dispatching 重选执行方式,consumers.md §三B)——
|
|
86
|
+
// complete 的审批闸口(D1 边表)要求先过 review 流程;用例可在返回后覆写 sessions
|
|
87
|
+
planCtx.states.set("test-session", {
|
|
88
|
+
...DEFAULT_PLAN_STATE,
|
|
89
|
+
isActive: true,
|
|
90
|
+
planFilePath: "/tmp/test-project/.tmp/plans/auth/plan.md",
|
|
91
|
+
requirement: "refactor auth",
|
|
92
|
+
state: "approved",
|
|
93
|
+
docs: [{ fileName: "design.md", absPath: "/tmp/test-project/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
|
|
94
|
+
});
|
|
95
|
+
|
|
81
96
|
const exec = (params: Record<string, unknown>, signal?: AbortSignal) =>
|
|
82
97
|
executeFn!("tc0", params, signal, undefined, ctx);
|
|
83
|
-
return { pi, sessions, controllers, ctx, exec };
|
|
98
|
+
return { pi, planCtx, sessions: planCtx.states, controllers: planCtx.controllers, ctx, exec };
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
describe("registerPlanTool", () => {
|
|
@@ -90,6 +105,12 @@ describe("registerPlanTool", () => {
|
|
|
90
105
|
expect((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0].name).toBe("plan");
|
|
91
106
|
});
|
|
92
107
|
|
|
108
|
+
it("declares executionMode 'sequential'(D-B1-4:action 就地突变共享 PlanState,串行声明消除同批并行交错)", () => {
|
|
109
|
+
const { pi } = setup();
|
|
110
|
+
const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as { executionMode?: string };
|
|
111
|
+
expect(tool.executionMode).toBe("sequential");
|
|
112
|
+
});
|
|
113
|
+
|
|
93
114
|
it("tool description and promptSnippet no longer mention list-template (D1 删链不留兼容通道)", () => {
|
|
94
115
|
const { pi } = setup();
|
|
95
116
|
const tool = (pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
|
|
@@ -128,11 +149,13 @@ describe("registerPlanTool", () => {
|
|
|
128
149
|
});
|
|
129
150
|
|
|
130
151
|
it("content carries the winner file's full text and details has no content field (D7 全文通道唯一化)", async () => {
|
|
131
|
-
const { exec } = setup();
|
|
152
|
+
const { exec, ctx } = setup();
|
|
132
153
|
const res = await exec({ action: "select-template", templateName: "feature-plan" });
|
|
133
154
|
const text = res.content[0].text;
|
|
134
|
-
//
|
|
135
|
-
|
|
155
|
+
// 全文到达模型可见通道(对照同参数合并视图胜者 path 的直读——与生产侧同一次
|
|
156
|
+
// 扫描语义,运行机用户级遮蔽时两轨拿同一胜者,断言同样成立)
|
|
157
|
+
const winner = listTemplates({ projectRoot: ctx.cwd }).find((t) => t.name === "feature-plan");
|
|
158
|
+
const winnerContent = winner ? fs.readFileSync(winner.path, "utf-8") : null;
|
|
136
159
|
expect(winnerContent).not.toBeNull();
|
|
137
160
|
expect(text).toContain(`<template>\n${winnerContent}\n</template>`);
|
|
138
161
|
// details 收窄:仅 action + templateName,全文不再双份持久化
|
|
@@ -164,7 +187,7 @@ describe("registerPlanTool", () => {
|
|
|
164
187
|
planFilePath: "/tmp/test-project/.tmp/plans/retro/plan.md",
|
|
165
188
|
requirement: "retro",
|
|
166
189
|
templateName: "retro-template",
|
|
167
|
-
|
|
190
|
+
templateProvided: true,
|
|
168
191
|
});
|
|
169
192
|
const error = await exec({ action: "select-template", templateName: "retro-template" }).then(
|
|
170
193
|
() => new Error("expected rejection"),
|
|
@@ -180,21 +203,17 @@ describe("registerPlanTool", () => {
|
|
|
180
203
|
|
|
181
204
|
// --- removed actions (D1 / D3) ---
|
|
182
205
|
describe("removed action rejections", () => {
|
|
183
|
-
it("
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
).rejects.toThrow(
|
|
195
|
-
"Unknown plan action: create-template. Valid actions: enter, select-template, complete, abort, register-doc, submit-review",
|
|
196
|
-
);
|
|
197
|
-
});
|
|
206
|
+
it.each(["list-template", "create-template"] as const)(
|
|
207
|
+
"rejects plan(action='%s') as an unknown action with the 6-action list (D1/D3)",
|
|
208
|
+
async (removed) => {
|
|
209
|
+
const { exec } = setup();
|
|
210
|
+
await expect(
|
|
211
|
+
exec(removed === "create-template" ? { action: removed, templateName: "my-plan", templateContent: "# hello" } : { action: removed }),
|
|
212
|
+
).rejects.toThrow(
|
|
213
|
+
`Unknown plan action: ${removed}. Valid actions: enter, select-template, complete, abort, register-doc, submit-review`,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
);
|
|
198
217
|
});
|
|
199
218
|
|
|
200
219
|
// --- renderResult 兜底(MF-1-7 旧持久化 details 形态)---
|
|
@@ -220,43 +239,32 @@ describe("registerPlanTool", () => {
|
|
|
220
239
|
return tool.renderResult;
|
|
221
240
|
}
|
|
222
241
|
|
|
223
|
-
it(
|
|
242
|
+
it.each([
|
|
243
|
+
{ action: "list-template", details: { action: "list-template", templates: ["feature-plan", "bugfix"] }, text: "Available templates: feature-plan, bugfix" },
|
|
244
|
+
{ action: "create-template", details: { action: "create-template" }, text: "legacy entry" },
|
|
245
|
+
])("legacy/unknown action details ($action) render without throwing, falling back to the content text", ({ details, text }) => {
|
|
224
246
|
const { pi } = setup();
|
|
225
247
|
const render = renderFn(pi);
|
|
226
|
-
// git 2ab33c46c 旧版形态:details.action
|
|
227
|
-
//
|
|
248
|
+
// git 2ab33c46c 旧版形态:details.action 不在现版 PlanDetails 联合内(list-template 为
|
|
249
|
+
// 已删 action 的历史 entry 形态)。修复前 switch 落空返回 undefined → pi TUI 渲染循环
|
|
250
|
+
// 对 undefined 调 .render() TypeError;任意未知 action 同走 default 兜底(防御未来再删)。
|
|
228
251
|
const result = {
|
|
229
|
-
content: [{ type: "text", text
|
|
230
|
-
details
|
|
252
|
+
content: [{ type: "text", text }],
|
|
253
|
+
details,
|
|
231
254
|
};
|
|
232
255
|
|
|
233
256
|
const component = render(result, { expanded: false }, renderTheme);
|
|
234
257
|
|
|
235
258
|
expect(component).toBeInstanceOf(Text);
|
|
236
|
-
expect(component.render(400).join("\n")).toContain(
|
|
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");
|
|
259
|
+
expect(component.render(400).join("\n")).toContain(text);
|
|
251
260
|
});
|
|
252
261
|
});
|
|
253
262
|
|
|
254
263
|
// --- complete ---
|
|
255
264
|
describe("complete", () => {
|
|
256
265
|
beforeEach(() => {
|
|
257
|
-
//
|
|
258
|
-
// goal-unavailable
|
|
259
|
-
(detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
266
|
+
// goal outcome 用例经 handlePlanComplete mock 构造结果(execute 档 tryGoalInit
|
|
267
|
+
// 在 execution-notice.ts 内部自理 goal-unavailable 降级,不在本文件消费面)
|
|
260
268
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
|
|
261
269
|
(detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
|
|
262
270
|
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
@@ -271,28 +279,40 @@ describe("registerPlanTool", () => {
|
|
|
271
279
|
return ((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0]) as Record<string, unknown>;
|
|
272
280
|
}
|
|
273
281
|
|
|
274
|
-
it("
|
|
282
|
+
it("schema carries no isolation field (D-B1-6: compact|direct 两档分发砍除,单一直接投递路径)", async () => {
|
|
275
283
|
const { pi } = setup();
|
|
276
284
|
const parameters = registeredTool(pi).parameters as {
|
|
277
|
-
properties:
|
|
285
|
+
properties: Record<string, unknown>;
|
|
278
286
|
};
|
|
279
|
-
expect(parameters.properties.isolation
|
|
280
|
-
expect(parameters.properties.isolation.enum).not.toContain("tree");
|
|
287
|
+
expect(parameters.properties.isolation).toBeUndefined();
|
|
281
288
|
});
|
|
282
289
|
|
|
283
|
-
it("does not advance when user picks Not now", async () => {
|
|
290
|
+
it("does not advance when user picks Not now — later 边(dispatching→approved)+ later 文案,不进解散文案桶(A9 反向)", async () => {
|
|
284
291
|
const { exec, ctx, pi } = setup();
|
|
292
|
+
// 有 plan-exec 技能才挂表单(D7②:空集直通,暂不执行档只存在于表单内)
|
|
293
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
294
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
295
|
+
]);
|
|
285
296
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Not now");
|
|
286
297
|
const res = await exec({ action: "complete" });
|
|
287
|
-
expect(res.details.action).toBe("complete-
|
|
288
|
-
expect(res.details.
|
|
289
|
-
//
|
|
290
|
-
expect(res.content[0].text).toContain("
|
|
298
|
+
expect(res.details.action).toBe("complete-later");
|
|
299
|
+
expect(res.details.choice).toBe("Not now");
|
|
300
|
+
// later 档文案(D3 连带段):已批准、未派发、可再调 complete——**不得**是外部解散文案
|
|
301
|
+
expect(res.content[0].text).toContain("NOT dispatched");
|
|
302
|
+
expect(res.content[0].text).toContain("plan(action='complete')");
|
|
303
|
+
expect(res.content[0].text).not.toContain("interrupted");
|
|
304
|
+
expect(res.content[0].text).not.toContain("exited");
|
|
305
|
+
// later 边落盘:approved(批准事实保留,留在 plan mode)
|
|
306
|
+
const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
|
|
307
|
+
expect(entries.at(-1)?.state).toBe("approved");
|
|
291
308
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
292
309
|
});
|
|
293
310
|
|
|
294
|
-
it("turn abort during the pending execution-method select dissolves the dialog →
|
|
311
|
+
it("turn abort during the pending execution-method select dissolves the dialog → 归口②外部解散(review_aborted→approved,MF-1-8)", async () => {
|
|
295
312
|
const { exec, ctx, pi } = setup();
|
|
313
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
314
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
315
|
+
]);
|
|
296
316
|
// select mock 对齐 pi 实装 createDialogPromise 语义(rpc-mode.js:48):
|
|
297
317
|
// signal 已 abort 首行短路 resolve undefined;挂起中 abort → resolve undefined
|
|
298
318
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(
|
|
@@ -309,12 +329,18 @@ describe("registerPlanTool", () => {
|
|
|
309
329
|
const pending = exec({ action: "complete" }, turn.signal);
|
|
310
330
|
turn.abort();
|
|
311
331
|
const res = await pending;
|
|
332
|
+
// turn abort 级联(未经入口,不打标)→ 外部解散:dispatching --review_aborted--> approved
|
|
312
333
|
expect(res.details.action).toBe("complete-cancelled");
|
|
313
334
|
expect(res.details.reason).toBe("cancelled");
|
|
335
|
+
expect(res.details.source).toBe("external");
|
|
336
|
+
expect(res.content[0].text).toContain("interrupted");
|
|
337
|
+
expect(res.content[0].text).toContain("APPROVED");
|
|
314
338
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
339
|
+
const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
|
|
340
|
+
expect(entries.at(-1)?.state).toBe("approved");
|
|
315
341
|
});
|
|
316
342
|
|
|
317
|
-
it("resets state and restores tools on execute", async () => {
|
|
343
|
+
it("resets state and restores tools on execute — exec_chosen 边(dispatching→completed 终局)", async () => {
|
|
318
344
|
const { exec, ctx, pi } = setup();
|
|
319
345
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
320
346
|
const res = await exec({ action: "complete" });
|
|
@@ -322,18 +348,28 @@ describe("registerPlanTool", () => {
|
|
|
322
348
|
expect(res.details.execMode).toBe("execute");
|
|
323
349
|
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
324
350
|
expect(handlePlanComplete).toHaveBeenCalled();
|
|
325
|
-
|
|
351
|
+
// 内部路径显示为相对路径(A2 边界判断正向:projectDir 后紧随分隔符才切)
|
|
352
|
+
expect(res.details.planFilePath).toBe(".tmp/plans/auth/plan.md");
|
|
353
|
+
// 终局落盘:terminal='completed'(防 reset 覆写 completed,D3 连带段)
|
|
354
|
+
const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string; isActive?: boolean });
|
|
355
|
+
expect(entries.at(-1)).toMatchObject({ state: "completed", isActive: false });
|
|
326
356
|
});
|
|
327
357
|
|
|
328
|
-
it("
|
|
329
|
-
const { exec, ctx } = setup();
|
|
358
|
+
it("A2 边界锚:兄弟前缀目录(/tmp/test-project-2)不被误切进 /tmp/test-project 的相对路径", async () => {
|
|
359
|
+
const { exec, ctx, sessions } = setup();
|
|
360
|
+
sessions.set("test-session", {
|
|
361
|
+
...DEFAULT_PLAN_STATE,
|
|
362
|
+
isActive: true,
|
|
363
|
+
planFilePath: "/tmp/test-project-2/.tmp/plans/auth/plan.md",
|
|
364
|
+
requirement: "refactor auth",
|
|
365
|
+
state: "approved",
|
|
366
|
+
docs: [{ fileName: "design.md", absPath: "/tmp/test-project-2/.tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
|
|
367
|
+
});
|
|
330
368
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
331
|
-
await exec({ action: "complete" });
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"Not now",
|
|
336
|
-
]);
|
|
369
|
+
const res = await exec({ action: "complete" });
|
|
370
|
+
// 裸 startsWith 曾把 test-project-2 误吃进 test-project 前缀(错切为 2/.tmp/...)——
|
|
371
|
+
// 边界判断(分隔符或全等)后兄弟前缀原样显示
|
|
372
|
+
expect(res.details.planFilePath).toBe("/tmp/test-project-2/.tmp/plans/auth/plan.md");
|
|
337
373
|
});
|
|
338
374
|
|
|
339
375
|
it("execute choice maps to execMode execute (goal + auto-parallel 整合档)", async () => {
|
|
@@ -341,7 +377,7 @@ describe("registerPlanTool", () => {
|
|
|
341
377
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
342
378
|
const res = await exec({ action: "complete" });
|
|
343
379
|
expect(res.details.execMode).toBe("execute");
|
|
344
|
-
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "
|
|
380
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
|
|
345
381
|
});
|
|
346
382
|
|
|
347
383
|
it("headless (!hasUI) defaults to execute without any select (D4 三路分流第 1 路)", async () => {
|
|
@@ -353,7 +389,7 @@ describe("registerPlanTool", () => {
|
|
|
353
389
|
expect(ctx.ui.select).not.toHaveBeenCalled(); // 不进任何 select(noOp 软门修复)
|
|
354
390
|
expect(detectExecSkills).not.toHaveBeenCalled(); // 选择已预定,跳过 skill 扫描
|
|
355
391
|
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
356
|
-
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "
|
|
392
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
|
|
357
393
|
});
|
|
358
394
|
|
|
359
395
|
it("detected plan-exec skills lead the option set (first + second) and map to skill:<name> with skillEntryPath (D10 重排)", async () => {
|
|
@@ -379,24 +415,36 @@ describe("registerPlanTool", () => {
|
|
|
379
415
|
expect(res.details.action).toBe("complete");
|
|
380
416
|
expect(res.details.execMode).toBe("skill:dev-flow");
|
|
381
417
|
// skillEntryPath 数据通路:CompleteChoiceOutcome → handlePlanComplete(steer 文案的路径来源)
|
|
382
|
-
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "
|
|
418
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "skill:dev-flow", skillEntryPath);
|
|
383
419
|
});
|
|
384
420
|
|
|
385
|
-
it("
|
|
386
|
-
const { exec, ctx } = setup();
|
|
421
|
+
it("D7② 空集直通(TUI):无 plan-exec 技能不挂任何选择器,直接走执行派发链 + 文案明示", async () => {
|
|
422
|
+
const { exec, ctx, pi } = setup();
|
|
387
423
|
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
expect(
|
|
424
|
+
const res = await exec({ action: "complete" });
|
|
425
|
+
|
|
426
|
+
// 不挂执行方式选择(恒两项死表单构造性消除,S4 通过标准)
|
|
427
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
428
|
+
expect(res.details.action).toBe("complete");
|
|
429
|
+
expect(res.details.execMode).toBe("execute");
|
|
430
|
+
expect(res.details.execModeSource).toBe("no-exec-skills");
|
|
431
|
+
// 仍现扫(直通判定依赖检测结果,不是跳过检测——rpc 形态与本形态同分支,原独立用例已合并)
|
|
432
|
+
expect(detectExecSkills).toHaveBeenCalled();
|
|
433
|
+
// 工具结果文案明示「无 plan-exec 技能,直接执行」(不静默吞掉没弹表单的事实)
|
|
434
|
+
expect(res.content[0].text).toContain("No plan-exec skill was detected");
|
|
435
|
+
expect(res.content[0].text).toContain("executed directly");
|
|
436
|
+
// 复用既有执行派发链(goal 桥/直执 steer)+ 终局 completed
|
|
437
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
|
|
438
|
+
const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
|
|
439
|
+
expect(entries.at(-1)?.state).toBe("completed");
|
|
392
440
|
});
|
|
393
441
|
|
|
394
442
|
it("execute tier carries the goal outcome into result content and details (整合档 D2)", async () => {
|
|
395
443
|
const { exec, ctx } = setup();
|
|
396
444
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
397
445
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: false, reason: "no-steps" });
|
|
398
|
-
const res = await exec({ action: "complete"
|
|
399
|
-
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "
|
|
446
|
+
const res = await exec({ action: "complete" });
|
|
447
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "execute", undefined);
|
|
400
448
|
expect(res.content[0].text).toContain("Goal tracking was not started (no-steps)");
|
|
401
449
|
expect(res.content[0].text).toContain("Implementation Steps"); // 恢复动作
|
|
402
450
|
expect(res.details.goalOutcome).toEqual({ started: false, reason: "no-steps" });
|
|
@@ -406,20 +454,20 @@ describe("registerPlanTool", () => {
|
|
|
406
454
|
const { exec, ctx } = setup();
|
|
407
455
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
408
456
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: true });
|
|
409
|
-
const res = await exec({ action: "complete"
|
|
457
|
+
const res = await exec({ action: "complete" });
|
|
410
458
|
expect(res.content[0].text).toContain("Goal tracking started via /goal");
|
|
411
459
|
expect(res.details.goalOutcome).toEqual({ started: true });
|
|
412
460
|
});
|
|
413
461
|
|
|
414
|
-
it("
|
|
462
|
+
it("no goal-bridge outcome (undefined): result keeps the plain approved line", async () => {
|
|
415
463
|
const { exec, ctx } = setup();
|
|
416
464
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Execute");
|
|
417
465
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
|
418
|
-
const res = await exec({ action: "complete"
|
|
466
|
+
const res = await exec({ action: "complete" });
|
|
419
467
|
expect(res.content[0].text).toMatch(/^Plan approved\. File: /);
|
|
420
468
|
expect(res.content[0].text).not.toContain("Goal tracking");
|
|
421
469
|
expect(res.details.goalOutcome).toBeUndefined();
|
|
422
|
-
expect(res.details.isolation).
|
|
470
|
+
expect(res.details.isolation).toBeUndefined(); // isolation 字段随 D-B1-6 两档分发砍除退役
|
|
423
471
|
});
|
|
424
472
|
});
|
|
425
473
|
|
|
@@ -427,7 +475,6 @@ describe("registerPlanTool", () => {
|
|
|
427
475
|
describe("complete via taiji form channel", () => {
|
|
428
476
|
beforeEach(() => {
|
|
429
477
|
vi.stubEnv("TAIJI_AGENT_EXT_LOG", "1");
|
|
430
|
-
(detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
431
478
|
(handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
|
|
432
479
|
(detectExecSkills as ReturnType<typeof vi.fn>).mockReset();
|
|
433
480
|
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
@@ -451,8 +498,11 @@ describe("registerPlanTool", () => {
|
|
|
451
498
|
return payload.formQuestions[0].options;
|
|
452
499
|
}
|
|
453
500
|
|
|
454
|
-
it("sends a single choice question via UI_FORM_MARKER and maps the execute answer (无 tab
|
|
501
|
+
it("sends a single choice question via UI_FORM_MARKER and maps the execute answer (无 tab 条单视图;D7② 有技能才挂)", async () => {
|
|
455
502
|
const { exec, ctx } = setupGui();
|
|
503
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
504
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
505
|
+
]);
|
|
456
506
|
(ctx.ui.select as ReturnType<typeof vi.fn>)
|
|
457
507
|
.mockResolvedValue(JSON.stringify({ "Execution method": "Execute" }));
|
|
458
508
|
const res = await exec({ action: "complete" });
|
|
@@ -461,6 +511,7 @@ describe("registerPlanTool", () => {
|
|
|
461
511
|
expect(title).toBe(UI_FORM_MARKER);
|
|
462
512
|
const labels = formOptionLabels(ctx.ui.select as ReturnType<typeof vi.fn>).map((o) => o.label);
|
|
463
513
|
expect(labels).toEqual([
|
|
514
|
+
"Execute via skill: dev-flow",
|
|
464
515
|
"Execute",
|
|
465
516
|
"Not now",
|
|
466
517
|
]);
|
|
@@ -482,66 +533,96 @@ describe("registerPlanTool", () => {
|
|
|
482
533
|
const skillOption = options.find((o) => o.label === "Execute via skill: dev-flow");
|
|
483
534
|
expect(skillOption?.description).toBe("Deliver a plan via dev-flow.");
|
|
484
535
|
expect(res.details.execMode).toBe("skill:dev-flow");
|
|
485
|
-
|
|
536
|
+
// 对话档来源标记 + 表单恰挂一次 + 无直通文案(原 rpc 非空独立用例的增量断言并入)
|
|
537
|
+
expect(res.details.execModeSource).toBe("dialog");
|
|
538
|
+
expect(ctx.ui.select).toHaveBeenCalledOnce();
|
|
539
|
+
expect(res.content[0].text).not.toContain("No plan-exec skill");
|
|
540
|
+
expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "skill:dev-flow", skillEntryPath);
|
|
486
541
|
});
|
|
487
542
|
|
|
488
|
-
it("timeout via undefined resolve (signal not aborted) folds to
|
|
543
|
+
it("timeout via undefined resolve (signal not aborted) folds to 归口②外部解散(构造点④,review_aborted→approved)", async () => {
|
|
489
544
|
// rpc 模式 GUI 用户取消 resolve undefined,与超时不可区分(signal 未 abort 折叠
|
|
490
|
-
// timeout,库层 callMarkerRpc
|
|
491
|
-
//
|
|
545
|
+
// timeout,库层 callMarkerRpc 判别),构造点③④同折 via 'dissolved' reason='cancelled'。
|
|
546
|
+
// 未经入口解散(不打标)→ 外部解散:批准事实保留,文案不得声称已退出
|
|
492
547
|
const { exec, ctx, pi } = setupGui();
|
|
548
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
549
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
550
|
+
]);
|
|
493
551
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
494
552
|
const res = await exec({ action: "complete" });
|
|
495
553
|
expect(res.details.action).toBe("complete-cancelled");
|
|
496
554
|
expect(res.details.reason).toBe("cancelled");
|
|
497
|
-
expect(res.
|
|
498
|
-
expect(res.content[0].text).
|
|
555
|
+
expect(res.details.source).toBe("external");
|
|
556
|
+
expect(res.content[0].text).toContain("interrupted");
|
|
557
|
+
expect(res.content[0].text).toContain("plan(action='complete')");
|
|
558
|
+
expect(res.content[0].text).not.toContain("has been exited");
|
|
499
559
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
500
560
|
});
|
|
501
561
|
|
|
502
|
-
it("
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
562
|
+
it("命令解散(handleAbort 全序列:markDissolved('self') + controller.abort() + reset 介入)→ 归口② no-op(dissolvedBy 判别,不落盘)", async () => {
|
|
563
|
+
// 真实通道注入 = command.ts exitPlanMode 的因果链:markDissolved('self') 与
|
|
564
|
+
// controller.abort() 后 resetPlanState 同步落盘(同步临界段,无让出点)——归口点
|
|
565
|
+
// 在微任务里运行时来源已置位
|
|
506
566
|
const { exec, ctx, controllers, pi } = setupGui();
|
|
567
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
568
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
569
|
+
]);
|
|
507
570
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
508
|
-
|
|
571
|
+
// 模拟 exitPlanMode 入口动作(真实序列:markDissolved 先于 abort)
|
|
572
|
+
const pending = controllers.get("test-session");
|
|
573
|
+
pending?.markDissolved("self");
|
|
574
|
+
pending?.controller.abort();
|
|
509
575
|
return undefined;
|
|
510
576
|
});
|
|
511
577
|
const res = await exec({ action: "complete" });
|
|
512
578
|
expect(res.details.action).toBe("complete-cancelled");
|
|
513
579
|
expect(res.details.reason).toBe("cancelled");
|
|
514
|
-
expect(res.
|
|
580
|
+
expect(res.details.source).toBe("self");
|
|
581
|
+
expect(res.content[0].text).toContain("has been exited");
|
|
582
|
+
expect(res.content[0].text).toContain("full tool set is restored");
|
|
583
|
+
// 归口 no-op:不追加任何落盘(reset 已由命令侧落终态)——最后一条仍是入函数时的 dispatching
|
|
584
|
+
const entries = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1] as { state?: string });
|
|
585
|
+
expect(entries.at(-1)?.state).toBe("dispatching");
|
|
515
586
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
516
587
|
});
|
|
517
588
|
|
|
518
|
-
it("channel-error (echo payload) folds to
|
|
589
|
+
it("channel-error (echo payload) folds to 归口②外部解散(构造点⑤)with channel note, no throw", async () => {
|
|
519
590
|
const { exec, ctx, pi } = setupGui();
|
|
591
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
592
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
593
|
+
]);
|
|
520
594
|
// echo 检测:宿主不识别 UI_FORM_MARKER 时 band 单选项 = payload 自身,点选即回显
|
|
521
595
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockImplementation(async (_t: string, options: string[]) => options[0]);
|
|
522
596
|
const res = await exec({ action: "complete" });
|
|
523
597
|
expect(res.details.action).toBe("complete-cancelled");
|
|
524
598
|
expect(res.details.reason).toBe("channel-error");
|
|
525
|
-
expect(res.
|
|
599
|
+
expect(res.details.source).toBe("external");
|
|
600
|
+
expect(res.content[0].text).toContain("interrupted");
|
|
601
|
+
expect(res.content[0].text).toContain("channel-error"); // 构造点原因透出
|
|
526
602
|
expect(res.content[0].text).toContain("upgrade taiji"); // echo 升级指引透出
|
|
527
603
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
528
604
|
});
|
|
529
605
|
|
|
530
|
-
it("non-json response folds to
|
|
606
|
+
it("non-json response folds to 归口②外部解散(构造点⑤)with channel note, no throw", async () => {
|
|
531
607
|
const { exec, ctx } = setupGui();
|
|
608
|
+
(detectExecSkills as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
609
|
+
{ name: "dev-flow", description: "d", skillEntryPath: "/tmp/skills/dev-flow/SKILL.md" },
|
|
610
|
+
]);
|
|
532
611
|
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
|
|
533
612
|
const res = await exec({ action: "complete" });
|
|
534
613
|
expect(res.details.action).toBe("complete-cancelled");
|
|
535
614
|
expect(res.details.reason).toBe("non-json");
|
|
536
|
-
expect(res.
|
|
615
|
+
expect(res.details.source).toBe("external");
|
|
616
|
+
expect(res.content[0].text).toContain("interrupted");
|
|
617
|
+
expect(res.content[0].text).toContain("non-json");
|
|
537
618
|
});
|
|
538
619
|
});
|
|
539
620
|
|
|
540
621
|
// --- abort ---
|
|
541
622
|
describe("abort", () => {
|
|
542
|
-
it("resets state and cleans up session", async () => {
|
|
543
|
-
const { exec, pi, sessions } = setup();
|
|
544
|
-
// Pre-populate a session(工具层 abort 走
|
|
623
|
+
it("resets state and cleans up session — exit 边(活跃族非终态→exited)", async () => {
|
|
624
|
+
const { exec, pi, sessions, ctx } = setup();
|
|
625
|
+
// Pre-populate a session(工具层 abort 走 exitPlanMode 单入口——命令层 abort 联动的顺序断言在 command.test.ts)
|
|
545
626
|
sessions.set("test-session", {
|
|
546
627
|
...DEFAULT_PLAN_STATE,
|
|
547
628
|
isActive: true,
|
|
@@ -550,19 +631,86 @@ describe("registerPlanTool", () => {
|
|
|
550
631
|
templateName: "t",
|
|
551
632
|
skills: ["tech-design"],
|
|
552
633
|
docs: [{ fileName: "design.md", absPath: "/tmp/design.md", sourceSkill: "tech-design", version: 1 }],
|
|
553
|
-
|
|
634
|
+
state: "reviewing",
|
|
554
635
|
});
|
|
555
636
|
const res = await exec({ action: "abort" });
|
|
556
637
|
expect(res.details.action).toBe("abort");
|
|
557
638
|
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
558
639
|
expect(sessions.has("test-session")).toBe(false);
|
|
559
|
-
expect(updatePlanWidget).
|
|
560
|
-
// 终态矩阵:reset entry 落 isActive=false + skills/
|
|
561
|
-
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: false }));
|
|
640
|
+
expect(updatePlanWidget).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ isActive: false, state: "exited" }));
|
|
641
|
+
// 终态矩阵:reset entry 落 isActive=false + state='exited' + skills/selfReview 清空 + docs 保留
|
|
642
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: false, state: "exited" }));
|
|
562
643
|
const entry = (pi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
|
563
644
|
expect(entry.docs).toHaveLength(1);
|
|
564
645
|
expect(entry.skills).toEqual([]);
|
|
565
|
-
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("终态上 abort → 幂等退出:warn 纠偏 + 不覆写 completed、不落盘 + ④工具集幂等恢复(D-B1-1 守卫极性)", async () => {
|
|
649
|
+
const { exec, pi, sessions } = setup();
|
|
650
|
+
sessions.set("test-session", {
|
|
651
|
+
...DEFAULT_PLAN_STATE,
|
|
652
|
+
isActive: false,
|
|
653
|
+
state: "completed",
|
|
654
|
+
docs: [{ fileName: "design.md", absPath: "/tmp/design.md", sourceSkill: "", version: 1 }],
|
|
655
|
+
});
|
|
656
|
+
const res = await exec({ action: "abort" });
|
|
657
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
658
|
+
expect(res.content[0].text).toContain("Plan mode is not active");
|
|
659
|
+
// ok:false 不落盘(completed 终态不被覆写为 exited)
|
|
660
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
661
|
+
// ④ 工具集照常幂等恢复(D-B1-1:ok:false → ①③④⑤照常,已恢复则 no-op)
|
|
662
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("idle 常态格 abort → warn 纠偏文案、不落盘(D-B1-1 行为变更:噪音 exited entry 消灭,V1 ⑧ 锚)", async () => {
|
|
666
|
+
const { exec, pi, sessions } = setup();
|
|
667
|
+
// idle 常态格:从未进 plan(isActive=false + state='idle'——删 exit 边后 ok:false)
|
|
668
|
+
sessions.set("test-session", { ...DEFAULT_PLAN_STATE });
|
|
669
|
+
const res = await exec({ action: "abort" });
|
|
670
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
671
|
+
expect(res.content[0].text).toBe("No active plan mode.");
|
|
672
|
+
// 行为变更核心断言:不落 exited 噪音 entry
|
|
673
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
674
|
+
// ④ 幂等恢复(idle 格工具集已全量,重设 = 可观察零变化)
|
|
675
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
676
|
+
expect(sessions.has("test-session")).toBe(true); // 无 reset(缓存不动)
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("坏数据格·终态残留(isActive=true + state=completed)abort → 清洗 entry(终态值不变 + isActive=false,不覆写为 exited)", async () => {
|
|
680
|
+
const { exec, pi, sessions } = setup();
|
|
681
|
+
sessions.set("test-session", {
|
|
682
|
+
...DEFAULT_PLAN_STATE,
|
|
683
|
+
isActive: true, // 坏格:伪 active 投影
|
|
684
|
+
planFilePath: "/tmp/plans/auth/plan.md",
|
|
685
|
+
state: "completed",
|
|
686
|
+
docs: [{ fileName: "design.md", absPath: "/tmp/plans/auth/design.md", sourceSkill: "", version: 1 }],
|
|
687
|
+
});
|
|
688
|
+
const res = await exec({ action: "abort" });
|
|
689
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
690
|
+
// 清洗例外:唯一保留的落盘——isActive 投影复位 + state 取当前终态值(completed 不被 exited 覆写)
|
|
691
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
692
|
+
"plan-state",
|
|
693
|
+
expect.objectContaining({ isActive: false, state: "completed" }),
|
|
694
|
+
);
|
|
695
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
696
|
+
expect(sessions.has("test-session")).toBe(false); // 清洗后缓存清理(重开经 entry 恢复正常态)
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("坏数据格·idle 残留(isActive=true + state=idle)abort → 清洗 entry(exited + isActive=false)", async () => {
|
|
700
|
+
const { exec, pi, sessions } = setup();
|
|
701
|
+
sessions.set("test-session", {
|
|
702
|
+
...DEFAULT_PLAN_STATE,
|
|
703
|
+
isActive: true, // 坏格:'idle' 是值域白名单合法值 + isActive=true → 重开复活伪 active
|
|
704
|
+
planFilePath: "/tmp/plans/auth/plan.md",
|
|
705
|
+
state: "idle",
|
|
706
|
+
});
|
|
707
|
+
const res = await exec({ action: "abort" });
|
|
708
|
+
expect(res.details).toEqual({ action: "review-error", reason: "inactive" });
|
|
709
|
+
// 清洗例外 idle 残留形态:abort 是用户显式退出意图,exited 是本次动作的真实记录
|
|
710
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
711
|
+
"plan-state",
|
|
712
|
+
expect.objectContaining({ isActive: false, state: "exited" }),
|
|
713
|
+
);
|
|
566
714
|
});
|
|
567
715
|
});
|
|
568
716
|
});
|