@zhushanwen/pi-plan 0.4.9 → 0.5.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.
@@ -1,13 +1,22 @@
1
+ import { mkdtempSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
1
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { describe, expect, it, vi } from "vitest";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
7
 
4
8
  import {
9
+ capPlanRequirement,
5
10
  DEFAULT_PLAN_STATE,
11
+ freshAbortController,
6
12
  getPlanState,
13
+ MAX_PLAN_REQUIREMENT_LENGTH,
7
14
  persistPlanState,
15
+ type PlanAbortControllers,
8
16
  type PlanSessionMap,
9
17
  type PlanState,
10
18
  reconstructPlanState,
19
+ resetPlanState,
11
20
  } from "../state.js";
12
21
 
13
22
  describe("PlanState", () => {
@@ -16,6 +25,10 @@ describe("PlanState", () => {
16
25
  expect(DEFAULT_PLAN_STATE.planFilePath).toBe("");
17
26
  expect(DEFAULT_PLAN_STATE.requirement).toBe("");
18
27
  expect(DEFAULT_PLAN_STATE.templateName).toBe("");
28
+ expect(DEFAULT_PLAN_STATE.skills).toEqual([]);
29
+ expect(DEFAULT_PLAN_STATE.docs).toEqual([]);
30
+ expect(DEFAULT_PLAN_STATE.reviewState).toBeUndefined();
31
+ expect(DEFAULT_PLAN_STATE.reviewStateSource).toBeUndefined();
19
32
  });
20
33
 
21
34
  it("getPlanState returns cached state if exists", () => {
@@ -39,7 +52,7 @@ describe("PlanState", () => {
39
52
  {
40
53
  type: "custom",
41
54
  customType: "plan-state",
42
- data: { isActive: true, phase: "writing", planFilePath: ".taiji-harness/test/plan.md", requirement: "test", templateName: "feature-plan" },
55
+ data: { isActive: true, phase: "writing", planFilePath: ".tmp/plans/test/plan.md", requirement: "test", templateName: "feature-plan" },
43
56
  },
44
57
  ],
45
58
  },
@@ -52,23 +65,33 @@ describe("PlanState", () => {
52
65
  });
53
66
 
54
67
  describe("State persistence", () => {
55
- it("persistPlanState calls appendEntry with correct data (no phase field — D6)", () => {
68
+ it("persistPlanState calls appendEntry with the full schema (no phase field — D6)", () => {
56
69
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
57
70
  const state: PlanState = {
58
71
  isActive: true,
59
- planFilePath: ".taiji-harness/test/plan.md",
72
+ planFilePath: ".tmp/plans/test/plan.md",
60
73
  requirement: "test requirement",
61
74
  templateName: "feature-plan",
75
+ skills: ["tech-design"],
76
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
77
+ reviewState: "awaiting",
62
78
  };
63
79
 
64
80
  persistPlanState(mockPi, state);
65
81
 
66
- // 精确匹配:新写的 plan-state entry 无 phase 字段(V5② 守卫)
82
+ // 精确匹配:新写的 plan-state entry 为全字段 schema(D1:四现状 + skills/docs/reviewState
83
+ // + reviewStateSource + lastSubmitReviewDocsFingerprint);无值 optional 字段为 undefined
84
+ // (JSON 序列化自然消失——D4)
67
85
  expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
68
86
  isActive: true,
69
- planFilePath: ".taiji-harness/test/plan.md",
87
+ planFilePath: ".tmp/plans/test/plan.md",
70
88
  requirement: "test requirement",
71
89
  templateName: "feature-plan",
90
+ skills: ["tech-design"],
91
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
92
+ reviewState: "awaiting",
93
+ reviewStateSource: undefined,
94
+ lastSubmitReviewDocsFingerprint: undefined,
72
95
  });
73
96
  });
74
97
 
@@ -81,7 +104,7 @@ describe("State persistence", () => {
81
104
  expect(state).toEqual(DEFAULT_PLAN_STATE);
82
105
  });
83
106
 
84
- it("reconstructPlanState restores state from entries", () => {
107
+ it("reconstructPlanState restores the full new-schema state from entries (D1)", () => {
85
108
  const mockCtx = {
86
109
  sessionManager: {
87
110
  getEntries: () => [
@@ -90,9 +113,39 @@ describe("State persistence", () => {
90
113
  customType: "plan-state",
91
114
  data: {
92
115
  isActive: true,
93
- phase: "writing",
94
- planFilePath: ".taiji-harness/test/plan.md",
95
- requirement: "test",
116
+ planFilePath: ".tmp/plans/auth/plan.md",
117
+ requirement: "auth",
118
+ templateName: "",
119
+ skills: ["tech-design", "dev-flow"],
120
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
121
+ reviewState: "awaiting",
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ } as unknown as ExtensionContext;
127
+
128
+ const state = reconstructPlanState(mockCtx);
129
+ expect(state.isActive).toBe(true);
130
+ expect(state.skills).toEqual(["tech-design", "dev-flow"]);
131
+ expect(state.docs).toEqual([
132
+ { fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 },
133
+ ]);
134
+ expect(state.reviewState).toBe("awaiting");
135
+ });
136
+
137
+ it("old four-field entries (no new fields) reconstruct with field-level fallback (D4 兼容读)", () => {
138
+ const mockCtx = {
139
+ sessionManager: {
140
+ getEntries: () => [
141
+ {
142
+ type: "custom",
143
+ customType: "plan-state",
144
+ data: {
145
+ isActive: true,
146
+ phase: "brainstorming",
147
+ planFilePath: ".tmp/plans/legacy/plan.md",
148
+ requirement: "legacy",
96
149
  templateName: "feature-plan",
97
150
  },
98
151
  },
@@ -102,7 +155,40 @@ describe("State persistence", () => {
102
155
 
103
156
  const state = reconstructPlanState(mockCtx);
104
157
  expect(state.isActive).toBe(true);
105
- expect(state.planFilePath).toBe(".taiji-harness/test/plan.md");
158
+ expect(state.planFilePath).toBe(".tmp/plans/legacy/plan.md");
159
+ // 新字段降级为空清单/无值(前端据此显示「(未指定)」+ 单文件形态)
160
+ expect(state.skills).toEqual([]);
161
+ expect(state.docs).toEqual([]);
162
+ expect(state.reviewState).toBeUndefined();
163
+ });
164
+
165
+ it("malformed new-field values are dropped, not propagated (垃圾数据不进内存态)", () => {
166
+ const mockCtx = {
167
+ sessionManager: {
168
+ getEntries: () => [
169
+ {
170
+ type: "custom",
171
+ customType: "plan-state",
172
+ data: {
173
+ isActive: true,
174
+ planFilePath: "/p/plan.md",
175
+ requirement: "",
176
+ templateName: "",
177
+ skills: ["ok", 42, null],
178
+ docs: [{ fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 }, "junk", { bad: true }],
179
+ reviewState: "corrupted",
180
+ },
181
+ },
182
+ ],
183
+ },
184
+ } as unknown as ExtensionContext;
185
+
186
+ const state = reconstructPlanState(mockCtx);
187
+ expect(state.skills).toEqual(["ok"]);
188
+ expect(state.docs).toEqual([
189
+ { fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 },
190
+ ]);
191
+ expect(state.reviewState).toBeUndefined();
106
192
  });
107
193
 
108
194
  it("reconstructPlanState ignores the legacy phase field in old entries (D6 兼容读 — V5②)", () => {
@@ -116,7 +202,7 @@ describe("State persistence", () => {
116
202
  data: {
117
203
  isActive: true,
118
204
  phase: "brainstorming",
119
- planFilePath: ".taiji-harness/legacy/plan.md",
205
+ planFilePath: ".tmp/plans/legacy/plan.md",
120
206
  requirement: "legacy",
121
207
  templateName: "feature-plan",
122
208
  },
@@ -126,10 +212,303 @@ describe("State persistence", () => {
126
212
  } as unknown as ExtensionContext;
127
213
 
128
214
  const state = reconstructPlanState(mockCtx);
129
- expect(Object.keys(state).sort()).toEqual(["isActive", "planFilePath", "requirement", "templateName"]);
215
+ expect(Object.keys(state).sort()).toEqual(["docs", "isActive", "lastSubmitReviewDocsFingerprint", "planFilePath", "requirement", "reviewState", "reviewStateSource", "skills", "templateName", "templateProvidedPath"]);
130
216
  expect(state.isActive).toBe(true);
131
- expect(state.planFilePath).toBe(".taiji-harness/legacy/plan.md");
132
- expect(state.requirement).toBe("legacy");
133
- expect(state.templateName).toBe("feature-plan");
217
+ });
218
+
219
+ it("lastSubmitReviewDocsFingerprint persists and reconstructs (E3 重挂恢复);非 string 值按无既往提交丢弃", () => {
220
+ const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
221
+ const state: PlanState = {
222
+ isActive: true,
223
+ planFilePath: ".tmp/plans/auth/plan.md",
224
+ requirement: "auth",
225
+ templateName: "",
226
+ skills: [],
227
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
228
+ reviewState: "awaiting",
229
+ lastSubmitReviewDocsFingerprint: "design.md:1",
230
+ };
231
+
232
+ persistPlanState(mockPi, state);
233
+
234
+ // 用持久化 entry 走冷启动重建(E3:submit-review 崩溃 → 重开 session 后恢复快照,
235
+ // 重提交无变化检测仍能比对到上次基线)
236
+ const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
237
+ const reopenCtx = {
238
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
239
+ } as unknown as ExtensionContext;
240
+ expect(reconstructPlanState(reopenCtx).lastSubmitReviewDocsFingerprint).toBe("design.md:1");
241
+
242
+ // 垃圾数据不进内存态:非 string 指纹按无既往提交处理(不警告语义)
243
+ const badCtx = {
244
+ sessionManager: {
245
+ getEntries: () => [
246
+ {
247
+ type: "custom",
248
+ customType: "plan-state",
249
+ data: { ...persisted, lastSubmitReviewDocsFingerprint: 42 },
250
+ },
251
+ ],
252
+ },
253
+ } as unknown as ExtensionContext;
254
+ expect(reconstructPlanState(badCtx).lastSubmitReviewDocsFingerprint).toBeUndefined();
255
+ });
256
+
257
+ it("reviewStateSource persists and reconstructs;旧 entry 无字段 / 非法值 / 旧 'explain' 存量值按无值处理(D4 兼容读)", () => {
258
+ const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
259
+ const state: PlanState = {
260
+ isActive: true,
261
+ planFilePath: ".tmp/plans/auth/plan.md",
262
+ requirement: "auth",
263
+ templateName: "",
264
+ skills: [],
265
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
266
+ reviewState: "awaiting",
267
+ reviewStateSource: "resubmit",
268
+ };
269
+
270
+ persistPlanState(mockPi, state);
271
+
272
+ // 持久化 entry 走冷启动重建:resubmit 等待态跨重开可恢复(E3 重挂同款受益)
273
+ const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
274
+ const reopenCtx = {
275
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
276
+ } as unknown as ExtensionContext;
277
+ expect(reconstructPlanState(reopenCtx).reviewStateSource).toBe("resubmit");
278
+
279
+ // 旧 entry(升级前落盘)无该字段:reviewState 有值但无来源 → 无值(renderer 渲染通用降级文案)
280
+ const legacyCtx = {
281
+ sessionManager: {
282
+ getEntries: () => [
283
+ {
284
+ type: "custom",
285
+ customType: "plan-state",
286
+ data: { isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "awaiting" },
287
+ },
288
+ ],
289
+ },
290
+ } as unknown as ExtensionContext;
291
+ expect(reconstructPlanState(legacyCtx).reviewStateSource).toBeUndefined();
292
+
293
+ // 旧版 'explain' 存量值(explain 交互已删)与垃圾值一并按无值处理(值域守卫白名单只认 'resubmit')
294
+ const explainCtx = {
295
+ sessionManager: {
296
+ getEntries: () => [
297
+ { type: "custom", customType: "plan-state", data: { ...persisted, reviewStateSource: "explain" } },
298
+ ],
299
+ },
300
+ } as unknown as ExtensionContext;
301
+ expect(reconstructPlanState(explainCtx).reviewStateSource).toBeUndefined();
302
+
303
+ // 值域守卫:'resubmit' 之外的垃圾值按无值处理(与 readReviewState 同风格)
304
+ const badCtx = {
305
+ sessionManager: {
306
+ getEntries: () => [
307
+ { type: "custom", customType: "plan-state", data: { ...persisted, reviewStateSource: "bogus" } },
308
+ ],
309
+ },
310
+ } as unknown as ExtensionContext;
311
+ expect(reconstructPlanState(badCtx).reviewStateSource).toBeUndefined();
312
+ });
313
+ });
314
+
315
+ describe("resetPlanState 终态矩阵(D5/E10)", () => {
316
+ function setupActiveSession() {
317
+ const sessions: PlanSessionMap = new Map();
318
+ const mockCtx = {
319
+ sessionManager: { getEntries: () => [] },
320
+ } as unknown as ExtensionContext;
321
+ sessions.set("session-1", {
322
+ isActive: true,
323
+ planFilePath: ".tmp/plans/auth/plan.md",
324
+ requirement: "refactor auth",
325
+ templateName: "feature-plan",
326
+ skills: ["tech-design", "dev-flow"],
327
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
328
+ reviewState: "awaiting",
329
+ });
330
+ const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
331
+ return { sessions, mockCtx, mockPi };
332
+ }
333
+
334
+ it("isActive=false + reviewState cleared + skills cleared + docs KEPT", () => {
335
+ const { sessions, mockCtx, mockPi } = setupActiveSession();
336
+
337
+ const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
338
+
339
+ expect(state.isActive).toBe(false);
340
+ expect(state.reviewState).toBeUndefined();
341
+ expect(state.skills).toEqual([]);
342
+ // docs 保留:产物 tab 与 isActive 解耦,执行期/退出后都可回看产物
343
+ expect(state.docs).toEqual([
344
+ { fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 },
345
+ ]);
346
+ });
347
+
348
+ it("reset entry persists the terminal matrix and the session cache is cleaned up", () => {
349
+ const { sessions, mockCtx, mockPi } = setupActiveSession();
350
+
351
+ resetPlanState(mockPi, sessions, "session-1", mockCtx);
352
+
353
+ expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
354
+ isActive: false,
355
+ planFilePath: "",
356
+ requirement: "",
357
+ templateName: "",
358
+ skills: [],
359
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
360
+ reviewState: undefined,
361
+ });
362
+ expect(sessions.has("session-1")).toBe(false);
363
+ });
364
+
365
+ it("reset entry persisted → reopen reconstructs docs from it (产物 tab 跨重开留存)", () => {
366
+ const { sessions, mockCtx, mockPi } = setupActiveSession();
367
+ resetPlanState(mockPi, sessions, "session-1", mockCtx);
368
+
369
+ // 模拟重开:用 reset 落盘的 entry 数据走冷启动重建
370
+ const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1];
371
+ const reopenCtx = {
372
+ sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
373
+ } as unknown as ExtensionContext;
374
+
375
+ const state = reconstructPlanState(reopenCtx);
376
+ expect(state.isActive).toBe(false);
377
+ expect(state.docs).toEqual([
378
+ { fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 },
379
+ ]);
380
+ expect(state.skills).toEqual([]);
381
+ });
382
+
383
+ it("fingerprint snapshot is cleared on reset (新 plan 轮次从无既往提交重新计数)", () => {
384
+ const { sessions, mockCtx, mockPi } = setupActiveSession();
385
+ const active = sessions.get("session-1");
386
+ if (!active) throw new Error("setupActiveSession must seed session-1");
387
+ active.lastSubmitReviewDocsFingerprint = "design.md:2";
388
+
389
+ const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
390
+
391
+ // 指纹随退出失效(同 reviewState 同款 delete);docs 保留不影响——
392
+ // 保留的 docs 不作为下一轮检测基线,reset 后首次 submit-review 不警告
393
+ expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
394
+ expect(state.docs).toHaveLength(1);
395
+ });
396
+ it("reviewStateSource is cleared on reset (跨 plan run 残留防护,C-U2 同型缺陷)", () => {
397
+ 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
+
402
+ const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
403
+
404
+ // 来源标记与 reviewState 同生命周期随退出失效;reset entry 落盘同样无该键
405
+ // (undefined JSON 序列化自然消失——D4)
406
+ expect(state.reviewStateSource).toBeUndefined();
407
+ const lastEntry = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
408
+ expect(lastEntry.reviewStateSource).toBeUndefined();
409
+ });
410
+
411
+ describe("空 slug 目录清理(P3-10)", () => {
412
+ const tmpRoots: string[] = [];
413
+
414
+ function makePlanRoot(withFile: boolean): string {
415
+ const root = mkdtempSync(join(tmpdir(), "plan-state-test-"));
416
+ tmpRoots.push(root);
417
+ const slugDir = join(root, "my-slug");
418
+ mkdirSync(slugDir);
419
+ if (withFile) writeFileSync(join(slugDir, "notes.txt"), "leftover");
420
+ return join(slugDir, "plan.md");
421
+ }
422
+
423
+ afterEach(() => {
424
+ while (tmpRoots.length > 0) {
425
+ const root = tmpRoots.pop();
426
+ if (root) rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
427
+ }
428
+ });
429
+
430
+ it("未产文档即退出:空 slug 目录随 reset 删除", () => {
431
+ const planFilePath = makePlanRoot(false);
432
+ const slugDir = join(planFilePath, "..");
433
+ const sessions: PlanSessionMap = new Map();
434
+ const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
435
+ sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
436
+ const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
437
+
438
+ resetPlanState(mockPi, sessions, "s1", mockCtx);
439
+
440
+ expect(existsSync(slugDir)).toBe(false);
441
+ });
442
+
443
+ it("目录有残留文件:保留不删(保守,不做递归删除)", () => {
444
+ const planFilePath = makePlanRoot(true);
445
+ const slugDir = join(planFilePath, "..");
446
+ const sessions: PlanSessionMap = new Map();
447
+ const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
448
+ sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
449
+ const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
450
+
451
+ resetPlanState(mockPi, sessions, "s1", mockCtx);
452
+
453
+ expect(existsSync(slugDir)).toBe(true);
454
+ });
455
+ });
456
+ });
457
+
458
+ describe("freshAbortController(E10 生命周期)", () => {
459
+ it("registers the controller per session and creates a fresh one on each call", () => {
460
+ const controllers: PlanAbortControllers = new Map();
461
+
462
+ const first = freshAbortController(controllers, "s1");
463
+ expect(controllers.get("s1")).toBe(first);
464
+
465
+ // 禁复用:第二次调用必须新建(复用已 abort 的 controller 会让 select 瞬时静默取消)
466
+ const second = freshAbortController(controllers, "s1");
467
+ expect(second).not.toBe(first);
468
+ expect(controllers.get("s1")).toBe(second);
469
+ });
470
+ });
471
+
472
+ describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LARGE_FIELD_REGISTRY 的有界前提代码化)", () => {
473
+ it("capPlanRequirement: ≤64KB 原样返回;超长截断 + 省略标记(含被省略字符数)", () => {
474
+ const short = "重构 auth 模块";
475
+ expect(capPlanRequirement(short)).toBe(short);
476
+
477
+ // 恰好等于上限:不截断(边界含端)
478
+ const exact = "x".repeat(MAX_PLAN_REQUIREMENT_LENGTH);
479
+ expect(capPlanRequirement(exact)).toBe(exact);
480
+
481
+ const long = "y".repeat(MAX_PLAN_REQUIREMENT_LENGTH + 5000);
482
+ const capped = capPlanRequirement(long);
483
+ expect(capped).not.toBe(long);
484
+ expect(capped.length).toBeLessThan(long.length);
485
+ expect(capped.startsWith("y".repeat(MAX_PLAN_REQUIREMENT_LENGTH))).toBe(true);
486
+ expect(capped).toContain("5000 characters omitted");
487
+ });
488
+
489
+ it("重建读侧同样封顶:封顶前旧版 entry 的超长 requirement 不整段进内存态(派生帧恒有界)", () => {
490
+ const oversized = "z".repeat(MAX_PLAN_REQUIREMENT_LENGTH * 2);
491
+ const mockCtx = {
492
+ sessionManager: {
493
+ getEntries: () => [
494
+ { type: "custom", customType: "plan-state", data: { isActive: true, requirement: oversized } },
495
+ ],
496
+ },
497
+ } as unknown as ExtensionContext;
498
+
499
+ const state = reconstructPlanState(mockCtx);
500
+ expect(state.requirement.length).toBeLessThan(oversized.length);
501
+ expect(state.requirement).toContain("characters omitted");
502
+ });
503
+
504
+ it("非 string requirement(含缺失)仍归空串(白名单读取语义不变)", () => {
505
+ const mockCtx = {
506
+ sessionManager: {
507
+ getEntries: () => [
508
+ { type: "custom", customType: "plan-state", data: { isActive: false, requirement: 123 } },
509
+ ],
510
+ },
511
+ } as unknown as ExtensionContext;
512
+ expect(reconstructPlanState(mockCtx).requirement).toBe("");
134
513
  });
135
514
  });