@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.
- package/package.json +4 -2
- package/src/__tests__/command-adapter.test.ts +130 -81
- package/src/__tests__/deserialize-state.test.ts +79 -45
- package/src/__tests__/event-adapter.test.ts +26 -39
- package/src/__tests__/goal-control-adapter.test.ts +25 -57
- package/src/__tests__/goal-control-prompt.test.ts +45 -46
- package/src/__tests__/goal-control-rpc.test.ts +8 -49
- package/src/__tests__/gui.test.ts +32 -93
- package/src/__tests__/index.test.ts +19 -37
- package/src/__tests__/schema.test.ts +54 -0
- package/src/__tests__/service.test.ts +20 -52
- package/src/__tests__/session.test.ts +17 -56
- package/src/__tests__/stale-checker.test.ts +9 -40
- package/src/adapters/command-adapter.ts +46 -47
- package/src/adapters/event-adapter.ts +0 -1
- package/src/adapters/event-handlers/agent-end.ts +28 -31
- package/src/adapters/event-handlers/before-agent-start.ts +7 -8
- package/src/adapters/event-handlers/shared.ts +1 -20
- package/src/adapters/event-handlers/turn-end.ts +5 -9
- package/src/adapters/goal-control-adapter.ts +126 -235
- package/src/adapters/ports.ts +3 -0
- package/src/commands.ts +2 -8
- package/src/constants.ts +0 -1
- package/src/engine/__tests__/budget.test.ts +53 -95
- package/src/engine/__tests__/goal.test.ts +62 -102
- package/src/engine/budget.ts +50 -49
- package/src/engine/goal.ts +0 -2
- package/src/engine/types.ts +3 -10
- package/src/index.ts +3 -16
- package/src/persistence.ts +18 -9
- package/src/ports.ts +0 -2
- package/src/projection/__tests__/format.test.ts +161 -0
- package/src/projection/__tests__/prompts.test.ts +48 -77
- package/src/projection/__tests__/widget.test.ts +34 -50
- package/src/projection/gui.ts +126 -0
- package/src/projection/prompts.ts +32 -101
- package/src/projection/widget.ts +12 -31
- package/src/service.ts +17 -40
- package/src/__tests__/stubs/typebox.ts +0 -18
- 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.
|
|
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: () =>
|
|
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)
|
|
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
|
|
136
|
-
expect(notifyText(h).
|
|
137
|
-
expect(notifyText(h).
|
|
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)
|
|
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)
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
188
|
+
expect(h.states).toHaveLength(1); // persist 恰好 1 次
|
|
178
189
|
// FR-8.12: resume 后触发 AI
|
|
179
|
-
expect(h.piCalls.
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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.
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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.
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* round-trip property 覆盖全字段 serialize→deserialize 双向一致性,替代手写还原用例。
|
|
5
|
+
* 保留:throw 路径(缺必填)+ 向后兼容(缺 optional)+ 旧 tasks 字段忽略。
|
|
6
6
|
*/
|
|
7
|
-
import { describe, expect
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
+
it("缺 tokenWarning70Sent → throw", () => {
|
|
54
79
|
const data = { ...FULL_DATA };
|
|
55
|
-
delete
|
|
80
|
+
delete data.tokenWarning70Sent;
|
|
56
81
|
expect(() => deserializeState(data)).toThrow();
|
|
57
82
|
});
|
|
83
|
+
});
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(
|
|
85
|
+
describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)", () => {
|
|
86
|
+
it("缺 completedAtTurnIndex → undefined", () => {
|
|
87
|
+
expect(deserializeState(FULL_DATA).completedAtTurnIndex).toBeUndefined();
|
|
62
88
|
});
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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("
|
|
77
|
-
const
|
|
78
|
-
expect(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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("
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
});
|