@zhushanwen/pi-goal 0.2.0 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +13 -12
  2. package/package.json +8 -4
  3. package/src/__tests__/command-adapter.test.ts +404 -0
  4. package/src/__tests__/deserialize-state.test.ts +80 -0
  5. package/src/__tests__/event-adapter.test.ts +339 -0
  6. package/src/__tests__/goal-control-adapter.test.ts +303 -0
  7. package/src/__tests__/index.test.ts +119 -0
  8. package/src/__tests__/service.test.ts +317 -0
  9. package/src/__tests__/session.test.ts +220 -0
  10. package/src/__tests__/stale-checker.test.ts +78 -0
  11. package/src/__tests__/stubs/pi-sdk.ts +16 -0
  12. package/src/__tests__/stubs/typebox.ts +14 -0
  13. package/src/adapters/command-adapter.ts +360 -0
  14. package/src/adapters/event-adapter.ts +17 -0
  15. package/src/adapters/event-handlers/agent-end.ts +177 -0
  16. package/src/adapters/event-handlers/agent-start.ts +17 -0
  17. package/src/adapters/event-handlers/before-agent-start.ts +115 -0
  18. package/src/adapters/event-handlers/message-end.ts +37 -0
  19. package/src/adapters/event-handlers/session-start.ts +24 -0
  20. package/src/adapters/event-handlers/shared.ts +46 -0
  21. package/src/adapters/event-handlers/turn-end.ts +37 -0
  22. package/src/adapters/goal-control-adapter.ts +296 -0
  23. package/src/adapters/ports.ts +89 -0
  24. package/src/commands.ts +7 -19
  25. package/src/constants.ts +1 -3
  26. package/src/engine/__tests__/budget.test.ts +182 -0
  27. package/src/engine/__tests__/goal.test.ts +126 -0
  28. package/src/engine/budget.ts +146 -0
  29. package/src/engine/goal.ts +62 -0
  30. package/src/engine/types.ts +80 -0
  31. package/src/index.ts +110 -333
  32. package/src/persistence.ts +83 -0
  33. package/src/ports.ts +68 -0
  34. package/src/projection/__tests__/prompts.test.ts +245 -0
  35. package/src/projection/__tests__/widget.test.ts +255 -0
  36. package/src/projection/prompts.ts +250 -0
  37. package/src/projection/widget.ts +254 -0
  38. package/src/service.ts +276 -0
  39. package/src/session.ts +114 -0
  40. package/src/action-handlers.ts +0 -341
  41. package/src/agent-end-handler.ts +0 -304
  42. package/src/before-agent-start-handler.ts +0 -182
  43. package/src/budget.ts +0 -159
  44. package/src/command-handler.ts +0 -280
  45. package/src/state.ts +0 -250
  46. package/src/templates.ts +0 -232
  47. package/src/tool-handler.ts +0 -252
  48. package/src/widget.ts +0 -186
@@ -0,0 +1,119 @@
1
+ /**
2
+ * index.ts 测试 — 工厂入口 + 跨扩展 API(pi.__goalInit)
3
+ *
4
+ * 覆盖 T1.8 (NFR-AC-8):__goalInit 忽略 tasks 参数(tasks 废弃,D-16/FR-4 双轨消除)。
5
+ * 设计保证:__goalInit 签名仅 (objective, budget, ctx),结构上无法接收 tasks。
6
+ *
7
+ * 用最小 fake pi + fake ctx 实例化 goalExtension 工厂,再调 pi.__goalInit。
8
+ */
9
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import goalExtension from "../index";
13
+
14
+ // ── Minimal fake pi / ctx ─────────────────────────────
15
+
16
+ interface FactoryFixture {
17
+ pi: ExtensionAPI & { __goalInit?: (...args: never[]) => unknown };
18
+ ctx: ExtensionContext;
19
+ states: unknown[];
20
+ history: unknown[];
21
+ sendUser: unknown[];
22
+ }
23
+
24
+ /** 工厂所需的最小 pi:registerCommand/registerTool/on/registerMessageRenderer + appendEntry/sendMessage/sendUserMessage */
25
+ function makeFactoryFixture(): FactoryFixture {
26
+ const states: unknown[] = [];
27
+ const history: unknown[] = [];
28
+ const sendUser: unknown[] = [];
29
+ const pi = {
30
+ registerCommand: () => {},
31
+ registerTool: () => {},
32
+ on: () => {},
33
+ registerMessageRenderer: () => {},
34
+ appendEntry(customType: string, data?: unknown): void {
35
+ if (customType === "goal-history") history.push(data);
36
+ else states.push(data);
37
+ },
38
+ sendMessage: () => {},
39
+ sendUserMessage(content: unknown): void {
40
+ sendUser.push(content);
41
+ },
42
+ } as unknown as ExtensionAPI & { __goalInit?: (...args: never[]) => unknown };
43
+
44
+ const ctx = {
45
+ hasUI: true,
46
+ signal: { aborted: false } as AbortSignal,
47
+ getContextUsage: () => null,
48
+ ui: {
49
+ notify: () => {},
50
+ setStatus: () => {},
51
+ setWidget: () => {},
52
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
53
+ },
54
+ sessionManager: { getEntries: () => [], getBranch: () => undefined },
55
+ } as unknown as ExtensionContext;
56
+
57
+ return { pi, ctx, states, history, sendUser };
58
+ }
59
+
60
+ // ── pi.__goalInit(NFR-AC-8 / T1.8)──────────────────
61
+
62
+ describe("pi.__goalInit — tasks 参数废弃(NFR-AC-8 / T1.8)", () => {
63
+ it("工厂实例化后 __goalInit 存在且为函数", () => {
64
+ const { pi } = makeFactoryFixture();
65
+ goalExtension(pi);
66
+ expect(typeof pi.__goalInit).toBe("function");
67
+ });
68
+
69
+ it("正常调用 → 创建 goal(返回 true + 持久化 state)", () => {
70
+ const { pi, ctx, states } = makeFactoryFixture();
71
+ goalExtension(pi);
72
+ const ok = (pi.__goalInit as (o: string, b: unknown, c: ExtensionContext) => boolean)(
73
+ "build feature X",
74
+ undefined,
75
+ ctx,
76
+ );
77
+ expect(ok).toBe(true);
78
+ expect(states.length).toBeGreaterThanOrEqual(1); // appendState 调用
79
+ const persisted = states[0] as { objective?: string };
80
+ expect(persisted.objective).toBe("build feature X");
81
+ });
82
+
83
+ it("ctx 缺失 → 返回 false(创建失败,不 throw)", () => {
84
+ // FR-4.2/D-16: ctx 必填
85
+ const { pi } = makeFactoryFixture();
86
+ goalExtension(pi);
87
+ const init = pi.__goalInit as (o: string, b: unknown, c: unknown) => boolean;
88
+ expect(() => init("obj", undefined, undefined)).not.toThrow();
89
+ expect(init("obj", undefined, undefined)).toBe(false);
90
+ });
91
+
92
+ it("已有 active goal → 返回 false(拒绝重复创建)", () => {
93
+ const { pi, ctx } = makeFactoryFixture();
94
+ goalExtension(pi);
95
+ const init = pi.__goalInit as (o: string, b: unknown, c: ExtensionContext) => boolean;
96
+ expect(init("first goal", undefined, ctx)).toBe(true);
97
+ // 再创建 → 已有 active,拒绝
98
+ expect(init("second goal", undefined, ctx)).toBe(false);
99
+ });
100
+
101
+ it("传入 tasks(第 4 参数)→ 不 throw、不接收(结构保证:签名只取前 3 参)", () => {
102
+ // NFR-AC-8 核心断言:tasks 废弃后,即便调用方误传 tasks 也不影响创建。
103
+ // __goalInit 签名 (objective, budget, ctx) 不含 tasks 参数位,
104
+ // JS 多余实参被静默忽略,TS 层签名也不暴露 tasks。
105
+ const { pi, ctx, states } = makeFactoryFixture();
106
+ goalExtension(pi);
107
+ const init = pi.__goalInit as (...args: unknown[]) => boolean;
108
+ // 误传第 4 参 tasks(模拟旧调用方残留)→ 不 throw + 创建成功 + tasks 被忽略
109
+ const strayTasks = [{ id: 1, text: "legacy task" }];
110
+ let ok = false;
111
+ expect(() => {
112
+ ok = init("obj with stray tasks", undefined, ctx, strayTasks);
113
+ }).not.toThrow();
114
+ expect(ok).toBe(true);
115
+ // 创建的 state 不含 tasks 字段(tasks 被忽略)
116
+ const persisted = states[states.length - 1] as { tasks?: unknown };
117
+ expect(persisted.tasks).toBeUndefined();
118
+ });
119
+ });
@@ -0,0 +1,317 @@
1
+ /**
2
+ * service.ts 测试 — 用 fake ports(内存实现 ports.ts 接口)
3
+ *
4
+ * FR-7.2: service 层测试,不 import Pi SDK
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { createGoalState } from "../engine/goal";
9
+ import type { GoalRuntimeState } from "../engine/types";
10
+ import { applyEvent, createGoal, finalizeGoal, persistAndUpdate, type ServicePorts } from "../service";
11
+ import { createGoalSession } from "../session";
12
+
13
+ // ── Fake Ports ───────────────────────────────────────
14
+
15
+ function makeFakePorts(): ServicePorts & {
16
+ states: GoalRuntimeState[];
17
+ history: unknown[];
18
+ } {
19
+ const states: GoalRuntimeState[] = [];
20
+ const history: unknown[] = [];
21
+ return {
22
+ states,
23
+ history,
24
+ persistence: {
25
+ appendState: (s) => {
26
+ states.push(s);
27
+ },
28
+ appendHistory: (e) => {
29
+ history.push(e);
30
+ },
31
+ },
32
+ ui: {
33
+ setWidget: () => {},
34
+ setStatus: () => {},
35
+ notify: () => {},
36
+ hasUI: true,
37
+ // ThemeLike 直接成员(widget 经 asTheme 取出,见 projection/widget.ts)
38
+ fg: (_color: string, text: string) => text,
39
+ bold: (text: string) => text,
40
+ },
41
+ messaging: {
42
+ sendContextMessage: () => {},
43
+ sendUserMessage: () => {},
44
+ },
45
+ session: {
46
+ getEntries: () => [],
47
+ getContextUsage: () => null,
48
+ signal: undefined,
49
+ },
50
+ };
51
+ }
52
+
53
+ const makeState = (): GoalRuntimeState => createGoalState("test");
54
+
55
+ // ── createGoal 测试 ──────────────────────────────────
56
+
57
+ describe("createGoal — 唯一创建入口", () => {
58
+ it("成功创建:state 构造 + persist", () => {
59
+ const session = createGoalSession();
60
+ const ports = makeFakePorts();
61
+ const ok = createGoal(session, "my objective", {}, ports, false);
62
+ expect(ok).toBe(true);
63
+ expect(session.state).not.toBeNull();
64
+ expect(session.state!.objective).toBe("my objective");
65
+ expect(ports.states.length).toBeGreaterThanOrEqual(1);
66
+ });
67
+
68
+ it("已有 active goal → 拒绝创建(返回 false)", () => {
69
+ const session = createGoalSession();
70
+ const ports = makeFakePorts();
71
+ createGoal(session, "first", {}, ports, false);
72
+ const ok = createGoal(session, "second", {}, ports, false);
73
+ expect(ok).toBe(false);
74
+ expect(session.state!.objective).toBe("first"); // 保持原 goal
75
+ });
76
+
77
+ it("终态 goal → 允许创建(覆盖)", () => {
78
+ const session = createGoalSession();
79
+ const ports = makeFakePorts();
80
+ session.state = makeState();
81
+ session.state.status = "complete"; // 终态
82
+ const ok = createGoal(session, "new", {}, ports, false);
83
+ expect(ok).toBe(true);
84
+ expect(session.state.objective).toBe("new");
85
+ });
86
+ });
87
+
88
+ // ── finalizeGoal — history 写入矩阵 ──────────────────
89
+
90
+ describe("finalizeGoal — history 写入矩阵", () => {
91
+ it("complete → 写 history", () => {
92
+ const ports = makeFakePorts();
93
+ const state = makeState();
94
+ finalizeGoal(state, "complete", ports, { completedTasks: 1 });
95
+ expect(ports.history.length).toBe(1);
96
+ expect((ports.history[0] as { status: string }).status).toBe("complete");
97
+ expect(state.completedAtTurnIndex).toBe(state.currentTurnIndex);
98
+ });
99
+
100
+ it("cancelled → 写 history", () => {
101
+ const ports = makeFakePorts();
102
+ const state = makeState();
103
+ finalizeGoal(state, "cancelled", ports, { completedTasks: 0 });
104
+ expect(ports.history.length).toBe(1);
105
+ });
106
+
107
+ it("budget_limited → 写 history", () => {
108
+ const ports = makeFakePorts();
109
+ const state = makeState();
110
+ finalizeGoal(state, "budget_limited", ports, { completedTasks: 2 });
111
+ expect(ports.history.length).toBe(1);
112
+ });
113
+
114
+ it("time_limited → 写 history", () => {
115
+ const ports = makeFakePorts();
116
+ const state = makeState();
117
+ finalizeGoal(state, "time_limited", ports, { completedTasks: 0 });
118
+ expect(ports.history.length).toBe(1);
119
+ });
120
+
121
+ it("终态 goal 再 finalize → throw(查表非法转换)", () => {
122
+ const ports = makeFakePorts();
123
+ const state = makeState();
124
+ state.status = "complete";
125
+ // transitionStatus 查表:终态不可转,调用方须先 isTerminalStatus 守卫
126
+ expect(() => finalizeGoal(state, "cancelled", ports, { completedTasks: 0 })).toThrow();
127
+ expect(state.status).toBe("complete");
128
+ });
129
+ });
130
+
131
+ // ── applyEvent — 简单事件(路径 B)────────────────────
132
+
133
+ describe("applyEvent — 简单事件", () => {
134
+ it("message_end 累加 assistant token(FR-8.6)", () => {
135
+ const session = createGoalSession();
136
+ session.state = makeState();
137
+ const before = session.state.tokensUsed;
138
+ applyEvent(session, "message_end", {
139
+ message: {
140
+ role: "assistant",
141
+ usage: { input: 100, output: 50, cacheRead: 20 },
142
+ },
143
+ }, makeFakePorts());
144
+ // accumulateTokens: max(100 - 20, 0) + 50 = 130
145
+ expect(session.state.tokensUsed).toBe(before + 130);
146
+ });
147
+
148
+ it("message_end 忽略非 assistant 消息", () => {
149
+ const session = createGoalSession();
150
+ session.state = makeState();
151
+ const before = session.state.tokensUsed;
152
+ applyEvent(session, "message_end", {
153
+ message: { role: "user", usage: { input: 100, output: 50 } },
154
+ }, makeFakePorts());
155
+ expect(session.state.tokensUsed).toBe(before);
156
+ });
157
+
158
+ it("message_end 缺 usage → 不变", () => {
159
+ const session = createGoalSession();
160
+ session.state = makeState();
161
+ const before = session.state.tokensUsed;
162
+ applyEvent(session, "message_end", { message: { role: "assistant" } }, makeFakePorts());
163
+ expect(session.state.tokensUsed).toBe(before);
164
+ });
165
+
166
+ it("回归#1: message_end 非 active(blocked)→ 不累加 token(FR-8.6 G-R2-001)", () => {
167
+ const session = createGoalSession();
168
+ session.state = makeState();
169
+ session.state.status = "blocked"; // 非 active
170
+ const before = session.state.tokensUsed;
171
+ applyEvent(session, "message_end", {
172
+ message: {
173
+ role: "assistant",
174
+ usage: { input: 100, output: 50, cacheRead: 20 },
175
+ },
176
+ }, makeFakePorts());
177
+ // 回归修复:blocked 状态不累加 token(原 bug:缺 isActiveStatus 守卫)
178
+ expect(session.state.tokensUsed).toBe(before);
179
+ });
180
+
181
+ it("turn_end → currentTurnIndex++ + updateWidget effect", () => {
182
+ const session = createGoalSession();
183
+ session.state = makeState();
184
+ const before = session.state.currentTurnIndex;
185
+ const effects = applyEvent(session, "turn_end", {}, makeFakePorts());
186
+ expect(session.state.currentTurnIndex).toBe(before + 1);
187
+ expect(effects).toContainEqual({ kind: "updateWidget" });
188
+ });
189
+
190
+ it("agent_start → 无副作用(task 已移除)", () => {
191
+ const session = createGoalSession();
192
+ session.state = makeState();
193
+ const effects = applyEvent(session, "agent_start", {}, makeFakePorts());
194
+ expect(effects).toEqual([]);
195
+ });
196
+
197
+ it("session.state=null → 返回空 effects", () => {
198
+ const session = createGoalSession();
199
+ const effects = applyEvent(session, "turn_end", {}, makeFakePorts());
200
+ expect(effects).toEqual([]);
201
+ });
202
+
203
+ it("未知事件 → 返回空 effects(不报错)", () => {
204
+ const session = createGoalSession();
205
+ session.state = makeState();
206
+ const effects = applyEvent(session, "unknown_event", {}, makeFakePorts());
207
+ expect(effects).toEqual([]);
208
+ });
209
+ });
210
+
211
+ // ── persistAndUpdate — #5 budget 终态检查(事件路径单一检查点,NFR F2)──
212
+
213
+ describe("persistAndUpdate — #5 budget 终态检查(事件路径单一检查点)", () => {
214
+ it("active + token 超额(steering 已发)→ status 转 budget_limited + 写 history", () => {
215
+ const session = createGoalSession();
216
+ session.state = {
217
+ ...makeState(),
218
+ status: "active",
219
+ timeStartedAt: 0, // 关闭时间累计,保证 tick 不额外累加
220
+ budget: { tokenBudget: 1000 },
221
+ tokensUsed: 1000, // >= tokenBudget
222
+ budgetLimitSteeringSent: true, // token terminal 要求 steering 已发
223
+ };
224
+ const ports = makeFakePorts();
225
+ const stale = persistAndUpdate(session, ports);
226
+
227
+ expect(stale).toBe(false);
228
+ expect(session.state!.status).toBe("budget_limited");
229
+ expect(ports.history.length).toBe(1); // FR-8.7: 终态写 history
230
+ expect((ports.history[0] as { status: string }).status).toBe("budget_limited");
231
+ });
232
+
233
+ it("token 终态分支不重复 appendState(finalizeAndPersist 已含持久化)", () => {
234
+ const session = createGoalSession();
235
+ session.state = {
236
+ ...makeState(),
237
+ status: "active",
238
+ timeStartedAt: 0,
239
+ budget: { tokenBudget: 1000 },
240
+ tokensUsed: 1000,
241
+ budgetLimitSteeringSent: true,
242
+ };
243
+ const ports = makeFakePorts();
244
+ persistAndUpdate(session, ports);
245
+ // 仅 finalizeAndPersist 内部 1 次 appendState,不再走正常 appendState
246
+ expect(ports.states.length).toBe(1);
247
+ });
248
+
249
+ it("active + time 超额 → status 转 time_limited + 写 history", () => {
250
+ const session = createGoalSession();
251
+ session.state = {
252
+ ...makeState(),
253
+ status: "active",
254
+ timeStartedAt: 0,
255
+ budget: { timeBudgetMinutes: 10 },
256
+ timeUsedSeconds: 600, // >= 10*60
257
+ };
258
+ const ports = makeFakePorts();
259
+ persistAndUpdate(session, ports);
260
+
261
+ expect(session.state!.status).toBe("time_limited");
262
+ expect(ports.history.length).toBe(1);
263
+ expect((ports.history[0] as { status: string }).status).toBe("time_limited");
264
+ });
265
+
266
+ it("非 active(blocked)→ 不触发 budget 检查,保持 blocked", () => {
267
+ const session = createGoalSession();
268
+ session.state = {
269
+ ...makeState(),
270
+ status: "blocked",
271
+ timeStartedAt: 0,
272
+ budget: { tokenBudget: 1000 },
273
+ tokensUsed: 1000, // 已超额,但 blocked 不检查
274
+ budgetLimitSteeringSent: true,
275
+ };
276
+ const ports = makeFakePorts();
277
+ persistAndUpdate(session, ports);
278
+
279
+ expect(session.state!.status).toBe("blocked"); // 未变
280
+ expect(ports.history.length).toBe(0); // blocked 不写 history
281
+ expect(ports.states.length).toBe(1); // 走正常 appendState 路径
282
+ });
283
+
284
+ it("active 但未超额 → 正常 persist(无终态、无 history)", () => {
285
+ const session = createGoalSession();
286
+ session.state = {
287
+ ...makeState(),
288
+ status: "active",
289
+ timeStartedAt: 0,
290
+ budget: { tokenBudget: 1000 },
291
+ tokensUsed: 100, // 未超额
292
+ };
293
+ const ports = makeFakePorts();
294
+ persistAndUpdate(session, ports);
295
+
296
+ expect(session.state!.status).toBe("active"); // 未变
297
+ expect(ports.history.length).toBe(0);
298
+ expect(ports.states.length).toBe(1); // 正常 appendState
299
+ });
300
+
301
+ it("终态处理后 checkStale 触发 → 返回 true(status 已转终态)", () => {
302
+ const session = createGoalSession();
303
+ session.state = {
304
+ ...makeState(),
305
+ status: "active",
306
+ timeStartedAt: 0,
307
+ budget: { tokenBudget: 1000 },
308
+ tokensUsed: 1000,
309
+ budgetLimitSteeringSent: true,
310
+ };
311
+ const ports = makeFakePorts();
312
+ const stale = persistAndUpdate(session, ports, () => true);
313
+ expect(stale).toBe(true);
314
+ // finalizeAndPersist 已执行,status 已转终态
315
+ expect(session.state!.status).toBe("budget_limited");
316
+ });
317
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * session.ts 测试 — reconstructGoalState + isStaleContextError
3
+ *
4
+ * 覆盖:
5
+ * - MF-7: reconstructGoalState 3 条 FR(G-006 entry GC / G-015 强制激活 / G-024 throw→null)
6
+ * - MF-8: isStaleContextError 5 pattern 匹配(G-010)
7
+ *
8
+ * 用 fake SessionPort(内存 entries 数组)。
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { createGoalState } from "../engine/goal";
13
+ import type { GoalRuntimeState } from "../engine/types";
14
+ import {
15
+ ENTRY_TYPE,
16
+ HISTORY_ENTRY_TYPE,
17
+ makeHistoryEntry,
18
+ serializeState,
19
+ } from "../persistence";
20
+ import type { SessionEntryLike, SessionPort } from "../ports";
21
+ import {
22
+ createGoalSession,
23
+ isStaleContextError,
24
+ reconstructGoalState,
25
+ STALE_CONTEXT_PATTERNS,
26
+ } from "../session";
27
+
28
+ // ── Fake SessionPort ─────────────────────────────────
29
+
30
+ function makeFakeSessionPort(entries: SessionEntryLike[]): SessionPort {
31
+ return {
32
+ // 模拟 Pi SDK filter-copy 语义:返回新数组,避免测试掩盖 splice GC 失效
33
+ getEntries: () => entries.slice(),
34
+ getContextUsage: () => null,
35
+ signal: undefined,
36
+ };
37
+ }
38
+
39
+ function makeGoalStateEntry(state: GoalRuntimeState): SessionEntryLike {
40
+ return { type: "custom", customType: ENTRY_TYPE, data: serializeState(state) };
41
+ }
42
+
43
+ function makeHistoryEntryWrapper(entry: unknown): SessionEntryLike {
44
+ return { type: "custom", customType: HISTORY_ENTRY_TYPE, data: entry };
45
+ }
46
+
47
+ // ── isStaleContextError(MF-8, FR-8.2 G-010)──────────
48
+
49
+ describe("isStaleContextError (FR-8.2 G-010)", () => {
50
+ it("5 个 STALE_CONTEXT_PATTERNS 各匹配", () => {
51
+ for (const pattern of STALE_CONTEXT_PATTERNS) {
52
+ const err = new Error(`something ${pattern} happened`);
53
+ expect(isStaleContextError(err)).toBe(true);
54
+ }
55
+ });
56
+
57
+ it("大小写混合匹配", () => {
58
+ expect(isStaleContextError(new Error("Context CANCELED"))).toBe(true);
59
+ expect(isStaleContextError(new Error("ABORTED by user"))).toBe(true);
60
+ });
61
+
62
+ it("非 stale 错误 → false", () => {
63
+ expect(isStaleContextError(new Error("network timeout"))).toBe(false);
64
+ expect(isStaleContextError(new Error("permission denied"))).toBe(false);
65
+ });
66
+
67
+ it("字符串输入(非 Error)", () => {
68
+ expect(isStaleContextError("the context was aborted")).toBe(true);
69
+ expect(isStaleContextError("random error string")).toBe(false);
70
+ });
71
+
72
+ it("null/undefined 输入 → false(不抛错)", () => {
73
+ expect(isStaleContextError(null)).toBe(false);
74
+ expect(isStaleContextError(undefined)).toBe(false);
75
+ });
76
+ });
77
+
78
+ // ── reconstructGoalState(MF-7)──────────────────────
79
+
80
+ describe("reconstructGoalState", () => {
81
+ it("无 goal-state entry → state=null", () => {
82
+ const session = createGoalSession();
83
+ const port = makeFakeSessionPort([]);
84
+ reconstructGoalState(session, port);
85
+ expect(session.state).toBeNull();
86
+ });
87
+
88
+ it("有 goal-state entry → 恢复 state", () => {
89
+ const session = createGoalSession();
90
+ const state = createGoalState("my objective");
91
+ const port = makeFakeSessionPort([makeGoalStateEntry(state)]);
92
+ reconstructGoalState(session, port);
93
+ expect(session.state).not.toBeNull();
94
+ expect(session.state!.objective).toBe("my objective");
95
+ });
96
+
97
+ it("G-006: 多个 goal-state entry → 恢复最新(append-only,不 splice)", () => {
98
+ const session = createGoalSession();
99
+ const oldState = createGoalState("old");
100
+ const newState = createGoalState("new");
101
+ const entries: SessionEntryLike[] = [
102
+ makeGoalStateEntry(oldState),
103
+ makeGoalStateEntry(newState), // 最新(在后面)
104
+ ];
105
+ const port = makeFakeSessionPort(entries);
106
+ reconstructGoalState(session, port);
107
+ // 恢复的是最新的
108
+ expect(session.state!.objective).toBe("new");
109
+ // append-only:旧 entry 不被删除(splice GC 在生产不生效)
110
+ const remaining = entries.filter((e) => e.customType === ENTRY_TYPE);
111
+ expect(remaining).toHaveLength(2);
112
+ });
113
+
114
+ it("G-006: goal-history 超出 20 条仍正确恢复 state(append-only,不 splice)", () => {
115
+ const session = createGoalSession();
116
+ const state = createGoalState("active goal");
117
+ // 插入 25 个 history entry + 1 个 goal-state
118
+ const entries: SessionEntryLike[] = [];
119
+ for (let i = 0; i < 25; i++) {
120
+ entries.push(makeHistoryEntryWrapper({ goalId: `g-${i}`, timestamp: i }));
121
+ }
122
+ entries.push(makeGoalStateEntry(state));
123
+ const port = makeFakeSessionPort(entries);
124
+ reconstructGoalState(session, port);
125
+ // append-only:history 不被删除(显示侧截断,splice GC 在生产不生效)
126
+ const historyRemaining = entries.filter((e) => e.customType === HISTORY_ENTRY_TYPE);
127
+ expect(historyRemaining).toHaveLength(25);
128
+ // state 仍正确恢复
129
+ expect(session.state!.objective).toBe("active goal");
130
+ });
131
+
132
+ it("FR-3: blocked 非终态 → 保持 blocked(崩溃不抹除 agent 叫停状态)", () => {
133
+ const session = createGoalSession();
134
+ const state = createGoalState("blocked goal");
135
+ state.status = "blocked";
136
+ state.timeStartedAt = 1000; // 旧值
137
+ const port = makeFakeSessionPort([makeGoalStateEntry(state)]);
138
+ reconstructGoalState(session, port);
139
+ // blocked 保持 blocked(不强制 active)
140
+ expect(session.state!.status).toBe("blocked");
141
+ // timeStartedAt 不重置(非 active 不重启计时)
142
+ expect(session.state!.timeStartedAt).toBe(1000);
143
+ });
144
+
145
+ it("FR-3: paused 非终态 → 保持 paused(崩溃不抹除用户叫停状态)", () => {
146
+ const session = createGoalSession();
147
+ const state = createGoalState("paused goal");
148
+ state.status = "paused";
149
+ state.timeStartedAt = 2000;
150
+ const port = makeFakeSessionPort([makeGoalStateEntry(state)]);
151
+ reconstructGoalState(session, port);
152
+ expect(session.state!.status).toBe("paused");
153
+ expect(session.state!.timeStartedAt).toBe(2000);
154
+ });
155
+
156
+ it("FR-3: active → 保持 active + 重启计时(timeStartedAt = now)", () => {
157
+ const session = createGoalSession();
158
+ const state = createGoalState("active goal");
159
+ state.timeStartedAt = 1000; // 旧值
160
+ const port = makeFakeSessionPort([makeGoalStateEntry(state)]);
161
+ const before = Date.now();
162
+ reconstructGoalState(session, port);
163
+ const after = Date.now();
164
+ expect(session.state!.status).toBe("active");
165
+ expect(session.state!.timeStartedAt).toBeGreaterThanOrEqual(before);
166
+ expect(session.state!.timeStartedAt).toBeLessThanOrEqual(after);
167
+ });
168
+
169
+ it("G-015: 终态保持终态(不强制激活)", () => {
170
+ const session = createGoalSession();
171
+ const state = createGoalState("completed goal");
172
+ state.status = "complete";
173
+ const port = makeFakeSessionPort([makeGoalStateEntry(state)]);
174
+ reconstructGoalState(session, port);
175
+ expect(session.state!.status).toBe("complete");
176
+ });
177
+
178
+ it("G-024: deserialize throw(损坏 data)→ state=null", () => {
179
+ const session = createGoalSession();
180
+ // 缺少必填字段 → deserializeState throw
181
+ const brokenEntry: SessionEntryLike = {
182
+ type: "custom",
183
+ customType: ENTRY_TYPE,
184
+ data: { goalId: "x" }, // 缺大量必填字段
185
+ };
186
+ const port = makeFakeSessionPort([brokenEntry]);
187
+ reconstructGoalState(session, port);
188
+ expect(session.state).toBeNull();
189
+ });
190
+
191
+ it("data=undefined 的 entry → 跳过(state=null)", () => {
192
+ const session = createGoalSession();
193
+ const entry: SessionEntryLike = {
194
+ type: "custom",
195
+ customType: ENTRY_TYPE,
196
+ data: undefined,
197
+ };
198
+ const port = makeFakeSessionPort([entry]);
199
+ reconstructGoalState(session, port);
200
+ expect(session.state).toBeNull();
201
+ });
202
+
203
+ it("makeHistoryEntry + serializeState 往返一致性", () => {
204
+ // 验证辅助函数本身正确(makeHistoryEntry 用于 history entry 构造)
205
+ const state = createGoalState("roundtrip");
206
+ state.status = "complete";
207
+ const hist = makeHistoryEntry(state, 5);
208
+ expect(hist.goalId).toBe(state.goalId);
209
+ expect(hist.objective).toBe("roundtrip");
210
+ expect(hist.status).toBe("complete");
211
+ expect(hist.completedTasks).toBe(5);
212
+ expect(typeof hist.elapsedSeconds).toBe("number");
213
+ expect(typeof hist.timestamp).toBe("number");
214
+ // serializeState 返回深拷贝(修改返回值不影响原 state)
215
+ const serialized = serializeState(state);
216
+ expect(serialized.objective).toBe("roundtrip");
217
+ serialized.status = "cancelled";
218
+ expect(state.status).toBe("complete"); // 原状态未被修改
219
+ });
220
+ });