@zhushanwen/pi-goal 0.12.2 → 0.13.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.
- package/package.json +2 -2
- package/src/__tests__/circuit-breaker.test.ts +449 -0
- package/src/__tests__/deserialize-state.test.ts +26 -0
- package/src/__tests__/event-adapter.test.ts +1 -1
- package/src/__tests__/liveness.test.ts +162 -0
- package/src/adapters/command-adapter.ts +30 -5
- package/src/adapters/event-adapter.ts +2 -1
- package/src/adapters/event-handlers/agent-end.ts +184 -28
- package/src/adapters/event-handlers/before-agent-start.ts +5 -1
- package/src/adapters/event-handlers/session-shutdown.ts +23 -0
- package/src/adapters/event-handlers/session-start.ts +4 -1
- package/src/constants.ts +13 -0
- package/src/engine/goal.ts +6 -0
- package/src/engine/liveness.ts +150 -0
- package/src/engine/types.ts +21 -0
- package/src/index.ts +13 -0
- package/src/persistence.ts +18 -0
- package/src/session.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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,7 +29,7 @@
|
|
|
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.
|
|
32
|
+
"@zhushanwen/pi-pending-notifications": "0.6.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@xyz-agent/extension-protocol": "0.8.2"
|
|
@@ -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 对齐 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
|
+
});
|
|
@@ -41,6 +41,12 @@ const goalStateArb = fc.record({
|
|
|
41
41
|
lastTurnTokensUsed: fc.nat(),
|
|
42
42
|
currentTurnIndex: fc.nat(),
|
|
43
43
|
completedAtTurnIndex: fc.option(fc.nat()),
|
|
44
|
+
// W5 熔断计数(chat-domain-v1x D4):round-trip 双向一致性覆盖
|
|
45
|
+
continuationsSent: fc.nat(),
|
|
46
|
+
noProgressTurns: fc.nat(),
|
|
47
|
+
continuationCapNotified: fc.boolean(),
|
|
48
|
+
lastDeferredPendingIds: fc.array(fc.string()),
|
|
49
|
+
toolCallsSeen: fc.nat(),
|
|
44
50
|
}) as unknown as fc.Arbitrary<GoalRuntimeState>;
|
|
45
51
|
|
|
46
52
|
// 旧 entry 模拟(含已废弃 tasks 字段,验证向后兼容忽略)
|
|
@@ -115,6 +121,26 @@ describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)"
|
|
|
115
121
|
it("缺 successCriteria → undefined", () => {
|
|
116
122
|
expect(deserializeState(FULL_DATA).successCriteria).toBeUndefined();
|
|
117
123
|
});
|
|
124
|
+
// W5:旧持久化数据(熔断字段引入前)缺 5 个熔断字段 → 归零/空集/false(新周期语义)
|
|
125
|
+
it("缺 W5 熔断字段 → 计数归零 / 空集合 / flag false", () => {
|
|
126
|
+
const state = deserializeState(FULL_DATA);
|
|
127
|
+
expect(state.continuationsSent).toBe(0);
|
|
128
|
+
expect(state.noProgressTurns).toBe(0);
|
|
129
|
+
expect(state.continuationCapNotified).toBe(false);
|
|
130
|
+
expect(state.lastDeferredPendingIds).toEqual([]);
|
|
131
|
+
expect(state.toolCallsSeen).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
it("W5 熔断字段为脏值(负数/非数组)→ 同样归零/空集(防御性默认)", () => {
|
|
134
|
+
const state = deserializeState({
|
|
135
|
+
...FULL_DATA,
|
|
136
|
+
continuationsSent: -3,
|
|
137
|
+
noProgressTurns: "many" as unknown as number,
|
|
138
|
+
lastDeferredPendingIds: [1, null] as unknown as string[],
|
|
139
|
+
});
|
|
140
|
+
expect(state.continuationsSent).toBe(0);
|
|
141
|
+
expect(state.noProgressTurns).toBe(0);
|
|
142
|
+
expect(state.lastDeferredPendingIds).toEqual([]);
|
|
143
|
+
});
|
|
118
144
|
});
|
|
119
145
|
|
|
120
146
|
describe("deserializeState — 旧数据迁移(time_limited → budget_limited,MF-1)", () => {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/liveness.ts 纯函数测试 — 轮次活性熔断判定单元(chat-domain-v1x D4 / W5)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:env 配置解析(合法/非法/缺省)、退避等级数学(第 N 轮起 ×2、指数天花板)、
|
|
5
|
+
* 进展信号边界、toolCall 块计数(duck-typed 守卫)、pending 集合变化判定。
|
|
6
|
+
* 行为级四断言(封顶/退避/恢复/defer 去重经 handleAgentEnd)见 circuit-breaker.test.ts。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
continuationBackoffDelayMs,
|
|
12
|
+
countToolCallBlocks,
|
|
13
|
+
isProgressSignal,
|
|
14
|
+
nextNoProgressTurns,
|
|
15
|
+
pendingSetChanged,
|
|
16
|
+
resolveLivenessConfig,
|
|
17
|
+
type LivenessConfig,
|
|
18
|
+
} from "../engine/liveness";
|
|
19
|
+
import { createGoalState } from "../engine/goal";
|
|
20
|
+
|
|
21
|
+
const CFG: LivenessConfig = resolveLivenessConfig({});
|
|
22
|
+
|
|
23
|
+
// ── resolveLivenessConfig(env 覆盖)──────────────────
|
|
24
|
+
|
|
25
|
+
describe("resolveLivenessConfig", () => {
|
|
26
|
+
it("缺省 env → 全默认值(50 / 5 / 1000 / 10s / 10min)", () => {
|
|
27
|
+
const cfg = resolveLivenessConfig({});
|
|
28
|
+
expect(cfg.continuationCap).toBe(50);
|
|
29
|
+
expect(cfg.noProgressTurnsThreshold).toBe(5);
|
|
30
|
+
expect(cfg.noProgressTokenThreshold).toBe(1000);
|
|
31
|
+
expect(cfg.backoffBaseMs).toBe(10_000);
|
|
32
|
+
expect(cfg.backoffMaxMs).toBe(600_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("PI_GOAL_* 合法正整数 → 覆盖默认值", () => {
|
|
36
|
+
const cfg = resolveLivenessConfig({
|
|
37
|
+
PI_GOAL_CONTINUATION_CAP: "10",
|
|
38
|
+
PI_GOAL_NO_PROGRESS_TURNS: "3",
|
|
39
|
+
PI_GOAL_NO_PROGRESS_TOKEN_THRESHOLD: "500",
|
|
40
|
+
PI_GOAL_BACKOFF_BASE_MS: "1000",
|
|
41
|
+
PI_GOAL_BACKOFF_MAX_MS: "30000",
|
|
42
|
+
});
|
|
43
|
+
expect(cfg.continuationCap).toBe(10);
|
|
44
|
+
expect(cfg.noProgressTurnsThreshold).toBe(3);
|
|
45
|
+
expect(cfg.noProgressTokenThreshold).toBe(500);
|
|
46
|
+
expect(cfg.backoffBaseMs).toBe(1000);
|
|
47
|
+
expect(cfg.backoffMaxMs).toBe(30000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([
|
|
51
|
+
["非数字", "abc"],
|
|
52
|
+
["零", "0"],
|
|
53
|
+
["负数", "-5"],
|
|
54
|
+
["小数", "2.7"],
|
|
55
|
+
])("非法值(%s)→ 回退默认(安全网不因坏配置挂掉)", (_label, raw) => {
|
|
56
|
+
const cfg = resolveLivenessConfig({ PI_GOAL_CONTINUATION_CAP: raw });
|
|
57
|
+
expect(cfg.continuationCap).toBe(50);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── 进展信号 + 无进展计数 ────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("isProgressSignal / nextNoProgressTurns", () => {
|
|
64
|
+
it("有工具调用(toolDelta>0)即进展,即使 tokenDelta 低", () => {
|
|
65
|
+
expect(isProgressSignal(1, 100, CFG)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("tokenDelta 越阈值即进展,即使无工具调用(纯文本迭代型任务豁免退避)", () => {
|
|
69
|
+
expect(isProgressSignal(0, 1000, CFG)).toBe(true);
|
|
70
|
+
expect(isProgressSignal(0, 999, CFG)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("无进展 → 计数 +1;进展 → 清零", () => {
|
|
74
|
+
expect(nextNoProgressTurns(4, false)).toBe(5);
|
|
75
|
+
expect(nextNoProgressTurns(4, true)).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── 退避等级数学 ─────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("continuationBackoffDelayMs", () => {
|
|
82
|
+
it("未达阈值(1~4 轮)→ 0(立即发)", () => {
|
|
83
|
+
expect(continuationBackoffDelayMs(0, CFG)).toBe(0);
|
|
84
|
+
expect(continuationBackoffDelayMs(4, CFG)).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("第 5 轮起 ×2 递增:5 轮 → 2×base,6 轮 → 4×base,7 轮 → 8×base", () => {
|
|
88
|
+
expect(continuationBackoffDelayMs(5, CFG)).toBe(20_000);
|
|
89
|
+
expect(continuationBackoffDelayMs(6, CFG)).toBe(40_000);
|
|
90
|
+
expect(continuationBackoffDelayMs(7, CFG)).toBe(80_000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("指数封顶:delay 永不超过 backoffMaxMs", () => {
|
|
94
|
+
expect(continuationBackoffDelayMs(50, CFG)).toBe(600_000);
|
|
95
|
+
expect(continuationBackoffDelayMs(1000, CFG)).toBe(600_000);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── toolCall 块计数 ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("countToolCallBlocks", () => {
|
|
102
|
+
it("assistant content 内 toolCall 块计数(多个 entry 累计)", () => {
|
|
103
|
+
const entries = [
|
|
104
|
+
{ type: "message", message: { role: "user", content: "hi" } },
|
|
105
|
+
{
|
|
106
|
+
type: "message",
|
|
107
|
+
message: {
|
|
108
|
+
role: "assistant",
|
|
109
|
+
content: [
|
|
110
|
+
{ type: "text", text: "thinking..." },
|
|
111
|
+
{ type: "toolCall", id: "tc-1", name: "bash" },
|
|
112
|
+
{ type: "toolCall", id: "tc-2", name: "read" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: "message",
|
|
118
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-3", name: "bash" }] },
|
|
119
|
+
},
|
|
120
|
+
{ type: "message", message: { role: "toolResult", content: [], toolName: "bash", toolCallId: "tc-1" } },
|
|
121
|
+
{ type: "custom", customType: "goal-state", data: {} },
|
|
122
|
+
];
|
|
123
|
+
expect(countToolCallBlocks(entries)).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("畸形 entry(null/非对象/缺 message/content 非数组)→ 跳过不计数", () => {
|
|
127
|
+
expect(
|
|
128
|
+
countToolCallBlocks([null, 42, "x", { type: "message" }, { type: "message", message: null }, {
|
|
129
|
+
type: "message",
|
|
130
|
+
message: { role: "assistant", content: "plain-string" },
|
|
131
|
+
}]),
|
|
132
|
+
).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── pending 集合变化 ─────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("pendingSetChanged", () => {
|
|
139
|
+
it("集合相同(含顺序不同)→ 无变化", () => {
|
|
140
|
+
expect(pendingSetChanged(["a", "b"], ["b", "a"])).toBe(false);
|
|
141
|
+
expect(pendingSetChanged([], [])).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("首次进入 defer(last 为空)或有增删 → 变化", () => {
|
|
145
|
+
expect(pendingSetChanged(["a"], [])).toBe(true);
|
|
146
|
+
expect(pendingSetChanged(["a"], ["a", "b"])).toBe(true);
|
|
147
|
+
expect(pendingSetChanged(["a", "c"], ["a", "b"])).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── createGoalState 初始化 ───────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("createGoalState — W5 计数初始化", () => {
|
|
154
|
+
it("新 goal 熔断计数全部从零开始", () => {
|
|
155
|
+
const state = createGoalState("obj");
|
|
156
|
+
expect(state.continuationsSent).toBe(0);
|
|
157
|
+
expect(state.noProgressTurns).toBe(0);
|
|
158
|
+
expect(state.continuationCapNotified).toBe(false);
|
|
159
|
+
expect(state.lastDeferredPendingIds).toEqual([]);
|
|
160
|
+
expect(state.toolCallsSeen).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|