@zhushanwen/pi-goal 0.3.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.
- package/README.md +13 -12
- package/package.json +1 -1
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +65 -94
- package/src/__tests__/event-adapter.test.ts +339 -0
- package/src/__tests__/goal-control-adapter.test.ts +303 -0
- package/src/__tests__/index.test.ts +119 -0
- package/src/__tests__/service.test.ts +317 -0
- package/src/__tests__/session.test.ts +220 -0
- package/src/__tests__/stale-checker.test.ts +78 -0
- package/src/__tests__/stubs/pi-sdk.ts +5 -0
- package/src/adapters/command-adapter.ts +360 -0
- package/src/adapters/event-adapter.ts +17 -0
- package/src/adapters/event-handlers/agent-end.ts +177 -0
- package/src/adapters/event-handlers/agent-start.ts +17 -0
- package/src/adapters/event-handlers/before-agent-start.ts +115 -0
- package/src/adapters/event-handlers/message-end.ts +37 -0
- package/src/adapters/event-handlers/session-start.ts +24 -0
- package/src/adapters/event-handlers/shared.ts +46 -0
- package/src/adapters/event-handlers/turn-end.ts +37 -0
- package/src/adapters/goal-control-adapter.ts +296 -0
- package/src/adapters/ports.ts +89 -0
- package/src/commands.ts +8 -23
- package/src/constants.ts +1 -8
- package/src/engine/__tests__/budget.test.ts +182 -0
- package/src/engine/__tests__/goal.test.ts +126 -0
- package/src/engine/budget.ts +146 -0
- package/src/engine/goal.ts +62 -0
- package/src/engine/types.ts +80 -0
- package/src/index.ts +110 -355
- package/src/persistence.ts +83 -0
- package/src/ports.ts +68 -0
- package/src/projection/__tests__/prompts.test.ts +245 -0
- package/src/projection/__tests__/widget.test.ts +255 -0
- package/src/projection/prompts.ts +250 -0
- package/src/projection/widget.ts +254 -0
- package/src/service.ts +276 -0
- package/src/session.ts +114 -0
- package/src/__tests__/is-task-done.test.ts +0 -43
- package/src/__tests__/validate-update-tasks.test.ts +0 -172
- package/src/action-handlers.ts +0 -412
- package/src/agent-end-handler.ts +0 -306
- package/src/before-agent-start-handler.ts +0 -180
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -299
- package/src/state.ts +0 -274
- package/src/templates.ts +0 -274
- package/src/tool-handler.ts +0 -291
- package/src/widget.ts +0 -225
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-adapter.ts 测试 — agent_end / before_agent_start 核心逻辑
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 agent_end 的 continuation + ESC 守卫(FR-6.7)+
|
|
5
|
+
* before_agent_start 的 AUTO_CLEAR/context wrap-up。
|
|
6
|
+
*
|
|
7
|
+
* 全解耦后不再测 allTasksDone followUp(原依赖 pi.__todoGetList,已删)。
|
|
8
|
+
* agent_end 流程简化为:budget 检查 → continuation 去抖。
|
|
9
|
+
*
|
|
10
|
+
* 用 fake pi + fake ctx(不 import Pi SDK)。handler 签名 (pi, session, ctx) → void/result。
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { handleAgentEnd } from "../adapters/event-handlers/agent-end";
|
|
16
|
+
import { handleBeforeAgentStart } from "../adapters/event-handlers/before-agent-start";
|
|
17
|
+
import { createGoalState } from "../engine/goal";
|
|
18
|
+
import type { GoalRuntimeState } from "../engine/types";
|
|
19
|
+
import { createGoalSession } from "../session";
|
|
20
|
+
|
|
21
|
+
// ── Fake pi / ctx ────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
interface RecordedCall {
|
|
24
|
+
kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser" | "setStatus" | "setWidget";
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
text?: string;
|
|
27
|
+
level?: string;
|
|
28
|
+
content?: string;
|
|
29
|
+
deliverAs?: string;
|
|
30
|
+
customType?: string;
|
|
31
|
+
key?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeFakePi(): { pi: ExtensionAPI; calls: RecordedCall[]; states: unknown[]; history: unknown[] } {
|
|
35
|
+
const calls: RecordedCall[] = [];
|
|
36
|
+
const states: unknown[] = [];
|
|
37
|
+
const history: unknown[] = [];
|
|
38
|
+
const pi = {
|
|
39
|
+
appendEntry(customType: string, data?: unknown): void {
|
|
40
|
+
calls.push({ kind: customType === "goal-history" ? "appendHistory" : "appendState", payload: data });
|
|
41
|
+
if (customType === "goal-history") history.push(data);
|
|
42
|
+
else states.push(data);
|
|
43
|
+
},
|
|
44
|
+
sendMessage(message: unknown, _options?: unknown): void {
|
|
45
|
+
const msg = message as { customType?: string; content?: string; display?: boolean };
|
|
46
|
+
calls.push({
|
|
47
|
+
kind: "sendContext",
|
|
48
|
+
content: msg.content,
|
|
49
|
+
customType: msg.customType,
|
|
50
|
+
payload: _options,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
sendUserMessage(content: string | unknown[], options?: unknown): void {
|
|
54
|
+
calls.push({
|
|
55
|
+
kind: "sendUser",
|
|
56
|
+
content: typeof content === "string" ? content : undefined,
|
|
57
|
+
payload: options,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
} as unknown as ExtensionAPI;
|
|
61
|
+
return { pi, calls, states, history };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeFakeCtx(overrides?: {
|
|
65
|
+
aborted?: boolean;
|
|
66
|
+
hasUI?: boolean;
|
|
67
|
+
contextUsage?: { tokens?: number; contextWindow?: number };
|
|
68
|
+
}): { ctx: ExtensionContext; calls: RecordedCall[] } {
|
|
69
|
+
const calls: RecordedCall[] = [];
|
|
70
|
+
const signal = { aborted: overrides?.aborted ?? false } as AbortSignal;
|
|
71
|
+
const ctx = {
|
|
72
|
+
hasUI: overrides?.hasUI ?? true,
|
|
73
|
+
signal,
|
|
74
|
+
getContextUsage: () => overrides?.contextUsage,
|
|
75
|
+
ui: {
|
|
76
|
+
notify: (text: string, level: string) => {
|
|
77
|
+
calls.push({ kind: "notify", text, level });
|
|
78
|
+
},
|
|
79
|
+
setStatus: (key: string, text: string | undefined) => {
|
|
80
|
+
calls.push({ kind: "setStatus", key, text });
|
|
81
|
+
},
|
|
82
|
+
setWidget: (key: string, _content: unknown) => {
|
|
83
|
+
calls.push({ kind: "setWidget", key });
|
|
84
|
+
},
|
|
85
|
+
theme: {
|
|
86
|
+
fg: (_color: string, text: string) => text,
|
|
87
|
+
bold: (text: string) => text,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
sessionManager: { getEntries: () => [], getBranch: () => undefined },
|
|
91
|
+
} as unknown as ExtensionContext;
|
|
92
|
+
return { ctx, calls };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function allCalls(piCalls: RecordedCall[], ctxCalls: RecordedCall[]): RecordedCall[] {
|
|
96
|
+
return [...piCalls, ...ctxCalls];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── 辅助:构造 state ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function makeRunningState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
|
|
102
|
+
return {
|
|
103
|
+
...createGoalState("test objective"),
|
|
104
|
+
timeStartedAt: 0, // 关闭时间累计(避免测试随机性)
|
|
105
|
+
...overrides,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── handleAgentEnd:ESC 守卫 ──────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("handleAgentEnd — FR-6.7 ESC 守卫", () => {
|
|
112
|
+
it("aborted=true:不发 continuation、不递增 stall、不做 budget 检查、goal 保持 active", async () => {
|
|
113
|
+
const { pi, calls: piCalls, states } = makeFakePi();
|
|
114
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({ aborted: true });
|
|
115
|
+
const session = createGoalSession();
|
|
116
|
+
session.state = makeRunningState({ tokensUsed: 1000, lastTurnTokensUsed: 0 });
|
|
117
|
+
|
|
118
|
+
await handleAgentEnd(pi, session, ctx);
|
|
119
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
120
|
+
|
|
121
|
+
// 不发 continuation(无 sendContext/sendUser)
|
|
122
|
+
expect(all.filter((c) => c.kind === "sendContext" || c.kind === "sendUser")).toHaveLength(0);
|
|
123
|
+
// 不做 budget 检查(无 notify 关于 budget)
|
|
124
|
+
expect(all.filter((c) => c.kind === "notify")).toHaveLength(0);
|
|
125
|
+
// goal 保持 active(status 不变)
|
|
126
|
+
expect(session.state!.status).toBe("active");
|
|
127
|
+
// 不 persist(ESC 不触发副作用)
|
|
128
|
+
expect(states).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("aborted=true + 终态:仍走终态 notify(ESC 不影响终态路径)", async () => {
|
|
132
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
133
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({ aborted: true });
|
|
134
|
+
const session = createGoalSession();
|
|
135
|
+
session.state = makeRunningState({ status: "complete" });
|
|
136
|
+
|
|
137
|
+
await handleAgentEnd(pi, session, ctx);
|
|
138
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
139
|
+
|
|
140
|
+
// 终态 notify 仍触发
|
|
141
|
+
const notify = all.filter((c) => c.kind === "notify");
|
|
142
|
+
expect(notify).toHaveLength(1);
|
|
143
|
+
expect(notify[0]!.text).toContain("Objective completed");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── handleAgentEnd:continuation(去抖 + 正常发送)──────
|
|
148
|
+
|
|
149
|
+
describe("handleAgentEnd — continuation", () => {
|
|
150
|
+
it("continuation 去抖:tokenDelta=0(空 turn)不发", async () => {
|
|
151
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
152
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx();
|
|
153
|
+
const session = createGoalSession();
|
|
154
|
+
session.state = makeRunningState({
|
|
155
|
+
tokensUsed: 100,
|
|
156
|
+
lastTurnTokensUsed: 100, // tokenDelta = 0
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await handleAgentEnd(pi, session, ctx);
|
|
160
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
161
|
+
|
|
162
|
+
const sendContext = all.filter((c) => c.kind === "sendContext");
|
|
163
|
+
expect(sendContext).toHaveLength(0); // 空 turn 不发
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("continuation 正常:tokenDelta>0 → 发 continuationPrompt", async () => {
|
|
167
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
168
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx();
|
|
169
|
+
const session = createGoalSession();
|
|
170
|
+
session.state = makeRunningState({
|
|
171
|
+
tokensUsed: 200,
|
|
172
|
+
lastTurnTokensUsed: 0, // tokenDelta = 200 > 0
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await handleAgentEnd(pi, session, ctx);
|
|
176
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
177
|
+
|
|
178
|
+
const sendContext = all.filter((c) => c.kind === "sendContext");
|
|
179
|
+
expect(sendContext).toHaveLength(1);
|
|
180
|
+
expect(sendContext[0]!.content).toContain("[GOAL]");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── handleAgentEnd:并发保护(G-021)+ stale(G-020)───
|
|
185
|
+
|
|
186
|
+
describe("handleAgentEnd — 并发保护 + stale 快照", () => {
|
|
187
|
+
it("isProcessing=true → 直接返回(防重入)", async () => {
|
|
188
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
189
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx();
|
|
190
|
+
const session = createGoalSession();
|
|
191
|
+
session.state = makeRunningState();
|
|
192
|
+
session.isProcessing = true; // 已锁
|
|
193
|
+
|
|
194
|
+
await handleAgentEnd(pi, session, ctx);
|
|
195
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
196
|
+
|
|
197
|
+
// 无任何副作用
|
|
198
|
+
expect(all).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("finally 块释放 isProcessing(即使中途 return)", async () => {
|
|
202
|
+
const { pi } = makeFakePi();
|
|
203
|
+
const { ctx } = makeFakeCtx({ aborted: true });
|
|
204
|
+
const session = createGoalSession();
|
|
205
|
+
session.state = makeRunningState();
|
|
206
|
+
|
|
207
|
+
await handleAgentEnd(pi, session, ctx);
|
|
208
|
+
|
|
209
|
+
expect(session.isProcessing).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("state=null → 直接返回(无 goal 时)", async () => {
|
|
213
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
214
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx();
|
|
215
|
+
const session = createGoalSession();
|
|
216
|
+
// session.state 保持 null
|
|
217
|
+
|
|
218
|
+
await handleAgentEnd(pi, session, ctx);
|
|
219
|
+
|
|
220
|
+
expect(allCalls(piCalls, ctxCalls)).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── handleBeforeAgentStart ────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("handleBeforeAgentStart", () => {
|
|
227
|
+
it("state=null → 返回 undefined", async () => {
|
|
228
|
+
const { pi } = makeFakePi();
|
|
229
|
+
const { ctx } = makeFakeCtx();
|
|
230
|
+
const session = createGoalSession();
|
|
231
|
+
|
|
232
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
233
|
+
|
|
234
|
+
expect(result).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("正常 active goal → 返回 context injection 消息", async () => {
|
|
238
|
+
const { pi } = makeFakePi();
|
|
239
|
+
const { ctx } = makeFakeCtx();
|
|
240
|
+
const session = createGoalSession();
|
|
241
|
+
session.state = makeRunningState();
|
|
242
|
+
|
|
243
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
244
|
+
|
|
245
|
+
expect(result).toBeDefined();
|
|
246
|
+
expect(result!.message.customType).toBe("goal-context");
|
|
247
|
+
expect(result!.message.content).toContain("[GOAL mode activated]");
|
|
248
|
+
expect(result!.message.display).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// 全解耦后:planAvailable 恒 true(不再运行时探测 pi.__planStart)。
|
|
252
|
+
// contextInjectionPrompt 恒定注入 plan mode 建议段落,AI 自行判断是否用。
|
|
253
|
+
it("恒定注入 plan mode 建议段落(全解耦:不再探测 plan extension)", async () => {
|
|
254
|
+
const { pi } = makeFakePi();
|
|
255
|
+
const { ctx } = makeFakeCtx();
|
|
256
|
+
const session = createGoalSession();
|
|
257
|
+
session.state = makeRunningState();
|
|
258
|
+
|
|
259
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
260
|
+
|
|
261
|
+
expect(result).toBeDefined();
|
|
262
|
+
expect(result!.message.customType).toBe("goal-context");
|
|
263
|
+
expect(result!.message.content).toContain("plan mode");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// FR-8.1 G-007: AUTO_CLEAR_TURNS=2
|
|
267
|
+
it("终态 + turnsInTerminal >= 2 → clearGoalSession(state 清空)", async () => {
|
|
268
|
+
const { pi } = makeFakePi();
|
|
269
|
+
const { ctx } = makeFakeCtx();
|
|
270
|
+
const session = createGoalSession();
|
|
271
|
+
session.state = makeRunningState({
|
|
272
|
+
status: "complete",
|
|
273
|
+
currentTurnIndex: 10,
|
|
274
|
+
completedAtTurnIndex: 8, // turnsInTerminal = 2 >= AUTO_CLEAR_TURNS
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
278
|
+
|
|
279
|
+
expect(result).toBeUndefined();
|
|
280
|
+
expect(session.state).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("终态 + turnsInTerminal < 2 → 不清理,折叠 status bar", async () => {
|
|
284
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
285
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({ hasUI: true });
|
|
286
|
+
const session = createGoalSession();
|
|
287
|
+
session.state = makeRunningState({
|
|
288
|
+
status: "complete",
|
|
289
|
+
currentTurnIndex: 10,
|
|
290
|
+
completedAtTurnIndex: 9, // turnsInTerminal = 1 < 2
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
294
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
295
|
+
|
|
296
|
+
expect(result).toBeUndefined();
|
|
297
|
+
expect(session.state).not.toBeNull(); // 未清理
|
|
298
|
+
// setStatus 触发(折叠为终态单行)
|
|
299
|
+
expect(all.filter((c) => c.kind === "setStatus" && c.key === "goal").length).toBeGreaterThan(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ADR-002 context usage 提示(CONTEXT_USAGE_RATIO_LIMIT=0.85,保持 active)
|
|
303
|
+
it("context 使用率 > 85% → 保持 active + 注入 wrap-up 指令", async () => {
|
|
304
|
+
const { pi } = makeFakePi();
|
|
305
|
+
const { ctx } = makeFakeCtx({ contextUsage: { tokens: 9000, contextWindow: 10000 } }); // 90% > 85%
|
|
306
|
+
const session = createGoalSession();
|
|
307
|
+
session.state = makeRunningState();
|
|
308
|
+
|
|
309
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
310
|
+
|
|
311
|
+
expect(result).toBeDefined();
|
|
312
|
+
expect(result!.message.customType).toBe("goal-context-exceeded");
|
|
313
|
+
// ADR-002:goal 保持 active(不转 paused)
|
|
314
|
+
expect(session.state!.status).toBe("active");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("context 使用率 <= 85% → 正常 context injection", async () => {
|
|
318
|
+
const { pi } = makeFakePi();
|
|
319
|
+
const { ctx } = makeFakeCtx({ contextUsage: { tokens: 5000, contextWindow: 10000 } }); // 50%
|
|
320
|
+
const session = createGoalSession();
|
|
321
|
+
session.state = makeRunningState();
|
|
322
|
+
|
|
323
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
324
|
+
|
|
325
|
+
expect(result!.message.customType).toBe("goal-context");
|
|
326
|
+
expect(session.state!.status).toBe("active");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("非 active(blocked)→ 返回 undefined(不注入)", async () => {
|
|
330
|
+
const { pi } = makeFakePi();
|
|
331
|
+
const { ctx } = makeFakeCtx();
|
|
332
|
+
const session = createGoalSession();
|
|
333
|
+
session.state = makeRunningState({ status: "blocked" });
|
|
334
|
+
|
|
335
|
+
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
336
|
+
|
|
337
|
+
expect(result).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal_control adapter 测试 — handler 用 fake ports
|
|
3
|
+
*
|
|
4
|
+
* 覆盖 #3 验收:
|
|
5
|
+
* - complete: active 守卫 + evidence 必填 + finalizeAndPersist(status→complete + history)
|
|
6
|
+
* - report_blocked: active 守卫 + reason 必填 + tickState + transitionStatus(active→blocked) + persistState
|
|
7
|
+
*
|
|
8
|
+
* 全解耦后不再测 todo 前置检查(checkCompletePrerequisites / buildProgressInput / findIncompleteTodos 已删)。
|
|
9
|
+
* complete 不做 todo 完成硬检查——AI 自行判断(prompt 软建议)。
|
|
10
|
+
*
|
|
11
|
+
* 不 import Pi SDK(handler 接收 ServicePorts,纯逻辑)。
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
handleComplete,
|
|
17
|
+
handleCreate,
|
|
18
|
+
handleReportBlocked,
|
|
19
|
+
} from "../adapters/goal-control-adapter";
|
|
20
|
+
import { createGoalState, transitionStatus } from "../engine/goal";
|
|
21
|
+
import type { GoalRuntimeState } from "../engine/types";
|
|
22
|
+
import type { UiPort } from "../ports";
|
|
23
|
+
import type { ServicePorts } from "../service";
|
|
24
|
+
import { createGoalSession } from "../session";
|
|
25
|
+
|
|
26
|
+
// ── Fake Ports ───────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function makeFakePorts(): ServicePorts & {
|
|
29
|
+
states: GoalRuntimeState[];
|
|
30
|
+
history: unknown[];
|
|
31
|
+
notifications: Array<{ text: string; level: string }>;
|
|
32
|
+
} {
|
|
33
|
+
const states: GoalRuntimeState[] = [];
|
|
34
|
+
const history: unknown[] = [];
|
|
35
|
+
const notifications: Array<{ text: string; level: string }> = [];
|
|
36
|
+
return {
|
|
37
|
+
states,
|
|
38
|
+
history,
|
|
39
|
+
notifications,
|
|
40
|
+
persistence: {
|
|
41
|
+
appendState: (s) => {
|
|
42
|
+
states.push(s);
|
|
43
|
+
},
|
|
44
|
+
appendHistory: (e) => {
|
|
45
|
+
history.push(e);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
ui: {
|
|
49
|
+
setWidget: () => {},
|
|
50
|
+
setStatus: () => {},
|
|
51
|
+
notify: (text, level) => {
|
|
52
|
+
notifications.push({ text, level });
|
|
53
|
+
},
|
|
54
|
+
hasUI: true,
|
|
55
|
+
// widget.asTheme 期望 fg/bold(ports.ts 的 uiPort 同样携带)
|
|
56
|
+
fg: (_color: string, text: string) => text,
|
|
57
|
+
bold: (text: string) => text,
|
|
58
|
+
} as UiPort,
|
|
59
|
+
messaging: {
|
|
60
|
+
sendContextMessage: () => {},
|
|
61
|
+
sendUserMessage: () => {},
|
|
62
|
+
},
|
|
63
|
+
session: {
|
|
64
|
+
getEntries: () => [],
|
|
65
|
+
getContextUsage: () => null,
|
|
66
|
+
signal: undefined,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const activeState = (overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState => ({
|
|
72
|
+
...createGoalState("test"),
|
|
73
|
+
...overrides,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── handleComplete ───────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("handleComplete — active 守卫 + evidence + finalizeAndPersist", () => {
|
|
79
|
+
it("active + evidence → status complete + 写 history", () => {
|
|
80
|
+
const session = createGoalSession();
|
|
81
|
+
session.state = activeState();
|
|
82
|
+
const ports = makeFakePorts();
|
|
83
|
+
|
|
84
|
+
const details = handleComplete({ action: "complete", evidence: "tests pass" }, session, ports);
|
|
85
|
+
|
|
86
|
+
expect(details.action).toBe("complete");
|
|
87
|
+
expect(details.status).toBe("complete");
|
|
88
|
+
expect(session.state!.status).toBe("complete");
|
|
89
|
+
// finalizeAndPersist 写 history(FR-8.7)
|
|
90
|
+
expect(ports.history).toHaveLength(1);
|
|
91
|
+
expect(ports.notifications[0]?.level).toBe("info");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("非 active(blocked)→ throw", () => {
|
|
95
|
+
const session = createGoalSession();
|
|
96
|
+
session.state = activeState({ status: "blocked" });
|
|
97
|
+
expect(() => handleComplete({ action: "complete", evidence: "x" }, session, makeFakePorts())).toThrow(
|
|
98
|
+
/not active/,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("evidence 空 → throw", () => {
|
|
103
|
+
const session = createGoalSession();
|
|
104
|
+
session.state = activeState();
|
|
105
|
+
expect(() =>
|
|
106
|
+
handleComplete({ action: "complete", evidence: " " }, session, makeFakePorts()),
|
|
107
|
+
).toThrow(/evidence/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("completedTasks 写入 history", () => {
|
|
111
|
+
const session = createGoalSession();
|
|
112
|
+
session.state = activeState();
|
|
113
|
+
const ports = makeFakePorts();
|
|
114
|
+
handleComplete({ action: "complete", evidence: "done", completedTasks: 5 }, session, ports);
|
|
115
|
+
expect((ports.history[0] as { completedTasks?: number }).completedTasks).toBe(5);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── handleCreate ─────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal", () => {
|
|
122
|
+
it("无旧 goal + slug + objective → status active + slug 写入 + 持久化 + notify", () => {
|
|
123
|
+
const session = createGoalSession();
|
|
124
|
+
const ports = makeFakePorts();
|
|
125
|
+
|
|
126
|
+
const details = handleCreate(
|
|
127
|
+
{ action: "create", slug: "ship-feature-x", objective: "ship feature X" },
|
|
128
|
+
session,
|
|
129
|
+
ports,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(details.action).toBe("create");
|
|
133
|
+
expect(details.status).toBe("active");
|
|
134
|
+
expect(details.slug).toBe("ship-feature-x");
|
|
135
|
+
expect(session.state).not.toBeNull();
|
|
136
|
+
expect(session.state!.objective).toBe("ship feature X");
|
|
137
|
+
expect(session.state!.slug).toBe("ship-feature-x");
|
|
138
|
+
expect(session.state!.status).toBe("active");
|
|
139
|
+
// createGoal 内部 persistState 写一条 state
|
|
140
|
+
expect(ports.states).toHaveLength(1);
|
|
141
|
+
expect(ports.notifications[0]?.level).toBe("info");
|
|
142
|
+
expect(ports.notifications[0]?.text).toContain("ship feature X");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("objective 空 → throw(即使有 slug)", () => {
|
|
146
|
+
const session = createGoalSession();
|
|
147
|
+
expect(() =>
|
|
148
|
+
handleCreate({ action: "create", slug: "x", objective: " " }, session, makeFakePorts()),
|
|
149
|
+
).toThrow(/objective/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("slug 空 → throw(即使有 objective)", () => {
|
|
153
|
+
const session = createGoalSession();
|
|
154
|
+
expect(() =>
|
|
155
|
+
handleCreate({ action: "create", slug: " ", objective: "do thing" }, session, makeFakePorts()),
|
|
156
|
+
).toThrow(/slug/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("slug 缺省 → throw", () => {
|
|
160
|
+
const session = createGoalSession();
|
|
161
|
+
expect(() =>
|
|
162
|
+
handleCreate({ action: "create", objective: "do thing" }, session, makeFakePorts()),
|
|
163
|
+
).toThrow(/slug/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("已有 active goal → throw(D25 非终态守卫)", () => {
|
|
167
|
+
const session = createGoalSession();
|
|
168
|
+
session.state = activeState({ status: "active" });
|
|
169
|
+
expect(() =>
|
|
170
|
+
handleCreate(
|
|
171
|
+
{ action: "create", slug: "new", objective: "new obj" },
|
|
172
|
+
session,
|
|
173
|
+
makeFakePorts(),
|
|
174
|
+
),
|
|
175
|
+
).toThrow(/already active/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("已有 paused goal → throw(paused 也是非终态)", () => {
|
|
179
|
+
const session = createGoalSession();
|
|
180
|
+
session.state = activeState({ status: "paused" });
|
|
181
|
+
expect(() =>
|
|
182
|
+
handleCreate(
|
|
183
|
+
{ action: "create", slug: "new", objective: "new obj" },
|
|
184
|
+
session,
|
|
185
|
+
makeFakePorts(),
|
|
186
|
+
),
|
|
187
|
+
).toThrow(/already active/i);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("已有 blocked goal → throw(blocked 也是非终态)", () => {
|
|
191
|
+
const session = createGoalSession();
|
|
192
|
+
session.state = activeState({ status: "blocked" });
|
|
193
|
+
expect(() =>
|
|
194
|
+
handleCreate(
|
|
195
|
+
{ action: "create", slug: "new", objective: "new obj" },
|
|
196
|
+
session,
|
|
197
|
+
makeFakePorts(),
|
|
198
|
+
),
|
|
199
|
+
).toThrow(/already active/i);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("已有终态 goal → 覆盖创建(快速路径,goalId 变新,slug 写入)", () => {
|
|
203
|
+
const session = createGoalSession();
|
|
204
|
+
session.state = activeState({ status: "complete" });
|
|
205
|
+
const oldId = session.state!.goalId;
|
|
206
|
+
const ports = makeFakePorts();
|
|
207
|
+
|
|
208
|
+
const details = handleCreate(
|
|
209
|
+
{ action: "create", slug: "next-obj", objective: "next obj" },
|
|
210
|
+
session,
|
|
211
|
+
ports,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(details.status).toBe("active");
|
|
215
|
+
expect(session.state!.objective).toBe("next obj");
|
|
216
|
+
expect(session.state!.slug).toBe("next-obj");
|
|
217
|
+
expect(session.state!.goalId).not.toBe(oldId);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("tokenBudget <= 0 → throw", () => {
|
|
221
|
+
const session = createGoalSession();
|
|
222
|
+
expect(() =>
|
|
223
|
+
handleCreate(
|
|
224
|
+
{ action: "create", slug: "x", objective: "x", tokenBudget: 0 },
|
|
225
|
+
session,
|
|
226
|
+
makeFakePorts(),
|
|
227
|
+
),
|
|
228
|
+
).toThrow(/tokenBudget/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("timeBudgetMinutes <= 0 → throw", () => {
|
|
232
|
+
const session = createGoalSession();
|
|
233
|
+
expect(() =>
|
|
234
|
+
handleCreate(
|
|
235
|
+
{ action: "create", slug: "x", objective: "x", timeBudgetMinutes: -5 },
|
|
236
|
+
session,
|
|
237
|
+
makeFakePorts(),
|
|
238
|
+
),
|
|
239
|
+
).toThrow(/timeBudgetMinutes/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("合法 budget → 合并进新 state", () => {
|
|
243
|
+
const session = createGoalSession();
|
|
244
|
+
const ports = makeFakePorts();
|
|
245
|
+
|
|
246
|
+
handleCreate(
|
|
247
|
+
{ action: "create", slug: "x", objective: "x", tokenBudget: 8000, timeBudgetMinutes: 30 },
|
|
248
|
+
session,
|
|
249
|
+
ports,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(session.state!.budget.tokenBudget).toBe(8000);
|
|
253
|
+
expect(session.state!.budget.timeBudgetMinutes).toBe(30);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── handleReportBlocked ──────────────────────────────
|
|
258
|
+
|
|
259
|
+
describe("handleReportBlocked — active 守卫 + tick + transition + persist", () => {
|
|
260
|
+
it("active + reason → status blocked + reason 记录 + tick 累加", () => {
|
|
261
|
+
const session = createGoalSession();
|
|
262
|
+
const past = Date.now() - 4000;
|
|
263
|
+
session.state = activeState({ timeStartedAt: past, timeUsedSeconds: 6 });
|
|
264
|
+
const ports = makeFakePorts();
|
|
265
|
+
|
|
266
|
+
const details = handleReportBlocked(
|
|
267
|
+
{ action: "report_blocked", reason: "stuck on X" },
|
|
268
|
+
session,
|
|
269
|
+
ports,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
expect(details.action).toBe("report_blocked");
|
|
273
|
+
expect(details.status).toBe("blocked");
|
|
274
|
+
expect(session.state!.status).toBe("blocked");
|
|
275
|
+
expect(session.state!.lastBlockerReason).toBe("stuck on X");
|
|
276
|
+
// MF-3 tick:转 blocked 前累加当前运行段(6 + ~4s)
|
|
277
|
+
expect(session.state!.timeUsedSeconds).toBeGreaterThanOrEqual(9);
|
|
278
|
+
// persistState 持久化
|
|
279
|
+
expect(ports.states).toHaveLength(1);
|
|
280
|
+
expect(ports.notifications[0]?.level).toBe("warning");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("非 active(complete)→ throw", () => {
|
|
284
|
+
const session = createGoalSession();
|
|
285
|
+
session.state = activeState({ status: "complete" });
|
|
286
|
+
expect(() =>
|
|
287
|
+
handleReportBlocked({ action: "report_blocked", reason: "x" }, session, makeFakePorts()),
|
|
288
|
+
).toThrow(/not active/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("reason 空 → throw", () => {
|
|
292
|
+
const session = createGoalSession();
|
|
293
|
+
session.state = activeState();
|
|
294
|
+
expect(() =>
|
|
295
|
+
handleReportBlocked({ action: "report_blocked", reason: "" }, session, makeFakePorts()),
|
|
296
|
+
).toThrow(/reason/);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("active→blocked 是合法转换", () => {
|
|
300
|
+
// transitionStatus 自身已由 engine 测试覆盖,此处验证集成不破坏
|
|
301
|
+
expect(transitionStatus("active", "blocked")).toBe("blocked");
|
|
302
|
+
});
|
|
303
|
+
});
|