@zhushanwen/pi-goal 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +4 -2
  2. package/src/__tests__/command-adapter.test.ts +130 -81
  3. package/src/__tests__/deserialize-state.test.ts +79 -45
  4. package/src/__tests__/event-adapter.test.ts +26 -39
  5. package/src/__tests__/goal-control-adapter.test.ts +25 -57
  6. package/src/__tests__/goal-control-prompt.test.ts +45 -46
  7. package/src/__tests__/goal-control-rpc.test.ts +8 -49
  8. package/src/__tests__/gui.test.ts +32 -93
  9. package/src/__tests__/index.test.ts +19 -37
  10. package/src/__tests__/schema.test.ts +54 -0
  11. package/src/__tests__/service.test.ts +20 -52
  12. package/src/__tests__/session.test.ts +17 -56
  13. package/src/__tests__/stale-checker.test.ts +9 -40
  14. package/src/adapters/command-adapter.ts +46 -47
  15. package/src/adapters/event-adapter.ts +0 -1
  16. package/src/adapters/event-handlers/agent-end.ts +28 -31
  17. package/src/adapters/event-handlers/before-agent-start.ts +7 -8
  18. package/src/adapters/event-handlers/shared.ts +1 -20
  19. package/src/adapters/event-handlers/turn-end.ts +5 -9
  20. package/src/adapters/goal-control-adapter.ts +126 -235
  21. package/src/adapters/ports.ts +3 -0
  22. package/src/commands.ts +2 -8
  23. package/src/constants.ts +0 -1
  24. package/src/engine/__tests__/budget.test.ts +53 -95
  25. package/src/engine/__tests__/goal.test.ts +62 -102
  26. package/src/engine/budget.ts +50 -49
  27. package/src/engine/goal.ts +0 -2
  28. package/src/engine/types.ts +3 -10
  29. package/src/index.ts +3 -16
  30. package/src/persistence.ts +18 -9
  31. package/src/ports.ts +0 -2
  32. package/src/projection/__tests__/format.test.ts +161 -0
  33. package/src/projection/__tests__/prompts.test.ts +48 -77
  34. package/src/projection/__tests__/widget.test.ts +34 -50
  35. package/src/projection/gui.ts +126 -0
  36. package/src/projection/prompts.ts +32 -101
  37. package/src/projection/widget.ts +12 -31
  38. package/src/service.ts +17 -40
  39. package/src/__tests__/stubs/typebox.ts +0 -18
  40. package/src/adapters/event-handlers/agent-start.ts +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -23,8 +23,8 @@
23
23
  ],
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-ai": "*",
26
- "@earendil-works/pi-tui": "*",
27
26
  "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*",
28
28
  "typebox": "*",
29
29
  "@zhushanwen/pi-pending-notifications": "0.3.1"
30
30
  },
@@ -40,6 +40,8 @@
40
40
  }
41
41
  },
42
42
  "devDependencies": {
43
+ "@fast-check/vitest": "^0.4.1",
44
+ "fast-check": "^4.9.0",
43
45
  "vitest": "^4.1.8"
44
46
  },
45
47
  "scripts": {
@@ -13,8 +13,14 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
13
13
  import { describe, expect, it } from "vitest";
14
14
 
15
15
  import { handleGoalCommand } from "../adapters/command-adapter";
16
+ import {
17
+ MAX_HISTORY_ENTRIES,
18
+ OBJECTIVE_DISPLAY_LIMIT,
19
+ OBJECTIVE_TRUNCATE_KEEP,
20
+ } from "../constants";
16
21
  import { createGoalState } from "../engine/goal";
17
22
  import type { GoalRuntimeState } from "../engine/types";
23
+ import type { GoalHistoryEntry, SessionEntryLike } from "../ports";
18
24
  import { createGoalSession } from "../session";
19
25
 
20
26
  // ── Fake pi / ctx ────────────────────────────────────
@@ -38,7 +44,7 @@ interface FakeHarness {
38
44
  ctxCalls: RecordedCall[];
39
45
  }
40
46
 
41
- function makeHarness(): FakeHarness {
47
+ function makeHarness(entries: SessionEntryLike[] = []): FakeHarness {
42
48
  const piCalls: RecordedCall[] = [];
43
49
  const ctxCalls: RecordedCall[] = [];
44
50
  const states: unknown[] = [];
@@ -72,7 +78,7 @@ function makeHarness(): FakeHarness {
72
78
  setWidget: () => {},
73
79
  theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
74
80
  },
75
- sessionManager: { getEntries: () => [], getBranch: () => undefined },
81
+ sessionManager: { getEntries: () => entries, getBranch: () => undefined },
76
82
  } as unknown as ExtensionContext;
77
83
 
78
84
  return { pi, ctx, states, history, piCalls, ctxCalls };
@@ -89,6 +95,21 @@ function makeActiveState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeStat
89
95
  };
90
96
  }
91
97
 
98
+ // 构造 goal-history custom entry(模拟 Pi sessionManager.getEntries 返回的 append-only 历史)
99
+ function makeHistoryEntries(...overrides: Partial<GoalHistoryEntry>[]): SessionEntryLike[] {
100
+ return overrides.map((o, i) => {
101
+ const data: GoalHistoryEntry = {
102
+ goalId: `g${i}`,
103
+ objective: `objective ${i}`,
104
+ status: "complete",
105
+ elapsedSeconds: 60,
106
+ timestamp: 1000 + i,
107
+ ...o,
108
+ };
109
+ return { type: "custom", customType: "goal-history", data };
110
+ });
111
+ }
112
+
92
113
  function allCalls(h: FakeHarness): RecordedCall[] {
93
114
  return [...h.piCalls, ...h.ctxCalls];
94
115
  }
@@ -106,7 +127,7 @@ describe("handleGoalCommand — status", () => {
106
127
  const h = makeHarness();
107
128
  const session = createGoalSession();
108
129
  await handleGoalCommand(h.pi, session, "status", h.ctx);
109
- expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
130
+ expect(notifyText(h)[0]).toContain("not active");
110
131
  });
111
132
 
112
133
  it("有 active goal → 显示 status 面板", async () => {
@@ -132,9 +153,9 @@ describe("handleGoalCommand — pause (FR-3 active→paused)", () => {
132
153
  expect(session.state!.status).toBe("paused");
133
154
  // tick 前置:转 paused 前累加当前运行段(6 + ~4s)
134
155
  expect(session.state!.timeUsedSeconds).toBeGreaterThanOrEqual(9);
135
- expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
136
- expect(notifyText(h).some((t) => t.includes("paused"))).toBe(true);
137
- expect(notifyText(h).some((t) => t.includes("resume"))).toBe(true);
156
+ expect(h.states).toHaveLength(1); // persist 恰好 1 次
157
+ expect(notifyText(h).join("\n")).toContain("paused");
158
+ expect(notifyText(h).join("\n")).toContain("resume");
138
159
  });
139
160
 
140
161
  it("非 active(blocked)→ 拒绝 pause", async () => {
@@ -143,40 +164,30 @@ describe("handleGoalCommand — pause (FR-3 active→paused)", () => {
143
164
  session.state = makeActiveState({ status: "blocked" });
144
165
  await handleGoalCommand(h.pi, session, "pause", h.ctx);
145
166
  expect(session.state!.status).toBe("blocked"); // 未变
146
- expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
167
+ expect(notifyText(h)[0]).toContain("not active");
147
168
  });
148
169
 
149
170
  it("无 active goal → 提示未激活", async () => {
150
171
  const h = makeHarness();
151
172
  const session = createGoalSession();
152
173
  await handleGoalCommand(h.pi, session, "pause", h.ctx);
153
- expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
174
+ expect(notifyText(h)[0]).toContain("not active");
154
175
  });
155
176
  });
156
177
 
157
178
  // ── /goal resume(FR-3:paused/blocked→active 对称 + G-014 预算重检)──
158
179
 
159
180
  describe("handleGoalCommand — resume (FR-3 paused/blocked→active + G-014)", () => {
160
- it("blocked → active:resume 成功 + persist + 触发 AI", async () => {
161
- const h = makeHarness();
162
- const session = createGoalSession();
163
- session.state = makeActiveState({ status: "blocked" });
164
- await handleGoalCommand(h.pi, session, "resume", h.ctx);
165
- expect(session.state!.status).toBe("active");
166
- expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
167
- // FR-8.12: resume 后触发 AI
168
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
169
- });
170
-
171
- it("paused → active:resume 成功 + persist + 触发 AI(FR-3 对称)", async () => {
181
+ const RESUME_STATUSES: GoalRuntimeState["status"][] = ["blocked", "paused"];
182
+ it.each(RESUME_STATUSES)("resume %s → active:成功 + persist + 触发 AI(FR-3 对称)", async (status) => {
172
183
  const h = makeHarness();
173
184
  const session = createGoalSession();
174
- session.state = makeActiveState({ status: "paused" });
185
+ session.state = makeActiveState({ status });
175
186
  await handleGoalCommand(h.pi, session, "resume", h.ctx);
176
187
  expect(session.state!.status).toBe("active");
177
- expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
188
+ expect(h.states).toHaveLength(1); // persist 恰好 1 次
178
189
  // FR-8.12: resume 后触发 AI
179
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
190
+ expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(1);
180
191
  });
181
192
 
182
193
  it("resume 重置 timeStartedAt=now(FR-3.2 重启计时器)", async () => {
@@ -202,33 +213,12 @@ describe("handleGoalCommand — resume (FR-3 paused/blocked→active + G-014)",
202
213
  status: "blocked",
203
214
  budget: {
204
215
  tokenBudget: 1000,
205
- timeBudgetMinutes: 30,
206
216
  },
207
217
  tokensUsed: 1200, // 已超 tokenBudget
208
218
  });
209
219
  await handleGoalCommand(h.pi, session, "resume", h.ctx);
210
220
  expect(session.state!.status).toBe("budget_limited");
211
- expect(notifyText(h).some((t) => t.includes("Token budget exhausted"))).toBe(true);
212
- });
213
-
214
- it("time 预算耗尽 → resume 转 time_limited(G-014)", async () => {
215
- const h = makeHarness();
216
- const session = createGoalSession();
217
- session.state = makeActiveState({
218
- status: "blocked",
219
- timeStartedAt: 0,
220
- budget: {
221
- tokenBudget: 1000,
222
- timeBudgetMinutes: 30,
223
- },
224
- tokensUsed: 100, // token 未超
225
- timeUsedSeconds: 30 * 60, // 已超 timeBudgetMinutes*60
226
- });
227
- await handleGoalCommand(h.pi, session, "resume", h.ctx);
228
- expect(session.state!.status).toBe("time_limited");
229
- expect(notifyText(h).some((t) => t.includes("Time budget exhausted"))).toBe(true);
230
- // 拒绝 resume:不触发 AI
231
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false);
221
+ expect(notifyText(h)[0]).toContain("Token budget exhausted");
232
222
  });
233
223
 
234
224
  it("非 paused/blocked 状态(active)→ 无需 resume", async () => {
@@ -236,7 +226,7 @@ describe("handleGoalCommand — resume (FR-3 paused/blocked→active + G-014)",
236
226
  const session = createGoalSession();
237
227
  session.state = makeActiveState({ status: "active" });
238
228
  await handleGoalCommand(h.pi, session, "resume", h.ctx);
239
- expect(notifyText(h).some((t) => t.includes("not paused or blocked"))).toBe(true);
229
+ expect(notifyText(h)[0]).toContain("not paused or blocked");
240
230
  });
241
231
  });
242
232
 
@@ -250,7 +240,7 @@ describe("handleGoalCommand — clear (FR-6.3)", () => {
250
240
  await handleGoalCommand(h.pi, session, "clear", h.ctx);
251
241
  expect(session.state).toBeNull(); // clearGoalSession 清空
252
242
  expect(h.history.length).toBe(1); // 写 cancelled history
253
- expect(notifyText(h).some((t) => t.includes("cleared"))).toBe(true);
243
+ expect(notifyText(h)[0]).toContain("cleared");
254
244
  });
255
245
 
256
246
  it("clear:MF-3 tick — active goal 转 cancelled 前累加时间", async () => {
@@ -285,7 +275,7 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
285
275
  expect(session.state!.currentTurnIndex).toBe(0);
286
276
  expect(session.state!.budgetLimitSteeringSent).toBe(false);
287
277
  expect(session.state!.slug).toBeUndefined(); // GAP-6: update 重置 slug
288
- expect(h.states.length).toBeGreaterThanOrEqual(1); // persist
278
+ expect(h.states).toHaveLength(1); // persist
289
279
  });
290
280
 
291
281
  it("无参数 → usage 提示", async () => {
@@ -293,7 +283,7 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
293
283
  const session = createGoalSession();
294
284
  session.state = makeActiveState();
295
285
  await handleGoalCommand(h.pi, session, "update", h.ctx);
296
- expect(notifyText(h).some((t) => t.includes("Usage"))).toBe(true);
286
+ expect(notifyText(h)[0]).toContain("Usage");
297
287
  });
298
288
 
299
289
  it("带 --criteria → 替换 successCriteria", async () => {
@@ -328,7 +318,7 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
328
318
  session.state = makeActiveState({ objective: "old" });
329
319
  await handleGoalCommand(h.pi, session, "update new obj", h.ctx);
330
320
  // FR-8.4: active 时发送 steering
331
- expect(h.piCalls.some((c) => c.kind === "sendContext")).toBe(true);
321
+ expect(h.piCalls.filter((c) => c.kind === "sendContext")).toHaveLength(1);
332
322
  });
333
323
  });
334
324
 
@@ -349,31 +339,25 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
349
339
  expect(sendUserCalls[0]?.content).toContain("goal_control");
350
340
  });
351
341
 
352
- it("非终态旧 goal(active)→ 拒绝 + 提示(#11/D25),不发触发消息", async () => {
353
- const h = makeHarness();
354
- const session = createGoalSession();
355
- session.state = makeActiveState({ objective: "old active goal" });
356
- const historyBefore = h.history.length;
357
- await handleGoalCommand(h.pi, session, "new objective", h.ctx);
358
- // #11: 拒绝,不写 history,不覆盖旧 goal,不发触发消息
359
- expect(h.history.length).toBe(historyBefore); // 不写 history
360
- expect(notifyText(h).some((t) => t.includes("Goal already active"))).toBe(true);
361
- expect(notifyText(h).some((t) => t.includes("resume"))).toBe(true);
362
- expect(notifyText(h).some((t) => t.includes("clear"))).toBe(true);
363
- expect(session.state!.objective).toBe("old active goal"); // 旧 goal 保留,未覆盖
364
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false); // 不触发 AI
365
- });
366
-
367
- it("非终态旧 goal(paused)→ 拒绝创建(#11/D25)", async () => {
368
- const h = makeHarness();
369
- const session = createGoalSession();
370
- session.state = makeActiveState({ status: "paused", objective: "old paused goal" });
371
- await handleGoalCommand(h.pi, session, "new objective", h.ctx);
372
- expect(notifyText(h).some((t) => t.includes("Goal already active"))).toBe(true);
373
- expect(session.state!.status).toBe("paused"); // 状态不变
374
- expect(session.state!.objective).toBe("old paused goal"); // 旧 goal 保留
375
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false); // 不触发 AI
376
- });
342
+ const SET_REJECT_STATUSES: GoalRuntimeState["status"][] = ["active", "paused"];
343
+ it.each(SET_REJECT_STATUSES)(
344
+ "非终态旧 goal(%s)→ 拒绝 + 提示,不发触发消息(#11/D25)",
345
+ async (status) => {
346
+ const h = makeHarness();
347
+ const session = createGoalSession();
348
+ session.state = makeActiveState({ status, objective: `old ${status} goal` });
349
+ const historyBefore = h.history.length;
350
+ await handleGoalCommand(h.pi, session, "new objective", h.ctx);
351
+ // #11: 拒绝,不写 history,不覆盖旧 goal,不发触发消息
352
+ expect(h.history.length).toBe(historyBefore); // 不写 history
353
+ expect(notifyText(h).join("\n")).toContain("Goal already active");
354
+ expect(notifyText(h).join("\n")).toContain("resume");
355
+ expect(notifyText(h).join("\n")).toContain("clear");
356
+ expect(session.state!.status).toBe(status); // 状态不变
357
+ expect(session.state!.objective).toBe(`old ${status} goal`); // 旧 goal 保留
358
+ expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(0); // 不触发 AI
359
+ },
360
+ );
377
361
 
378
362
  it("终态旧 goal → 发触发消息(AI 会覆盖终态 goal)", async () => {
379
363
  const h = makeHarness();
@@ -383,7 +367,7 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
383
367
  await handleGoalCommand(h.pi, session, "new objective", h.ctx);
384
368
  expect(h.history.length).toBe(historyBefore); // 不写 history(触发器不直接创建)
385
369
  // 终态旧 goal 不挡触发器,发消息让 AI 创建
386
- expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
370
+ expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(1);
387
371
  });
388
372
 
389
373
  it("空 objective → usage 提示", async () => {
@@ -392,7 +376,7 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
392
376
  // parseGoalArgs("") → { action: "status" },空字符串走 status 路径
393
377
  await handleGoalCommand(h.pi, session, "", h.ctx);
394
378
  // 空字符串在 parseGoalArgs 里被识别为 status,不是 set;status 路径提示 "not active"
395
- expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
379
+ expect(notifyText(h)[0]).toContain("not active");
396
380
  });
397
381
 
398
382
  it("--tokens 0 → parseGoalArgs 过滤(val > 0 校验),发触发消息", async () => {
@@ -408,23 +392,88 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
408
392
  expect(sendUserCalls[0]?.content).toContain("obj");
409
393
  });
410
394
 
411
- it("--tokens N --timeout M → 触发消息含 budget 值", async () => {
395
+ it("--tokens N → 触发消息含 budget 值", async () => {
412
396
  const h = makeHarness();
413
397
  const session = createGoalSession();
414
- await handleGoalCommand(h.pi, session, "obj --tokens 5000 --timeout 30", h.ctx);
398
+ await handleGoalCommand(h.pi, session, "obj --tokens 5000", h.ctx);
415
399
  const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
416
400
  expect(sendUserCalls).toHaveLength(1);
417
401
  expect(sendUserCalls[0]?.content).toContain("5000");
418
- expect(sendUserCalls[0]?.content).toContain("30");
419
402
  });
420
403
  });
421
404
 
422
405
  // ── /goal history ────────────────────────────────────
423
406
 
424
407
  describe("handleGoalCommand — history", () => {
425
- it("无 history → 提示", async () => {
408
+ it("无 history → 提示 No goal history", async () => {
426
409
  const h = makeHarness();
427
410
  await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
428
411
  expect(notifyText(h).some((t) => t.includes("No goal history"))).toBe(true);
429
412
  });
413
+
414
+ // icon 映射 + duration + status 三件套(核心渲染分支,原零覆盖)
415
+ it.each([
416
+ ["complete → ✓", "complete", "✓"],
417
+ ["cancelled → ✗", "cancelled", "✗"],
418
+ ["budget_limited → ⊗", "budget_limited", "⊗"],
419
+ ["未知 status → ?", "active", "?"],
420
+ ])("有 history → notify 含 icon + duration + status(%s)", async (_name, status, icon) => {
421
+ const entries = makeHistoryEntries({
422
+ status,
423
+ objective: `${status} goal`,
424
+ elapsedSeconds: 90,
425
+ });
426
+ const h = makeHarness(entries);
427
+ await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
428
+ const text = notifyText(h).join("\n");
429
+ expect(text).toContain(icon);
430
+ expect(text).toContain(`| ${status}`);
431
+ expect(text).toContain("1m30s"); // formatDuration(90) = 始终带秒
432
+ });
433
+
434
+ it("有 slug → 标题用 slug(不显示 objective)", async () => {
435
+ const entries = makeHistoryEntries({
436
+ slug: "my-slug",
437
+ objective: "ignored long objective text",
438
+ });
439
+ const h = makeHarness(entries);
440
+ await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
441
+ const text = notifyText(h).join("\n");
442
+ expect(text).toContain("my-slug");
443
+ expect(text).not.toContain("ignored long objective text");
444
+ });
445
+
446
+ it("无 slug + 长 objective → 截断到 OBJECTIVE_TRUNCATE_KEEP + ...", async () => {
447
+ const long = "x".repeat(OBJECTIVE_DISPLAY_LIMIT + 10);
448
+ const entries = makeHistoryEntries({ objective: long, slug: undefined });
449
+ const h = makeHarness(entries);
450
+ await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
451
+ const text = notifyText(h).join("\n");
452
+ expect(text).toContain(`${"x".repeat(OBJECTIVE_TRUNCATE_KEEP)}...`);
453
+ });
454
+
455
+ it("多条 history → 倒序渲染(最近在前)+ 序号 1..N", async () => {
456
+ const entries = makeHistoryEntries({ slug: "first" }, { slug: "second" }, { slug: "third" });
457
+ const h = makeHarness(entries);
458
+ await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
459
+ const text = notifyText(h).join("\n");
460
+ // reverse: third(最后插入=最近)排第一
461
+ expect(text.indexOf("third")).toBeLessThan(text.indexOf("first"));
462
+ expect(text).toContain("1. "); // 序号格式
463
+ });
464
+
465
+ it("超过 MAX_HISTORY_ENTRIES → 只显示最近 N 条(最旧被截断)", async () => {
466
+ const entries = makeHistoryEntries(
467
+ ...Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, i) => ({ slug: `slug-${i}` })),
468
+ );
469
+ const h = makeHarness(entries);
470
+ await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
471
+ const text = notifyText(h).join("\n");
472
+ // slice(-MAX) 后 reverse:最近一条(slug-24)排第一,第 5 条(slug-5)是保留的最旧一条
473
+ expect(text).toContain("slug-24");
474
+ expect(text).toContain("slug-5");
475
+ // 最旧 5 条(slug-0..slug-4)被截断
476
+ expect(text).not.toContain("slug-0");
477
+ expect(text).not.toContain("slug-4");
478
+ });
430
479
  });
@@ -1,25 +1,46 @@
1
1
  /**
2
2
  * FR-5/FR-7.3: deserializeState — 新格式严格解析(字段缺失 throw)
3
3
  *
4
- * #1 向后兼容:旧 entry 可能含 `tasks` 字段(task CRUD 删除前的格式),
5
- * 反序列化时忽略该字段不 throw。
4
+ * round-trip property 覆盖全字段 serialize→deserialize 双向一致性,替代手写还原用例。
5
+ * 保留:throw 路径(缺必填)+ 向后兼容(缺 optional)+ 旧 tasks 字段忽略。
6
6
  */
7
- import { describe, expect, it } from "vitest";
7
+ import { describe, expect } from "vitest";
8
+ import { it, fc } from "@fast-check/vitest";
8
9
 
9
- import { deserializeState } from "../persistence";
10
+ import { deserializeState, serializeState } from "../persistence";
11
+ import { isTerminalStatus } from "../engine/goal";
12
+ import type { GoalRuntimeState, GoalStatus } from "../engine/types";
10
13
 
11
- const FULL_DATA = {
14
+ const STATUS_VALUES: GoalStatus[] = ["active", "paused", "blocked", "complete", "budget_limited", "cancelled"];
15
+
16
+ // GoalRuntimeState 全字段生成器(optional 字段用 fc.option 覆盖有/无两态)
17
+ const goalStateArb = fc.record({
18
+ goalId: fc.uuid(),
19
+ objective: fc.string({ minLength: 1 }),
20
+ successCriteria: fc.option(fc.string()),
21
+ slug: fc.option(fc.string({ minLength: 1 })),
22
+ status: fc.constantFrom(...STATUS_VALUES),
23
+ tokensUsed: fc.nat(),
24
+ timeStartedAt: fc.integer({ min: 0 }),
25
+ timeUsedSeconds: fc.nat(),
26
+ budget: fc.record({ tokenBudget: fc.option(fc.integer({ min: 0 })) }),
27
+ lastProgressTurn: fc.nat(),
28
+ budgetLimitSteeringSent: fc.boolean(),
29
+ objectiveUpdatedAt: fc.integer({ min: 0 }),
30
+ lastBlockerReason: fc.option(fc.string(), { nil: null }), // string | null
31
+ tokenWarning70Sent: fc.boolean(),
32
+ tokenWarning90Sent: fc.boolean(),
33
+ lastTurnTokensUsed: fc.nat(),
34
+ currentTurnIndex: fc.nat(),
35
+ completedAtTurnIndex: fc.option(fc.nat()),
36
+ }) as unknown as fc.Arbitrary<GoalRuntimeState>;
37
+
38
+ // 旧 entry 模拟(含已废弃 tasks 字段,验证向后兼容忽略)
39
+ const FULL_DATA: Record<string, unknown> = {
12
40
  goalId: "g1",
13
41
  objective: "test",
14
42
  status: "active",
15
- // tasks 字段保留以模拟旧 entry(#1 后被忽略,不 throw)
16
- tasks: [{
17
- id: 1,
18
- description: "task 1",
19
- status: "completed",
20
- lastUpdatedTurn: 5,
21
- verification: { method: "pnpm test", expected: "all pass", actual: "passed" },
22
- }],
43
+ tasks: [{ id: 1, description: "task 1", status: "completed", lastUpdatedTurn: 5 }],
23
44
  tokensUsed: 0,
24
45
  timeStartedAt: 1000,
25
46
  timeUsedSeconds: 0,
@@ -30,62 +51,75 @@ const FULL_DATA = {
30
51
  lastBlockerReason: null,
31
52
  tokenWarning70Sent: false,
32
53
  tokenWarning90Sent: false,
33
- timeWarning70Sent: false,
34
- timeWarning90Sent: false,
35
54
  lastTurnTokensUsed: 0,
36
55
  currentTurnIndex: 0,
37
56
  };
38
57
 
39
- describe("deserializeState — 新格式严格解析", () => {
58
+ describe("deserializeState — round-trip", () => {
59
+ // ⭐ serialize→deserialize 深相等:覆盖全 18 字段双向一致性(含 optional 有/无两态)
60
+ it.prop([goalStateArb])("deserializeState(serializeState(s)) 深相等 s", (s) => {
61
+ const rt = deserializeState(serializeState(s) as unknown as Record<string, unknown>);
62
+ expect(rt).toEqual(s);
63
+ });
64
+ });
65
+
66
+ describe("deserializeState — 严格解析(缺必填 throw)", () => {
40
67
  it("完整数据(含旧 tasks 字段)→ 正确还原,tasks 被忽略", () => {
41
68
  const state = deserializeState(FULL_DATA);
42
69
  expect(state.tokenWarning70Sent).toBe(false);
43
70
  expect(state.objective).toBe("test");
44
- // tasks 字段不还原到 state(GoalRuntimeState 已无 tasks 字段)
45
71
  expect((state as unknown as { tasks?: unknown }).tasks).toBeUndefined();
46
72
  });
47
73
 
48
- it("顶层缺 budget → throw(不再兜底默认值)", () => {
49
- const data = { goalId: "g1", objective: "test", status: "active" };
50
- expect(() => deserializeState(data)).toThrow();
74
+ it("顶层缺 budget → throw", () => {
75
+ expect(() => deserializeState({ goalId: "g1", objective: "test", status: "active" })).toThrow();
51
76
  });
52
77
 
53
- it("缺 tokenWarning70Sent → throw(新格式必须包含 4 个独立 flag)", () => {
78
+ it("缺 tokenWarning70Sent → throw", () => {
54
79
  const data = { ...FULL_DATA };
55
- delete (data as Record<string, unknown>).tokenWarning70Sent;
80
+ delete data.tokenWarning70Sent;
56
81
  expect(() => deserializeState(data)).toThrow();
57
82
  });
83
+ });
58
84
 
59
- it("completedAtTurnIndex 可选(缺失 → undefined)", () => {
60
- const state = deserializeState(FULL_DATA);
61
- expect(state.completedAtTurnIndex).toBeUndefined();
85
+ describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)", () => {
86
+ it("缺 completedAtTurnIndex → undefined", () => {
87
+ expect(deserializeState(FULL_DATA).completedAtTurnIndex).toBeUndefined();
62
88
  });
63
-
64
- it("有 completedAtTurnIndex → 正确还原", () => {
65
- const data = { ...FULL_DATA, completedAtTurnIndex: 42 };
66
- expect(deserializeState(data).completedAtTurnIndex).toBe(42);
89
+ it("缺 slug → undefined", () => {
90
+ expect(deserializeState(FULL_DATA).slug).toBeUndefined();
67
91
  });
68
-
69
- // GAP-4: slug 向后兼容——旧持久化数据无 slug 字段,deserialize 不能 throw
70
- // (误用 req() 会导致旧数据整个 state 丢失,G-024 部分损坏全丢)
71
- it("缺 slug → 不 throw,slug 为 undefined(GAP-4 旧数据兼容)", () => {
72
- const state = deserializeState(FULL_DATA); // FULL_DATA 无 slug
73
- expect(state.slug).toBeUndefined();
92
+ it("缺 successCriteria → undefined", () => {
93
+ expect(deserializeState(FULL_DATA).successCriteria).toBeUndefined();
74
94
  });
95
+ });
96
+
97
+ describe("deserializeState — 旧数据迁移(time_limited → budget_limited,MF-1)", () => {
98
+ // npm pi-goal 0.7.x 持久化格式:status=time_limited + timeWarning* 遗留字段(时间预算维度已移除)
99
+ const LEGACY_DATA: Record<string, unknown> = {
100
+ ...FULL_DATA,
101
+ status: "time_limited",
102
+ timeWarning70Sent: true,
103
+ timeWarning90Sent: true,
104
+ };
75
105
 
76
- it("有 slug → 正确还原", () => {
77
- const data = { ...FULL_DATA, slug: "refactor-auth" };
78
- expect(deserializeState(data).slug).toBe("refactor-auth");
106
+ it("旧 entry status=time_limited → 归一化 budget_limited,遗留字段被忽略", () => {
107
+ const state = deserializeState(LEGACY_DATA);
108
+ expect(state.status).toBe("budget_limited");
109
+ expect((state as unknown as { timeWarning70Sent?: unknown }).timeWarning70Sent).toBeUndefined();
110
+ expect((state as unknown as { timeWarning90Sent?: unknown }).timeWarning90Sent).toBeUndefined();
79
111
  });
80
112
 
81
- // successCriteria 向后兼容——与 slug 同模式(GAP-4),旧持久化数据无此字段不 throw
82
- it("缺 successCriteria → 不 throw,值为 undefined", () => {
83
- const state = deserializeState(FULL_DATA); // FULL_DATA 无 successCriteria
84
- expect(state.successCriteria).toBeUndefined();
113
+ it("归一化后是合法终态:/goal clear 守卫(!isTerminalStatus)跳过 transitionStatus,不 throw", () => {
114
+ const state = deserializeState(LEGACY_DATA);
115
+ // handleClear(command-adapter.ts)的守卫:!isTerminalStatus 为 false → 直接 clearSession,
116
+ // 不再走 finalizeAndPersist("cancelled") 的 transitionStatus 查表(旧值会 throw)。
117
+ expect(isTerminalStatus(state.status)).toBe(true);
85
118
  });
86
119
 
87
- it("有 successCriteria → 正确还原", () => {
88
- const data = { ...FULL_DATA, successCriteria: "pnpm test green; tsc clean" };
89
- expect(deserializeState(data).successCriteria).toBe("pnpm test green; tsc clean");
120
+ it("新格式 status 不受影响(time_limited 之外的六态原样透传)", () => {
121
+ for (const status of STATUS_VALUES) {
122
+ expect(deserializeState({ ...FULL_DATA, status }).status).toBe(status);
123
+ }
90
124
  });
91
125
  });