@zhushanwen/pi-goal 0.8.3 → 0.9.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 +1 -1
- package/src/__tests__/goal-control-rpc.test.ts +28 -54
- package/src/__tests__/gui.test.ts +94 -81
- package/src/__tests__/ports.test.ts +77 -0
- package/src/adapters/goal-control-adapter.ts +2 -8
- package/src/adapters/ports.ts +14 -0
- package/src/ports.ts +10 -1
- package/src/projection/__tests__/widget.test.ts +86 -2
- package/src/projection/gui.ts +85 -68
- package/src/projection/widget.ts +19 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* goal_control tool — execute handler
|
|
3
|
-
*
|
|
4
|
-
* PR review 补测:registerGoalControlTool execute handler 中
|
|
5
|
-
* `if (ctx.mode === "rpc" && session.state)` 分支和 __gui__ 注入。
|
|
2
|
+
* goal_control tool — execute handler 级测试(M17 后:tool result 无 __gui__)
|
|
6
3
|
*
|
|
7
4
|
* 覆盖场景:
|
|
8
|
-
* - RPC 模式
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* - RPC
|
|
12
|
-
* - RPC 模式 + 无 budget → __gui__ 为 stats-line 类型
|
|
5
|
+
* - RPC 模式 create/complete/report_blocked → details 无 __gui__ 字段(状态展示改由
|
|
6
|
+
* handle* 内 updateWidget 经 guiSetWidget 推送 M17 对话流 widget 面板)
|
|
7
|
+
* - RPC 模式 + session.state = null → 前置 handler throw(分支不可达)
|
|
8
|
+
* - 非 RPC 模式(tui/json/print)→ details 同样无 __gui__,content 文本正常
|
|
13
9
|
*
|
|
14
10
|
* 范式参考 ask-user/src/__tests__/index.test.ts R-1~R-7(handler 级:注册 → 捕获 → 直接调 execute)。
|
|
15
11
|
* 不 mock handleCreate:走真实 createGoal 让 session.state 含完整字段(slug/objective/budget),
|
|
@@ -117,47 +113,36 @@ function createViaHandler(
|
|
|
117
113
|
|
|
118
114
|
// ── 测试场景 ─────────────────────────────────────────
|
|
119
115
|
|
|
120
|
-
describe("goal_control execute — RPC __gui__
|
|
121
|
-
it("RPC + 有
|
|
116
|
+
describe("goal_control execute — RPC 模式 tool result 无 __gui__", () => {
|
|
117
|
+
it("RPC + 有 budget 的 create → details 无 __gui__ 字段(content 仍正常)", async () => {
|
|
122
118
|
const { pi, ctx } = makeFixture("rpc");
|
|
123
119
|
const tool = captureTool(pi);
|
|
124
|
-
// create 后 session.state 有 tokenBudget → buildGoalGui 走 card 分支
|
|
125
120
|
const result = await createViaHandler(tool, pi, ctx, {
|
|
126
121
|
slug: "rpc-card",
|
|
127
122
|
objective: "rpc card goal",
|
|
128
123
|
tokenBudget: 10000,
|
|
129
124
|
});
|
|
130
|
-
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const types = body.map((c) => c.type);
|
|
135
|
-
expect(types).toContain("progress-bar");
|
|
136
|
-
expect(types).toContain("stats-line");
|
|
125
|
+
expect("__gui__" in result.details).toBe(false);
|
|
126
|
+
expect(result.details.action).toBe("create");
|
|
127
|
+
expect(result.details.status).toBe("active");
|
|
128
|
+
expect(result.content[0].text).toContain("Goal created");
|
|
137
129
|
});
|
|
138
130
|
|
|
139
|
-
it("RPC +
|
|
131
|
+
it("RPC + 无 budget 的 create → details 无 __gui__ 字段", async () => {
|
|
140
132
|
const { pi, ctx } = makeFixture("rpc");
|
|
141
133
|
const tool = captureTool(pi);
|
|
142
|
-
// create 不传 budget → buildGoalGui 走 stats-line 分支
|
|
143
134
|
const result = await createViaHandler(tool, pi, ctx, {
|
|
144
135
|
slug: "rpc-stats",
|
|
145
136
|
objective: "rpc stats goal",
|
|
146
137
|
});
|
|
147
|
-
|
|
148
|
-
expect(
|
|
149
|
-
// stats-line items 含 goal/status/turn/tokens
|
|
150
|
-
const items = gui.component.props.items as Array<{ label: string }>;
|
|
151
|
-
const labels = items.map((i) => i.label);
|
|
152
|
-
expect(labels).toContain("goal");
|
|
153
|
-
expect(labels).toContain("status");
|
|
138
|
+
expect("__gui__" in result.details).toBe(false);
|
|
139
|
+
expect(result.details.action).toBe("create");
|
|
154
140
|
});
|
|
155
141
|
|
|
156
|
-
it("RPC + session.state = null →
|
|
157
|
-
// 源码 line 380: `if (ctx.mode === "rpc" && session.state)` 的 session.state 守卫。
|
|
142
|
+
it("RPC + session.state = null → 前置 handler throw(分支不可达)", async () => {
|
|
158
143
|
// execute 路径上,create 总会 set session.state;complete/report_blocked 前置守卫
|
|
159
|
-
// 要求 session.state 非 null 否则 throw——所以 session.state=null
|
|
160
|
-
// 验证:全新 session(state=null)下,complete 前置守卫先 throw
|
|
144
|
+
// 要求 session.state 非 null 否则 throw——所以 session.state=null 时不会走到返回路径。
|
|
145
|
+
// 验证:全新 session(state=null)下,complete 前置守卫先 throw。
|
|
161
146
|
const { pi, ctx } = makeFixture("rpc");
|
|
162
147
|
let captured: CapturedTool | undefined;
|
|
163
148
|
const capturePi = {
|
|
@@ -168,7 +153,7 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
|
|
|
168
153
|
} as unknown as ExtensionAPI;
|
|
169
154
|
registerGoalControlTool(capturePi, createGoalSession());
|
|
170
155
|
const tool2 = captured!;
|
|
171
|
-
// complete 在 state=null 时 throw(handleComplete
|
|
156
|
+
// complete 在 state=null 时 throw(handleComplete 前置守卫)
|
|
172
157
|
await expect(
|
|
173
158
|
tool2.execute(
|
|
174
159
|
"id",
|
|
@@ -180,13 +165,12 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
|
|
|
180
165
|
).rejects.toThrow(/not active/i);
|
|
181
166
|
});
|
|
182
167
|
|
|
183
|
-
it("RPC + complete 动作 → __gui__
|
|
184
|
-
// 验证 __gui__ 注入不限于 create:complete 后 session.state 仍非 null(status=complete)
|
|
168
|
+
it("RPC + complete 动作 → details 无 __gui__ 字段(终态 status 正确)", async () => {
|
|
185
169
|
const { pi, ctx } = makeFixture("rpc");
|
|
186
170
|
const tool = captureTool(pi);
|
|
187
171
|
// 先 create(建 active state)
|
|
188
172
|
await createViaHandler(tool, pi, ctx, { slug: "comp", objective: "to complete" });
|
|
189
|
-
// 再 complete —— session.state.status=complete
|
|
173
|
+
// 再 complete —— session.state.status=complete
|
|
190
174
|
const result = await tool.execute(
|
|
191
175
|
"call-2",
|
|
192
176
|
{ action: "complete", evidence: "tests green" },
|
|
@@ -195,15 +179,10 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
|
|
|
195
179
|
ctx,
|
|
196
180
|
);
|
|
197
181
|
expect(result.details.status).toBe("complete");
|
|
198
|
-
|
|
199
|
-
// 无 budget → stats-line
|
|
200
|
-
expect(gui.component.type).toBe("stats-line");
|
|
201
|
-
const items = gui.component.props.items as Array<{ label: string; value: string }>;
|
|
202
|
-
const statusItem = items.find((i) => i.label === "status");
|
|
203
|
-
expect(statusItem!.value).toBe("complete");
|
|
182
|
+
expect("__gui__" in result.details).toBe(false);
|
|
204
183
|
});
|
|
205
184
|
|
|
206
|
-
it("RPC + report_blocked 动作 → __gui__
|
|
185
|
+
it("RPC + report_blocked 动作 → details 无 __gui__ 字段(blocked status 正确)", async () => {
|
|
207
186
|
const { pi, ctx } = makeFixture("rpc");
|
|
208
187
|
const tool = captureTool(pi);
|
|
209
188
|
await createViaHandler(tool, pi, ctx, { slug: "blk", objective: "to block" });
|
|
@@ -215,21 +194,16 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
|
|
|
215
194
|
ctx,
|
|
216
195
|
);
|
|
217
196
|
expect(result.details.status).toBe("blocked");
|
|
218
|
-
|
|
219
|
-
const gui = result.details.__gui__!;
|
|
220
|
-
expect(gui.component.type).toBe("stats-line");
|
|
221
|
-
const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
|
|
222
|
-
const statusItem = items.find((i) => i.label === "status");
|
|
223
|
-
expect(statusItem!.severity).toBe("danger");
|
|
197
|
+
expect("__gui__" in result.details).toBe(false);
|
|
224
198
|
});
|
|
225
199
|
|
|
226
200
|
});
|
|
227
201
|
|
|
228
|
-
// ── 边界:非 RPC 模式(tui/json/print
|
|
202
|
+
// ── 边界:非 RPC 模式(tui/json/print)同样无 __gui__ ──
|
|
229
203
|
|
|
230
|
-
describe("goal_control execute — 非 RPC
|
|
204
|
+
describe("goal_control execute — 非 RPC 模式无 __gui__", () => {
|
|
231
205
|
const NON_RPC_MODES: Array<"tui" | "json" | "print"> = ["tui", "json", "print"];
|
|
232
|
-
it.each(NON_RPC_MODES)("%s 模式 → __gui__
|
|
206
|
+
it.each(NON_RPC_MODES)("%s 模式 → details 无 __gui__ 字段(content 仍正常)", async (mode) => {
|
|
233
207
|
const { pi, ctx } = makeFixture(mode);
|
|
234
208
|
const tool = captureTool(pi);
|
|
235
209
|
const result = await createViaHandler(tool, pi, ctx, {
|
|
@@ -237,7 +211,7 @@ describe("goal_control execute — 非 RPC 模式不注入 __gui__", () => {
|
|
|
237
211
|
objective: `${mode} goal`,
|
|
238
212
|
tokenBudget: 1000,
|
|
239
213
|
});
|
|
240
|
-
expect(result.details
|
|
241
|
-
expect(result.content[0]
|
|
214
|
+
expect("__gui__" in result.details).toBe(false);
|
|
215
|
+
expect(result.content[0].text).toContain("Goal created");
|
|
242
216
|
});
|
|
243
217
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* buildGoalGui 测试 —
|
|
2
|
+
* buildGoalGui 测试 — v1.1 meta head 架构。
|
|
3
3
|
*
|
|
4
4
|
* 覆盖:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
5
|
+
* - 内容根 = group(透明组合容器):stats-line(status/turn[+tokens])+ list-tree(criteria)
|
|
6
|
+
* - meta:title=slug、状态点语义(done/failed/idle/running)、token 进度(取整 + 百分比 + 阈值 severity)
|
|
7
|
+
* - successCriteria 逐行 list-tree(不压扁/不截断/无 icon/不编号)
|
|
8
|
+
* - token 展示走 formatTokens(无 budget 分支)
|
|
9
9
|
*/
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
11
11
|
|
|
@@ -20,30 +20,58 @@ function makeState(overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** 从
|
|
24
|
-
function
|
|
23
|
+
/** 从 group children 中按 type 取组件(每个类型至多一个)。 */
|
|
24
|
+
function findChildComp(
|
|
25
25
|
gui: ReturnType<typeof buildGoalGui>,
|
|
26
26
|
type: string,
|
|
27
27
|
): { type: string; props: Record<string, unknown> } {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (gui.component.type !== "group") throw new Error(`root 应为 group,实际 ${gui.component.type}`);
|
|
29
|
+
const children = gui.component.props.children as { type: string; props: Record<string, unknown> }[];
|
|
30
|
+
const found = children.find((c) => c.type === type);
|
|
31
|
+
if (!found) throw new Error(`children 中无 ${type} 组件`);
|
|
32
|
+
return found;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
describe("buildGoalGui", () => {
|
|
33
|
-
it("
|
|
35
|
+
describe("buildGoalGui(v1.1 meta head 架构)", () => {
|
|
36
|
+
it("内容根 = group(stats-line + list-tree),无 card(卡壳/head 归宿主壳层)", () => {
|
|
34
37
|
const gui = buildGoalGui(
|
|
35
38
|
makeState({
|
|
36
39
|
tokensUsed: 4200,
|
|
37
40
|
budget: { tokenBudget: 10000 },
|
|
38
41
|
currentTurnIndex: 3,
|
|
42
|
+
successCriteria: "all tests green",
|
|
39
43
|
}),
|
|
40
44
|
);
|
|
41
45
|
expect(gui.v).toBe(1);
|
|
42
|
-
expect(gui.component.type).toBe("
|
|
43
|
-
const
|
|
44
|
-
expect(tokenBar.props).toMatchObject({ current: 4200, total: 10000, severity: "ok" });
|
|
45
|
-
const stats = findBodyComp(gui, "stats-line");
|
|
46
|
+
expect(gui.component.type).toBe("group");
|
|
47
|
+
const stats = findChildComp(gui, "stats-line");
|
|
46
48
|
expect(stats.props.items).toContainEqual(expect.objectContaining({ label: "status", value: "active" }));
|
|
49
|
+
expect(findChildComp(gui, "list-tree")).toBeDefined();
|
|
50
|
+
// 无 progress-bar(token 进度移入 head meta)
|
|
51
|
+
expect(() => findChildComp(gui, "progress-bar")).toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("meta:title=slug、status=running、progress=取整 current + 百分比 label + 阈值 severity", () => {
|
|
55
|
+
const gui = buildGoalGui(
|
|
56
|
+
makeState({
|
|
57
|
+
slug: "fix-auth",
|
|
58
|
+
tokensUsed: 4200,
|
|
59
|
+
budget: { tokenBudget: 10000 },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(gui.meta).toEqual({
|
|
63
|
+
title: "fix-auth",
|
|
64
|
+
status: "running",
|
|
65
|
+
progress: { current: 4200, total: 10000, label: "42%", severity: "ok" },
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("tokensUsed 浮点 → meta.progress.current 取整(1454.84…1 不进 UI)", () => {
|
|
70
|
+
const gui = buildGoalGui(
|
|
71
|
+
makeState({ tokensUsed: 1454.8400000000001, budget: { tokenBudget: 5000 } }),
|
|
72
|
+
);
|
|
73
|
+
expect(gui.meta!.progress!.current).toBe(1455);
|
|
74
|
+
expect(gui.meta!.progress!.label).toBe("29%");
|
|
47
75
|
});
|
|
48
76
|
|
|
49
77
|
it.each([
|
|
@@ -51,111 +79,96 @@ describe("buildGoalGui", () => {
|
|
|
51
79
|
["9000/10000 =90%", 9000, "danger"],
|
|
52
80
|
["7500/10000 ≥70%", 7500, "warn"],
|
|
53
81
|
["7000/10000 =70%", 7000, "warn"],
|
|
54
|
-
])("token 消耗 %s → severity %s(S#14 边界 ≥)", (_label, used, severity) => {
|
|
82
|
+
])("token 消耗 %s → meta.progress.severity %s(S#14 边界 ≥)", (_label, used, severity) => {
|
|
55
83
|
const gui = buildGoalGui(makeState({ tokensUsed: used, budget: { tokenBudget: 10000 } }));
|
|
56
|
-
|
|
57
|
-
expect(tokenBar.props.severity).toBe(severity);
|
|
84
|
+
expect(gui.meta!.progress!.severity).toBe(severity);
|
|
58
85
|
});
|
|
59
86
|
|
|
60
|
-
it(
|
|
87
|
+
it.each([
|
|
88
|
+
["complete", "done"],
|
|
89
|
+
["blocked", "failed"],
|
|
90
|
+
["budget_limited", "failed"],
|
|
91
|
+
["cancelled", "failed"],
|
|
92
|
+
["paused", "idle"],
|
|
93
|
+
])("status %s → meta.status %s", (status, metaStatus) => {
|
|
61
94
|
const gui = buildGoalGui(
|
|
62
|
-
makeState({
|
|
63
|
-
currentTurnIndex: 5,
|
|
64
|
-
tokensUsed: 3000,
|
|
65
|
-
}),
|
|
95
|
+
makeState({ status: status as GoalStatus, budget: { tokenBudget: 10000 } }),
|
|
66
96
|
);
|
|
67
|
-
expect(gui.
|
|
68
|
-
const items = gui.component.props.items as { label?: string }[];
|
|
69
|
-
const labels = items.map((i) => i.label);
|
|
70
|
-
expect(labels).toContain("goal");
|
|
71
|
-
expect(labels).toContain("status");
|
|
72
|
-
expect(labels).toContain("turn");
|
|
73
|
-
expect(labels).toContain("tokens");
|
|
97
|
+
expect(gui.meta!.status).toBe(metaStatus);
|
|
74
98
|
});
|
|
75
99
|
|
|
76
|
-
it("
|
|
100
|
+
it("无 budget → stats-line 含 tokens(formatTokens,3k),meta 无 progress", () => {
|
|
77
101
|
const gui = buildGoalGui(
|
|
78
102
|
makeState({
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
currentTurnIndex: 5,
|
|
104
|
+
tokensUsed: 3000,
|
|
81
105
|
}),
|
|
82
106
|
);
|
|
83
|
-
expect(gui.
|
|
84
|
-
|
|
107
|
+
expect(gui.meta).toEqual({ title: expect.any(String), status: "running" });
|
|
108
|
+
const stats = findChildComp(gui, "stats-line");
|
|
109
|
+
const items = stats.props.items as { label?: string; value?: string }[];
|
|
110
|
+
expect(items.find((i) => i.label === "tokens")).toMatchObject({ value: "3k" });
|
|
85
111
|
});
|
|
86
112
|
|
|
87
|
-
it("
|
|
88
|
-
const gui = buildGoalGui(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
expect(gui.component.type).toBe("card");
|
|
95
|
-
expect(gui.component.props.variant).toBe("success");
|
|
113
|
+
it("有 budget 时 stats-line 不重复 tokens(head 进度已表达)", () => {
|
|
114
|
+
const gui = buildGoalGui(makeState({ tokensUsed: 3000, budget: { tokenBudget: 10000 } }));
|
|
115
|
+
const stats = findChildComp(gui, "stats-line");
|
|
116
|
+
const labels = (stats.props.items as { label?: string }[]).map((i) => i.label);
|
|
117
|
+
expect(labels).not.toContain("tokens");
|
|
96
118
|
});
|
|
97
119
|
|
|
98
|
-
it("slug 缺省 →
|
|
120
|
+
it("slug 缺省 → meta.title 用 goalId 前 8 字符", () => {
|
|
99
121
|
const state = makeState({ slug: undefined, budget: { tokenBudget: 10000 } });
|
|
100
|
-
|
|
101
|
-
expect(gui.component.props.header).toBe(state.goalId.slice(0, 8));
|
|
122
|
+
expect(buildGoalGui(state).meta!.title).toBe(state.goalId.slice(0, 8));
|
|
102
123
|
});
|
|
103
124
|
|
|
104
|
-
// ── S#2: statusSeverity 完整覆盖 ──
|
|
105
|
-
|
|
106
125
|
it.each([
|
|
107
126
|
["budget_limited", "danger"],
|
|
108
127
|
["cancelled", "danger"],
|
|
109
128
|
["paused", "warn"],
|
|
110
129
|
])("status %s → stats-line status severity %s(S#2)", (status, severity) => {
|
|
111
130
|
const gui = buildGoalGui(makeState({ status: status as GoalStatus, budget: { tokenBudget: 10000 } }));
|
|
112
|
-
const stats =
|
|
131
|
+
const stats = findChildComp(gui, "stats-line");
|
|
113
132
|
const items = stats.props.items as Array<{ label: string; severity?: string }>;
|
|
114
133
|
const statusItem = items.find((i) => i.label === "status")!;
|
|
115
134
|
expect(statusItem.severity).toBe(severity);
|
|
116
135
|
});
|
|
117
136
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
it("tokenBudget=0 → 无 progress-bar(口径 >0,I#1)", () => {
|
|
137
|
+
it("tokenBudget=0 → 无预算形态(meta 无 progress,stats-line 含 tokens)(I#1 口径 >0)", () => {
|
|
121
138
|
const gui = buildGoalGui(makeState({ tokensUsed: 0, budget: { tokenBudget: 0 } }));
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// ── 无 budget 分支的 status severity ──
|
|
127
|
-
|
|
128
|
-
it("无 budget 时 status severity 正确(S#14)", () => {
|
|
129
|
-
const gui = buildGoalGui(makeState({ status: "active" }));
|
|
130
|
-
const items = gui.component.props.items as Array<{ label: string; severity?: string }>;
|
|
131
|
-
const statusItem = items.find((i) => i.label === "status");
|
|
132
|
-
expect(statusItem!.severity).toBe("ok");
|
|
139
|
+
expect(gui.meta!.progress).toBeUndefined();
|
|
140
|
+
const stats = findChildComp(gui, "stats-line");
|
|
141
|
+
const labels = (stats.props.items as { label?: string }[]).map((i) => i.label);
|
|
142
|
+
expect(labels).toContain("tokens");
|
|
133
143
|
});
|
|
134
144
|
|
|
135
|
-
// ── successCriteria
|
|
145
|
+
// ── successCriteria 逐行 list-tree ──
|
|
136
146
|
|
|
137
|
-
it("
|
|
147
|
+
it("多行 successCriteria → list-tree 每行一条纯文本 item(用户报障场景)", () => {
|
|
138
148
|
const gui = buildGoalGui(
|
|
139
149
|
makeState({
|
|
140
|
-
successCriteria: "
|
|
150
|
+
successCriteria: "1. 输出 Node.js 版本号;\n2. 输出 extensions 目录下的子目录列表",
|
|
141
151
|
budget: { tokenBudget: 10000 },
|
|
142
152
|
}),
|
|
143
153
|
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)!;
|
|
152
|
-
expect(doneStats.props.items).toContainEqual({ label: "done", value: "all tests green" });
|
|
154
|
+
const tree = findChildComp(gui, "list-tree");
|
|
155
|
+
// 无 icon(所有行同 icon 是无信息量装饰)、不编号(criteria 文本常自带编号)
|
|
156
|
+
expect(tree.props.numbered).toBeUndefined();
|
|
157
|
+
expect(tree.props.items).toEqual([
|
|
158
|
+
{ label: "1. 输出 Node.js 版本号;", depth: 0 },
|
|
159
|
+
{ label: "2. 输出 extensions 目录下的子目录列表", depth: 0 },
|
|
160
|
+
]);
|
|
153
161
|
});
|
|
154
162
|
|
|
155
|
-
it("
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
it("超 80 字符的 criteria 行不截断;空行过滤;无 criteria → 无 list-tree", () => {
|
|
164
|
+
const longLine = "x".repeat(120);
|
|
165
|
+
const longGui = buildGoalGui(makeState({ successCriteria: longLine }));
|
|
166
|
+
expect(findChildComp(longGui, "list-tree").props.items).toEqual([{ label: longLine, depth: 0 }]);
|
|
167
|
+
|
|
168
|
+
const gapGui = buildGoalGui(makeState({ successCriteria: "a\n\n \nb" }));
|
|
169
|
+
expect(findChildComp(gapGui, "list-tree").props.items).toHaveLength(2);
|
|
170
|
+
|
|
171
|
+
const noneGui = buildGoalGui(makeState({ successCriteria: undefined, budget: { tokenBudget: 10000 } }));
|
|
172
|
+
expect(() => findChildComp(noneGui, "list-tree")).toThrow();
|
|
160
173
|
});
|
|
161
174
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/ports.ts 测试 — UiPort GUI 协议接线(marker 编码集成)。
|
|
3
|
+
*
|
|
4
|
+
* 验证 buildPorts 构造的 uiPort:
|
|
5
|
+
* - setGuiWidget 经 guiSetWidget 编码 marker 进 ctx.ui.setWidget(RPC 模式)
|
|
6
|
+
* - isGui 反映 ctx.mode(rpc → GUI 渲染通道;tui → 原生文本)
|
|
7
|
+
*
|
|
8
|
+
* 运行:cd extensions/goal && npx vitest run src/__tests__/ports.test.ts
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { GUI_WIDGET_MARKER, guiComponent } from "@xyz-agent/extension-protocol";
|
|
12
|
+
|
|
13
|
+
import { buildPorts } from "../adapters/ports";
|
|
14
|
+
|
|
15
|
+
/** mock pi(buildPorts 仅用 pi.appendEntry,此处不测 persistence)。 */
|
|
16
|
+
function makePi(): Parameters<typeof buildPorts>[0] {
|
|
17
|
+
return { appendEntry: () => {} } as unknown as Parameters<typeof buildPorts>[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** mock ctx,捕获 ui.setWidget 收到的 lines(验证 marker 编码)。 */
|
|
21
|
+
function makeCtx(opts: { mode?: string; hasUI?: boolean } = {}) {
|
|
22
|
+
let captured: string[] | undefined = undefined;
|
|
23
|
+
const ctx = {
|
|
24
|
+
mode: opts.mode ?? "rpc",
|
|
25
|
+
hasUI: opts.hasUI ?? true,
|
|
26
|
+
ui: {
|
|
27
|
+
setWidget: (_key: string, lines: string[] | undefined) => {
|
|
28
|
+
captured = lines;
|
|
29
|
+
},
|
|
30
|
+
setStatus: () => {},
|
|
31
|
+
notify: () => {},
|
|
32
|
+
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
33
|
+
},
|
|
34
|
+
} as unknown as Parameters<typeof buildPorts>[1];
|
|
35
|
+
return { ctx, getCaptured: () => captured };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
|
|
39
|
+
it("RPC 模式 setGuiWidget → ctx.ui.setWidget 收到 marker 编码的 string[]", () => {
|
|
40
|
+
const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
|
|
41
|
+
const ports = buildPorts(makePi(), ctx);
|
|
42
|
+
const comp = guiComponent("stats-line", { items: [{ value: "x" }] });
|
|
43
|
+
|
|
44
|
+
ports.ui.setGuiWidget("goal", comp);
|
|
45
|
+
|
|
46
|
+
const captured = getCaptured();
|
|
47
|
+
expect(captured).toBeDefined();
|
|
48
|
+
expect(captured).toHaveLength(1);
|
|
49
|
+
// marker 前缀(host 侧 event-adapter 据此解码成 extension:widgetGui)
|
|
50
|
+
expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
|
|
51
|
+
// 解码还原 GuiComponent
|
|
52
|
+
const json = captured![0].slice(GUI_WIDGET_MARKER.length);
|
|
53
|
+
const parsed = JSON.parse(json);
|
|
54
|
+
expect(parsed.type).toBe("stats-line");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("setGuiWidget(undefined) → ctx.ui.setWidget 收到 undefined(清除语义)", () => {
|
|
58
|
+
const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
|
|
59
|
+
const ports = buildPorts(makePi(), ctx);
|
|
60
|
+
|
|
61
|
+
ports.ui.setGuiWidget("goal", undefined);
|
|
62
|
+
|
|
63
|
+
expect(getCaptured()).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildPorts UiPort.isGui(ctx.mode 反映)", () => {
|
|
68
|
+
it("rpc 模式 → isGui=true(GUI 渲染通道有效)", () => {
|
|
69
|
+
const { ctx } = makeCtx({ mode: "rpc" });
|
|
70
|
+
expect(buildPorts(makePi(), ctx).ui.isGui).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("tui 模式 → isGui=false(走原生文本行)", () => {
|
|
74
|
+
const { ctx } = makeCtx({ mode: "tui" });
|
|
75
|
+
expect(buildPorts(makePi(), ctx).ui.isGui).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -33,13 +33,11 @@
|
|
|
33
33
|
|
|
34
34
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
35
35
|
import { Text } from "@earendil-works/pi-tui";
|
|
36
|
-
import { type GuiRenderResult } from "@xyz-agent/extension-protocol";
|
|
37
36
|
import { type Static, Type } from "typebox";
|
|
38
37
|
|
|
39
38
|
import { SHORT_ID_LENGTH } from "../constants";
|
|
40
39
|
import { isActiveStatus, isTerminalStatus, transitionStatus } from "../engine/goal";
|
|
41
40
|
import type { BudgetConfig, GoalStatus } from "../engine/types";
|
|
42
|
-
import { buildGoalGui } from "../projection/gui";
|
|
43
41
|
import { updateWidget } from "../projection/widget";
|
|
44
42
|
import { createGoal, finalizeAndPersist, persistState, type ServicePorts, tickState } from "../service";
|
|
45
43
|
import type { GoalSession } from "../session";
|
|
@@ -111,8 +109,6 @@ export interface GoalControlDetails {
|
|
|
111
109
|
goalId: string;
|
|
112
110
|
status: GoalStatus;
|
|
113
111
|
slug?: string;
|
|
114
|
-
/** RPC 模式下的 GUI 渲染描述符(progress-bar 预算进度)。TUI 模式无此字段。 */
|
|
115
|
-
__gui__?: GuiRenderResult;
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
// ── 业务 handler(契约对齐 §3,可测:fake ports)──────
|
|
@@ -344,10 +340,8 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
|
|
|
344
340
|
? `Goal completed.\nGoal ID: ${details.goalId}`
|
|
345
341
|
: `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
|
|
346
342
|
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
return { content: [{ type: "text", text }], details: { ...details, __gui__: buildGoalGui(session.state) } };
|
|
350
|
-
}
|
|
343
|
+
// 状态展示不再进 tool result(GUI 渲染字段已移除):GUI 由 handle* 内的 updateWidget
|
|
344
|
+
// 经 guiSetWidget 推送(M17 对话流 widget 面板)。
|
|
351
345
|
return { content: [{ type: "text", text }], details };
|
|
352
346
|
},
|
|
353
347
|
|
package/src/adapters/ports.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI, ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { guiSetWidget, isGuiCapable } from "@xyz-agent/extension-protocol";
|
|
14
|
+
import type { GuiContext, GuiRenderResult } from "@xyz-agent/extension-protocol";
|
|
13
15
|
|
|
14
16
|
import { ENTRY_TYPE, HISTORY_ENTRY_TYPE } from "../persistence";
|
|
15
17
|
import type { MessagingPort, PersistencePort, SessionPort, UiPort } from "../ports";
|
|
@@ -45,6 +47,14 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
45
47
|
const lines = typeof content === "string" ? [content] : content;
|
|
46
48
|
ctx.ui.setWidget(name, lines);
|
|
47
49
|
},
|
|
50
|
+
setGuiWidget(name: string, result: GuiRenderResult | undefined): void {
|
|
51
|
+
// GUI 协议 widget:guiSetWidget 在 RPC 模式用 marker 编码 GuiRenderResult JSON
|
|
52
|
+
// (component + meta 宿主元数据)进 string[],复用 ctx.ui.setWidget 通道;
|
|
53
|
+
// host 侧 event-adapter 检测 marker 解码还原。guiSetWidget 无 isGui 守卫
|
|
54
|
+
// (helpers.ts 仅查 ctx.ui?.setWidget 存在性),isGui 判定在 updateWidget
|
|
55
|
+
// 外层(projection/widget.ts)。
|
|
56
|
+
guiSetWidget(ctx as unknown as GuiContext, name, result);
|
|
57
|
+
},
|
|
48
58
|
setStatus(name: string, text: string | undefined): void {
|
|
49
59
|
ctx.ui.setStatus(name, text);
|
|
50
60
|
},
|
|
@@ -54,6 +64,10 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
54
64
|
get hasUI(): boolean {
|
|
55
65
|
return Boolean(ctx.hasUI);
|
|
56
66
|
},
|
|
67
|
+
get isGui(): boolean {
|
|
68
|
+
// RPC 模式 = GUI 渲染通道有效;TUI/json/print 走 pi 原生渲染
|
|
69
|
+
return isGuiCapable(ctx as unknown as GuiContext);
|
|
70
|
+
},
|
|
57
71
|
// ThemeLike 形状:透传 ctx.ui.theme 的 fg/bold。
|
|
58
72
|
// Theme.fg 只接受 ThemeColor 字面量 union(SDK 契约);projection 层保证传入 union 内字面量,
|
|
59
73
|
// string → ThemeColor 是宽到窄单步断言(无需 unknown 中转)。
|
package/src/ports.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { GoalRuntimeState } from "./engine/types";
|
|
10
|
+
import type { GuiRenderResult } from "@xyz-agent/extension-protocol";
|
|
10
11
|
|
|
11
12
|
// ── GoalHistoryEntry(DTO,非 aggregate,D-09)─────────
|
|
12
13
|
|
|
@@ -36,12 +37,20 @@ export interface PersistencePort {
|
|
|
36
37
|
export interface UiPort {
|
|
37
38
|
/** 设置 widget(undefined = 清除)。hasUI=false 时 adapter 跳过(FR-6.6) */
|
|
38
39
|
setWidget(name: string, content: string[] | string | undefined): void;
|
|
40
|
+
/**
|
|
41
|
+
* 设置 GUI 协议 widget(GuiRenderResult:component + meta 宿主元数据,
|
|
42
|
+
* RPC 模式经 guiSetWidget marker 编码)。TUI 模式 adapter 内部 no-op
|
|
43
|
+
* (走 setWidget 文本行)。undefined = 清除。
|
|
44
|
+
*/
|
|
45
|
+
setGuiWidget(name: string, result: GuiRenderResult | undefined): void;
|
|
39
46
|
/** 设置 status bar */
|
|
40
47
|
setStatus(name: string, text: string | undefined): void;
|
|
41
48
|
/** 弹通知 */
|
|
42
49
|
notify(text: string, level: "info" | "warning" | "error"): void;
|
|
43
|
-
/** 是否有 UI(headless
|
|
50
|
+
/** 是否有 UI(headless 为 false) */
|
|
44
51
|
readonly hasUI: boolean;
|
|
52
|
+
/** 是否 GUI 渲染模式(RPC → GuiComponent;TUI → 原生文本行) */
|
|
53
|
+
readonly isGui: boolean;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
// ── MessagingPort ────────────────────────────────────
|
|
@@ -194,17 +194,21 @@ describe("renderWidgetLines", () => {
|
|
|
194
194
|
|
|
195
195
|
describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
196
196
|
interface RecordedCall {
|
|
197
|
-
method: "setWidget" | "setStatus";
|
|
197
|
+
method: "setWidget" | "setGuiWidget" | "setStatus";
|
|
198
198
|
args: unknown[];
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
function makeUiPort(hasUI: boolean): { ui: UiPort; calls: RecordedCall[] } {
|
|
201
|
+
function makeUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
|
|
202
202
|
const calls: RecordedCall[] = [];
|
|
203
203
|
const ui = {
|
|
204
204
|
hasUI,
|
|
205
|
+
isGui,
|
|
205
206
|
setWidget(name: string, content: unknown) {
|
|
206
207
|
calls.push({ method: "setWidget", args: [name, content] });
|
|
207
208
|
},
|
|
209
|
+
setGuiWidget(name: string, component: unknown) {
|
|
210
|
+
calls.push({ method: "setGuiWidget", args: [name, component] });
|
|
211
|
+
},
|
|
208
212
|
setStatus(name: string, text: unknown) {
|
|
209
213
|
calls.push({ method: "setStatus", args: [name, text] });
|
|
210
214
|
},
|
|
@@ -264,3 +268,83 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
|
264
268
|
);
|
|
265
269
|
});
|
|
266
270
|
});
|
|
271
|
+
|
|
272
|
+
// ── updateWidget GUI 协议分支(isGui=true,复用 projection/gui.ts buildGoalGui)──
|
|
273
|
+
|
|
274
|
+
describe("updateWidget GUI 协议分支(isGui=true)", () => {
|
|
275
|
+
interface RecordedCall {
|
|
276
|
+
method: "setWidget" | "setGuiWidget" | "setStatus";
|
|
277
|
+
args: unknown[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function makeGuiUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
|
|
281
|
+
const calls: RecordedCall[] = [];
|
|
282
|
+
const ui = {
|
|
283
|
+
hasUI,
|
|
284
|
+
isGui,
|
|
285
|
+
setWidget(name: string, content: unknown) {
|
|
286
|
+
calls.push({ method: "setWidget", args: [name, content] });
|
|
287
|
+
},
|
|
288
|
+
setGuiWidget(name: string, component: unknown) {
|
|
289
|
+
calls.push({ method: "setGuiWidget", args: [name, component] });
|
|
290
|
+
},
|
|
291
|
+
setStatus(name: string, text: unknown) {
|
|
292
|
+
calls.push({ method: "setStatus", args: [name, text] });
|
|
293
|
+
},
|
|
294
|
+
notify() {},
|
|
295
|
+
fg: (_color: string, text: string) => text,
|
|
296
|
+
bold: (text: string) => text,
|
|
297
|
+
} as UiPort;
|
|
298
|
+
return { ui, calls };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it("active + isGui + 有预算 → setGuiWidget(v1.1 result: group + meta) + 不调 setWidget TUI lines", () => {
|
|
302
|
+
const { ui, calls } = makeGuiUiPort(true, true);
|
|
303
|
+
const session = createGoalSession();
|
|
304
|
+
session.state = makeState({ status: "active", budget: { tokenBudget: 10000 } });
|
|
305
|
+
updateWidget(session, ui);
|
|
306
|
+
const guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
|
|
307
|
+
expect(guiCall).toBeDefined();
|
|
308
|
+
// v1.1:整个 GuiRenderResult(group 组合根 + meta 宿主元数据,head 由壳层渲染)
|
|
309
|
+
const result = guiCall!.args[1] as {
|
|
310
|
+
component: { type: string };
|
|
311
|
+
meta: { title: string; progress?: { total: number } };
|
|
312
|
+
};
|
|
313
|
+
expect(result.component.type).toBe("group");
|
|
314
|
+
expect(result.meta.progress).toMatchObject({ total: 10000 });
|
|
315
|
+
// GUI 模式不走 TUI 文本行
|
|
316
|
+
expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("active + isGui 无预算 → setGuiWidget(group,meta 无 progress)", () => {
|
|
320
|
+
const { ui, calls } = makeGuiUiPort(true, true);
|
|
321
|
+
const session = createGoalSession();
|
|
322
|
+
session.state = makeState({ status: "active" });
|
|
323
|
+
updateWidget(session, ui);
|
|
324
|
+
const guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
|
|
325
|
+
expect(guiCall).toBeDefined();
|
|
326
|
+
const result = guiCall!.args[1] as {
|
|
327
|
+
component: { type: string };
|
|
328
|
+
meta: { title: string; progress?: unknown };
|
|
329
|
+
};
|
|
330
|
+
expect(result.component.type).toBe("group");
|
|
331
|
+
expect(result.meta.progress).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("cancelled + isGui → setGuiWidget(undefined)", () => {
|
|
335
|
+
const { ui, calls } = makeGuiUiPort(true, true);
|
|
336
|
+
const session = createGoalSession();
|
|
337
|
+
session.state = makeState({ status: "cancelled" });
|
|
338
|
+
updateWidget(session, ui);
|
|
339
|
+
expect(calls.some((c) => c.method === "setGuiWidget" && c.args[1] === undefined)).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("isGui=false → 走 TUI setWidget(不调 setGuiWidget)", () => {
|
|
343
|
+
const { ui, calls } = makeGuiUiPort(true, false);
|
|
344
|
+
const session = createGoalSession();
|
|
345
|
+
session.state = makeState({ status: "active" });
|
|
346
|
+
updateWidget(session, ui);
|
|
347
|
+
expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(true);
|
|
348
|
+
expect(calls.some((c) => c.method === "setGuiWidget")).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
package/src/projection/gui.ts
CHANGED
|
@@ -2,22 +2,29 @@
|
|
|
2
2
|
* GUI 渲染描述符构造(projection 层)— buildGoalGui + goalStatusSeverity
|
|
3
3
|
*
|
|
4
4
|
* H4 拆层:从 adapters/goal-control-adapter.ts 抽出。GUI 渲染描述符归 projection 层,
|
|
5
|
-
* goal-control-adapter
|
|
5
|
+
* goal-control-adapter 回归纯 tool 注册(M17 后不再调用本模块)。
|
|
6
6
|
*
|
|
7
7
|
* 与 projection/widget.ts 的分工:
|
|
8
8
|
* - widget.ts:TUI 模式渲染(ANSI 字符串,经 ctx.ui.setWidget)
|
|
9
|
-
* - gui.ts:RPC 模式渲染(结构化 GuiComponent
|
|
9
|
+
* - gui.ts:RPC 模式渲染(结构化 GuiComponent 描述符,经 guiSetWidget 推送给 M17 对话流 widget 面板)
|
|
10
10
|
*
|
|
11
11
|
* 预算阈值经 engine/budget.ts 的 getBudgetSeverity 单源化(H4):
|
|
12
12
|
* buildGoalGui(percent→severity)与 widget.getBudgetColor(percent→color)共用阈值。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type GuiComponent,
|
|
17
|
+
guiComponent,
|
|
18
|
+
type GuiRenderResult,
|
|
19
|
+
guiResult,
|
|
20
|
+
type TreeItem,
|
|
21
|
+
type WidgetMeta,
|
|
22
|
+
} from "@xyz-agent/extension-protocol";
|
|
16
23
|
|
|
17
|
-
import {
|
|
24
|
+
import { PERCENT_FACTOR, SHORT_ID_LENGTH } from "../constants";
|
|
18
25
|
import { getBudgetSeverity } from "../engine/budget";
|
|
19
26
|
import type { GoalRuntimeState, GoalStatus } from "../engine/types";
|
|
20
|
-
import {
|
|
27
|
+
import { formatTokens } from "./widget";
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* 按 GoalStatus 映射 stats-line severity(S#2)。
|
|
@@ -44,83 +51,93 @@ export function goalStatusSeverity(status: GoalStatus): "ok" | "warn" | "danger"
|
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/** GoalStatus → head 状态点语义(WidgetMeta.status)。 */
|
|
55
|
+
function metaStatus(status: GoalStatus): WidgetMeta["status"] {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case "complete":
|
|
58
|
+
return "done";
|
|
59
|
+
case "blocked":
|
|
60
|
+
case "budget_limited":
|
|
61
|
+
case "cancelled":
|
|
62
|
+
return "failed";
|
|
63
|
+
case "paused":
|
|
64
|
+
return "idle";
|
|
65
|
+
case "active":
|
|
66
|
+
return "running";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
71
|
+
* successCriteria 按行拆分为非空 trim 行。
|
|
72
|
+
* 原实现用 toSingleLine 压扁 + 截断后塞进 stats-line 单行 value,多行验收标准
|
|
73
|
+
* 变成一坨长串,是 goal widget 可读性问题的根因——多行数据应改用 list-tree 多行呈现。
|
|
74
|
+
*/
|
|
75
|
+
function splitCriteriaLines(criteria: string | undefined): string[] {
|
|
76
|
+
return criteria
|
|
77
|
+
? criteria
|
|
78
|
+
.split(/\r?\n/)
|
|
79
|
+
.map((line) => line.trim())
|
|
80
|
+
.filter((line) => line.length > 0)
|
|
81
|
+
: [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 构造 goal 的 GUI 渲染描述符(v1.1 meta head 架构)。
|
|
52
86
|
*
|
|
53
|
-
* -
|
|
54
|
-
*
|
|
87
|
+
* - meta(标题=slug / 状态点 / token 进度)由宿主壳层渲染成唯一 head;
|
|
88
|
+
* 有预算时进度计数用百分比 + 预算阈值 severity(70/90 warn/danger 单源)。
|
|
89
|
+
* - 内容根 = group(透明组合容器):stats-line(status/turn,无预算时补 token
|
|
90
|
+
* 绝对值)+ list-tree(criteria 逐行,无 icon——所有行同 icon 是无信息量装饰,
|
|
91
|
+
* 不编号——criteria 文本常自带 "1. 2." 编号)。
|
|
55
92
|
*/
|
|
56
93
|
export function buildGoalGui(state: GoalRuntimeState): GuiRenderResult {
|
|
57
94
|
const slug = state.slug ?? state.goalId.slice(0, SHORT_ID_LENGTH);
|
|
58
|
-
//
|
|
59
|
-
const criteriaSnippet = state.successCriteria
|
|
60
|
-
? toSingleLine(state.successCriteria).slice(0, OBJECTIVE_DISPLAY_LIMIT)
|
|
61
|
-
: undefined;
|
|
62
|
-
// statusSeverity 按 GoalStatus 完整覆盖(S#2):
|
|
63
|
-
// active/complete → ok;blocked → danger;paused → warn;
|
|
64
|
-
// budget_limited/cancelled → danger(预算耗尽/取消是错误终态)
|
|
95
|
+
// statusSeverity 按 GoalStatus 完整覆盖(S#2),stats-line status item 消费
|
|
65
96
|
const statusSeverity = goalStatusSeverity(state.status);
|
|
66
97
|
|
|
67
|
-
// hasBudget
|
|
68
|
-
const
|
|
98
|
+
// hasBudget 与进度判定统一口径:用 > 0 而非 truthy(I#1:tokenBudget=0 不应触发预算展示)
|
|
99
|
+
const tokenBudget = state.budget.tokenBudget;
|
|
100
|
+
const hasBudget = (tokenBudget ?? 0) > 0;
|
|
69
101
|
|
|
70
|
-
|
|
71
|
-
const body: GuiComponent[] = [];
|
|
72
|
-
// token 进度条(>0 判定,与 hasBudget 口径一致)
|
|
73
|
-
const tokenBudget = state.budget.tokenBudget;
|
|
74
|
-
if ((tokenBudget ?? 0) > 0) {
|
|
75
|
-
const tb = tokenBudget!;
|
|
76
|
-
const tokenPct = state.tokensUsed / tb;
|
|
77
|
-
body.push(
|
|
78
|
-
guiComponent("progress-bar", {
|
|
79
|
-
label: "tokens",
|
|
80
|
-
current: state.tokensUsed,
|
|
81
|
-
total: tb,
|
|
82
|
-
unit: "tok",
|
|
83
|
-
// H4:阈值经 getBudgetSeverity 单源化(原内联 percent→severity 三元)
|
|
84
|
-
severity: getBudgetSeverity(tokenPct),
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
// 状态 + turn 统计行
|
|
89
|
-
body.push(
|
|
90
|
-
guiComponent("stats-line", {
|
|
91
|
-
items: [
|
|
92
|
-
{ label: "status", value: state.status, severity: statusSeverity },
|
|
93
|
-
{ label: "turn", value: String(state.currentTurnIndex) },
|
|
94
|
-
],
|
|
95
|
-
}),
|
|
96
|
-
);
|
|
97
|
-
// successCriteria 摘要(与 objective 成对,让用户看到「怎么算完成」)
|
|
98
|
-
if (criteriaSnippet) {
|
|
99
|
-
body.push(
|
|
100
|
-
guiComponent("stats-line", {
|
|
101
|
-
items: [{ label: "done", value: criteriaSnippet }],
|
|
102
|
-
}),
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
return guiResult(
|
|
106
|
-
guiComponent("card", {
|
|
107
|
-
variant: state.status === "blocked" ? "danger" : state.status === "complete" ? "success" : "default",
|
|
108
|
-
header: slug,
|
|
109
|
-
body,
|
|
110
|
-
}),
|
|
111
|
-
);
|
|
112
|
-
}
|
|
102
|
+
const children: GuiComponent[] = [];
|
|
113
103
|
|
|
114
|
-
//
|
|
115
|
-
|
|
104
|
+
// 状态 + turn 统计行;无预算时补 token 绝对值(有预算时 head 进度已表达,不重复)。
|
|
105
|
+
// token 走 formatTokens(12000 → "12k"),避免浮点原样泄漏到 UI
|
|
106
|
+
children.push(
|
|
116
107
|
guiComponent("stats-line", {
|
|
117
108
|
items: [
|
|
118
|
-
{ label: "goal", value: slug },
|
|
119
109
|
{ label: "status", value: state.status, severity: statusSeverity },
|
|
120
110
|
{ label: "turn", value: String(state.currentTurnIndex) },
|
|
121
|
-
{ label: "tokens", value:
|
|
122
|
-
...(criteriaSnippet ? [{ label: "done", value: criteriaSnippet }] : []),
|
|
111
|
+
...(hasBudget ? [] : [{ label: "tokens", value: formatTokens(state.tokensUsed) }]),
|
|
123
112
|
],
|
|
124
113
|
}),
|
|
125
114
|
);
|
|
115
|
+
|
|
116
|
+
// successCriteria 逐行呈现(用户看「怎么算完成」)
|
|
117
|
+
const criteriaLines = splitCriteriaLines(state.successCriteria);
|
|
118
|
+
if (criteriaLines.length > 0) {
|
|
119
|
+
children.push(
|
|
120
|
+
guiComponent("list-tree", {
|
|
121
|
+
items: criteriaLines.map((line): TreeItem => ({ label: line, depth: 0 })),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const meta: WidgetMeta = {
|
|
127
|
+
title: slug,
|
|
128
|
+
status: metaStatus(state.status),
|
|
129
|
+
...(hasBudget
|
|
130
|
+
? {
|
|
131
|
+
progress: {
|
|
132
|
+
// tokensUsed 是累计加权浮点(如 1454.8400…1),取整后再进 UI
|
|
133
|
+
current: Math.round(state.tokensUsed),
|
|
134
|
+
total: tokenBudget!,
|
|
135
|
+
label: `${Math.round((state.tokensUsed / tokenBudget!) * PERCENT_FACTOR)}%`,
|
|
136
|
+
severity: getBudgetSeverity(state.tokensUsed / tokenBudget!),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return guiResult(guiComponent("group", { children }), meta);
|
|
126
143
|
}
|
package/src/projection/widget.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { isTerminalStatus } from "../engine/goal";
|
|
|
28
28
|
import type { GoalRuntimeState } from "../engine/types";
|
|
29
29
|
import type { UiPort } from "../ports";
|
|
30
30
|
import type { GoalSession } from "../session";
|
|
31
|
+
import { buildGoalGui } from "./gui";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
|
|
@@ -220,16 +221,22 @@ export { asTheme };
|
|
|
220
221
|
/**
|
|
221
222
|
* 刷新 widget + status bar。
|
|
222
223
|
*
|
|
223
|
-
* FR-6.6:`uiPort.hasUI === false`(headless
|
|
224
|
-
* 不调 setWidget / setStatus,避免无 UI 环境崩溃或无意义写入。
|
|
224
|
+
* FR-6.6:`uiPort.hasUI === false`(headless)时直接 return。
|
|
225
225
|
*
|
|
226
|
-
*
|
|
226
|
+
* GUI 模式(RPC,uiPort.isGui):推送 buildGoalGui 的 GuiComponent(guiSetWidget marker
|
|
227
|
+
* 编码,host 侧解码 → GuiComponentRenderer 渲染)。
|
|
228
|
+
* TUI 模式:推送 renderWidgetLines 文本行(向后兼容)。
|
|
229
|
+
*
|
|
230
|
+
* 终态折叠为单行 status bar(widget 清除);cancelled/无 state 清除 widget + status。
|
|
227
231
|
*/
|
|
228
232
|
export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
229
233
|
if (!uiPort.hasUI) return;
|
|
230
234
|
|
|
235
|
+
const isGui = uiPort.isGui;
|
|
236
|
+
|
|
231
237
|
if (!session.state || session.state.status === "cancelled") {
|
|
232
|
-
uiPort.
|
|
238
|
+
if (isGui) uiPort.setGuiWidget("goal", undefined);
|
|
239
|
+
else uiPort.setWidget("goal", undefined);
|
|
233
240
|
uiPort.setStatus("goal", undefined);
|
|
234
241
|
return;
|
|
235
242
|
}
|
|
@@ -240,10 +247,16 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
|
240
247
|
if (statusText) {
|
|
241
248
|
uiPort.setStatus("goal", statusText);
|
|
242
249
|
}
|
|
243
|
-
uiPort.
|
|
250
|
+
if (isGui) uiPort.setGuiWidget("goal", undefined);
|
|
251
|
+
else uiPort.setWidget("goal", undefined);
|
|
244
252
|
return;
|
|
245
253
|
}
|
|
246
254
|
|
|
247
255
|
uiPort.setStatus("goal", renderStatusLine(session.state, asTheme(uiPort)));
|
|
248
|
-
|
|
256
|
+
if (isGui) {
|
|
257
|
+
// 复用 projection/gui.ts 的 buildGoalGui(整个 GuiRenderResult:component + meta 宿主元数据)
|
|
258
|
+
uiPort.setGuiWidget("goal", buildGoalGui(session.state));
|
|
259
|
+
} else {
|
|
260
|
+
uiPort.setWidget("goal", renderWidgetLines(session.state, asTheme(uiPort)));
|
|
261
|
+
}
|
|
249
262
|
}
|