@zhushanwen/pi-goal 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -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,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: max(100 - 20, 0) + 50 = 130
145
- expect(session.state.tokensUsed).toBe(before + 130);
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 "@mariozechner/pi-ai";
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)──────
@@ -221,6 +225,115 @@ export function handleReportBlocked(
221
225
  return { action: "report_blocked", goalId: state.goalId, status: state.status };
222
226
  }
223
227
 
228
+ // ── GUI 渲染描述符构造 ───────────────────────────────
229
+
230
+ /**
231
+ * 按 GoalStatus 映射 stats-line severity(S#2)。
232
+ *
233
+ * active/complete → ok(正常运行/成功完成)
234
+ * paused → warn(暂停可恢复)
235
+ * blocked → danger(阻塞需干预)
236
+ * budget_limited/time_limited/cancelled → danger(预算耗尽/取消,错误终态)
237
+ *
238
+ * 对齐 projection/widget.ts 的 getBudgetColor 语义——终态预算耗尽渲染为 error。
239
+ */
240
+ function goalStatusSeverity(status: GoalStatus): "ok" | "warn" | "danger" {
241
+ switch (status) {
242
+ case "active":
243
+ case "complete":
244
+ return "ok";
245
+ case "paused":
246
+ return "warn";
247
+ case "blocked":
248
+ case "budget_limited":
249
+ case "time_limited":
250
+ case "cancelled":
251
+ return "danger";
252
+ }
253
+ }
254
+
255
+ /**
256
+ * 构造 goal 的 GUI 渲染描述符(RPC 模式下放进 details.__gui__)。
257
+ *
258
+ * 逻辑参考 projection/widget.ts 的 renderWidgetLines 预算计算,但此处只构造
259
+ * 结构化数据(GuiComponent),不做 ANSI 渲染。
260
+ *
261
+ * - 有 tokenBudget 或 timeBudgetMinutes → card(progress-bar + stats-line) 展示预算消耗
262
+ * - 无 budget → stats-line 展示状态摘要
263
+ */
264
+ export function buildGoalGui(state: GoalRuntimeState): GuiRenderResult {
265
+ const slug = state.slug ?? state.goalId.slice(0, SHORT_ID_LENGTH);
266
+ // statusSeverity 按 GoalStatus 完整覆盖(S#2):
267
+ // active/complete → ok;blocked → danger;paused → warn;
268
+ // budget_limited/time_limited/cancelled → danger(预算耗尽/取消是错误终态)
269
+ const statusSeverity = goalStatusSeverity(state.status);
270
+
271
+ // hasBudget 与进度条判定统一口径:用 > 0 而非 truthy(I#1:tokenBudget=0 不应触发 card 容器)
272
+ const hasBudget = (state.budget.tokenBudget ?? 0) > 0 || (state.budget.timeBudgetMinutes ?? 0) > 0;
273
+
274
+ if (hasBudget) {
275
+ const body: GuiComponent[] = [];
276
+ // token 进度条(>0 判定,与 hasBudget 口径一致)
277
+ const tokenBudget = state.budget.tokenBudget;
278
+ if ((tokenBudget ?? 0) > 0) {
279
+ const tb = tokenBudget!;
280
+ const tokenPct = state.tokensUsed / tb;
281
+ body.push(
282
+ guiComponent("progress-bar", {
283
+ label: "tokens",
284
+ current: state.tokensUsed,
285
+ total: tb,
286
+ unit: "tok",
287
+ severity: tokenPct >= BUDGET_RATIO_HIGH ? "danger" : tokenPct >= BUDGET_RATIO_LOW ? "warn" : "ok",
288
+ }),
289
+ );
290
+ }
291
+ // time 进度条(>0 判定,与 hasBudget 口径一致)
292
+ const timeBudgetMinutes = state.budget.timeBudgetMinutes;
293
+ if ((timeBudgetMinutes ?? 0) > 0) {
294
+ const timeBudgetSec = timeBudgetMinutes! * SECONDS_PER_MINUTE;
295
+ const timePct = state.timeUsedSeconds / timeBudgetSec;
296
+ body.push(
297
+ guiComponent("progress-bar", {
298
+ label: "time",
299
+ current: state.timeUsedSeconds,
300
+ total: timeBudgetSec,
301
+ unit: "s",
302
+ severity: timePct >= BUDGET_RATIO_HIGH ? "danger" : timePct >= BUDGET_RATIO_LOW ? "warn" : "ok",
303
+ }),
304
+ );
305
+ }
306
+ // 状态 + turn 统计行
307
+ body.push(
308
+ guiComponent("stats-line", {
309
+ items: [
310
+ { label: "status", value: state.status, severity: statusSeverity },
311
+ { label: "turn", value: String(state.currentTurnIndex) },
312
+ ],
313
+ }),
314
+ );
315
+ return guiResult(
316
+ guiComponent("card", {
317
+ variant: state.status === "blocked" ? "danger" : state.status === "complete" ? "success" : "default",
318
+ header: slug,
319
+ body,
320
+ }),
321
+ );
322
+ }
323
+
324
+ // 无 budget:stats-line 摘要
325
+ return guiResult(
326
+ guiComponent("stats-line", {
327
+ items: [
328
+ { label: "goal", value: slug },
329
+ { label: "status", value: state.status, severity: statusSeverity },
330
+ { label: "turn", value: String(state.currentTurnIndex) },
331
+ { label: "tokens", value: String(state.tokensUsed) },
332
+ ],
333
+ }),
334
+ );
335
+ }
336
+
224
337
  // ── Tool 注册 ────────────────────────────────────────
225
338
 
226
339
  export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession): void {
@@ -262,6 +375,11 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
262
375
  : details.action === "complete"
263
376
  ? `Goal completed.\nGoal ID: ${details.goalId}`
264
377
  : `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
378
+
379
+ // RPC 模式下附加 __gui__(用展开避免 details 来自 frozen 对象时加字段失败)
380
+ if (ctx.mode === "rpc" && session.state) {
381
+ return { content: [{ type: "text", text }], details: { ...details, __gui__: buildGoalGui(session.state) } };
382
+ }
265
383
  return { content: [{ type: "text", text }], details };
266
384
  },
267
385
 
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 有 → max(input-cacheRead,0)+output", () => {
42
- expect(accumulateTokens(1000, { input: 100, output: 50, cacheRead: 20 })).toBe(1130);
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 → max=0", () => {
45
- expect(accumulateTokens(1000, { input: 50, output: 30, cacheRead: 100 })).toBe(1030);
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(150);
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
 
@@ -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
- return currentTokensUsed + Math.max(input - cacheRead, 0) + output;
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
  }