@zhushanwen/pi-goal 0.4.1 → 0.5.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.
- package/package.json +4 -1
- package/src/__tests__/goal-control-prompt.test.ts +79 -0
- package/src/__tests__/goal-control-rpc.test.ts +284 -0
- package/src/__tests__/gui.test.ts +195 -0
- package/src/__tests__/service.test.ts +2 -2
- package/src/adapters/goal-control-adapter.ts +158 -8
- package/src/constants.ts +1 -0
- package/src/engine/__tests__/budget.test.ts +8 -6
- package/src/engine/budget.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"@mariozechner/pi-coding-agent": "*",
|
|
28
28
|
"@sinclair/typebox": "*"
|
|
29
29
|
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@xyz-agent/extension-protocol": "^0.2.0"
|
|
32
|
+
},
|
|
30
33
|
"peerDependenciesMeta": {
|
|
31
34
|
"@earendil-works/pi-tui": {
|
|
32
35
|
"optional": true
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 提示词质量回归:goal_control tool description + runtime 错误文案必须是
|
|
2
|
+
// "可纠正的行为约束器",而非干巴巴的错误。
|
|
3
|
+
//
|
|
4
|
+
// agent(LLM)唯一能看到的 tool 元信息就是 description + 报错文案。弱模型
|
|
5
|
+
// (如 step-3.7-flash)会在首次调用时省略条件必填字段(create 漏 slug、
|
|
6
|
+
// complete 漏 evidence——complete 需模型主动生成内容,省略动因最强)。
|
|
7
|
+
// description 补完整 JSON 正例 + 结构反例,runtime throw 补 "Correct:" 纠正
|
|
8
|
+
// 正例,能让弱模型在首轮或报错后无需猜测即可对齐。
|
|
9
|
+
//
|
|
10
|
+
// 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把正例/反例
|
|
11
|
+
// 或纠错文案删掉。读源码而非 import,避免 mock 链(goal-control-adapter.ts
|
|
12
|
+
// 依赖 pi-ai/typebox/pi-tui/ExtensionAPI 等值导入)。
|
|
13
|
+
//
|
|
14
|
+
// 模式参考:subagent-workflow/src/interface/__tests__/subagent-tool-prompt.test.ts。
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const ADAPTER_SRC = readFileSync(
|
|
24
|
+
join(__dirname, "../adapters/goal-control-adapter.ts"),
|
|
25
|
+
"utf-8",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** 提取 description: `...` 模板字符串的原始内容。 */
|
|
29
|
+
function extractDescription(src: string): string {
|
|
30
|
+
const m = src.match(/description:\s*`([\s\S]*?)`/);
|
|
31
|
+
if (!m) throw new Error("description template literal not found");
|
|
32
|
+
return m[1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DESCRIPTION = extractDescription(ADAPTER_SRC);
|
|
36
|
+
|
|
37
|
+
describe("goal_control description — 含完整 JSON 正例(弱模型首轮误用防御)", () => {
|
|
38
|
+
it("含 create 正例子串", () => {
|
|
39
|
+
// create 是弱模型最易漏 slug 的 action;完整 JSON 正例给出可直接复用的形态。
|
|
40
|
+
expect(DESCRIPTION).toContain('{"action":"create"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("含 complete 正例子串", () => {
|
|
44
|
+
// complete 需模型主动生成 evidence,省略动因最强;正例锁定必填字段。
|
|
45
|
+
expect(DESCRIPTION).toContain('{"action":"complete"');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("含 report_blocked 正例子串", () => {
|
|
49
|
+
expect(DESCRIPTION).toContain('{"action":"report_blocked"');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("goal_control description — 含参数结构反例(Don't 段)", () => {
|
|
54
|
+
it("含 Don't 段", () => {
|
|
55
|
+
expect(DESCRIPTION).toMatch(/Don't/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("Don't 段含 evidence 结构反例关键词(complete 漏 evidence)", () => {
|
|
59
|
+
// complete 漏 evidence 是核心失败模式之一;反例文案必须提及 evidence。
|
|
60
|
+
expect(DESCRIPTION).toMatch(/evidence/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("goal_control runtime 错误文案 — 含 'Correct:' 纠正正例(≥4 处)", () => {
|
|
65
|
+
it("源码含 ≥4 处 'Correct:' 纠错文案(4 条 required throw 各一带正例)", () => {
|
|
66
|
+
// objective/slug/evidence/reason 四条必填 throw 各应带完整 JSON 正例,
|
|
67
|
+
// 让弱模型在报错后无需猜测即可纠正。读整份源码统计出现次数。
|
|
68
|
+
const matches = ADAPTER_SRC.match(/Correct:/g) || [];
|
|
69
|
+
expect(matches.length).toBeGreaterThanOrEqual(4);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("objective 报错带 Correct 正例", () => {
|
|
73
|
+
expect(ADAPTER_SRC).toMatch(/'objective' is required[\s\S]*?Correct:/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("evidence 报错带 Correct 正例", () => {
|
|
77
|
+
expect(ADAPTER_SRC).toMatch(/'evidence' is required[\s\S]*?Correct:/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal_control tool — execute handler RPC 分支 handler 级测试(W3 Wave __gui__ 协议)
|
|
3
|
+
*
|
|
4
|
+
* PR review 补测:registerGoalControlTool execute handler 中
|
|
5
|
+
* `if (ctx.mode === "rpc" && session.state)` 分支和 __gui__ 注入。
|
|
6
|
+
*
|
|
7
|
+
* 覆盖场景:
|
|
8
|
+
* - RPC 模式 + session.state 非 null → details.__gui__ 存在(card 或 stats-line)
|
|
9
|
+
* - RPC 模式 + session.state = null → 跳过路径守卫生效(前置 handler throw,验证分支不可达)
|
|
10
|
+
* - TUI 模式 → __gui__ 不附加
|
|
11
|
+
* - RPC 模式 + 有 budget → __gui__ 为 card 类型(progress-bar + stats-line)
|
|
12
|
+
* - RPC 模式 + 无 budget → __gui__ 为 stats-line 类型
|
|
13
|
+
*
|
|
14
|
+
* 范式参考 ask-user/src/__tests__/index.test.ts R-1~R-7(handler 级:注册 → 捕获 → 直接调 execute)。
|
|
15
|
+
* 不 mock handleCreate:走真实 createGoal 让 session.state 含完整字段(slug/objective/budget),
|
|
16
|
+
* 避免 hand-rolled state 与生产路径不一致。pi/ctx 用最小 fake(对齐 index.test.ts makeFactoryFixture)。
|
|
17
|
+
*/
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { registerGoalControlTool, type GoalControlDetails } from "../adapters/goal-control-adapter";
|
|
22
|
+
import { createGoalSession } from "../session";
|
|
23
|
+
|
|
24
|
+
// ── Types ─────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type ExecuteResult = {
|
|
27
|
+
content: Array<{ type: "text"; text: string }>;
|
|
28
|
+
details: GoalControlDetails;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface CapturedTool {
|
|
32
|
+
name: string;
|
|
33
|
+
execute: (
|
|
34
|
+
toolCallId: string,
|
|
35
|
+
params: Record<string, unknown>,
|
|
36
|
+
signal: AbortSignal | undefined,
|
|
37
|
+
onUpdate: unknown,
|
|
38
|
+
ctx: ExtensionContext,
|
|
39
|
+
) => Promise<ExecuteResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Fake pi + ctx(最小化,对齐 index.test.ts 的 makeFactoryFixture)──
|
|
43
|
+
|
|
44
|
+
interface FakeFixture {
|
|
45
|
+
pi: ExtensionAPI;
|
|
46
|
+
ctx: ExtensionContext;
|
|
47
|
+
notifications: Array<{ text: string; level: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 构造最小 fake pi / ctx。ctx.mode 由调用方覆盖(默认 "rpc")。
|
|
52
|
+
*
|
|
53
|
+
* ports.ts 的 buildPorts 读 ctx.ui.theme.fg/bold、ctx.ui.setWidget/setStatus/notify、
|
|
54
|
+
* ctx.sessionManager.getEntries、ctx.getContextUsage、ctx.signal、ctx.hasUI——全部 mock。
|
|
55
|
+
*/
|
|
56
|
+
function makeFixture(mode: "rpc" | "tui" | "json" | "print" = "rpc"): FakeFixture {
|
|
57
|
+
const notifications: Array<{ text: string; level: string }> = [];
|
|
58
|
+
const pi = {
|
|
59
|
+
registerTool: () => {},
|
|
60
|
+
on: () => {},
|
|
61
|
+
registerCommand: () => {},
|
|
62
|
+
registerMessageRenderer: () => {},
|
|
63
|
+
appendEntry: () => {},
|
|
64
|
+
sendMessage: () => {},
|
|
65
|
+
sendUserMessage: () => {},
|
|
66
|
+
} as unknown as ExtensionAPI;
|
|
67
|
+
|
|
68
|
+
const ctx = {
|
|
69
|
+
mode,
|
|
70
|
+
hasUI: mode === "tui" || mode === "rpc",
|
|
71
|
+
signal: undefined as AbortSignal | undefined,
|
|
72
|
+
cwd: "/tmp",
|
|
73
|
+
getContextUsage: () => null,
|
|
74
|
+
ui: {
|
|
75
|
+
notify: (text: string, level: string) => {
|
|
76
|
+
notifications.push({ text, level });
|
|
77
|
+
},
|
|
78
|
+
setStatus: () => {},
|
|
79
|
+
setWidget: () => {},
|
|
80
|
+
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
81
|
+
},
|
|
82
|
+
sessionManager: { getEntries: () => [], getBranch: () => undefined },
|
|
83
|
+
} as unknown as ExtensionContext;
|
|
84
|
+
|
|
85
|
+
return { pi, ctx, notifications };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 注册 tool 并捕获其 execute handler。 */
|
|
89
|
+
function captureTool(pi: ExtensionAPI): CapturedTool {
|
|
90
|
+
let captured: CapturedTool | undefined;
|
|
91
|
+
const capturePi = {
|
|
92
|
+
...pi,
|
|
93
|
+
registerTool(tool: CapturedTool): void {
|
|
94
|
+
captured = tool;
|
|
95
|
+
},
|
|
96
|
+
} as unknown as ExtensionAPI;
|
|
97
|
+
registerGoalControlTool(capturePi, createGoalSession());
|
|
98
|
+
if (!captured) throw new Error("registerGoalControlTool did not register a tool");
|
|
99
|
+
return captured;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 调一次 create 让 session 持有带 budget 的 active state(走真实路径,state 字段完整)。 */
|
|
103
|
+
function createViaHandler(
|
|
104
|
+
tool: CapturedTool,
|
|
105
|
+
pi: ExtensionAPI,
|
|
106
|
+
ctx: ExtensionContext,
|
|
107
|
+
params: { slug: string; objective: string; tokenBudget?: number; timeBudgetMinutes?: number },
|
|
108
|
+
): Promise<ExecuteResult> {
|
|
109
|
+
return tool.execute(
|
|
110
|
+
"call-1",
|
|
111
|
+
{ action: "create", ...params },
|
|
112
|
+
undefined,
|
|
113
|
+
undefined,
|
|
114
|
+
ctx,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 测试场景 ─────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("goal_control execute — RPC __gui__ 注入分支", () => {
|
|
121
|
+
it("RPC + 有 state(有 budget)→ details.__gui__ 存在且为 card 类型", async () => {
|
|
122
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
123
|
+
const tool = captureTool(pi);
|
|
124
|
+
// create 后 session.state 有 tokenBudget → buildGoalGui 走 card 分支
|
|
125
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
126
|
+
slug: "rpc-card",
|
|
127
|
+
objective: "rpc card goal",
|
|
128
|
+
tokenBudget: 10000,
|
|
129
|
+
});
|
|
130
|
+
expect(result.details.__gui__).toBeDefined();
|
|
131
|
+
const gui = result.details.__gui__!;
|
|
132
|
+
expect(gui.component.type).toBe("card");
|
|
133
|
+
// card body 含 progress-bar(tokenBudget)+ stats-line
|
|
134
|
+
const body = gui.component.props.body as Array<{ type: string }>;
|
|
135
|
+
const types = body.map((c) => c.type);
|
|
136
|
+
expect(types).toContain("progress-bar");
|
|
137
|
+
expect(types).toContain("stats-line");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("RPC + 有 state(无 budget)→ details.__gui__ 存在且为 stats-line 类型", async () => {
|
|
141
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
142
|
+
const tool = captureTool(pi);
|
|
143
|
+
// create 不传 budget → buildGoalGui 走 stats-line 分支
|
|
144
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
145
|
+
slug: "rpc-stats",
|
|
146
|
+
objective: "rpc stats goal",
|
|
147
|
+
});
|
|
148
|
+
expect(result.details.__gui__).toBeDefined();
|
|
149
|
+
const gui = result.details.__gui__!;
|
|
150
|
+
expect(gui.component.type).toBe("stats-line");
|
|
151
|
+
// stats-line items 含 goal/status/turn/tokens
|
|
152
|
+
const items = gui.component.props.items as Array<{ label: string }>;
|
|
153
|
+
const labels = items.map((i) => i.label);
|
|
154
|
+
expect(labels).toContain("goal");
|
|
155
|
+
expect(labels).toContain("status");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("RPC + session.state = null → 跳过路径守卫生效(前置 handler throw,分支不可达)", async () => {
|
|
159
|
+
// 源码 line 380: `if (ctx.mode === "rpc" && session.state)` 的 session.state 守卫。
|
|
160
|
+
// execute 路径上,create 总会 set session.state;complete/report_blocked 前置守卫
|
|
161
|
+
// 要求 session.state 非 null 否则 throw——所以 session.state=null 时分支条件恒不成立。
|
|
162
|
+
// 验证:全新 session(state=null)下,complete 前置守卫先 throw,不进 __gui__ 注入逻辑。
|
|
163
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
164
|
+
let captured: CapturedTool | undefined;
|
|
165
|
+
const capturePi = {
|
|
166
|
+
...pi,
|
|
167
|
+
registerTool(tool: CapturedTool): void {
|
|
168
|
+
captured = tool;
|
|
169
|
+
},
|
|
170
|
+
} as unknown as ExtensionAPI;
|
|
171
|
+
registerGoalControlTool(capturePi, createGoalSession());
|
|
172
|
+
const tool2 = captured!;
|
|
173
|
+
// complete 在 state=null 时 throw(handleComplete 前置守卫)—— 不会走到 __gui__ 分支
|
|
174
|
+
await expect(
|
|
175
|
+
tool2.execute(
|
|
176
|
+
"id",
|
|
177
|
+
{ action: "complete", evidence: "x" },
|
|
178
|
+
undefined,
|
|
179
|
+
undefined,
|
|
180
|
+
ctx,
|
|
181
|
+
),
|
|
182
|
+
).rejects.toThrow(/not active/i);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("TUI 模式 → __gui__ 不附加(即使有 state)", async () => {
|
|
186
|
+
const { pi, ctx } = makeFixture("tui");
|
|
187
|
+
const tool = captureTool(pi);
|
|
188
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
189
|
+
slug: "tui-no-gui",
|
|
190
|
+
objective: "tui goal",
|
|
191
|
+
tokenBudget: 5000,
|
|
192
|
+
});
|
|
193
|
+
// TUI 模式不注入 __gui__
|
|
194
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
195
|
+
// 但 content 仍正常
|
|
196
|
+
expect(result.content[0]!.text).toContain("Goal created");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("RPC + complete 动作 → __gui__ 也附加(用终态 state 渲染)", async () => {
|
|
200
|
+
// 验证 __gui__ 注入不限于 create:complete 后 session.state 仍非 null(status=complete)
|
|
201
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
202
|
+
const tool = captureTool(pi);
|
|
203
|
+
// 先 create(建 active state)
|
|
204
|
+
await createViaHandler(tool, pi, ctx, { slug: "comp", objective: "to complete" });
|
|
205
|
+
// 再 complete —— session.state.status=complete,buildGoalGui 走 card variant=success
|
|
206
|
+
const result = await tool.execute(
|
|
207
|
+
"call-2",
|
|
208
|
+
{ action: "complete", evidence: "tests green" },
|
|
209
|
+
undefined,
|
|
210
|
+
undefined,
|
|
211
|
+
ctx,
|
|
212
|
+
);
|
|
213
|
+
expect(result.details.__gui__).toBeDefined();
|
|
214
|
+
expect(result.details.status).toBe("complete");
|
|
215
|
+
const gui = result.details.__gui__!;
|
|
216
|
+
// 无 budget → stats-line
|
|
217
|
+
expect(gui.component.type).toBe("stats-line");
|
|
218
|
+
const items = gui.component.props.items as Array<{ label: string; value: string }>;
|
|
219
|
+
const statusItem = items.find((i) => i.label === "status");
|
|
220
|
+
expect(statusItem!.value).toBe("complete");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("RPC + report_blocked 动作 → __gui__ 附加,status severity danger", async () => {
|
|
224
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
225
|
+
const tool = captureTool(pi);
|
|
226
|
+
await createViaHandler(tool, pi, ctx, { slug: "blk", objective: "to block" });
|
|
227
|
+
const result = await tool.execute(
|
|
228
|
+
"call-3",
|
|
229
|
+
{ action: "report_blocked", reason: "stuck on dependency" },
|
|
230
|
+
undefined,
|
|
231
|
+
undefined,
|
|
232
|
+
ctx,
|
|
233
|
+
);
|
|
234
|
+
expect(result.details.__gui__).toBeDefined();
|
|
235
|
+
expect(result.details.status).toBe("blocked");
|
|
236
|
+
// 无 budget → stats-line;blocked status severity=danger
|
|
237
|
+
const gui = result.details.__gui__!;
|
|
238
|
+
expect(gui.component.type).toBe("stats-line");
|
|
239
|
+
const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
|
|
240
|
+
const statusItem = items.find((i) => i.label === "status");
|
|
241
|
+
expect(statusItem!.severity).toBe("danger");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("RPC + 有 time budget → __gui__ card 含 time progress-bar", async () => {
|
|
245
|
+
const { pi, ctx } = makeFixture("rpc");
|
|
246
|
+
const tool = captureTool(pi);
|
|
247
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
248
|
+
slug: "time-budget",
|
|
249
|
+
objective: "time boxed",
|
|
250
|
+
timeBudgetMinutes: 30,
|
|
251
|
+
});
|
|
252
|
+
const gui = result.details.__gui__!;
|
|
253
|
+
expect(gui.component.type).toBe("card");
|
|
254
|
+
const body = gui.component.props.body as Array<{ type: string; props: { label?: string } }>;
|
|
255
|
+
const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time");
|
|
256
|
+
expect(timeBar).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── 边界:非 RPC 模式(json/print)也不注入 __gui__ ──
|
|
261
|
+
|
|
262
|
+
describe("goal_control execute — 非 RPC 模式不注入 __gui__", () => {
|
|
263
|
+
it("json 模式 → __gui__ 不附加", async () => {
|
|
264
|
+
const { pi, ctx } = makeFixture("json");
|
|
265
|
+
const tool = captureTool(pi);
|
|
266
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
267
|
+
slug: "json-mode",
|
|
268
|
+
objective: "json goal",
|
|
269
|
+
tokenBudget: 1000,
|
|
270
|
+
});
|
|
271
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("print 模式 → __gui__ 不附加", async () => {
|
|
275
|
+
const { pi, ctx } = makeFixture("print");
|
|
276
|
+
const tool = captureTool(pi);
|
|
277
|
+
const result = await createViaHandler(tool, pi, ctx, {
|
|
278
|
+
slug: "print-mode",
|
|
279
|
+
objective: "print goal",
|
|
280
|
+
tokenBudget: 1000,
|
|
281
|
+
});
|
|
282
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildGoalGui 测试 — goal 的 GUI 渲染描述符构造(W3 Wave __gui__ 协议)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 有 tokenBudget/timeBudget → card(progress-bar + stats-line)
|
|
6
|
+
* - 预算消耗阈值(≥70% warn, ≥90% danger)
|
|
7
|
+
* - 状态→severity/variant 映射
|
|
8
|
+
* - 无 budget → stats-line 摘要
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { buildGoalGui } from "../adapters/goal-control-adapter";
|
|
13
|
+
import { createGoalState } from "../engine/goal";
|
|
14
|
+
import type { GoalRuntimeState } from "../engine/types";
|
|
15
|
+
|
|
16
|
+
function makeState(overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState {
|
|
17
|
+
return {
|
|
18
|
+
...createGoalState("test"),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("buildGoalGui", () => {
|
|
24
|
+
it("有 tokenBudget → card 含 progress-bar + stats-line", () => {
|
|
25
|
+
const gui = buildGoalGui(
|
|
26
|
+
makeState({
|
|
27
|
+
tokensUsed: 4200,
|
|
28
|
+
budget: { tokenBudget: 10000 },
|
|
29
|
+
currentTurnIndex: 3,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
expect(gui.v).toBe(1);
|
|
33
|
+
expect(gui.component.type).toBe("card");
|
|
34
|
+
const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
|
|
35
|
+
// body[0] = progress-bar(tokens), body[1] = stats-line
|
|
36
|
+
const tokenBar = body.find((c) => c.type === "progress-bar")!;
|
|
37
|
+
expect(tokenBar.props).toMatchObject({ current: 4200, total: 10000, severity: "ok" });
|
|
38
|
+
const stats = body.find((c) => c.type === "stats-line")!;
|
|
39
|
+
expect(stats.props.items).toContainEqual(expect.objectContaining({ label: "status", value: "active" }));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("token 消耗 ≥90% → severity danger", () => {
|
|
43
|
+
const gui = buildGoalGui(
|
|
44
|
+
makeState({
|
|
45
|
+
tokensUsed: 9500,
|
|
46
|
+
budget: { tokenBudget: 10000 },
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
|
|
50
|
+
const tokenBar = body.find((c) => c.type === "progress-bar")!;
|
|
51
|
+
expect(tokenBar.props.severity).toBe("danger");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("token 消耗 ≥70% → severity warn", () => {
|
|
55
|
+
const gui = buildGoalGui(
|
|
56
|
+
makeState({
|
|
57
|
+
tokensUsed: 7500,
|
|
58
|
+
budget: { tokenBudget: 10000 },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
|
|
62
|
+
const tokenBar = body.find((c) => c.type === "progress-bar")!;
|
|
63
|
+
expect(tokenBar.props.severity).toBe("warn");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("无 budget → stats-line 摘要", () => {
|
|
67
|
+
const gui = buildGoalGui(
|
|
68
|
+
makeState({
|
|
69
|
+
currentTurnIndex: 5,
|
|
70
|
+
tokensUsed: 3000,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
expect(gui.component.type).toBe("stats-line");
|
|
74
|
+
const items = gui.component.props.items as { label?: string }[];
|
|
75
|
+
const labels = items.map((i) => i.label);
|
|
76
|
+
expect(labels).toContain("goal");
|
|
77
|
+
expect(labels).toContain("status");
|
|
78
|
+
expect(labels).toContain("turn");
|
|
79
|
+
expect(labels).toContain("tokens");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("blocked 状态 → card variant danger", () => {
|
|
83
|
+
const gui = buildGoalGui(
|
|
84
|
+
makeState({
|
|
85
|
+
status: "blocked",
|
|
86
|
+
budget: { tokenBudget: 10000 },
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect(gui.component.type).toBe("card");
|
|
90
|
+
expect(gui.component.props.variant).toBe("danger");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("complete 状态 → card variant success", () => {
|
|
94
|
+
const gui = buildGoalGui(
|
|
95
|
+
makeState({
|
|
96
|
+
status: "complete",
|
|
97
|
+
budget: { tokenBudget: 10000 },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(gui.component.type).toBe("card");
|
|
101
|
+
expect(gui.component.props.variant).toBe("success");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("time 进度条:≥90% → danger", () => {
|
|
105
|
+
const gui = buildGoalGui(
|
|
106
|
+
makeState({
|
|
107
|
+
timeUsedSeconds: 5 * 60 + 1, // 5.01 min / 5 min budget
|
|
108
|
+
budget: { timeBudgetMinutes: 5 },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
|
|
112
|
+
const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time")!;
|
|
113
|
+
expect(timeBar.props.severity).toBe("danger");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("slug 缺省 → header 用 goalId 前 8 字符", () => {
|
|
117
|
+
const state = makeState({ slug: undefined, budget: { tokenBudget: 10000 } });
|
|
118
|
+
const gui = buildGoalGui(state);
|
|
119
|
+
expect(gui.component.props.header).toBe(state.goalId.slice(0, 8));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── S#2: statusSeverity 完整覆盖 ──
|
|
123
|
+
|
|
124
|
+
it("budget_limited 状态 → status severity danger(S#2)", () => {
|
|
125
|
+
const gui = buildGoalGui(makeState({ status: "budget_limited", budget: { tokenBudget: 10000 } }));
|
|
126
|
+
const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
|
|
127
|
+
const stats = body.find((c) => c.type === "stats-line")!;
|
|
128
|
+
const statusItem = stats.props.items.find((i) => i.label === "status")!;
|
|
129
|
+
expect(statusItem.severity).toBe("danger");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("time_limited 状态 → status severity danger(S#2)", () => {
|
|
133
|
+
const gui = buildGoalGui(makeState({ status: "time_limited", budget: { tokenBudget: 10000 } }));
|
|
134
|
+
const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
|
|
135
|
+
const stats = body.find((c) => c.type === "stats-line")!;
|
|
136
|
+
const statusItem = stats.props.items.find((i) => i.label === "status")!;
|
|
137
|
+
expect(statusItem.severity).toBe("danger");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("cancelled 状态 → status severity danger(S#2)", () => {
|
|
141
|
+
const gui = buildGoalGui(makeState({ status: "cancelled", budget: { tokenBudget: 10000 } }));
|
|
142
|
+
const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
|
|
143
|
+
const stats = body.find((c) => c.type === "stats-line")!;
|
|
144
|
+
const statusItem = stats.props.items.find((i) => i.label === "status")!;
|
|
145
|
+
expect(statusItem.severity).toBe("danger");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("paused 状态 → status severity warn(S#2)", () => {
|
|
149
|
+
const gui = buildGoalGui(makeState({ status: "paused", budget: { tokenBudget: 10000 } }));
|
|
150
|
+
const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
|
|
151
|
+
const stats = body.find((c) => c.type === "stats-line")!;
|
|
152
|
+
const statusItem = stats.props.items.find((i) => i.label === "status")!;
|
|
153
|
+
expect(statusItem.severity).toBe("warn");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── S#14: 阈值边界精确值 ──
|
|
157
|
+
|
|
158
|
+
it("token 消耗正好 90% → severity danger(边界 ≥,S#14)", () => {
|
|
159
|
+
const gui = buildGoalGui(makeState({ tokensUsed: 9000, budget: { tokenBudget: 10000 } }));
|
|
160
|
+
const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
|
|
161
|
+
const tokenBar = body.find((c) => c.type === "progress-bar" && c.props.label === "tokens")!;
|
|
162
|
+
expect(tokenBar.props.severity).toBe("danger");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("token 消耗正好 70% → severity warn(边界 ≥,S#14)", () => {
|
|
166
|
+
const gui = buildGoalGui(makeState({ tokensUsed: 7000, budget: { tokenBudget: 10000 } }));
|
|
167
|
+
const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
|
|
168
|
+
const tokenBar = body.find((c) => c.type === "progress-bar" && c.props.label === "tokens")!;
|
|
169
|
+
expect(tokenBar.props.severity).toBe("warn");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("time 消耗正好 70% → severity warn(边界 ≥,S#14)", () => {
|
|
173
|
+
const gui = buildGoalGui(makeState({ timeUsedSeconds: 7 * 60, budget: { timeBudgetMinutes: 10 } }));
|
|
174
|
+
const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
|
|
175
|
+
const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time")!;
|
|
176
|
+
expect(timeBar.props.severity).toBe("warn");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── I#1: tokenBudget=0 口径统一 ──
|
|
180
|
+
|
|
181
|
+
it("tokenBudget=0 → 无 progress-bar(口径 >0,I#1)", () => {
|
|
182
|
+
const gui = buildGoalGui(makeState({ tokensUsed: 0, budget: { tokenBudget: 0 } }));
|
|
183
|
+
// tokenBudget=0 → hasBudget=false → 走无 budget 分支的 stats-line
|
|
184
|
+
expect(gui.component.type).toBe("stats-line");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── 无 budget 分支的 status severity ──
|
|
188
|
+
|
|
189
|
+
it("无 budget 时 status severity 正确(S#14)", () => {
|
|
190
|
+
const gui = buildGoalGui(makeState({ status: "active" }));
|
|
191
|
+
const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
|
|
192
|
+
const statusItem = items.find((i) => i.label === "status");
|
|
193
|
+
expect(statusItem!.severity).toBe("ok");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -141,8 +141,8 @@ describe("applyEvent — 简单事件", () => {
|
|
|
141
141
|
usage: { input: 100, output: 50, cacheRead: 20 },
|
|
142
142
|
},
|
|
143
143
|
}, makeFakePorts());
|
|
144
|
-
// accumulateTokens:
|
|
145
|
-
expect(session.state.tokensUsed).toBe(before +
|
|
144
|
+
// accumulateTokens: 100×1 + 20×0.02 + 50×2 = 200.4(加权口径,对齐 workflow 包)
|
|
145
|
+
expect(session.state.tokensUsed).toBe(before + 200.4);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
it("message_end 忽略非 assistant 消息", () => {
|
|
@@ -24,13 +24,15 @@
|
|
|
24
24
|
* 错误处理:用 throw new Error(CLAUDE.md Tool 设计规范),不返回错误成功模式。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { StringEnum } from "@
|
|
27
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
28
28
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
29
29
|
import { Text } from "@mariozechner/pi-tui";
|
|
30
|
+
import { type GuiComponent, guiComponent, type GuiRenderResult,guiResult } from "@xyz-agent/extension-protocol";
|
|
30
31
|
import { type Static, Type } from "typebox";
|
|
31
32
|
|
|
33
|
+
import { BUDGET_RATIO_HIGH, BUDGET_RATIO_LOW, SECONDS_PER_MINUTE, SHORT_ID_LENGTH } from "../constants";
|
|
32
34
|
import { isActiveStatus, isTerminalStatus, transitionStatus } from "../engine/goal";
|
|
33
|
-
import type { BudgetConfig, GoalStatus } from "../engine/types";
|
|
35
|
+
import type { BudgetConfig, GoalRuntimeState, GoalStatus } from "../engine/types";
|
|
34
36
|
import { updateWidget } from "../projection/widget";
|
|
35
37
|
import { createGoal, finalizeAndPersist, persistState, type ServicePorts, tickState } from "../service";
|
|
36
38
|
import type { GoalSession } from "../session";
|
|
@@ -90,6 +92,8 @@ export interface GoalControlDetails {
|
|
|
90
92
|
goalId: string;
|
|
91
93
|
status: GoalStatus;
|
|
92
94
|
slug?: string;
|
|
95
|
+
/** RPC 模式下的 GUI 渲染描述符(progress-bar 预算进度)。TUI 模式无此字段。 */
|
|
96
|
+
__gui__?: GuiRenderResult;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
// ── 业务 handler(契约对齐 §3,可测:fake ports)──────
|
|
@@ -114,11 +118,15 @@ export function handleCreate(
|
|
|
114
118
|
): GoalControlDetails {
|
|
115
119
|
const objective = params.objective?.trim();
|
|
116
120
|
if (!objective) {
|
|
117
|
-
throw new Error(
|
|
121
|
+
throw new Error(
|
|
122
|
+
"'objective' is required for create. Describe the concrete objective to pursue. Correct: {\"action\":\"create\",\"slug\":\"<kebab-case>\",\"objective\":\"<concrete objective>\"}",
|
|
123
|
+
);
|
|
118
124
|
}
|
|
119
125
|
const slug = params.slug?.trim();
|
|
120
126
|
if (!slug) {
|
|
121
|
-
throw new Error(
|
|
127
|
+
throw new Error(
|
|
128
|
+
"'slug' is required for create. Provide a short kebab-case identifier (e.g. 'refactor-auth'). Correct: {\"action\":\"create\",\"slug\":\"refactor-auth\",\"objective\":\"<concrete objective>\"}",
|
|
129
|
+
);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
// D25 严格守卫:非终态旧 goal(active/paused/blocked)→ 拒绝创建(防静默覆盖未完成工作)
|
|
@@ -177,7 +185,9 @@ export function handleComplete(
|
|
|
177
185
|
}
|
|
178
186
|
const evidence = params.evidence?.trim();
|
|
179
187
|
if (!evidence) {
|
|
180
|
-
throw new Error(
|
|
188
|
+
throw new Error(
|
|
189
|
+
"'evidence' is required for complete. Provide concrete completion evidence. Correct: {\"action\":\"complete\",\"evidence\":\"Modified src/auth.ts; pnpm test auth passed (12/12); tsc --noEmit clean.\"}",
|
|
190
|
+
);
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
// FR-3.3: 唯一终态序列入口(内部:tickState → finalizeGoal(transition+history) → persist)
|
|
@@ -206,7 +216,9 @@ export function handleReportBlocked(
|
|
|
206
216
|
}
|
|
207
217
|
const reason = params.reason?.trim();
|
|
208
218
|
if (!reason) {
|
|
209
|
-
throw new Error(
|
|
219
|
+
throw new Error(
|
|
220
|
+
"'reason' is required for report_blocked. Describe the blocking condition. Correct: {\"action\":\"report_blocked\",\"reason\":\"<blocker + what was tried (at least 3 approaches)>\"}",
|
|
221
|
+
);
|
|
210
222
|
}
|
|
211
223
|
|
|
212
224
|
state.lastBlockerReason = reason;
|
|
@@ -221,6 +233,124 @@ export function handleReportBlocked(
|
|
|
221
233
|
return { action: "report_blocked", goalId: state.goalId, status: state.status };
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
// ── GUI 渲染描述符构造 ───────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 按 GoalStatus 映射 stats-line severity(S#2)。
|
|
240
|
+
*
|
|
241
|
+
* active/complete → ok(正常运行/成功完成)
|
|
242
|
+
* paused → warn(暂停可恢复)
|
|
243
|
+
* blocked → danger(阻塞需干预)
|
|
244
|
+
* budget_limited/time_limited/cancelled → danger(预算耗尽/取消,错误终态)
|
|
245
|
+
*
|
|
246
|
+
* 对齐 projection/widget.ts 的 getBudgetColor 语义——终态预算耗尽渲染为 error。
|
|
247
|
+
*/
|
|
248
|
+
/** renderResult 的 result 是否含 details 字段(类型守卫,替代全可选结构断言 as {details?})。
|
|
249
|
+
* 收紧:除检查 "details" in r 外,还验证其值为 object 或 undefined(防 details 是 string/number
|
|
250
|
+
* 时下游读 d.status 得到 undefined 却被类型系统当作 GoalControlDetails)。 */
|
|
251
|
+
function hasGoalDetails(r: unknown): r is { details?: GoalControlDetails } {
|
|
252
|
+
if (typeof r !== "object" || r === null || !("details" in r)) return false;
|
|
253
|
+
const d = (r as Record<string, unknown>).details;
|
|
254
|
+
return d === undefined || typeof d === "object";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function goalStatusSeverity(status: GoalStatus): "ok" | "warn" | "danger" {
|
|
258
|
+
switch (status) {
|
|
259
|
+
case "active":
|
|
260
|
+
case "complete":
|
|
261
|
+
return "ok";
|
|
262
|
+
case "paused":
|
|
263
|
+
return "warn";
|
|
264
|
+
case "blocked":
|
|
265
|
+
case "budget_limited":
|
|
266
|
+
case "time_limited":
|
|
267
|
+
case "cancelled":
|
|
268
|
+
return "danger";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 构造 goal 的 GUI 渲染描述符(RPC 模式下放进 details.__gui__)。
|
|
274
|
+
*
|
|
275
|
+
* 逻辑参考 projection/widget.ts 的 renderWidgetLines 预算计算,但此处只构造
|
|
276
|
+
* 结构化数据(GuiComponent),不做 ANSI 渲染。
|
|
277
|
+
*
|
|
278
|
+
* - 有 tokenBudget 或 timeBudgetMinutes → card(progress-bar + stats-line) 展示预算消耗
|
|
279
|
+
* - 无 budget → stats-line 展示状态摘要
|
|
280
|
+
*/
|
|
281
|
+
export function buildGoalGui(state: GoalRuntimeState): GuiRenderResult {
|
|
282
|
+
const slug = state.slug ?? state.goalId.slice(0, SHORT_ID_LENGTH);
|
|
283
|
+
// statusSeverity 按 GoalStatus 完整覆盖(S#2):
|
|
284
|
+
// active/complete → ok;blocked → danger;paused → warn;
|
|
285
|
+
// budget_limited/time_limited/cancelled → danger(预算耗尽/取消是错误终态)
|
|
286
|
+
const statusSeverity = goalStatusSeverity(state.status);
|
|
287
|
+
|
|
288
|
+
// hasBudget 与进度条判定统一口径:用 > 0 而非 truthy(I#1:tokenBudget=0 不应触发 card 容器)
|
|
289
|
+
const hasBudget = (state.budget.tokenBudget ?? 0) > 0 || (state.budget.timeBudgetMinutes ?? 0) > 0;
|
|
290
|
+
|
|
291
|
+
if (hasBudget) {
|
|
292
|
+
const body: GuiComponent[] = [];
|
|
293
|
+
// token 进度条(>0 判定,与 hasBudget 口径一致)
|
|
294
|
+
const tokenBudget = state.budget.tokenBudget;
|
|
295
|
+
if ((tokenBudget ?? 0) > 0) {
|
|
296
|
+
const tb = tokenBudget!;
|
|
297
|
+
const tokenPct = state.tokensUsed / tb;
|
|
298
|
+
body.push(
|
|
299
|
+
guiComponent("progress-bar", {
|
|
300
|
+
label: "tokens",
|
|
301
|
+
current: state.tokensUsed,
|
|
302
|
+
total: tb,
|
|
303
|
+
unit: "tok",
|
|
304
|
+
severity: tokenPct >= BUDGET_RATIO_HIGH ? "danger" : tokenPct >= BUDGET_RATIO_LOW ? "warn" : "ok",
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
// time 进度条(>0 判定,与 hasBudget 口径一致)
|
|
309
|
+
const timeBudgetMinutes = state.budget.timeBudgetMinutes;
|
|
310
|
+
if ((timeBudgetMinutes ?? 0) > 0) {
|
|
311
|
+
const timeBudgetSec = timeBudgetMinutes! * SECONDS_PER_MINUTE;
|
|
312
|
+
const timePct = state.timeUsedSeconds / timeBudgetSec;
|
|
313
|
+
body.push(
|
|
314
|
+
guiComponent("progress-bar", {
|
|
315
|
+
label: "time",
|
|
316
|
+
current: state.timeUsedSeconds,
|
|
317
|
+
total: timeBudgetSec,
|
|
318
|
+
unit: "s",
|
|
319
|
+
severity: timePct >= BUDGET_RATIO_HIGH ? "danger" : timePct >= BUDGET_RATIO_LOW ? "warn" : "ok",
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// 状态 + turn 统计行
|
|
324
|
+
body.push(
|
|
325
|
+
guiComponent("stats-line", {
|
|
326
|
+
items: [
|
|
327
|
+
{ label: "status", value: state.status, severity: statusSeverity },
|
|
328
|
+
{ label: "turn", value: String(state.currentTurnIndex) },
|
|
329
|
+
],
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
return guiResult(
|
|
333
|
+
guiComponent("card", {
|
|
334
|
+
variant: state.status === "blocked" ? "danger" : state.status === "complete" ? "success" : "default",
|
|
335
|
+
header: slug,
|
|
336
|
+
body,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 无 budget:stats-line 摘要
|
|
342
|
+
return guiResult(
|
|
343
|
+
guiComponent("stats-line", {
|
|
344
|
+
items: [
|
|
345
|
+
{ label: "goal", value: slug },
|
|
346
|
+
{ label: "status", value: state.status, severity: statusSeverity },
|
|
347
|
+
{ label: "turn", value: String(state.currentTurnIndex) },
|
|
348
|
+
{ label: "tokens", value: String(state.tokensUsed) },
|
|
349
|
+
],
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
224
354
|
// ── Tool 注册 ────────────────────────────────────────
|
|
225
355
|
|
|
226
356
|
export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession): void {
|
|
@@ -228,7 +358,22 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
|
|
|
228
358
|
name: "goal_control",
|
|
229
359
|
label: "Goal Control",
|
|
230
360
|
description:
|
|
231
|
-
|
|
361
|
+
`Manage the goal for this thread.
|
|
362
|
+
|
|
363
|
+
Actions:
|
|
364
|
+
- create: start a new goal. Requires 'slug' (a short kebab-case identifier you generate) and 'objective' (the full description). Only use when the user explicitly asks to start a goal; do not infer goals from ordinary tasks. Fails if a goal is already active/paused/blocked (use /goal resume or /goal clear first).
|
|
365
|
+
- complete: mark the active goal complete. Requires 'evidence' with concrete proof (files/tests/commands). Recommend finishing all todos (including verification todos) first, but you decide.
|
|
366
|
+
- report_blocked: mark the active goal blocked by a real blocker. Requires 'reason' describing the block and what was tried. Only after genuine exhaustion of alternatives.
|
|
367
|
+
|
|
368
|
+
Examples:
|
|
369
|
+
{"action":"create","slug":"refactor-auth","objective":"Refactor the auth module to use JWT and add integration tests"}
|
|
370
|
+
{"action":"complete","evidence":"Modified src/auth.ts; pnpm test auth passed (12/12); tsc --noEmit clean."}
|
|
371
|
+
{"action":"report_blocked","reason":"Blocked: DB migration API changed mid-task (tried: regenerate client, pin old version, rewrite queries)."}
|
|
372
|
+
|
|
373
|
+
Don't:
|
|
374
|
+
- create without 'slug': {"action":"create","objective":"..."} — slug is required, generate a kebab-case id.
|
|
375
|
+
- complete without 'evidence': {"action":"complete"} — must provide concrete completion proof (files/tests/commands).
|
|
376
|
+
- complete when no goal is active — create or resume one first (fails with 'Goal mode not active').`,
|
|
232
377
|
promptSnippet:
|
|
233
378
|
"Use goal_control to manage the thread goal: create (with slug + objective, only when user asks) or complete (with evidence) or report_blocked (with reason, after trying alternatives).",
|
|
234
379
|
executionMode: "sequential",
|
|
@@ -262,6 +407,11 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
|
|
|
262
407
|
: details.action === "complete"
|
|
263
408
|
? `Goal completed.\nGoal ID: ${details.goalId}`
|
|
264
409
|
: `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
|
|
410
|
+
|
|
411
|
+
// RPC 模式下附加 __gui__(用展开避免 details 来自 frozen 对象时加字段失败)
|
|
412
|
+
if (ctx.mode === "rpc" && session.state) {
|
|
413
|
+
return { content: [{ type: "text", text }], details: { ...details, __gui__: buildGoalGui(session.state) } };
|
|
414
|
+
}
|
|
265
415
|
return { content: [{ type: "text", text }], details };
|
|
266
416
|
},
|
|
267
417
|
|
|
@@ -278,7 +428,7 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
|
|
|
278
428
|
},
|
|
279
429
|
|
|
280
430
|
renderResult(result: unknown, _options: { expanded: boolean }, theme: Theme): Text {
|
|
281
|
-
const d = (result
|
|
431
|
+
const d = hasGoalDetails(result) ? result.details : undefined;
|
|
282
432
|
if (!d) return new Text(theme.fg("dim", "goal_control"), 0, 0);
|
|
283
433
|
const statusColor =
|
|
284
434
|
d.status === "active"
|
package/src/constants.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const BUDGET_PERCENT_LOW = 70; // widget 颜色变黄
|
|
|
24
24
|
// ── 长度/数量上限 ───────────────────────────────────
|
|
25
25
|
|
|
26
26
|
export const UPDATE_PREFIX_LENGTH = 7; // "update ".length
|
|
27
|
+
export const SHORT_ID_LENGTH = 8; // goalId 截取前 8 字符作短标识(GUI 显示 fallback)
|
|
27
28
|
|
|
28
29
|
// ── 百分比换算因子 ──────────────────────────────────
|
|
29
30
|
|
|
@@ -38,11 +38,13 @@ const makeState = (overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState
|
|
|
38
38
|
// ── accumulateTokens(FR-8.6)─────────────────────────
|
|
39
39
|
|
|
40
40
|
describe("accumulateTokens", () => {
|
|
41
|
-
it("input/output 有 →
|
|
42
|
-
|
|
41
|
+
it("input/output 有 → 加权 input×1 + cacheRead×0.02 + output×2", () => {
|
|
42
|
+
// 100×1 + 20×0.02 + 50×2 = 200.4;1000 + 200.4 = 1200.4
|
|
43
|
+
expect(accumulateTokens(1000, { input: 100, output: 50, cacheRead: 20 })).toBe(1200.4);
|
|
43
44
|
});
|
|
44
|
-
it("cacheRead > input →
|
|
45
|
-
|
|
45
|
+
it("cacheRead > input → 加权后 input 仍计全量(不折底为 0)", () => {
|
|
46
|
+
// 50×1 + 100×0.02 + 30×2 = 112;1000 + 112 = 1112
|
|
47
|
+
expect(accumulateTokens(1000, { input: 50, output: 30, cacheRead: 100 })).toBe(1112);
|
|
46
48
|
});
|
|
47
49
|
it("input=0 output=0 → fallback totalTokens", () => {
|
|
48
50
|
expect(accumulateTokens(1000, { totalTokens: 200 })).toBe(1200);
|
|
@@ -50,8 +52,8 @@ describe("accumulateTokens", () => {
|
|
|
50
52
|
it("全空 → 不累加", () => {
|
|
51
53
|
expect(accumulateTokens(1000, {})).toBe(1000);
|
|
52
54
|
});
|
|
53
|
-
it("无 cacheRead → 视为 0", () => {
|
|
54
|
-
expect(accumulateTokens(0, { input: 100, output: 50 })).toBe(
|
|
55
|
+
it("无 cacheRead → 视为 0(加权 100×1 + 0×0.02 + 50×2 = 200)", () => {
|
|
56
|
+
expect(accumulateTokens(0, { input: 100, output: 50 })).toBe(200);
|
|
55
57
|
});
|
|
56
58
|
});
|
|
57
59
|
|
package/src/engine/budget.ts
CHANGED
|
@@ -18,6 +18,11 @@ const PERCENT_FACTOR = 100;
|
|
|
18
18
|
const SECONDS_PER_MINUTE = 60;
|
|
19
19
|
const MS_PER_SECOND = 1000;
|
|
20
20
|
|
|
21
|
+
// 加权系数(与 @zhushanwen/pi-subagent-workflow Budget.consume 对齐,ADR-030 token 口径统一)
|
|
22
|
+
const INPUT_WEIGHT = 1;
|
|
23
|
+
const CACHE_READ_WEIGHT = 0.02;
|
|
24
|
+
const OUTPUT_WEIGHT = 2;
|
|
25
|
+
|
|
21
26
|
// ── 类型 ────────────────────────────────────────────
|
|
22
27
|
|
|
23
28
|
export interface TokenUsage {
|
|
@@ -50,7 +55,10 @@ export function accumulateTokens(currentTokensUsed: number, usage: TokenUsage):
|
|
|
50
55
|
const output = usage.output ?? 0;
|
|
51
56
|
const cacheRead = usage.cacheRead ?? 0;
|
|
52
57
|
if (input > 0 || output > 0) {
|
|
53
|
-
|
|
58
|
+
// 加权口径:input×1 + cacheRead×0.02 + output×2
|
|
59
|
+
// 与 @zhushanwen/pi-subagent-workflow Budget.consume() 对齐(ADR-030 token 口径统一),
|
|
60
|
+
// 修复旧公式 max(input-cacheRead,0)+output 在长 session 中 cacheRead>0 时低估预算消耗
|
|
61
|
+
return currentTokensUsed + input * INPUT_WEIGHT + cacheRead * CACHE_READ_WEIGHT + output * OUTPUT_WEIGHT;
|
|
54
62
|
}
|
|
55
63
|
return currentTokensUsed + (usage.totalTokens ?? 0);
|
|
56
64
|
}
|