@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,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
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * index.ts 测试 — 工厂入口 + 跨扩展 API(pi.__goalInit)
3
+ *
4
+ * 覆盖 T1.8 (NFR-AC-8):__goalInit 忽略 tasks 参数(tasks 废弃,D-16/FR-4 双轨消除)。
5
+ * 设计保证:__goalInit 签名仅 (objective, budget, ctx),结构上无法接收 tasks。
6
+ *
7
+ * 用最小 fake pi + fake ctx 实例化 goalExtension 工厂,再调 pi.__goalInit。
8
+ */
9
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import goalExtension from "../index";
13
+
14
+ // ── Minimal fake pi / ctx ─────────────────────────────
15
+
16
+ interface FactoryFixture {
17
+ pi: ExtensionAPI & { __goalInit?: (...args: never[]) => unknown };
18
+ ctx: ExtensionContext;
19
+ states: unknown[];
20
+ history: unknown[];
21
+ sendUser: unknown[];
22
+ }
23
+
24
+ /** 工厂所需的最小 pi:registerCommand/registerTool/on/registerMessageRenderer + appendEntry/sendMessage/sendUserMessage */
25
+ function makeFactoryFixture(): FactoryFixture {
26
+ const states: unknown[] = [];
27
+ const history: unknown[] = [];
28
+ const sendUser: unknown[] = [];
29
+ const pi = {
30
+ registerCommand: () => {},
31
+ registerTool: () => {},
32
+ on: () => {},
33
+ registerMessageRenderer: () => {},
34
+ appendEntry(customType: string, data?: unknown): void {
35
+ if (customType === "goal-history") history.push(data);
36
+ else states.push(data);
37
+ },
38
+ sendMessage: () => {},
39
+ sendUserMessage(content: unknown): void {
40
+ sendUser.push(content);
41
+ },
42
+ } as unknown as ExtensionAPI & { __goalInit?: (...args: never[]) => unknown };
43
+
44
+ const ctx = {
45
+ hasUI: true,
46
+ signal: { aborted: false } as AbortSignal,
47
+ getContextUsage: () => null,
48
+ ui: {
49
+ notify: () => {},
50
+ setStatus: () => {},
51
+ setWidget: () => {},
52
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
53
+ },
54
+ sessionManager: { getEntries: () => [], getBranch: () => undefined },
55
+ } as unknown as ExtensionContext;
56
+
57
+ return { pi, ctx, states, history, sendUser };
58
+ }
59
+
60
+ // ── pi.__goalInit(NFR-AC-8 / T1.8)──────────────────
61
+
62
+ describe("pi.__goalInit — tasks 参数废弃(NFR-AC-8 / T1.8)", () => {
63
+ it("工厂实例化后 __goalInit 存在且为函数", () => {
64
+ const { pi } = makeFactoryFixture();
65
+ goalExtension(pi);
66
+ expect(typeof pi.__goalInit).toBe("function");
67
+ });
68
+
69
+ it("正常调用 → 创建 goal(返回 true + 持久化 state)", () => {
70
+ const { pi, ctx, states } = makeFactoryFixture();
71
+ goalExtension(pi);
72
+ const ok = (pi.__goalInit as (o: string, b: unknown, c: ExtensionContext) => boolean)(
73
+ "build feature X",
74
+ undefined,
75
+ ctx,
76
+ );
77
+ expect(ok).toBe(true);
78
+ expect(states.length).toBeGreaterThanOrEqual(1); // appendState 调用
79
+ const persisted = states[0] as { objective?: string };
80
+ expect(persisted.objective).toBe("build feature X");
81
+ });
82
+
83
+ it("ctx 缺失 → 返回 false(创建失败,不 throw)", () => {
84
+ // FR-4.2/D-16: ctx 必填
85
+ const { pi } = makeFactoryFixture();
86
+ goalExtension(pi);
87
+ const init = pi.__goalInit as (o: string, b: unknown, c: unknown) => boolean;
88
+ expect(() => init("obj", undefined, undefined)).not.toThrow();
89
+ expect(init("obj", undefined, undefined)).toBe(false);
90
+ });
91
+
92
+ it("已有 active goal → 返回 false(拒绝重复创建)", () => {
93
+ const { pi, ctx } = makeFactoryFixture();
94
+ goalExtension(pi);
95
+ const init = pi.__goalInit as (o: string, b: unknown, c: ExtensionContext) => boolean;
96
+ expect(init("first goal", undefined, ctx)).toBe(true);
97
+ // 再创建 → 已有 active,拒绝
98
+ expect(init("second goal", undefined, ctx)).toBe(false);
99
+ });
100
+
101
+ it("传入 tasks(第 4 参数)→ 不 throw、不接收(结构保证:签名只取前 3 参)", () => {
102
+ // NFR-AC-8 核心断言:tasks 废弃后,即便调用方误传 tasks 也不影响创建。
103
+ // __goalInit 签名 (objective, budget, ctx) 不含 tasks 参数位,
104
+ // JS 多余实参被静默忽略,TS 层签名也不暴露 tasks。
105
+ const { pi, ctx, states } = makeFactoryFixture();
106
+ goalExtension(pi);
107
+ const init = pi.__goalInit as (...args: unknown[]) => boolean;
108
+ // 误传第 4 参 tasks(模拟旧调用方残留)→ 不 throw + 创建成功 + tasks 被忽略
109
+ const strayTasks = [{ id: 1, text: "legacy task" }];
110
+ let ok = false;
111
+ expect(() => {
112
+ ok = init("obj with stray tasks", undefined, ctx, strayTasks);
113
+ }).not.toThrow();
114
+ expect(ok).toBe(true);
115
+ // 创建的 state 不含 tasks 字段(tasks 被忽略)
116
+ const persisted = states[states.length - 1] as { tasks?: unknown };
117
+ expect(persisted.tasks).toBeUndefined();
118
+ });
119
+ });