@zhushanwen/pi-goal 0.12.2 → 0.14.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 (41) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -3
  3. package/src/__tests__/circuit-breaker.test.ts +449 -0
  4. package/src/__tests__/criteria-array-adapter.test.ts +4 -4
  5. package/src/__tests__/criteria-array-migration.test.ts +0 -2
  6. package/src/__tests__/deserialize-state.test.ts +54 -6
  7. package/src/__tests__/event-adapter.test.ts +2 -2
  8. package/src/__tests__/goal-control-adapter.test.ts +5 -5
  9. package/src/__tests__/goal-control-rpc.test.ts +1 -1
  10. package/src/__tests__/liveness.test.ts +162 -0
  11. package/src/__tests__/ports.test.ts +1 -1
  12. package/src/__tests__/schema.test.ts +15 -0
  13. package/src/__tests__/service.test.ts +5 -5
  14. package/src/__tests__/session.test.ts +0 -2
  15. package/src/__tests__/stale-checker.test.ts +2 -2
  16. package/src/adapters/command-adapter.ts +31 -8
  17. package/src/adapters/event-handlers/agent-end.ts +202 -29
  18. package/src/adapters/event-handlers/before-agent-start.ts +7 -3
  19. package/src/adapters/event-handlers/message-end.ts +2 -14
  20. package/src/adapters/event-handlers/session-shutdown.ts +23 -0
  21. package/src/adapters/event-handlers/session-start.ts +4 -1
  22. package/src/adapters/goal-control-adapter.ts +1 -1
  23. package/src/adapters/ports.ts +17 -23
  24. package/src/constants.ts +13 -0
  25. package/src/engine/__tests__/budget.test.ts +7 -7
  26. package/src/engine/__tests__/goal.test.ts +40 -1
  27. package/src/engine/budget.ts +7 -13
  28. package/src/engine/goal.ts +6 -2
  29. package/src/engine/liveness.ts +150 -0
  30. package/src/engine/types.ts +21 -2
  31. package/src/index.ts +23 -50
  32. package/src/persistence.ts +22 -5
  33. package/src/ports.ts +13 -3
  34. package/src/projection/__tests__/prompts.test.ts +19 -11
  35. package/src/projection/__tests__/widget.test.ts +9 -6
  36. package/src/projection/prompts.ts +8 -28
  37. package/src/projection/widget.ts +6 -38
  38. package/src/service.ts +6 -6
  39. package/src/session.ts +25 -0
  40. package/src/adapters/event-adapter.ts +0 -16
  41. package/src/adapters/event-handlers/shared.ts +0 -27
package/README.md CHANGED
@@ -52,8 +52,8 @@ goal/
52
52
  ├── service.ts # 协调层 — createGoal / finalizeAndPersist / applyEvent
53
53
  ├── persistence.ts # serialize/deserialize + GoalHistoryEntry
54
54
  ├── session.ts # 运行时句柄 + 状态重建(append-only 只读最新一条,无 entry GC)
55
- ├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types)
56
- ├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter / event-adapter
57
- │ # + event-handlers/ + ports / success-criteria)
55
+ ├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types/liveness)
56
+ ├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter / ports /
57
+ │ # success-criteria + event-handlers/)
58
58
  └── projection/ # 渲染(widget / prompts / gui)
59
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token budget, agent-reported blocking, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,10 +29,10 @@
29
29
  "@earendil-works/pi-coding-agent": "^0.84.4",
30
30
  "@earendil-works/pi-tui": "^0.84.4",
31
31
  "typebox": "*",
32
- "@zhushanwen/pi-pending-notifications": "0.5.3"
32
+ "@zhushanwen/pi-pending-notifications": "0.7.0"
33
33
  },
34
34
  "dependencies": {
35
- "@xyz-agent/extension-protocol": "0.8.2"
35
+ "@xyz-agent/extension-protocol": "0.9.0"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@earendil-works/pi-tui": {
@@ -0,0 +1,449 @@
1
+ /**
2
+ * 轮次活性熔断行为测试(chat-domain-v1x 设计 §3.2 D4 / W5,验收 §4 A10 的单测面)
3
+ *
4
+ * 四断言(对齐 impl-plan W5 验收条款②):
5
+ * a. 主判据 50 次封顶必停发——含「每轮调一次工具」序列同样被封顶(正交性:
6
+ * 工具调用只清零退避计数,绝不清零总次数——设计 R1 击穿记录的反向锁定);
7
+ * b. 连续 5 轮无工具调用且低 tokenDelta → 第 5 轮起间隔翻倍(×2 递增);
8
+ * c. 恢复(真实工具调用)清零退避计数但不重置主判据总次数;
9
+ * d. defer 通知:pending 集合不变的连续 defer 轮中通知条数不增长。
10
+ *
11
+ * 另覆盖:停发通知含 /goal resume 恢复指引 + goal 保持 active;/goal resume
12
+ * 恢复通道重置计数;未封顶 active goal 的 resume 语义不变。
13
+ *
14
+ * fake timers + fake pi/ctx(不 import Pi SDK 真实实现,harness 对齐 __tests__/event-adapter.test.ts)。
15
+ */
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ import { handleAgentEnd } from "../adapters/event-handlers/agent-end";
20
+ import { handleBeforeAgentStart } from "../adapters/event-handlers/before-agent-start";
21
+ import { handleSessionShutdown } from "../adapters/event-handlers/session-shutdown";
22
+ import { handleSessionStart } from "../adapters/event-handlers/session-start";
23
+ import { handleGoalCommand } from "../adapters/command-adapter";
24
+ import { createGoalState } from "../engine/goal";
25
+ import type { GoalRuntimeState } from "../engine/types";
26
+ import { createGoalSession, type GoalSession } from "../session";
27
+
28
+ // ── Fake pi / ctx(entries 数组可变,模拟 append-only 会话流)──
29
+
30
+ interface RecordedCall {
31
+ kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser";
32
+ payload?: unknown;
33
+ text?: string;
34
+ level?: string;
35
+ content?: unknown;
36
+ }
37
+
38
+ interface FakeHarness {
39
+ pi: ExtensionAPI;
40
+ ctx: ExtensionContext;
41
+ entries: unknown[];
42
+ piCalls: RecordedCall[];
43
+ ctxCalls: RecordedCall[];
44
+ /** 可控 isIdle(MF-6① 守卫断言用:延迟窗内有活动 turn 时置 false) */
45
+ isIdle: { value: boolean };
46
+ /**
47
+ * 可控 appendEntry 抛错(MF-R2-1 测试前提修正):pi.appendEntry 与 ctx 同生命周期
48
+ * (loader.js appendEntry 首行 assertActive,0.84.4 实装核对)——模拟全量 stale
49
+ * 时必须置 true 按 SDK 契约抛错,否则测试默许了真实场景必抛的调用
50
+ */
51
+ appendEntryFails: { value: boolean };
52
+ }
53
+
54
+ /** runner.js invalidate 默认 stale 文案首句(0.84.4 实装),assertActive 据此抛出 */
55
+ const STALE_CTX_MESSAGE = "This extension ctx is stale after session replacement or reload.";
56
+
57
+ function makeHarness(): FakeHarness {
58
+ const piCalls: RecordedCall[] = [];
59
+ const ctxCalls: RecordedCall[] = [];
60
+ const entries: unknown[] = [];
61
+ const isIdle = { value: true };
62
+ const appendEntryFails = { value: false };
63
+
64
+ const pi = {
65
+ appendEntry(customType: string, data?: unknown): void {
66
+ if (appendEntryFails.value) {
67
+ throw new Error(STALE_CTX_MESSAGE);
68
+ }
69
+ piCalls.push({ kind: customType === "goal-history" ? "appendHistory" : "appendState", payload: data });
70
+ },
71
+ sendMessage(message: unknown, options?: unknown): void {
72
+ const msg = message as { content?: unknown };
73
+ piCalls.push({ kind: "sendContext", content: msg.content, payload: options });
74
+ },
75
+ sendUserMessage(content: string | unknown[], _options?: unknown): void {
76
+ piCalls.push({ kind: "sendUser", content });
77
+ },
78
+ } as unknown as ExtensionAPI;
79
+
80
+ const ctx = {
81
+ hasUI: true,
82
+ signal: { aborted: false } as AbortSignal,
83
+ // MF-6①:SDK ExtensionContext.isIdle(types.d.ts L232),agent_end 后为 true
84
+ isIdle: () => isIdle.value,
85
+ getContextUsage: () => null,
86
+ ui: {
87
+ notify: (text: string, level: string) => ctxCalls.push({ kind: "notify", text, level }),
88
+ setStatus: () => {},
89
+ setWidget: () => {},
90
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
91
+ },
92
+ sessionManager: {
93
+ getEntries: () => entries,
94
+ getBranch: () => undefined,
95
+ // W4 读侧过滤①消费口(agent-end 两处 countActiveFromEntries 传基准)
96
+ getSessionId: () => "test-session",
97
+ },
98
+ } as unknown as ExtensionContext;
99
+
100
+ return { pi, ctx, entries, piCalls, ctxCalls, isIdle, appendEntryFails };
101
+ }
102
+
103
+ // ── 辅助 ─────────────────────────────────────────────
104
+
105
+ function makeRunningState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
106
+ return {
107
+ ...createGoalState("test objective"),
108
+ timeStartedAt: 0, // 关闭时间累计口径(本文件不断言耗时)
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ /** assistant message entry,content 含 1 个 toolCall 块(pi 实测形态:toolCall 在 content 数组) */
114
+ function assistantToolCallEntry(id: string): unknown {
115
+ return {
116
+ type: "message",
117
+ message: {
118
+ role: "assistant",
119
+ content: [{ type: "text", text: "working" }, { type: "toolCall", id, name: "bash", arguments: {} }],
120
+ },
121
+ };
122
+ }
123
+
124
+ /** pending:register custom entry(对齐 countActiveFromEntries 读取契约) */
125
+ function pendingRegisterEntry(id: string): unknown {
126
+ return { type: "custom", customType: "pending:register", data: { id, type: "subagent", name: "worker" } };
127
+ }
128
+
129
+ /** 实际发出的 continuation(followUp custom message)计数 */
130
+ function followUpSends(h: FakeHarness): RecordedCall[] {
131
+ return h.piCalls.filter(
132
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
133
+ );
134
+ }
135
+
136
+ function notifyTexts(h: FakeHarness, level?: string): string[] {
137
+ return h.ctxCalls.filter((c) => c.kind === "notify" && (!level || c.level === level)).map((c) => c.text!);
138
+ }
139
+
140
+ /** 模拟一个 turn 结束:tokenDelta 注入 + 可选工具调用 + agent_end */
141
+ async function runTurn(
142
+ h: FakeHarness,
143
+ session: GoalSession,
144
+ opts: { tokenDelta: number; toolCallId?: string },
145
+ ): Promise<void> {
146
+ const state = session.state!;
147
+ state.tokensUsed += opts.tokenDelta;
148
+ if (opts.toolCallId) h.entries.push(assistantToolCallEntry(opts.toolCallId));
149
+ await handleAgentEnd(h.pi, session, h.ctx);
150
+ }
151
+
152
+ beforeEach(() => {
153
+ vi.useFakeTimers();
154
+ });
155
+
156
+ afterEach(() => {
157
+ vi.useRealTimers();
158
+ });
159
+
160
+ // ── 断言 a:主判据 50 次封顶(正交性)─────────────────
161
+
162
+ describe("主判据:continuation 连发总次数封顶", () => {
163
+ it("每轮调一次工具(R1 击穿形态)连续 50 轮后必停发;停发通知含恢复指引;goal 保持 active", async () => {
164
+ const h = makeHarness();
165
+ const session = createGoalSession();
166
+ session.state = makeRunningState();
167
+
168
+ for (let i = 1; i <= 50; i++) {
169
+ await runTurn(h, session, { tokenDelta: 200, toolCallId: `tc-${i}` });
170
+ }
171
+ expect(followUpSends(h)).toHaveLength(50);
172
+ expect(session.state!.continuationsSent).toBe(50);
173
+ // 每轮都有工具调用 → 有进展 → 从未进入退避(无待发定时器)
174
+ expect(session.continuationTimer).toBeNull();
175
+
176
+ // 第 51 轮:同样有工具调用、tokenDelta>0,仍被封顶拦截(正交:工具调用绕不过封顶)
177
+ await runTurn(h, session, { tokenDelta: 200, toolCallId: "tc-51" });
178
+ expect(followUpSends(h)).toHaveLength(50); // 停发
179
+ expect(session.state!.continuationsSent).toBe(50); // 总数封在 50
180
+ expect(session.state!.status).toBe("active"); // goal 保持 active(不擅自终态)
181
+
182
+ const warnings = notifyTexts(h, "warning");
183
+ expect(warnings).toHaveLength(1);
184
+ expect(warnings[0]).toContain("circuit breaker");
185
+ expect(warnings[0]).toContain("/goal resume"); // 恢复指引
186
+
187
+ // 第 52 轮:停发通知不重复
188
+ await runTurn(h, session, { tokenDelta: 200, toolCallId: "tc-52" });
189
+ expect(followUpSends(h)).toHaveLength(50);
190
+ expect(notifyTexts(h, "warning")).toHaveLength(1);
191
+ });
192
+ });
193
+
194
+ // ── 断言 b:辅判据无进展退避 ─────────────────────────
195
+
196
+ describe("辅判据:无进展退避(间隔 ×2 递增)", () => {
197
+ it("连续 5 轮无工具调用且低 tokenDelta → 第 5 轮起间隔翻倍(5 轮 2×base、6 轮 4×base)", async () => {
198
+ const h = makeHarness();
199
+ const session = createGoalSession();
200
+ session.state = makeRunningState();
201
+
202
+ // 轮 1-4:未达阈值 → 立即发出
203
+ for (let i = 1; i <= 4; i++) {
204
+ await runTurn(h, session, { tokenDelta: 200 });
205
+ expect(followUpSends(h)).toHaveLength(i); // 同步发出,无延迟
206
+ }
207
+
208
+ // 轮 5:noProgressTurns=5 → 延迟 2×base = 20s(base 默认 10s)
209
+ await runTurn(h, session, { tokenDelta: 200 });
210
+ expect(followUpSends(h)).toHaveLength(4); // 未立即发
211
+ expect(session.continuationTimer).not.toBeNull(); // 已排定退避定时器
212
+ vi.advanceTimersByTime(19_999);
213
+ expect(followUpSends(h)).toHaveLength(4); // 20s 边界前不发
214
+ vi.advanceTimersByTime(1);
215
+ expect(followUpSends(h)).toHaveLength(5); // 20s 整点发出
216
+ expect(session.state!.continuationsSent).toBe(5);
217
+
218
+ // 轮 6:间隔再翻倍 → 4×base = 40s
219
+ await runTurn(h, session, { tokenDelta: 200 });
220
+ expect(followUpSends(h)).toHaveLength(5);
221
+ vi.advanceTimersByTime(39_999);
222
+ expect(followUpSends(h)).toHaveLength(5);
223
+ vi.advanceTimersByTime(1);
224
+ expect(followUpSends(h)).toHaveLength(6);
225
+ });
226
+ });
227
+
228
+ // ── 断言 c:恢复清零退避但不重置总数 ─────────────────
229
+
230
+ describe("恢复条件:真实工具调用", () => {
231
+ it("清零退避计数(下一轮立即发),但不重置主判据总次数", async () => {
232
+ const h = makeHarness();
233
+ const session = createGoalSession();
234
+ session.state = makeRunningState();
235
+
236
+ // 进入退避:5 轮无进展(4 立即 + 1 延迟)
237
+ for (let i = 0; i < 4; i++) await runTurn(h, session, { tokenDelta: 200 });
238
+ await runTurn(h, session, { tokenDelta: 200 });
239
+ vi.advanceTimersByTime(20_000);
240
+ expect(followUpSends(h)).toHaveLength(5);
241
+ expect(session.state!.noProgressTurns).toBe(5);
242
+
243
+ // 轮 6:真实工具调用 → 进展 → noProgressTurns 清零,立即发(无定时器)
244
+ await runTurn(h, session, { tokenDelta: 200, toolCallId: "tc-recover" });
245
+ expect(session.state!.noProgressTurns).toBe(0);
246
+ expect(session.continuationTimer).toBeNull();
247
+ expect(followUpSends(h)).toHaveLength(6); // 同步发出
248
+ expect(session.state!.continuationsSent).toBe(6); // 总次数不重置(正交性)
249
+
250
+ // 轮 7:再次无进展 → 计数从 1 重新累计,间隔回到立即
251
+ await runTurn(h, session, { tokenDelta: 200 });
252
+ expect(session.state!.noProgressTurns).toBe(1);
253
+ expect(followUpSends(h)).toHaveLength(7);
254
+ });
255
+ });
256
+
257
+ // ── 断言 d:defer 通知去重 ───────────────────────────
258
+
259
+ describe("defer 通知去重", () => {
260
+ it("pending 集合不变的连续 defer 轮中通知条数不增长;集合变化才再发", async () => {
261
+ const h = makeHarness();
262
+ const session = createGoalSession();
263
+ session.state = makeRunningState();
264
+ h.entries.push(pendingRegisterEntry("bg-1"));
265
+
266
+ for (let i = 0; i < 3; i++) {
267
+ await runTurn(h, session, { tokenDelta: 200 });
268
+ }
269
+ expect(followUpSends(h)).toHaveLength(0); // defer 轮不发 continuation
270
+ const waitNotifies = () => notifyTexts(h).filter((t) => t.includes("waiting"));
271
+ expect(waitNotifies()).toHaveLength(1); // 集合不变 → 3 轮只通知 1 次
272
+ expect(waitNotifies()[0]).toContain("1 background task");
273
+
274
+ // 集合变化(新增 bg-2)→ 再通知一次(含新计数)
275
+ h.entries.push(pendingRegisterEntry("bg-2"));
276
+ await runTurn(h, session, { tokenDelta: 200 });
277
+ expect(waitNotifies()).toHaveLength(2);
278
+ expect(waitNotifies()[1]).toContain("2 background task");
279
+
280
+ // 集合再次不变 → 不增长
281
+ await runTurn(h, session, { tokenDelta: 200 });
282
+ expect(waitNotifies()).toHaveLength(2);
283
+ });
284
+ });
285
+
286
+ // ── 恢复通道:/goal resume ───────────────────────────
287
+
288
+ describe("/goal resume 恢复通道", () => {
289
+ it("封顶停发后 resume → 熔断计数清零 + 触发 AI + 后续 agent_end 恢复发送", async () => {
290
+ const h = makeHarness();
291
+ const session = createGoalSession();
292
+ // 直接构造封顶临界态(continuationsSent=50),经一次 agent_end 触发停发分支
293
+ session.state = makeRunningState({ continuationsSent: 50 });
294
+ await runTurn(h, session, { tokenDelta: 200 });
295
+ expect(session.state!.continuationCapNotified).toBe(true);
296
+ expect(followUpSends(h)).toHaveLength(0);
297
+
298
+ await handleGoalCommand(h.pi, session, "resume", h.ctx);
299
+ expect(session.state!.continuationsSent).toBe(0); // 新激活周期
300
+ expect(session.state!.continuationCapNotified).toBe(false);
301
+ expect(session.state!.status).toBe("active");
302
+ const userSends = h.piCalls.filter((c) => c.kind === "sendUser");
303
+ expect(userSends).toHaveLength(1); // FR-8.12 触发 AI
304
+
305
+ // 恢复后:下一次 agent_end 正常发 continuation(新周期额度)
306
+ await runTurn(h, session, { tokenDelta: 200 });
307
+ expect(followUpSends(h)).toHaveLength(1);
308
+ });
309
+
310
+ it("未封顶的普通 active goal → resume 提示无需恢复(原语义不变)", async () => {
311
+ const h = makeHarness();
312
+ const session = createGoalSession();
313
+ session.state = makeRunningState();
314
+
315
+ await handleGoalCommand(h.pi, session, "resume", h.ctx);
316
+
317
+ expect(notifyTexts(h).some((t) => t.includes("no need to resume"))).toBe(true);
318
+ expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(0);
319
+ });
320
+ });
321
+
322
+ // ── MF-6(round1 review):退避 timer 回调跨生命周期防护 ──
323
+
324
+ /** 进入退避态:4 轮立即发 + 第 5 轮延迟 20s 排定 timer(base 默认 10s ×2) */
325
+ async function enterBackoff(): Promise<{ h: FakeHarness; session: GoalSession }> {
326
+ const h = makeHarness();
327
+ const session = createGoalSession();
328
+ session.state = makeRunningState();
329
+ for (let i = 0; i < 4; i++) await runTurn(h, session, { tokenDelta: 200 });
330
+ await runTurn(h, session, { tokenDelta: 200 });
331
+ expect(session.continuationTimer).not.toBeNull();
332
+ return { h, session };
333
+ }
334
+
335
+ describe("MF-6① idle 守卫:ctx.signal 实时求值失效场景由 isIdle 拦截", () => {
336
+ it("延迟窗内有活动 turn(isIdle=false、signal=undefined)→ 到期不误发", async () => {
337
+ const { h, session } = await enterBackoff();
338
+
339
+ // agent idle 后 SDK get signal 实时求值为 undefined(旧 signal?.aborted 守卫恒放行);
340
+ // 用户在延迟窗内发新消息 → 活动 turn 占用 → isIdle=false
341
+ h.ctx.signal = undefined;
342
+ h.isIdle.value = false;
343
+ vi.advanceTimersByTime(20_000);
344
+
345
+ expect(followUpSends(h)).toHaveLength(4); // 不误发(该轮 agent_end 会重新决策)
346
+ expect(session.state!.continuationsSent).toBe(4);
347
+ });
348
+
349
+ it("延迟窗内 idle(isIdle=true、signal=undefined)→ 正常发出(守卫不误伤退避路径)", async () => {
350
+ const { h, session } = await enterBackoff();
351
+
352
+ h.ctx.signal = undefined;
353
+ vi.advanceTimersByTime(20_000);
354
+
355
+ expect(followUpSends(h)).toHaveLength(5);
356
+ expect(session.state!.continuationsSent).toBe(5);
357
+ });
358
+ });
359
+
360
+ describe("MF-6② stale 保护:timer 回调内异常不冒泡(catch 块自身零抛出)", () => {
361
+ it("全量 stale(isIdle / getEntries / appendEntry 均按 SDK 契约抛错)→ 不崩、不发、不产生任何调用", async () => {
362
+ const { h, session } = await enterBackoff();
363
+
364
+ // SDK 契约(0.84.4 实装核对):invalidate 后 isIdle / sessionManager getter
365
+ // (runner.js assertActive)与 pi.appendEntry(loader.js appendEntry 首行
366
+ // assertActive)全部抛错——旧 fake appendEntry 永不抛,默许了真实场景必抛的调用
367
+ h.ctx.isIdle = () => {
368
+ throw new Error(STALE_CTX_MESSAGE);
369
+ };
370
+ (h.ctx.sessionManager as { getEntries: () => unknown }).getEntries = () => {
371
+ throw new Error(STALE_CTX_MESSAGE);
372
+ };
373
+ h.appendEntryFails.value = true;
374
+ const callsBefore = h.piCalls.length;
375
+
376
+ expect(() => vi.advanceTimersByTime(20_000)).not.toThrow();
377
+
378
+ expect(followUpSends(h)).toHaveLength(4); // 不发 continuation
379
+ expect(session.state!.continuationsSent).toBe(4);
380
+ expect(h.piCalls).toHaveLength(callsBefore); // 降级日志 stale 下同样必抛 → 放弃
381
+ });
382
+
383
+ it("回调内非 stale 异常(getEntries 抛运行时错误、appendEntry 可用)→ 降级 warn 落一条", async () => {
384
+ const { h, session } = await enterBackoff();
385
+
386
+ (h.ctx.sessionManager as { getEntries: () => unknown }).getEntries = () => {
387
+ throw new Error("entries read failed");
388
+ };
389
+ expect(() => vi.advanceTimersByTime(20_000)).not.toThrow();
390
+
391
+ expect(followUpSends(h)).toHaveLength(4); // 不发 continuation
392
+ expect(session.state!.continuationsSent).toBe(4);
393
+ // 降级诊断:非 stale 异常时 pi.appendEntry 可用,goal:log 落一条 warn
394
+ const dropLogs = h.piCalls.filter(
395
+ (c) => c.kind === "appendState" && (c.payload as { message?: string } | undefined)?.message?.includes("dropped after error"),
396
+ );
397
+ expect(dropLogs).toHaveLength(1);
398
+ expect((dropLogs[0]!.payload as { level?: string }).level).toBe("warn");
399
+ });
400
+ });
401
+
402
+ describe("MF-R2-1 根修:session_shutdown 取消旧退避 timer", () => {
403
+ it("session_shutdown(invalidate 前触发)后旧 timer 到期不发;全量 stale 下不崩、不冒泡", async () => {
404
+ const { h, session } = await enterBackoff();
405
+
406
+ // SDK 触发序:所有失效路径(reload / new / resume / fork / quit)先 emit
407
+ // session_shutdown 再 runner.invalidate——此刻 pi/ctx 仍可用
408
+ await handleSessionShutdown(session);
409
+ expect(session.continuationTimer).toBeNull();
410
+
411
+ // 全量 stale 注入:若 timer 未被取消,回调在 isIdle 首查即抛、catch 内
412
+ // appendEntry 二次抛出 → advanceTimersByTime 冒泡(本用例即红)
413
+ h.ctx.isIdle = () => {
414
+ throw new Error(STALE_CTX_MESSAGE);
415
+ };
416
+ (h.ctx.sessionManager as { getEntries: () => unknown }).getEntries = () => {
417
+ throw new Error(STALE_CTX_MESSAGE);
418
+ };
419
+ h.appendEntryFails.value = true;
420
+ const callsBefore = h.piCalls.length;
421
+
422
+ expect(() => vi.advanceTimersByTime(20_000)).not.toThrow();
423
+ expect(followUpSends(h)).toHaveLength(4); // 不发 continuation
424
+ expect(session.state!.continuationsSent).toBe(4);
425
+ expect(h.piCalls).toHaveLength(callsBefore); // 到期零回调(timer 已取消)
426
+ });
427
+ });
428
+
429
+ describe("MF-6③ timer 清理面:session_start / before_agent_start 取消旧 timer", () => {
430
+ it("session_start 重建 → 旧退避 timer 被取消,到期不发", async () => {
431
+ const { h, session } = await enterBackoff();
432
+
433
+ await handleSessionStart(h.pi, session, h.ctx);
434
+
435
+ expect(session.continuationTimer).toBeNull();
436
+ vi.advanceTimersByTime(20_000);
437
+ expect(followUpSends(h)).toHaveLength(4);
438
+ });
439
+
440
+ it("before_agent_start(新用户活动)→ 旧退避 timer 被取消,到期不发", async () => {
441
+ const { h, session } = await enterBackoff();
442
+
443
+ await handleBeforeAgentStart(h.pi, session, h.ctx);
444
+
445
+ expect(session.continuationTimer).toBeNull();
446
+ vi.advanceTimersByTime(20_000);
447
+ expect(followUpSends(h)).toHaveLength(4);
448
+ });
449
+ });
@@ -38,8 +38,10 @@ function makeFakePorts(): ServicePorts & {
38
38
  hasUI: true,
39
39
  isGui: false,
40
40
  setGuiWidget: () => {},
41
- fg: (_color: string, text: string) => text,
42
- bold: (text: string) => text,
41
+ theme: {
42
+ fg: (_color: string, text: string) => text,
43
+ bold: (text: string) => text,
44
+ },
43
45
  } as UiPort,
44
46
  messaging: {
45
47
  sendContextMessage: () => {},
@@ -47,8 +49,6 @@ function makeFakePorts(): ServicePorts & {
47
49
  },
48
50
  session: {
49
51
  getEntries: () => [],
50
- getContextUsage: () => null,
51
- signal: undefined,
52
52
  },
53
53
  };
54
54
  }
@@ -24,9 +24,7 @@ function makeLegacyData(successCriteria: unknown): Record<string, unknown> {
24
24
  timeStartedAt: 1000,
25
25
  timeUsedSeconds: 0,
26
26
  budget: {},
27
- lastProgressTurn: 0,
28
27
  budgetLimitSteeringSent: false,
29
- objectiveUpdatedAt: 1000,
30
28
  lastBlockerReason: null,
31
29
  tokenWarning70Sent: false,
32
30
  tokenWarning90Sent: false,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * FR-5/FR-7.3: deserializeState — 新格式严格解析(字段缺失 throw)
2
+ * FR-5/FR-7.3: deserializeState — 严格解析(必填缺失 throw;旧格式容忍面见 persistence.ts 各迁移点注释)
3
3
  *
4
4
  * round-trip property 覆盖全字段 serialize→deserialize 双向一致性,替代手写还原用例。
5
5
  * 保留:throw 路径(缺必填)+ 向后兼容(缺 optional)+ 旧 tasks 字段忽略。
@@ -32,15 +32,19 @@ const goalStateArb = fc.record({
32
32
  timeStartedAt: fc.integer({ min: 0 }),
33
33
  timeUsedSeconds: fc.nat(),
34
34
  budget: fc.record({ tokenBudget: fc.option(fc.integer({ min: 0 })) }),
35
- lastProgressTurn: fc.nat(),
36
35
  budgetLimitSteeringSent: fc.boolean(),
37
- objectiveUpdatedAt: fc.integer({ min: 0 }),
38
36
  lastBlockerReason: fc.option(fc.string(), { nil: null }), // string | null
39
37
  tokenWarning70Sent: fc.boolean(),
40
38
  tokenWarning90Sent: fc.boolean(),
41
39
  lastTurnTokensUsed: fc.nat(),
42
40
  currentTurnIndex: fc.nat(),
43
41
  completedAtTurnIndex: fc.option(fc.nat()),
42
+ // W5 熔断计数(chat-domain-v1x D4):round-trip 双向一致性覆盖
43
+ continuationsSent: fc.nat(),
44
+ noProgressTurns: fc.nat(),
45
+ continuationCapNotified: fc.boolean(),
46
+ lastDeferredPendingIds: fc.array(fc.string()),
47
+ toolCallsSeen: fc.nat(),
44
48
  }) as unknown as fc.Arbitrary<GoalRuntimeState>;
45
49
 
46
50
  // 旧 entry 模拟(含已废弃 tasks 字段,验证向后兼容忽略)
@@ -53,9 +57,7 @@ const FULL_DATA: Record<string, unknown> = {
53
57
  timeStartedAt: 1000,
54
58
  timeUsedSeconds: 0,
55
59
  budget: {},
56
- lastProgressTurn: 0,
57
60
  budgetLimitSteeringSent: false,
58
- objectiveUpdatedAt: 1000,
59
61
  lastBlockerReason: null,
60
62
  tokenWarning70Sent: false,
61
63
  tokenWarning90Sent: false,
@@ -64,7 +66,7 @@ const FULL_DATA: Record<string, unknown> = {
64
66
  };
65
67
 
66
68
  describe("deserializeState — round-trip", () => {
67
- // ⭐ serialize→deserialize 深相等:覆盖全 18 字段双向一致性(含 optional 有/无两态)
69
+ // ⭐ serialize→deserialize 深相等:覆盖全 21 字段双向一致性(含 optional 有/无两态)
68
70
  it.prop([goalStateArb])("deserializeState(serializeState(s)) 深相等 s", (s) => {
69
71
  const rt = deserializeState(serializeState(s) as unknown as Record<string, unknown>);
70
72
  expect(rt).toEqual(s);
@@ -105,6 +107,32 @@ describe("deserializeState — 严格解析(缺必填 throw)", () => {
105
107
  });
106
108
  });
107
109
 
110
+ describe("deserializeState — legacy entry 含已删字段(P-m14-1,D2 白名单兼容)", () => {
111
+ // ext-simplify-03 D2:lastProgressTurn/objectiveUpdatedAt 已从 GoalRuntimeState 一步删除。
112
+ // 0.13.0 形状 entry 仍携带这两个字段——deserializeState 是白名单模式(只对已知 key 逐个
113
+ // req()/可选解析,从不遍历 entry 全部 key,与 tasks 字段先例同一机制),entry 多出的
114
+ // 字段天然被忽略:不 throw、state 完整重建(G3:旧数据加载兼容)。
115
+ const LEGACY_0_13: Record<string, unknown> = {
116
+ ...FULL_DATA,
117
+ lastProgressTurn: 3,
118
+ objectiveUpdatedAt: 2000,
119
+ };
120
+
121
+ it("0.13.0 形状 entry(含已删两字段)→ 不 throw,返回完整 state", () => {
122
+ const state = deserializeState(LEGACY_0_13);
123
+ expect(state.goalId).toBe("g1");
124
+ expect(state.objective).toBe("test");
125
+ expect(state.status).toBe("active");
126
+ expect(state.budgetLimitSteeringSent).toBe(false);
127
+ });
128
+
129
+ it("已删字段被白名单忽略:返回对象不含 lastProgressTurn/objectiveUpdatedAt", () => {
130
+ const state = deserializeState(LEGACY_0_13);
131
+ expect(state).not.toHaveProperty("lastProgressTurn");
132
+ expect(state).not.toHaveProperty("objectiveUpdatedAt");
133
+ });
134
+ });
135
+
108
136
  describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)", () => {
109
137
  it("缺 completedAtTurnIndex → undefined", () => {
110
138
  expect(deserializeState(FULL_DATA).completedAtTurnIndex).toBeUndefined();
@@ -115,6 +143,26 @@ describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)"
115
143
  it("缺 successCriteria → undefined", () => {
116
144
  expect(deserializeState(FULL_DATA).successCriteria).toBeUndefined();
117
145
  });
146
+ // W5:旧持久化数据(熔断字段引入前)缺 5 个熔断字段 → 归零/空集/false(新周期语义)
147
+ it("缺 W5 熔断字段 → 计数归零 / 空集合 / flag false", () => {
148
+ const state = deserializeState(FULL_DATA);
149
+ expect(state.continuationsSent).toBe(0);
150
+ expect(state.noProgressTurns).toBe(0);
151
+ expect(state.continuationCapNotified).toBe(false);
152
+ expect(state.lastDeferredPendingIds).toEqual([]);
153
+ expect(state.toolCallsSeen).toBe(0);
154
+ });
155
+ it("W5 熔断字段为脏值(负数/非数组)→ 同样归零/空集(防御性默认)", () => {
156
+ const state = deserializeState({
157
+ ...FULL_DATA,
158
+ continuationsSent: -3,
159
+ noProgressTurns: "many" as unknown as number,
160
+ lastDeferredPendingIds: [1, null] as unknown as string[],
161
+ });
162
+ expect(state.continuationsSent).toBe(0);
163
+ expect(state.noProgressTurns).toBe(0);
164
+ expect(state.lastDeferredPendingIds).toEqual([]);
165
+ });
118
166
  });
119
167
 
120
168
  describe("deserializeState — 旧数据迁移(time_limited → budget_limited,MF-1)", () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * event-adapter.ts 测试 — agent_end / before_agent_start 核心逻辑
2
+ * event-handlers 行为测试 — agent_end / before_agent_start 核心逻辑
3
3
  *
4
4
  * 覆盖 agent_end 的 continuation + ESC 守卫(FR-6.7)+
5
5
  * before_agent_start 的 AUTO_CLEAR/context wrap-up。
@@ -88,7 +88,7 @@ function makeFakeCtx(overrides?: {
88
88
  bold: (text: string) => text,
89
89
  },
90
90
  },
91
- sessionManager: { getEntries: () => overrides?.entries ?? [], getBranch: () => undefined },
91
+ sessionManager: { getEntries: () => overrides?.entries ?? [], getBranch: () => undefined, getSessionId: () => "test-session" },
92
92
  } as unknown as ExtensionContext;
93
93
  return { ctx, calls };
94
94
  }
@@ -52,9 +52,11 @@ function makeFakePorts(): ServicePorts & {
52
52
  notifications.push({ text, level });
53
53
  },
54
54
  hasUI: true,
55
- // widget.asTheme 期望 fg/bold(ports.ts 的 uiPort 同样携带)
56
- fg: (_color: string, text: string) => text,
57
- bold: (text: string) => text,
55
+ // widget 渲染读 UiPort.theme(ports.ts 的 uiPort 同样以 theme 嵌套成员提供)
56
+ theme: {
57
+ fg: (_color: string, text: string) => text,
58
+ bold: (text: string) => text,
59
+ },
58
60
  } as UiPort,
59
61
  messaging: {
60
62
  sendContextMessage: () => {},
@@ -62,8 +64,6 @@ function makeFakePorts(): ServicePorts & {
62
64
  },
63
65
  session: {
64
66
  getEntries: () => [],
65
- getContextUsage: () => null,
66
- signal: undefined,
67
67
  },
68
68
  };
69
69
  }
@@ -47,7 +47,7 @@ interface FakeFixture {
47
47
  * 构造最小 fake pi / ctx。ctx.mode 由调用方覆盖(默认 "rpc")。
48
48
  *
49
49
  * ports.ts 的 buildPorts 读 ctx.ui.theme.fg/bold、ctx.ui.setWidget/setStatus/notify、
50
- * ctx.sessionManager.getEntries、ctx.getContextUsage、ctx.signal、ctx.hasUI——全部 mock。
50
+ * ctx.sessionManager.getEntries、ctx.hasUI——全部 mock。
51
51
  */
52
52
  function makeFixture(mode: "rpc" | "tui" | "json" | "print" = "rpc"): FakeFixture {
53
53
  const notifications: Array<{ text: string; level: string }> = [];