@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
|
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
18
18
|
|
|
19
19
|
import { parsePlanArgs, registerPlanCommand } from "../command.js";
|
|
20
20
|
import { resolveSkills } from "../enter.js";
|
|
21
|
-
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
21
|
+
import { createPlanCtx, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
22
22
|
|
|
23
23
|
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
24
24
|
|
|
@@ -132,13 +132,22 @@ describe("resolveSkills(E1 技能枚举比对,双向剥 skill: 前缀归一
|
|
|
132
132
|
expect(resolution.missing).toEqual(["tech-desig"]);
|
|
133
133
|
}
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("empty request = 同族 fail-fast:ok:false + 全量 available + 空 missing(空 --skills 报错材料复用本函数产出)", () => {
|
|
137
|
+
const resolution = resolveSkills(pi, []);
|
|
138
|
+
expect(resolution.ok).toBe(false);
|
|
139
|
+
if (!resolution.ok) {
|
|
140
|
+
// missing 空 → 报错器走「no skill names followed it」文案分支;available 同源枚举
|
|
141
|
+
expect(resolution.missing).toEqual([]);
|
|
142
|
+
expect(resolution.available).toEqual(["skill:tech-design", "skill:dev-flow", "skill:code review"]);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
135
145
|
});
|
|
136
146
|
|
|
137
147
|
describe("E1 fail-fast via /plan handler", () => {
|
|
138
148
|
let pi: ExtensionAPI;
|
|
139
149
|
let ctx: ExtensionContext;
|
|
140
150
|
let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
141
|
-
let controllers: Map<string, AbortController>;
|
|
142
151
|
|
|
143
152
|
beforeEach(() => {
|
|
144
153
|
vi.clearAllMocks();
|
|
@@ -157,6 +166,7 @@ describe("E1 fail-fast via /plan handler", () => {
|
|
|
157
166
|
cwd: "/tmp/test-project",
|
|
158
167
|
sessionManager: {
|
|
159
168
|
getSessionId: () => "test-session",
|
|
169
|
+
getLeafId: () => null,
|
|
160
170
|
getEntries: () => [] as unknown[],
|
|
161
171
|
},
|
|
162
172
|
ui: {
|
|
@@ -166,8 +176,7 @@ describe("E1 fail-fast via /plan handler", () => {
|
|
|
166
176
|
theme: { fg: (_t: string, text: string) => text },
|
|
167
177
|
},
|
|
168
178
|
} as unknown as ExtensionContext;
|
|
169
|
-
|
|
170
|
-
registerPlanCommand(pi, new Map(), controllers);
|
|
179
|
+
registerPlanCommand(pi, createPlanCtx());
|
|
171
180
|
handler = capturedHandler!;
|
|
172
181
|
});
|
|
173
182
|
|
|
@@ -221,7 +230,7 @@ describe("E1 fail-fast via /plan handler", () => {
|
|
|
221
230
|
it("valid skills: enters plan mode, persists skills and injects skill paths in the prompt", async () => {
|
|
222
231
|
await handler("重构 auth --skills tech-design,code review", ctx);
|
|
223
232
|
|
|
224
|
-
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
233
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
|
|
225
234
|
// slug 只保留 [a-z0-9]:「重构 auth」→ "auth"
|
|
226
235
|
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth", { recursive: true });
|
|
227
236
|
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
@@ -3,12 +3,13 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { PLAN_SELF_REVIEW_MAX_BYTES } from "@zhushanwen/extension-protocol";
|
|
6
7
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
capPlanRequirement,
|
|
10
11
|
DEFAULT_PLAN_STATE,
|
|
11
|
-
|
|
12
|
+
freshPendingSelect,
|
|
12
13
|
getPlanState,
|
|
13
14
|
MAX_PLAN_REQUIREMENT_LENGTH,
|
|
14
15
|
persistPlanState,
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
type PlanState,
|
|
18
19
|
reconstructPlanState,
|
|
19
20
|
resetPlanState,
|
|
21
|
+
clearRoundFields,
|
|
22
|
+
createPlanCtx,
|
|
20
23
|
} from "../state.js";
|
|
21
24
|
|
|
22
25
|
describe("PlanState", () => {
|
|
@@ -27,8 +30,10 @@ describe("PlanState", () => {
|
|
|
27
30
|
expect(DEFAULT_PLAN_STATE.templateName).toBe("");
|
|
28
31
|
expect(DEFAULT_PLAN_STATE.skills).toEqual([]);
|
|
29
32
|
expect(DEFAULT_PLAN_STATE.docs).toEqual([]);
|
|
30
|
-
|
|
31
|
-
expect(DEFAULT_PLAN_STATE.
|
|
33
|
+
// D1/D2:缺省态 = 生命周期 'idle'(无 plan);selfReview/resumeHint 无值
|
|
34
|
+
expect(DEFAULT_PLAN_STATE.state).toBe("idle");
|
|
35
|
+
expect(DEFAULT_PLAN_STATE.selfReview).toBeUndefined();
|
|
36
|
+
expect(DEFAULT_PLAN_STATE.resumeHint).toBeUndefined();
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
it("getPlanState returns cached state if exists", () => {
|
|
@@ -37,7 +42,7 @@ describe("PlanState", () => {
|
|
|
37
42
|
sessions.set("session-1", cached);
|
|
38
43
|
|
|
39
44
|
const mockCtx = {
|
|
40
|
-
sessionManager: { getEntries: () => [] },
|
|
45
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [] },
|
|
41
46
|
} as unknown as ExtensionContext;
|
|
42
47
|
|
|
43
48
|
const result = getPlanState(sessions, "session-1", mockCtx);
|
|
@@ -48,6 +53,7 @@ describe("PlanState", () => {
|
|
|
48
53
|
const sessions: PlanSessionMap = new Map();
|
|
49
54
|
const mockCtx = {
|
|
50
55
|
sessionManager: {
|
|
56
|
+
getLeafId: () => null,
|
|
51
57
|
getEntries: () => [
|
|
52
58
|
{
|
|
53
59
|
type: "custom",
|
|
@@ -65,7 +71,7 @@ describe("PlanState", () => {
|
|
|
65
71
|
});
|
|
66
72
|
|
|
67
73
|
describe("State persistence", () => {
|
|
68
|
-
it("persistPlanState calls appendEntry with the full schema (
|
|
74
|
+
it("persistPlanState calls appendEntry with the full schema (state/selfReview/resumeHint 取代式 — D2;无 phase — D6)", () => {
|
|
69
75
|
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
70
76
|
const state: PlanState = {
|
|
71
77
|
isActive: true,
|
|
@@ -74,39 +80,48 @@ describe("State persistence", () => {
|
|
|
74
80
|
templateName: "feature-plan",
|
|
75
81
|
skills: ["tech-design"],
|
|
76
82
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
|
|
77
|
-
|
|
83
|
+
state: "reviewing",
|
|
84
|
+
selfReview: "covered all 3 requirements",
|
|
85
|
+
resumeHint: "resubmit",
|
|
78
86
|
};
|
|
79
87
|
|
|
80
88
|
persistPlanState(mockPi, state);
|
|
81
89
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
90
|
+
// 精确匹配:新写 entry 全字段 schema(D2 取代式——只落 state/resumeHint/selfReview,
|
|
91
|
+
// reviewState/reviewStateSource 停写);无值 optional 字段为 undefined
|
|
84
92
|
// (JSON 序列化自然消失——D4)
|
|
85
93
|
expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
86
94
|
isActive: true,
|
|
87
95
|
planFilePath: ".tmp/plans/test/plan.md",
|
|
88
96
|
requirement: "test requirement",
|
|
89
97
|
templateName: "feature-plan",
|
|
98
|
+
templateProvided: undefined,
|
|
90
99
|
skills: ["tech-design"],
|
|
91
100
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
state: "reviewing",
|
|
102
|
+
selfReview: "covered all 3 requirements",
|
|
103
|
+
resumeHint: "resubmit",
|
|
94
104
|
lastSubmitReviewDocsFingerprint: undefined,
|
|
95
105
|
});
|
|
106
|
+
// 停写断言(取代式演进,D2):旧键不得回写
|
|
107
|
+
const entry = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
|
|
108
|
+
expect("reviewState" in entry).toBe(false);
|
|
109
|
+
expect("reviewStateSource" in entry).toBe(false);
|
|
96
110
|
});
|
|
97
111
|
|
|
98
112
|
it("reconstructPlanState returns DEFAULT_PLAN_STATE when no entries", () => {
|
|
99
113
|
const mockCtx = {
|
|
100
|
-
sessionManager: { getEntries: () => [] },
|
|
114
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [] },
|
|
101
115
|
} as unknown as ExtensionContext;
|
|
102
116
|
|
|
103
117
|
const state = reconstructPlanState(mockCtx);
|
|
104
118
|
expect(state).toEqual(DEFAULT_PLAN_STATE);
|
|
105
119
|
});
|
|
106
120
|
|
|
107
|
-
it("reconstructPlanState restores
|
|
121
|
+
it("reconstructPlanState restores state/selfReview/resumeHint from new-schema entries (D1/D2)", () => {
|
|
108
122
|
const mockCtx = {
|
|
109
123
|
sessionManager: {
|
|
124
|
+
getLeafId: () => null,
|
|
110
125
|
getEntries: () => [
|
|
111
126
|
{
|
|
112
127
|
type: "custom",
|
|
@@ -118,7 +133,9 @@ describe("State persistence", () => {
|
|
|
118
133
|
templateName: "",
|
|
119
134
|
skills: ["tech-design", "dev-flow"],
|
|
120
135
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
|
|
121
|
-
|
|
136
|
+
state: "dispatching",
|
|
137
|
+
selfReview: "all covered",
|
|
138
|
+
resumeHint: "resubmit",
|
|
122
139
|
},
|
|
123
140
|
},
|
|
124
141
|
],
|
|
@@ -131,12 +148,38 @@ describe("State persistence", () => {
|
|
|
131
148
|
expect(state.docs).toEqual([
|
|
132
149
|
{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 },
|
|
133
150
|
]);
|
|
134
|
-
expect(state.
|
|
151
|
+
expect(state.state).toBe("dispatching");
|
|
152
|
+
expect(state.selfReview).toBe("all covered");
|
|
153
|
+
expect(state.resumeHint).toBe("resubmit");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("旧 entry 无 state:reviewState 映射(awaiting→reviewing / revising→revising)+ reviewStateSource→resumeHint 同义映射(D2 读方①)", () => {
|
|
157
|
+
const entryFor = (data: Record<string, unknown>) => ({
|
|
158
|
+
sessionManager: {
|
|
159
|
+
getEntries: () => [{ type: "custom", customType: "plan-state", data }],
|
|
160
|
+
},
|
|
161
|
+
}) as unknown as ExtensionContext;
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "awaiting" })).state,
|
|
165
|
+
).toBe("reviewing");
|
|
166
|
+
expect(
|
|
167
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "revising" })).state,
|
|
168
|
+
).toBe("revising");
|
|
169
|
+
// reviewStateSource:'resubmit' → resumeHint:'resubmit'(同义映射,consumers.md 二①)
|
|
170
|
+
expect(
|
|
171
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "awaiting", reviewStateSource: "resubmit" })).resumeHint,
|
|
172
|
+
).toBe("resubmit");
|
|
173
|
+
// 新字段优先于旧字段映射(混装过渡格:同 entry 双写形态也取新值)
|
|
174
|
+
expect(
|
|
175
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", state: "approved", reviewState: "awaiting" })).state,
|
|
176
|
+
).toBe("approved");
|
|
135
177
|
});
|
|
136
178
|
|
|
137
179
|
it("old four-field entries (no new fields) reconstruct with field-level fallback (D4 兼容读)", () => {
|
|
138
180
|
const mockCtx = {
|
|
139
181
|
sessionManager: {
|
|
182
|
+
getLeafId: () => null,
|
|
140
183
|
getEntries: () => [
|
|
141
184
|
{
|
|
142
185
|
type: "custom",
|
|
@@ -159,12 +202,14 @@ describe("State persistence", () => {
|
|
|
159
202
|
// 新字段降级为空清单/无值(前端据此显示「(未指定)」+ 单文件形态)
|
|
160
203
|
expect(state.skills).toEqual([]);
|
|
161
204
|
expect(state.docs).toEqual([]);
|
|
162
|
-
|
|
205
|
+
// D2 读方①:无 state 无 reviewState → 按 isActive 推断(isActive=true → planning)
|
|
206
|
+
expect(state.state).toBe("planning");
|
|
163
207
|
});
|
|
164
208
|
|
|
165
209
|
it("malformed new-field values are dropped, not propagated (垃圾数据不进内存态)", () => {
|
|
166
210
|
const mockCtx = {
|
|
167
211
|
sessionManager: {
|
|
212
|
+
getLeafId: () => null,
|
|
168
213
|
getEntries: () => [
|
|
169
214
|
{
|
|
170
215
|
type: "custom",
|
|
@@ -176,6 +221,9 @@ describe("State persistence", () => {
|
|
|
176
221
|
templateName: "",
|
|
177
222
|
skills: ["ok", 42, null],
|
|
178
223
|
docs: [{ fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 }, "junk", { bad: true }],
|
|
224
|
+
state: "bogus-state",
|
|
225
|
+
selfReview: 42,
|
|
226
|
+
resumeHint: "explain",
|
|
179
227
|
reviewState: "corrupted",
|
|
180
228
|
},
|
|
181
229
|
},
|
|
@@ -188,13 +236,18 @@ describe("State persistence", () => {
|
|
|
188
236
|
expect(state.docs).toEqual([
|
|
189
237
|
{ fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 },
|
|
190
238
|
]);
|
|
191
|
-
|
|
239
|
+
// state 值域外垃圾按缺失处理 → 落旧字段映射(reviewState 也坏)→ 按 isActive 推断
|
|
240
|
+
expect(state.state).toBe("planning");
|
|
241
|
+
expect(state.selfReview).toBeUndefined();
|
|
242
|
+
// resumeHint 值域守卫:'resubmit' 之外(含旧 'explain' 存量值)按无值处理
|
|
243
|
+
expect(state.resumeHint).toBeUndefined();
|
|
192
244
|
});
|
|
193
245
|
|
|
194
246
|
it("reconstructPlanState ignores the legacy phase field in old entries (D6 兼容读 — V5②)", () => {
|
|
195
247
|
// 旧版(含 phase)写的 entry:重开后 plan mode 重建正常,phase 被白名单式读取自然忽略
|
|
196
248
|
const mockCtx = {
|
|
197
249
|
sessionManager: {
|
|
250
|
+
getLeafId: () => null,
|
|
198
251
|
getEntries: () => [
|
|
199
252
|
{
|
|
200
253
|
type: "custom",
|
|
@@ -212,7 +265,9 @@ describe("State persistence", () => {
|
|
|
212
265
|
} as unknown as ExtensionContext;
|
|
213
266
|
|
|
214
267
|
const state = reconstructPlanState(mockCtx);
|
|
215
|
-
|
|
268
|
+
// D2/D9:重建产物键集 = 四现状 + state/selfReview/resumeHint + skills/docs + 指纹
|
|
269
|
+
//(reviewState/reviewStateSource 不再是内存态字段,仅映射读)
|
|
270
|
+
expect(Object.keys(state).sort()).toEqual(["docs", "isActive", "lastSubmitReviewDocsFingerprint", "planFilePath", "requirement", "resumeHint", "selfReview", "skills", "state", "templateName", "templateProvided"]);
|
|
216
271
|
expect(state.isActive).toBe(true);
|
|
217
272
|
});
|
|
218
273
|
|
|
@@ -225,7 +280,7 @@ describe("State persistence", () => {
|
|
|
225
280
|
templateName: "",
|
|
226
281
|
skills: [],
|
|
227
282
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
|
|
228
|
-
|
|
283
|
+
state: "reviewing",
|
|
229
284
|
lastSubmitReviewDocsFingerprint: "design.md:1",
|
|
230
285
|
};
|
|
231
286
|
|
|
@@ -235,13 +290,14 @@ describe("State persistence", () => {
|
|
|
235
290
|
// 重提交无变化检测仍能比对到上次基线)
|
|
236
291
|
const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
|
237
292
|
const reopenCtx = {
|
|
238
|
-
sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
293
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
239
294
|
} as unknown as ExtensionContext;
|
|
240
295
|
expect(reconstructPlanState(reopenCtx).lastSubmitReviewDocsFingerprint).toBe("design.md:1");
|
|
241
296
|
|
|
242
297
|
// 垃圾数据不进内存态:非 string 指纹按无既往提交处理(不警告语义)
|
|
243
298
|
const badCtx = {
|
|
244
299
|
sessionManager: {
|
|
300
|
+
getLeafId: () => null,
|
|
245
301
|
getEntries: () => [
|
|
246
302
|
{
|
|
247
303
|
type: "custom",
|
|
@@ -254,7 +310,7 @@ describe("State persistence", () => {
|
|
|
254
310
|
expect(reconstructPlanState(badCtx).lastSubmitReviewDocsFingerprint).toBeUndefined();
|
|
255
311
|
});
|
|
256
312
|
|
|
257
|
-
it("
|
|
313
|
+
it("resumeHint persists and reconstructs(D2);旧 reviewStateSource 同义映射 / 垃圾值按无值处理", () => {
|
|
258
314
|
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
259
315
|
const state: PlanState = {
|
|
260
316
|
isActive: true,
|
|
@@ -263,8 +319,8 @@ describe("State persistence", () => {
|
|
|
263
319
|
templateName: "",
|
|
264
320
|
skills: [],
|
|
265
321
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
|
|
266
|
-
|
|
267
|
-
|
|
322
|
+
state: "reviewing",
|
|
323
|
+
resumeHint: "resubmit",
|
|
268
324
|
};
|
|
269
325
|
|
|
270
326
|
persistPlanState(mockPi, state);
|
|
@@ -272,13 +328,14 @@ describe("State persistence", () => {
|
|
|
272
328
|
// 持久化 entry 走冷启动重建:resubmit 等待态跨重开可恢复(E3 重挂同款受益)
|
|
273
329
|
const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
|
274
330
|
const reopenCtx = {
|
|
275
|
-
sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
331
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
276
332
|
} as unknown as ExtensionContext;
|
|
277
|
-
expect(reconstructPlanState(reopenCtx).
|
|
333
|
+
expect(reconstructPlanState(reopenCtx).resumeHint).toBe("resubmit");
|
|
278
334
|
|
|
279
|
-
// 旧 entry
|
|
335
|
+
// 旧 entry(升级前落盘)无该字段且无旧 reviewStateSource → 无值(renderer 渲染通用降级文案)
|
|
280
336
|
const legacyCtx = {
|
|
281
337
|
sessionManager: {
|
|
338
|
+
getLeafId: () => null,
|
|
282
339
|
getEntries: () => [
|
|
283
340
|
{
|
|
284
341
|
type: "custom",
|
|
@@ -288,27 +345,114 @@ describe("State persistence", () => {
|
|
|
288
345
|
],
|
|
289
346
|
},
|
|
290
347
|
} as unknown as ExtensionContext;
|
|
291
|
-
expect(reconstructPlanState(legacyCtx).
|
|
348
|
+
expect(reconstructPlanState(legacyCtx).resumeHint).toBeUndefined();
|
|
292
349
|
|
|
293
350
|
// 旧版 'explain' 存量值(explain 交互已删)与垃圾值一并按无值处理(值域守卫白名单只认 'resubmit')
|
|
294
351
|
const explainCtx = {
|
|
295
352
|
sessionManager: {
|
|
353
|
+
getLeafId: () => null,
|
|
296
354
|
getEntries: () => [
|
|
297
|
-
{ type: "custom", customType: "plan-state", data: { ...persisted,
|
|
355
|
+
{ type: "custom", customType: "plan-state", data: { ...persisted, resumeHint: "explain" } },
|
|
298
356
|
],
|
|
299
357
|
},
|
|
300
358
|
} as unknown as ExtensionContext;
|
|
301
|
-
expect(reconstructPlanState(explainCtx).
|
|
359
|
+
expect(reconstructPlanState(explainCtx).resumeHint).toBeUndefined();
|
|
302
360
|
|
|
303
|
-
// 值域守卫:'resubmit' 之外的垃圾值按无值处理(与
|
|
361
|
+
// 值域守卫:'resubmit' 之外的垃圾值按无值处理(与 readResumeHint 同风格)
|
|
304
362
|
const badCtx = {
|
|
305
363
|
sessionManager: {
|
|
364
|
+
getLeafId: () => null,
|
|
306
365
|
getEntries: () => [
|
|
307
|
-
{ type: "custom", customType: "plan-state", data: { ...persisted,
|
|
366
|
+
{ type: "custom", customType: "plan-state", data: { ...persisted, resumeHint: "bogus" } },
|
|
308
367
|
],
|
|
309
368
|
},
|
|
310
369
|
} as unknown as ExtensionContext;
|
|
311
|
-
expect(reconstructPlanState(badCtx).
|
|
370
|
+
expect(reconstructPlanState(badCtx).resumeHint).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("templateProvided 直传标记:新字段直读 + 旧 templateProvidedPath 映射(双字段合并后 entry 级旧字段映射——select-template 直传防御行为等价)", () => {
|
|
374
|
+
const entryFor = (data: Record<string, unknown>) => ({
|
|
375
|
+
sessionManager: {
|
|
376
|
+
getEntries: () => [{ type: "custom", customType: "plan-state", data }],
|
|
377
|
+
},
|
|
378
|
+
}) as unknown as ExtensionContext;
|
|
379
|
+
|
|
380
|
+
// 新形态直读:templateProvided=true → 直传格(防御拦截源)
|
|
381
|
+
expect(
|
|
382
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "t", templateProvided: true })).templateProvided,
|
|
383
|
+
).toBe(true);
|
|
384
|
+
// 模板流程格:字段缺失
|
|
385
|
+
expect(
|
|
386
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "" })).templateProvided,
|
|
387
|
+
).toBeUndefined();
|
|
388
|
+
// 旧双字段形态重放:templateProvidedPath 存在即直传(值不保留——路径无消费方)
|
|
389
|
+
expect(
|
|
390
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "t", templateProvidedPath: "/x/t.md" })).templateProvided,
|
|
391
|
+
).toBe(true);
|
|
392
|
+
// 垃圾值不进内存态:非 true 直读值与旧字段非 string 一并按模板流程处理
|
|
393
|
+
expect(
|
|
394
|
+
reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", templateProvided: "yes", templateProvidedPath: 42 })).templateProvided,
|
|
395
|
+
).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("selfReview 重建读侧 4KB 截断防御(D9③/R3:超长旧 entry 不整段进内存态,E3 回传恒有界)", () => {
|
|
399
|
+
const oversized = "s".repeat(PLAN_SELF_REVIEW_MAX_BYTES + 100);
|
|
400
|
+
const mockCtx = {
|
|
401
|
+
sessionManager: {
|
|
402
|
+
getEntries: () => [
|
|
403
|
+
{ type: "custom", customType: "plan-state", data: { isActive: true, state: "reviewing", selfReview: oversized } },
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
} as unknown as ExtensionContext;
|
|
407
|
+
const restored = reconstructPlanState(mockCtx).selfReview;
|
|
408
|
+
expect(restored).toBeDefined();
|
|
409
|
+
expect(restored!.length).toBeLessThan(oversized.length);
|
|
410
|
+
expect(PLAN_SELF_REVIEW_MAX_BYTES - restored!.length).toBeLessThan(4);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("clearRoundFields(D4 per-round 字段清理单函数出口)", () => {
|
|
415
|
+
/** 构造带全部 per-round 字段残留的状态(上一轮 submit-review + E3 重挂后的形态)。 */
|
|
416
|
+
function stateWithRoundResidue(): PlanState {
|
|
417
|
+
return {
|
|
418
|
+
...DEFAULT_PLAN_STATE,
|
|
419
|
+
isActive: true,
|
|
420
|
+
state: "reviewing",
|
|
421
|
+
selfReview: "上一轮的 selfReview",
|
|
422
|
+
resumeHint: "resubmit",
|
|
423
|
+
lastSubmitReviewDocsFingerprint: "design.md:2",
|
|
424
|
+
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
it("三字段(selfReview/resumeHint/指纹)一并清除——单出口锚:per-round 字段集只在本函数表达", () => {
|
|
429
|
+
const state = stateWithRoundResidue();
|
|
430
|
+
clearRoundFields(state);
|
|
431
|
+
expect(state.selfReview).toBeUndefined();
|
|
432
|
+
expect(state.resumeHint).toBeUndefined();
|
|
433
|
+
expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("非本轮字段不动:isActive/state/docs 等跨轮字段保持原值", () => {
|
|
437
|
+
const state = stateWithRoundResidue();
|
|
438
|
+
clearRoundFields(state);
|
|
439
|
+
expect(state.isActive).toBe(true);
|
|
440
|
+
expect(state.state).toBe("reviewing");
|
|
441
|
+
expect(state.docs).toEqual([
|
|
442
|
+
{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 },
|
|
443
|
+
]);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("幂等:无残留状态上重复调用不抛错、不引入新值", () => {
|
|
447
|
+
const state = stateWithRoundResidue();
|
|
448
|
+
clearRoundFields(state);
|
|
449
|
+
clearRoundFields(state);
|
|
450
|
+
expect(state.resumeHint).toBeUndefined();
|
|
451
|
+
// delete 移除键本身(与 persistPlanState 序列化时 undefined 值键自然消失同形态):
|
|
452
|
+
// 清理后键集 = 跨轮字段集,per-round 三键不残留
|
|
453
|
+
expect(Object.keys(state).sort()).toEqual(
|
|
454
|
+
["docs", "isActive", "planFilePath", "requirement", "skills", "state", "templateName"],
|
|
455
|
+
);
|
|
312
456
|
});
|
|
313
457
|
});
|
|
314
458
|
|
|
@@ -316,7 +460,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
316
460
|
function setupActiveSession() {
|
|
317
461
|
const sessions: PlanSessionMap = new Map();
|
|
318
462
|
const mockCtx = {
|
|
319
|
-
sessionManager: { getEntries: () => [] },
|
|
463
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [] },
|
|
320
464
|
} as unknown as ExtensionContext;
|
|
321
465
|
sessions.set("session-1", {
|
|
322
466
|
isActive: true,
|
|
@@ -325,19 +469,26 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
325
469
|
templateName: "feature-plan",
|
|
326
470
|
skills: ["tech-design", "dev-flow"],
|
|
327
471
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
|
|
328
|
-
|
|
472
|
+
state: "reviewing",
|
|
473
|
+
selfReview: "prev self-review",
|
|
474
|
+
resumeHint: "resubmit",
|
|
475
|
+
lastSubmitReviewDocsFingerprint: "design.md:2",
|
|
329
476
|
});
|
|
330
477
|
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
331
478
|
return { sessions, mockCtx, mockPi };
|
|
332
479
|
}
|
|
333
480
|
|
|
334
|
-
it("isActive=false +
|
|
481
|
+
it("isActive=false + state=terminal(exited) + selfReview/resumeHint/指纹 cleared + skills cleared + docs KEPT", () => {
|
|
335
482
|
const { sessions, mockCtx, mockPi } = setupActiveSession();
|
|
336
483
|
|
|
337
484
|
const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
|
|
338
485
|
|
|
339
486
|
expect(state.isActive).toBe(false);
|
|
340
|
-
|
|
487
|
+
// 终态矩阵(D3 连带段):默认 terminal='exited'
|
|
488
|
+
expect(state.state).toBe("exited");
|
|
489
|
+
// selfReview/resumeHint 随退出失效(clearRoundFields 单函数出口的 reset 调用点,S15 断言)
|
|
490
|
+
expect(state.selfReview).toBeUndefined();
|
|
491
|
+
expect(state.resumeHint).toBeUndefined();
|
|
341
492
|
expect(state.skills).toEqual([]);
|
|
342
493
|
// docs 保留:产物 tab 与 isActive 解耦,执行期/退出后都可回看产物
|
|
343
494
|
expect(state.docs).toEqual([
|
|
@@ -355,9 +506,13 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
355
506
|
planFilePath: "",
|
|
356
507
|
requirement: "",
|
|
357
508
|
templateName: "",
|
|
509
|
+
templateProvided: undefined,
|
|
358
510
|
skills: [],
|
|
359
511
|
docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
|
|
360
|
-
|
|
512
|
+
state: "exited",
|
|
513
|
+
selfReview: undefined,
|
|
514
|
+
resumeHint: undefined,
|
|
515
|
+
lastSubmitReviewDocsFingerprint: undefined,
|
|
361
516
|
});
|
|
362
517
|
expect(sessions.has("session-1")).toBe(false);
|
|
363
518
|
});
|
|
@@ -369,7 +524,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
369
524
|
// 模拟重开:用 reset 落盘的 entry 数据走冷启动重建
|
|
370
525
|
const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1];
|
|
371
526
|
const reopenCtx = {
|
|
372
|
-
sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
527
|
+
sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
|
|
373
528
|
} as unknown as ExtensionContext;
|
|
374
529
|
|
|
375
530
|
const state = reconstructPlanState(reopenCtx);
|
|
@@ -393,19 +548,14 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
393
548
|
expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
|
|
394
549
|
expect(state.docs).toHaveLength(1);
|
|
395
550
|
});
|
|
396
|
-
it("
|
|
551
|
+
it("terminal 参数(D3 连带段):complete 终局传 'completed' 不被 reset 覆写为 'exited'", () => {
|
|
397
552
|
const { sessions, mockCtx, mockPi } = setupActiveSession();
|
|
398
|
-
const active = sessions.get("session-1");
|
|
399
|
-
if (!active) throw new Error("setupActiveSession must seed session-1");
|
|
400
|
-
active.reviewStateSource = "resubmit";
|
|
401
553
|
|
|
402
|
-
const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
|
|
554
|
+
const state = resetPlanState(mockPi, sessions, "session-1", mockCtx, "completed");
|
|
403
555
|
|
|
404
|
-
|
|
405
|
-
// (undefined JSON 序列化自然消失——D4)
|
|
406
|
-
expect(state.reviewStateSource).toBeUndefined();
|
|
556
|
+
expect(state.state).toBe("completed");
|
|
407
557
|
const lastEntry = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
|
|
408
|
-
expect(lastEntry.
|
|
558
|
+
expect(lastEntry.state).toBe("completed");
|
|
409
559
|
});
|
|
410
560
|
|
|
411
561
|
describe("空 slug 目录清理(P3-10)", () => {
|
|
@@ -431,7 +581,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
431
581
|
const planFilePath = makePlanRoot(false);
|
|
432
582
|
const slugDir = join(planFilePath, "..");
|
|
433
583
|
const sessions: PlanSessionMap = new Map();
|
|
434
|
-
const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
|
|
584
|
+
const mockCtx = { sessionManager: { getLeafId: () => null, getEntries: () => [] } } as unknown as ExtensionContext;
|
|
435
585
|
sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
|
|
436
586
|
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
437
587
|
|
|
@@ -444,7 +594,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
444
594
|
const planFilePath = makePlanRoot(true);
|
|
445
595
|
const slugDir = join(planFilePath, "..");
|
|
446
596
|
const sessions: PlanSessionMap = new Map();
|
|
447
|
-
const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
|
|
597
|
+
const mockCtx = { sessionManager: { getLeafId: () => null, getEntries: () => [] } } as unknown as ExtensionContext;
|
|
448
598
|
sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
|
|
449
599
|
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
450
600
|
|
|
@@ -455,18 +605,68 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
|
|
|
455
605
|
});
|
|
456
606
|
});
|
|
457
607
|
|
|
458
|
-
describe("
|
|
459
|
-
it("registers the
|
|
608
|
+
describe("freshPendingSelect(E10 生命周期 + D-B1-2 dissolvedBy 直传原语)", () => {
|
|
609
|
+
it("registers the pending select per session and creates a fresh one on each call", () => {
|
|
460
610
|
const controllers: PlanAbortControllers = new Map();
|
|
461
611
|
|
|
462
|
-
const first =
|
|
612
|
+
const first = freshPendingSelect(controllers, "s1");
|
|
463
613
|
expect(controllers.get("s1")).toBe(first);
|
|
464
614
|
|
|
465
615
|
// 禁复用:第二次调用必须新建(复用已 abort 的 controller 会让 select 瞬时静默取消)
|
|
466
|
-
const second =
|
|
616
|
+
const second = freshPendingSelect(controllers, "s1");
|
|
467
617
|
expect(second).not.toBe(first);
|
|
468
618
|
expect(controllers.get("s1")).toBe(second);
|
|
469
619
|
});
|
|
620
|
+
|
|
621
|
+
it("dissolvedBy 缺省 undefined(外部解散不打标——等待处按「非 'self' 即外部」分派)", () => {
|
|
622
|
+
const controllers: PlanAbortControllers = new Map();
|
|
623
|
+
const pending = freshPendingSelect(controllers, "s1");
|
|
624
|
+
expect(pending.dissolvedBy).toBeUndefined();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("markDissolved('self') 一次置位且对同一闭包可读(来源随挂起闭包直达等待处)", () => {
|
|
628
|
+
const controllers: PlanAbortControllers = new Map();
|
|
629
|
+
const pending = freshPendingSelect(controllers, "s1");
|
|
630
|
+
pending.markDissolved("self");
|
|
631
|
+
expect(pending.dissolvedBy).toBe("self");
|
|
632
|
+
// 闭包身份天然携带轮次记忆:另一个挂起(新轮次)的来源变量独立,不受前轮打标污染
|
|
633
|
+
const next = freshPendingSelect(controllers, "s1");
|
|
634
|
+
expect(next.dissolvedBy).toBeUndefined();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("createPlanCtx / dissolveAll(D-B4-3 单 ctx 对象:两表收敛 + 解散方法承载 exitPlanMode 入口动作)", () => {
|
|
639
|
+
it("两表为空 Map 且独立:states / controllers 各自可读写", () => {
|
|
640
|
+
const planCtx = createPlanCtx();
|
|
641
|
+
expect(planCtx.states.size).toBe(0);
|
|
642
|
+
expect(planCtx.controllers.size).toBe(0);
|
|
643
|
+
const pending = freshPendingSelect(planCtx.controllers, "s1");
|
|
644
|
+
expect(planCtx.controllers.get("s1")).toBe(pending);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("dissolveAll:全部挂起 markDissolved 打标 + controller.abort + 注册表清空(exitPlanMode ① 的承载)", () => {
|
|
648
|
+
const planCtx = createPlanCtx();
|
|
649
|
+
const abortSpy = vi.fn();
|
|
650
|
+
const first = freshPendingSelect(planCtx.controllers, "s1");
|
|
651
|
+
const second = freshPendingSelect(planCtx.controllers, "s2");
|
|
652
|
+
vi.spyOn(first.controller, "abort").mockImplementation(abortSpy);
|
|
653
|
+
vi.spyOn(second.controller, "abort").mockImplementation(abortSpy);
|
|
654
|
+
|
|
655
|
+
planCtx.dissolveAll("self");
|
|
656
|
+
|
|
657
|
+
// 来源随闭包直达等待处(D-B1-2):打标先于 abort 不可在此直接断言(同步序列),
|
|
658
|
+
// 但两挂起都必须置位 + 中止;注册表不留已 dissolved 条目
|
|
659
|
+
expect(first.dissolvedBy).toBe("self");
|
|
660
|
+
expect(second.dissolvedBy).toBe("self");
|
|
661
|
+
expect(abortSpy).toHaveBeenCalledTimes(2);
|
|
662
|
+
expect(planCtx.controllers.size).toBe(0);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("dissolveAll 无挂起时为 no-op(无挂起退出的幂等形态)", () => {
|
|
666
|
+
const planCtx = createPlanCtx();
|
|
667
|
+
expect(() => planCtx.dissolveAll("self")).not.toThrow();
|
|
668
|
+
expect(planCtx.controllers.size).toBe(0);
|
|
669
|
+
});
|
|
470
670
|
});
|
|
471
671
|
|
|
472
672
|
describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LARGE_FIELD_REGISTRY 的有界前提代码化)", () => {
|
|
@@ -490,6 +690,7 @@ describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LAR
|
|
|
490
690
|
const oversized = "z".repeat(MAX_PLAN_REQUIREMENT_LENGTH * 2);
|
|
491
691
|
const mockCtx = {
|
|
492
692
|
sessionManager: {
|
|
693
|
+
getLeafId: () => null,
|
|
493
694
|
getEntries: () => [
|
|
494
695
|
{ type: "custom", customType: "plan-state", data: { isActive: true, requirement: oversized } },
|
|
495
696
|
],
|
|
@@ -504,6 +705,7 @@ describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LAR
|
|
|
504
705
|
it("非 string requirement(含缺失)仍归空串(白名单读取语义不变)", () => {
|
|
505
706
|
const mockCtx = {
|
|
506
707
|
sessionManager: {
|
|
708
|
+
getLeafId: () => null,
|
|
507
709
|
getEntries: () => [
|
|
508
710
|
{ type: "custom", customType: "plan-state", data: { isActive: false, requirement: 123 } },
|
|
509
711
|
],
|