@zhushanwen/pi-goal 0.2.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 +8 -4
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +80 -0
- 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 +16 -0
- package/src/__tests__/stubs/typebox.ts +14 -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 +7 -19
- package/src/constants.ts +1 -3
- 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 -333
- 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/action-handlers.ts +0 -341
- package/src/agent-end-handler.ts +0 -304
- package/src/before-agent-start-handler.ts +0 -182
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -280
- package/src/state.ts +0 -250
- package/src/templates.ts +0 -232
- package/src/tool-handler.ts +0 -252
- package/src/widget.ts +0 -186
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Codex 风格的 `/goal` 命令 — 持久目标驱动自主循环,支持任务
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
# symlink 方式(开发推荐)
|
|
17
|
-
ln -s /path/to/xyz-pi-extensions-workspace/
|
|
17
|
+
ln -s /path/to/xyz-pi-extensions-workspace/<branch>/extensions/goal \
|
|
18
18
|
~/.pi/agent/extensions/goal
|
|
19
19
|
|
|
20
20
|
# npm 方式(正式)
|
|
@@ -27,8 +27,7 @@ pi install npm:@zhushanwen/pi-goal
|
|
|
27
27
|
/goal 修复项目中所有失败的测试
|
|
28
28
|
/goal 实现用户认证功能 --tokens 500000 --timeout 30
|
|
29
29
|
/goal status # 查看进度
|
|
30
|
-
/goal
|
|
31
|
-
/goal resume # 恢复
|
|
30
|
+
/goal resume # 恢复(blocked → active)
|
|
32
31
|
/goal clear # 清除
|
|
33
32
|
```
|
|
34
33
|
|
|
@@ -36,14 +35,16 @@ pi install npm:@zhushanwen/pi-goal
|
|
|
36
35
|
|
|
37
36
|
```
|
|
38
37
|
goal/
|
|
39
|
-
├── index.ts
|
|
38
|
+
├── index.ts # 入口 — 命令、事件、工具注册 + __goalInit
|
|
40
39
|
└── src/
|
|
41
|
-
├── index.ts
|
|
42
|
-
├──
|
|
43
|
-
├──
|
|
44
|
-
├──
|
|
45
|
-
├──
|
|
46
|
-
├──
|
|
47
|
-
├──
|
|
48
|
-
|
|
40
|
+
├── index.ts # 工厂入口(注册 tool/command/events,全部委托 adapters)
|
|
41
|
+
├── commands.ts # /goal 命令参数解析
|
|
42
|
+
├── constants.ts # 语义常量
|
|
43
|
+
├── ports.ts # Pi 能力抽象(Persistence/Ui/Messaging/Session)
|
|
44
|
+
├── service.ts # 协调层 — applyToolAction / applyEvent 双入口
|
|
45
|
+
├── persistence.ts # serialize/deserialize + GoalHistoryEntry
|
|
46
|
+
├── session.ts # 运行时句柄 + 状态重建 + entry GC
|
|
47
|
+
├── engine/ # 零 Pi 依赖的纯状态机(goal/task/budget/types)
|
|
48
|
+
├── adapters/ # Pi 桥接(tool/command/event-adapter + actions)
|
|
49
|
+
└── projection/ # 渲染(widget/prompts/result)
|
|
49
50
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"index.ts"
|
|
23
23
|
],
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@mariozechner/pi-coding-agent": "*",
|
|
26
|
-
"@earendil-works/pi-tui": "*",
|
|
27
25
|
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*",
|
|
27
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
28
28
|
"@sinclair/typebox": "*"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"typecheck": "npx tsc --noEmit"
|
|
39
|
+
"typecheck": "npx tsc --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"vitest": "^4.1.8"
|
|
40
44
|
}
|
|
41
45
|
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-adapter.ts 测试 — /goal 子命令(FR-3 pause/resume 对称;#1 删除 abort + task CRUD)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - FR-3: pause(active→paused tick 前置)+ resume(paused/blocked→active 对称 + budget 重检)
|
|
6
|
+
* - MF-3 回归:clear/set-overwrite 转 cancelled 前 tick 累加时间
|
|
7
|
+
* - MF-6 覆盖:命令分发 + 各 FR 分支(G-R2-008/G-014/G-002)
|
|
8
|
+
*
|
|
9
|
+
* 用 fake pi + fake ctx(不 import Pi SDK 真实实现)。
|
|
10
|
+
* handleGoalCommand(pi, session, args, ctx) → Promise<void>。
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { handleGoalCommand } from "../adapters/command-adapter";
|
|
16
|
+
import { createGoalState } from "../engine/goal";
|
|
17
|
+
import type { GoalRuntimeState } from "../engine/types";
|
|
18
|
+
import { createGoalSession } from "../session";
|
|
19
|
+
|
|
20
|
+
// ── Fake pi / ctx ────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface RecordedCall {
|
|
23
|
+
kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser";
|
|
24
|
+
text?: string;
|
|
25
|
+
level?: string;
|
|
26
|
+
content?: string;
|
|
27
|
+
deliverAs?: string;
|
|
28
|
+
customType?: string;
|
|
29
|
+
payload?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FakeHarness {
|
|
33
|
+
pi: ExtensionAPI;
|
|
34
|
+
ctx: ExtensionContext;
|
|
35
|
+
states: unknown[];
|
|
36
|
+
history: unknown[];
|
|
37
|
+
piCalls: RecordedCall[];
|
|
38
|
+
ctxCalls: RecordedCall[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeHarness(): FakeHarness {
|
|
42
|
+
const piCalls: RecordedCall[] = [];
|
|
43
|
+
const ctxCalls: RecordedCall[] = [];
|
|
44
|
+
const states: unknown[] = [];
|
|
45
|
+
const history: unknown[] = [];
|
|
46
|
+
|
|
47
|
+
const pi = {
|
|
48
|
+
appendEntry(customType: string, data?: unknown): void {
|
|
49
|
+
if (customType === "goal-history") history.push(data);
|
|
50
|
+
else states.push(data);
|
|
51
|
+
piCalls.push({
|
|
52
|
+
kind: customType === "goal-history" ? "appendHistory" : "appendState",
|
|
53
|
+
payload: data,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
sendMessage(message: unknown, _options?: unknown): void {
|
|
57
|
+
const msg = message as { customType?: string; content?: string };
|
|
58
|
+
piCalls.push({ kind: "sendContext", content: msg.content, customType: msg.customType });
|
|
59
|
+
},
|
|
60
|
+
sendUserMessage(content: string | unknown[], _options?: unknown): void {
|
|
61
|
+
piCalls.push({ kind: "sendUser", content: typeof content === "string" ? content : undefined });
|
|
62
|
+
},
|
|
63
|
+
} as unknown as ExtensionAPI;
|
|
64
|
+
|
|
65
|
+
const ctx = {
|
|
66
|
+
hasUI: true,
|
|
67
|
+
signal: { aborted: false } as AbortSignal,
|
|
68
|
+
getContextUsage: () => null,
|
|
69
|
+
ui: {
|
|
70
|
+
notify: (text: string, level: string) => ctxCalls.push({ kind: "notify", text, level }),
|
|
71
|
+
setStatus: () => {},
|
|
72
|
+
setWidget: () => {},
|
|
73
|
+
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
74
|
+
},
|
|
75
|
+
sessionManager: { getEntries: () => [], getBranch: () => undefined },
|
|
76
|
+
} as unknown as ExtensionContext;
|
|
77
|
+
|
|
78
|
+
return { pi, ctx, states, history, piCalls, ctxCalls };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── 辅助 ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function makeActiveState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
|
|
84
|
+
return {
|
|
85
|
+
...createGoalState("test objective"),
|
|
86
|
+
status: "active",
|
|
87
|
+
timeStartedAt: 0, // 默认关闭时间累计
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function allCalls(h: FakeHarness): RecordedCall[] {
|
|
93
|
+
return [...h.piCalls, ...h.ctxCalls];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function notifyText(h: FakeHarness): string[] {
|
|
97
|
+
return allCalls(h)
|
|
98
|
+
.filter((c) => c.kind === "notify")
|
|
99
|
+
.map((c) => c.text ?? "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── /goal status ─────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("handleGoalCommand — status", () => {
|
|
105
|
+
it("无 active goal → 提示未激活", async () => {
|
|
106
|
+
const h = makeHarness();
|
|
107
|
+
const session = createGoalSession();
|
|
108
|
+
await handleGoalCommand(h.pi, session, "status", h.ctx);
|
|
109
|
+
expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("有 active goal → 显示 status 面板", async () => {
|
|
113
|
+
const h = makeHarness();
|
|
114
|
+
const session = createGoalSession();
|
|
115
|
+
session.state = makeActiveState();
|
|
116
|
+
await handleGoalCommand(h.pi, session, "status", h.ctx);
|
|
117
|
+
const text = notifyText(h).join("\n");
|
|
118
|
+
expect(text).toContain("test objective");
|
|
119
|
+
expect(text).toContain("Status: active");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── /goal pause(FR-3 用户暂停 active→paused)──
|
|
124
|
+
|
|
125
|
+
describe("handleGoalCommand — pause (FR-3 active→paused)", () => {
|
|
126
|
+
it("active → paused:tick 前置累加 + persist + notify", async () => {
|
|
127
|
+
const h = makeHarness();
|
|
128
|
+
const session = createGoalSession();
|
|
129
|
+
const past = Date.now() - 4000;
|
|
130
|
+
session.state = makeActiveState({ timeStartedAt: past, timeUsedSeconds: 6 });
|
|
131
|
+
await handleGoalCommand(h.pi, session, "pause", h.ctx);
|
|
132
|
+
expect(session.state!.status).toBe("paused");
|
|
133
|
+
// tick 前置:转 paused 前累加当前运行段(6 + ~4s)
|
|
134
|
+
expect(session.state!.timeUsedSeconds).toBeGreaterThanOrEqual(9);
|
|
135
|
+
expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
|
|
136
|
+
expect(notifyText(h).some((t) => t.includes("paused"))).toBe(true);
|
|
137
|
+
expect(notifyText(h).some((t) => t.includes("resume"))).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("非 active(blocked)→ 拒绝 pause", async () => {
|
|
141
|
+
const h = makeHarness();
|
|
142
|
+
const session = createGoalSession();
|
|
143
|
+
session.state = makeActiveState({ status: "blocked" });
|
|
144
|
+
await handleGoalCommand(h.pi, session, "pause", h.ctx);
|
|
145
|
+
expect(session.state!.status).toBe("blocked"); // 未变
|
|
146
|
+
expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("无 active goal → 提示未激活", async () => {
|
|
150
|
+
const h = makeHarness();
|
|
151
|
+
const session = createGoalSession();
|
|
152
|
+
await handleGoalCommand(h.pi, session, "pause", h.ctx);
|
|
153
|
+
expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── /goal resume(FR-3:paused/blocked→active 对称 + G-014 预算重检)──
|
|
158
|
+
|
|
159
|
+
describe("handleGoalCommand — resume (FR-3 paused/blocked→active + G-014)", () => {
|
|
160
|
+
it("blocked → active:resume 成功 + persist + 触发 AI", async () => {
|
|
161
|
+
const h = makeHarness();
|
|
162
|
+
const session = createGoalSession();
|
|
163
|
+
session.state = makeActiveState({ status: "blocked" });
|
|
164
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
165
|
+
expect(session.state!.status).toBe("active");
|
|
166
|
+
expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
|
|
167
|
+
// FR-8.12: resume 后触发 AI
|
|
168
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("paused → active:resume 成功 + persist + 触发 AI(FR-3 对称)", async () => {
|
|
172
|
+
const h = makeHarness();
|
|
173
|
+
const session = createGoalSession();
|
|
174
|
+
session.state = makeActiveState({ status: "paused" });
|
|
175
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
176
|
+
expect(session.state!.status).toBe("active");
|
|
177
|
+
expect(h.states.length).toBeGreaterThanOrEqual(1); // persist 调用
|
|
178
|
+
// FR-8.12: resume 后触发 AI
|
|
179
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("resume 重置 timeStartedAt=now(FR-3.2 重启计时器)", async () => {
|
|
183
|
+
// T2.3 显式断言:resume 时 timeStartedAt 必须重置为当前时刻(command-adapter.ts:144)
|
|
184
|
+
const h = makeHarness();
|
|
185
|
+
const session = createGoalSession();
|
|
186
|
+
const staleTimeStartedAt = Date.now() - 100_000; // 旧值,远早于现在
|
|
187
|
+
session.state = makeActiveState({ status: "paused", timeStartedAt: staleTimeStartedAt });
|
|
188
|
+
const before = Date.now();
|
|
189
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
190
|
+
const after = Date.now();
|
|
191
|
+
expect(session.state!.status).toBe("active");
|
|
192
|
+
// timeStartedAt 已重置为 resume 调用时刻(落在 [before, after] 窗口内)
|
|
193
|
+
expect(session.state!.timeStartedAt).toBeGreaterThanOrEqual(before);
|
|
194
|
+
expect(session.state!.timeStartedAt).toBeLessThanOrEqual(after);
|
|
195
|
+
expect(session.state!.timeStartedAt).not.toBe(staleTimeStartedAt); // 旧值已废弃
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("token 预算耗尽 → resume 转 budget_limited(G-014)", async () => {
|
|
199
|
+
const h = makeHarness();
|
|
200
|
+
const session = createGoalSession();
|
|
201
|
+
session.state = makeActiveState({
|
|
202
|
+
status: "blocked",
|
|
203
|
+
budget: {
|
|
204
|
+
tokenBudget: 1000,
|
|
205
|
+
timeBudgetMinutes: 30,
|
|
206
|
+
},
|
|
207
|
+
tokensUsed: 1200, // 已超 tokenBudget
|
|
208
|
+
});
|
|
209
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
210
|
+
expect(session.state!.status).toBe("budget_limited");
|
|
211
|
+
expect(notifyText(h).some((t) => t.includes("Token budget exhausted"))).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("time 预算耗尽 → resume 转 time_limited(G-014)", async () => {
|
|
215
|
+
const h = makeHarness();
|
|
216
|
+
const session = createGoalSession();
|
|
217
|
+
session.state = makeActiveState({
|
|
218
|
+
status: "blocked",
|
|
219
|
+
timeStartedAt: 0,
|
|
220
|
+
budget: {
|
|
221
|
+
tokenBudget: 1000,
|
|
222
|
+
timeBudgetMinutes: 30,
|
|
223
|
+
},
|
|
224
|
+
tokensUsed: 100, // token 未超
|
|
225
|
+
timeUsedSeconds: 30 * 60, // 已超 timeBudgetMinutes*60
|
|
226
|
+
});
|
|
227
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
228
|
+
expect(session.state!.status).toBe("time_limited");
|
|
229
|
+
expect(notifyText(h).some((t) => t.includes("Time budget exhausted"))).toBe(true);
|
|
230
|
+
// 拒绝 resume:不触发 AI
|
|
231
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("非 paused/blocked 状态(active)→ 无需 resume", async () => {
|
|
235
|
+
const h = makeHarness();
|
|
236
|
+
const session = createGoalSession();
|
|
237
|
+
session.state = makeActiveState({ status: "active" });
|
|
238
|
+
await handleGoalCommand(h.pi, session, "resume", h.ctx);
|
|
239
|
+
expect(notifyText(h).some((t) => t.includes("not paused or blocked"))).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── /goal clear(FR-6.3 强制清)──
|
|
244
|
+
|
|
245
|
+
describe("handleGoalCommand — clear (FR-6.3)", () => {
|
|
246
|
+
it("clear:强制清,写 cancelled history + clearSession", async () => {
|
|
247
|
+
const h = makeHarness();
|
|
248
|
+
const session = createGoalSession();
|
|
249
|
+
session.state = makeActiveState();
|
|
250
|
+
await handleGoalCommand(h.pi, session, "clear", h.ctx);
|
|
251
|
+
expect(session.state).toBeNull(); // clearGoalSession 清空
|
|
252
|
+
expect(h.history.length).toBe(1); // 写 cancelled history
|
|
253
|
+
expect(notifyText(h).some((t) => t.includes("cleared"))).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("clear:MF-3 tick — active goal 转 cancelled 前累加时间", async () => {
|
|
257
|
+
const h = makeHarness();
|
|
258
|
+
const session = createGoalSession();
|
|
259
|
+
const past = Date.now() - 3000;
|
|
260
|
+
session.state = makeActiveState({ timeStartedAt: past, timeUsedSeconds: 5 });
|
|
261
|
+
await handleGoalCommand(h.pi, session, "clear", h.ctx);
|
|
262
|
+
// history 里的 elapsedSeconds 应 ≈ 8(5 + 3)
|
|
263
|
+
const histEntry = h.history[0] as { elapsedSeconds?: number } | undefined;
|
|
264
|
+
expect(histEntry?.elapsedSeconds).toBeGreaterThanOrEqual(7);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── /goal update(FR-8.4 G-002 重塑)──────────────
|
|
269
|
+
|
|
270
|
+
describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
|
|
271
|
+
it("重塑:重置 objective/flags/slug,保留 goalId", async () => {
|
|
272
|
+
const h = makeHarness();
|
|
273
|
+
const session = createGoalSession();
|
|
274
|
+
const originalGoalId = "goal-original-123";
|
|
275
|
+
session.state = makeActiveState({
|
|
276
|
+
goalId: originalGoalId,
|
|
277
|
+
objective: "old objective",
|
|
278
|
+
currentTurnIndex: 8,
|
|
279
|
+
slug: "old-slug",
|
|
280
|
+
});
|
|
281
|
+
await handleGoalCommand(h.pi, session, "update brand new objective", h.ctx);
|
|
282
|
+
|
|
283
|
+
expect(session.state!.objective).toBe("brand new objective");
|
|
284
|
+
expect(session.state!.goalId).toBe(originalGoalId); // 保留
|
|
285
|
+
expect(session.state!.currentTurnIndex).toBe(0);
|
|
286
|
+
expect(session.state!.budgetLimitSteeringSent).toBe(false);
|
|
287
|
+
expect(session.state!.slug).toBeUndefined(); // GAP-6: update 重置 slug
|
|
288
|
+
expect(h.states.length).toBeGreaterThanOrEqual(1); // persist
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("无参数 → usage 提示", async () => {
|
|
292
|
+
const h = makeHarness();
|
|
293
|
+
const session = createGoalSession();
|
|
294
|
+
session.state = makeActiveState();
|
|
295
|
+
await handleGoalCommand(h.pi, session, "update", h.ctx);
|
|
296
|
+
expect(notifyText(h).some((t) => t.includes("Usage"))).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("active 状态重塑 → 注入 objectiveUpdated steering", async () => {
|
|
300
|
+
const h = makeHarness();
|
|
301
|
+
const session = createGoalSession();
|
|
302
|
+
session.state = makeActiveState({ objective: "old" });
|
|
303
|
+
await handleGoalCommand(h.pi, session, "update new obj", h.ctx);
|
|
304
|
+
// FR-8.4: active 时发送 steering
|
|
305
|
+
expect(h.piCalls.some((c) => c.kind === "sendContext")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── /goal set(提示词触发器:sendUserMessage 让 AI 调 goal_control create)──
|
|
310
|
+
|
|
311
|
+
describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终态)", () => {
|
|
312
|
+
it("无旧 goal → 发触发消息(不直接创建 state)", async () => {
|
|
313
|
+
const h = makeHarness();
|
|
314
|
+
const session = createGoalSession();
|
|
315
|
+
await handleGoalCommand(h.pi, session, "my objective", h.ctx);
|
|
316
|
+
// 提示词触发器:不直接 createGoal,state 仍为 null(由 AI 后续 toolcall 创建)
|
|
317
|
+
expect(session.state).toBeNull();
|
|
318
|
+
expect(h.states).toHaveLength(0); // 不写 state
|
|
319
|
+
// 发送 sendUserMessage 引导 AI 调 create
|
|
320
|
+
const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
|
|
321
|
+
expect(sendUserCalls).toHaveLength(1);
|
|
322
|
+
expect(sendUserCalls[0]?.content).toContain("my objective");
|
|
323
|
+
expect(sendUserCalls[0]?.content).toContain("goal_control");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("非终态旧 goal(active)→ 拒绝 + 提示(#11/D25),不发触发消息", async () => {
|
|
327
|
+
const h = makeHarness();
|
|
328
|
+
const session = createGoalSession();
|
|
329
|
+
session.state = makeActiveState({ objective: "old active goal" });
|
|
330
|
+
const historyBefore = h.history.length;
|
|
331
|
+
await handleGoalCommand(h.pi, session, "new objective", h.ctx);
|
|
332
|
+
// #11: 拒绝,不写 history,不覆盖旧 goal,不发触发消息
|
|
333
|
+
expect(h.history.length).toBe(historyBefore); // 不写 history
|
|
334
|
+
expect(notifyText(h).some((t) => t.includes("Goal already active"))).toBe(true);
|
|
335
|
+
expect(notifyText(h).some((t) => t.includes("resume"))).toBe(true);
|
|
336
|
+
expect(notifyText(h).some((t) => t.includes("clear"))).toBe(true);
|
|
337
|
+
expect(session.state!.objective).toBe("old active goal"); // 旧 goal 保留,未覆盖
|
|
338
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false); // 不触发 AI
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("非终态旧 goal(paused)→ 拒绝创建(#11/D25)", async () => {
|
|
342
|
+
const h = makeHarness();
|
|
343
|
+
const session = createGoalSession();
|
|
344
|
+
session.state = makeActiveState({ status: "paused", objective: "old paused goal" });
|
|
345
|
+
await handleGoalCommand(h.pi, session, "new objective", h.ctx);
|
|
346
|
+
expect(notifyText(h).some((t) => t.includes("Goal already active"))).toBe(true);
|
|
347
|
+
expect(session.state!.status).toBe("paused"); // 状态不变
|
|
348
|
+
expect(session.state!.objective).toBe("old paused goal"); // 旧 goal 保留
|
|
349
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(false); // 不触发 AI
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("终态旧 goal → 发触发消息(AI 会覆盖终态 goal)", async () => {
|
|
353
|
+
const h = makeHarness();
|
|
354
|
+
const session = createGoalSession();
|
|
355
|
+
session.state = makeActiveState({ status: "complete", objective: "old done" });
|
|
356
|
+
const historyBefore = h.history.length;
|
|
357
|
+
await handleGoalCommand(h.pi, session, "new objective", h.ctx);
|
|
358
|
+
expect(h.history.length).toBe(historyBefore); // 不写 history(触发器不直接创建)
|
|
359
|
+
// 终态旧 goal 不挡触发器,发消息让 AI 创建
|
|
360
|
+
expect(h.piCalls.some((c) => c.kind === "sendUser")).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("空 objective → usage 提示", async () => {
|
|
364
|
+
const h = makeHarness();
|
|
365
|
+
const session = createGoalSession();
|
|
366
|
+
// parseGoalArgs("") → { action: "status" },空字符串走 status 路径
|
|
367
|
+
await handleGoalCommand(h.pi, session, "", h.ctx);
|
|
368
|
+
// 空字符串在 parseGoalArgs 里被识别为 status,不是 set;status 路径提示 "not active"
|
|
369
|
+
expect(notifyText(h).some((t) => t.includes("not active"))).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("--tokens 0 → parseGoalArgs 过滤(val > 0 校验),发触发消息", async () => {
|
|
373
|
+
const h = makeHarness();
|
|
374
|
+
const session = createGoalSession();
|
|
375
|
+
// parseGoalArgs 对 --tokens 0 的 val>0 校验失败,budget.tokenBudget 不设置
|
|
376
|
+
// handleSet 收到 budgetOverrides=undefined(无 tokenBudget),发触发消息(objective 去掉了 flag)
|
|
377
|
+
await handleGoalCommand(h.pi, session, "obj --tokens 0", h.ctx);
|
|
378
|
+
// 提示词触发器不直接创建 state
|
|
379
|
+
expect(session.state).toBeNull();
|
|
380
|
+
const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
|
|
381
|
+
expect(sendUserCalls).toHaveLength(1);
|
|
382
|
+
expect(sendUserCalls[0]?.content).toContain("obj");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("--tokens N --timeout M → 触发消息含 budget 值", async () => {
|
|
386
|
+
const h = makeHarness();
|
|
387
|
+
const session = createGoalSession();
|
|
388
|
+
await handleGoalCommand(h.pi, session, "obj --tokens 5000 --timeout 30", h.ctx);
|
|
389
|
+
const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
|
|
390
|
+
expect(sendUserCalls).toHaveLength(1);
|
|
391
|
+
expect(sendUserCalls[0]?.content).toContain("5000");
|
|
392
|
+
expect(sendUserCalls[0]?.content).toContain("30");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── /goal history ────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
describe("handleGoalCommand — history", () => {
|
|
399
|
+
it("无 history → 提示", async () => {
|
|
400
|
+
const h = makeHarness();
|
|
401
|
+
await handleGoalCommand(h.pi, createGoalSession(), "history", h.ctx);
|
|
402
|
+
expect(notifyText(h).some((t) => t.includes("No goal history"))).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FR-5/FR-7.3: deserializeState — 新格式严格解析(字段缺失 throw)
|
|
3
|
+
*
|
|
4
|
+
* #1 向后兼容:旧 entry 可能含 `tasks` 字段(task CRUD 删除前的格式),
|
|
5
|
+
* 反序列化时忽略该字段不 throw。
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { deserializeState } from "../persistence";
|
|
10
|
+
|
|
11
|
+
const FULL_DATA = {
|
|
12
|
+
goalId: "g1",
|
|
13
|
+
objective: "test",
|
|
14
|
+
status: "active",
|
|
15
|
+
// tasks 字段保留以模拟旧 entry(#1 后被忽略,不 throw)
|
|
16
|
+
tasks: [{
|
|
17
|
+
id: 1,
|
|
18
|
+
description: "task 1",
|
|
19
|
+
status: "completed",
|
|
20
|
+
lastUpdatedTurn: 5,
|
|
21
|
+
verification: { method: "pnpm test", expected: "all pass", actual: "passed" },
|
|
22
|
+
}],
|
|
23
|
+
tokensUsed: 0,
|
|
24
|
+
timeStartedAt: 1000,
|
|
25
|
+
timeUsedSeconds: 0,
|
|
26
|
+
budget: {},
|
|
27
|
+
lastProgressTurn: 0,
|
|
28
|
+
budgetLimitSteeringSent: false,
|
|
29
|
+
objectiveUpdatedAt: 1000,
|
|
30
|
+
lastBlockerReason: null,
|
|
31
|
+
tokenWarning70Sent: false,
|
|
32
|
+
tokenWarning90Sent: false,
|
|
33
|
+
timeWarning70Sent: false,
|
|
34
|
+
timeWarning90Sent: false,
|
|
35
|
+
lastTurnTokensUsed: 0,
|
|
36
|
+
currentTurnIndex: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("deserializeState — 新格式严格解析", () => {
|
|
40
|
+
it("完整数据(含旧 tasks 字段)→ 正确还原,tasks 被忽略", () => {
|
|
41
|
+
const state = deserializeState(FULL_DATA);
|
|
42
|
+
expect(state.tokenWarning70Sent).toBe(false);
|
|
43
|
+
expect(state.objective).toBe("test");
|
|
44
|
+
// tasks 字段不还原到 state(GoalRuntimeState 已无 tasks 字段)
|
|
45
|
+
expect((state as unknown as { tasks?: unknown }).tasks).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("顶层缺 budget → throw(不再兜底默认值)", () => {
|
|
49
|
+
const data = { goalId: "g1", objective: "test", status: "active" };
|
|
50
|
+
expect(() => deserializeState(data)).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("缺 tokenWarning70Sent → throw(新格式必须包含 4 个独立 flag)", () => {
|
|
54
|
+
const data = { ...FULL_DATA };
|
|
55
|
+
delete (data as Record<string, unknown>).tokenWarning70Sent;
|
|
56
|
+
expect(() => deserializeState(data)).toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("completedAtTurnIndex 可选(缺失 → undefined)", () => {
|
|
60
|
+
const state = deserializeState(FULL_DATA);
|
|
61
|
+
expect(state.completedAtTurnIndex).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("有 completedAtTurnIndex → 正确还原", () => {
|
|
65
|
+
const data = { ...FULL_DATA, completedAtTurnIndex: 42 };
|
|
66
|
+
expect(deserializeState(data).completedAtTurnIndex).toBe(42);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// GAP-4: slug 向后兼容——旧持久化数据无 slug 字段,deserialize 不能 throw
|
|
70
|
+
// (误用 req() 会导致旧数据整个 state 丢失,G-024 部分损坏全丢)
|
|
71
|
+
it("缺 slug → 不 throw,slug 为 undefined(GAP-4 旧数据兼容)", () => {
|
|
72
|
+
const state = deserializeState(FULL_DATA); // FULL_DATA 无 slug
|
|
73
|
+
expect(state.slug).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("有 slug → 正确还原", () => {
|
|
77
|
+
const data = { ...FULL_DATA, slug: "refactor-auth" };
|
|
78
|
+
expect(deserializeState(data).slug).toBe("refactor-auth");
|
|
79
|
+
});
|
|
80
|
+
});
|