@zhushanwen/pi-goal 0.3.0 → 0.4.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 (49) hide show
  1. package/README.md +13 -12
  2. package/package.json +1 -1
  3. package/src/__tests__/command-adapter.test.ts +404 -0
  4. package/src/__tests__/deserialize-state.test.ts +65 -94
  5. package/src/__tests__/event-adapter.test.ts +410 -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 +5 -0
  12. package/src/adapters/command-adapter.ts +360 -0
  13. package/src/adapters/event-adapter.ts +17 -0
  14. package/src/adapters/event-handlers/agent-end.ts +177 -0
  15. package/src/adapters/event-handlers/agent-start.ts +17 -0
  16. package/src/adapters/event-handlers/before-agent-start.ts +157 -0
  17. package/src/adapters/event-handlers/message-end.ts +37 -0
  18. package/src/adapters/event-handlers/session-start.ts +24 -0
  19. package/src/adapters/event-handlers/shared.ts +46 -0
  20. package/src/adapters/event-handlers/turn-end.ts +37 -0
  21. package/src/adapters/goal-control-adapter.ts +296 -0
  22. package/src/adapters/ports.ts +89 -0
  23. package/src/commands.ts +8 -23
  24. package/src/constants.ts +1 -8
  25. package/src/engine/__tests__/budget.test.ts +182 -0
  26. package/src/engine/__tests__/goal.test.ts +126 -0
  27. package/src/engine/budget.ts +146 -0
  28. package/src/engine/goal.ts +62 -0
  29. package/src/engine/types.ts +80 -0
  30. package/src/index.ts +110 -355
  31. package/src/persistence.ts +83 -0
  32. package/src/ports.ts +68 -0
  33. package/src/projection/__tests__/prompts.test.ts +245 -0
  34. package/src/projection/__tests__/widget.test.ts +255 -0
  35. package/src/projection/prompts.ts +250 -0
  36. package/src/projection/widget.ts +254 -0
  37. package/src/service.ts +276 -0
  38. package/src/session.ts +114 -0
  39. package/src/__tests__/is-task-done.test.ts +0 -43
  40. package/src/__tests__/validate-update-tasks.test.ts +0 -172
  41. package/src/action-handlers.ts +0 -412
  42. package/src/agent-end-handler.ts +0 -306
  43. package/src/before-agent-start-handler.ts +0 -180
  44. package/src/budget.ts +0 -159
  45. package/src/command-handler.ts +0 -299
  46. package/src/state.ts +0 -274
  47. package/src/templates.ts +0 -274
  48. package/src/tool-handler.ts +0 -291
  49. package/src/widget.ts +0 -225
@@ -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
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * event-adapter 并发保护辅助函数测试(TC-3)
3
+ *
4
+ * 直接测试 makeStaleChecker / acquireProcessing / releaseProcessing 的快照语义,
5
+ * 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
6
+ * 后续任何新 goal 均视为 stale。
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import { acquireProcessing, makeStaleChecker, releaseProcessing } from "../adapters/event-handlers/shared";
11
+ import { createGoalState } from "../engine/goal";
12
+ import { createGoalSession } from "../session";
13
+
14
+ describe("makeStaleChecker", () => {
15
+ it("snapshot 时 state=null → 后续新 goal 视为 stale", () => {
16
+ const session = createGoalSession();
17
+ // state 为 null 时 snapshot goalId = undefined
18
+ const checkStale = makeStaleChecker(session);
19
+ // 后续出现新 goal(goalId 有值)
20
+ session.state = createGoalState("new goal");
21
+ expect(checkStale()).toBe(true); // undefined !== "goal-xxx" → stale
22
+ });
23
+
24
+ it("snapshot 时有 goal → 同 goalId 返回 false(未 stale)", () => {
25
+ const session = createGoalSession();
26
+ session.state = createGoalState("current");
27
+ const snapshotId = session.state.goalId;
28
+ const checkStale = makeStaleChecker(session);
29
+ // 后续 mutate 但 goalId 不变
30
+ session.state.currentTurnIndex = 99;
31
+ expect(checkStale()).toBe(false);
32
+ expect(session.state.goalId).toBe(snapshotId); // 确认 goalId 未变
33
+ });
34
+
35
+ it("snapshot 时有 goal → goalId 变更后视为 stale(被新 goal 覆盖)", () => {
36
+ const session = createGoalSession();
37
+ session.state = createGoalState("old goal");
38
+ const checkStale = makeStaleChecker(session);
39
+ expect(checkStale()).toBe(false); // 初始未 stale
40
+ // 模拟新 goal 覆盖(createGoalState 生成新 goalId)
41
+ session.state = createGoalState("new goal overwrote");
42
+ expect(checkStale()).toBe(true); // goalId 变了 → stale
43
+ });
44
+
45
+ it("state 被清空(null)后 → 视为 stale", () => {
46
+ const session = createGoalSession();
47
+ session.state = createGoalState("then cleared");
48
+ const checkStale = makeStaleChecker(session);
49
+ expect(checkStale()).toBe(false);
50
+ session.state = null; // clearGoalSession 清空
51
+ expect(checkStale()).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("acquireProcessing / releaseProcessing", () => {
56
+ it("首次 acquire → true 并设置 isProcessing", () => {
57
+ const session = createGoalSession();
58
+ expect(session.isProcessing).toBe(false);
59
+ expect(acquireProcessing(session)).toBe(true);
60
+ expect(session.isProcessing).toBe(true);
61
+ });
62
+
63
+ it("已占用时再 acquire → false(防重入)", () => {
64
+ const session = createGoalSession();
65
+ expect(acquireProcessing(session)).toBe(true); // 首次成功
66
+ expect(acquireProcessing(session)).toBe(false); // 重入拒绝
67
+ expect(acquireProcessing(session)).toBe(false); // 再入仍拒绝
68
+ });
69
+
70
+ it("release 后可再次 acquire", () => {
71
+ const session = createGoalSession();
72
+ acquireProcessing(session);
73
+ expect(session.isProcessing).toBe(true);
74
+ releaseProcessing(session);
75
+ expect(session.isProcessing).toBe(false);
76
+ expect(acquireProcessing(session)).toBe(true); // 释放后可重新获取
77
+ });
78
+ });
@@ -7,5 +7,10 @@ export function StringEnum<T extends readonly string[]>(values: T, _options?: Re
7
7
  return values[0] as T[number];
8
8
  }
9
9
 
10
+ // Text: pi-tui 的渲染节点 stub(goal-control-adapter renderCall/renderResult 返回值)
11
+ export class Text {
12
+ constructor(_content: unknown, _x: number, _y: number) {}
13
+ }
14
+
10
15
  // Pi SDK types used in import chains — re-export as empty
11
16
  export {};