@zhushanwen/pi-goal 0.14.4 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ goal/
46
46
  ├── index.ts # 入口 — re-export src/index.ts
47
47
  └── src/
48
48
  ├── index.ts # 工厂入口(注册 command/tool/events + goalInit slot,全部委托 adapters)
49
- ├── commands.ts # /goal 命令参数解析(仅识别 --tokens flag)
49
+ ├── commands.ts # /goal 命令参数解析(识别 --tokens / --criteria flag)
50
50
  ├── constants.ts # 语义常量
51
51
  ├── ports.ts # Pi 能力抽象(Persistence/Ui/Messaging/Session)
52
52
  ├── service.ts # 协调层 — createGoal / finalizeAndPersist / applyEvent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token budget, agent-reported blocking, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,10 +29,11 @@
29
29
  "@earendil-works/pi-coding-agent": "^0.84.4",
30
30
  "@earendil-works/pi-tui": "^0.84.4",
31
31
  "typebox": "*",
32
- "@zhushanwen/pi-pending-notifications": "0.7.4"
32
+ "@zhushanwen/pi-pending-notifications": "0.7.6"
33
33
  },
34
34
  "dependencies": {
35
- "@zhushanwen/extension-protocol": "0.11.0"
35
+ "@zhushanwen/extension-protocol": "0.13.0",
36
+ "@zhushanwen/pi-extension-logger": "0.6.1"
36
37
  },
37
38
  "peerDependenciesMeta": {
38
39
  "@earendil-works/pi-tui": {
@@ -28,11 +28,13 @@ import { createGoalSession, type GoalSession } from "../session";
28
28
  // ── Fake pi / ctx(entries 数组可变,模拟 append-only 会话流)──
29
29
 
30
30
  interface RecordedCall {
31
- kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser";
31
+ kind: "appendState" | "appendHistory" | "notify" | "sendContext";
32
32
  payload?: unknown;
33
33
  text?: string;
34
34
  level?: string;
35
35
  content?: unknown;
36
+ customType?: unknown;
37
+ display?: unknown;
36
38
  }
37
39
 
38
40
  interface FakeHarness {
@@ -69,11 +71,8 @@ function makeHarness(): FakeHarness {
69
71
  piCalls.push({ kind: customType === "goal-history" ? "appendHistory" : "appendState", payload: data });
70
72
  },
71
73
  sendMessage(message: unknown, options?: unknown): void {
72
- const msg = message as { content?: unknown };
73
- piCalls.push({ kind: "sendContext", content: msg.content, payload: options });
74
- },
75
- sendUserMessage(content: string | unknown[], _options?: unknown): void {
76
- piCalls.push({ kind: "sendUser", content });
74
+ const msg = message as { content?: unknown; customType?: unknown; display?: unknown };
75
+ piCalls.push({ kind: "sendContext", content: msg.content, customType: msg.customType, display: msg.display, payload: options });
77
76
  },
78
77
  } as unknown as ExtensionAPI;
79
78
 
@@ -299,12 +298,17 @@ describe("/goal resume 恢复通道", () => {
299
298
  expect(session.state!.continuationsSent).toBe(0); // 新激活周期
300
299
  expect(session.state!.continuationCapNotified).toBe(false);
301
300
  expect(session.state!.status).toBe("active");
302
- const userSends = h.piCalls.filter((c) => c.kind === "sendUser");
303
- expect(userSends).toHaveLength(1); // FR-8.12 触发 AI
304
-
305
- // 恢复后:下一次 agent_end 正常发 continuation(新周期额度)
301
+ // FR-8.12 触发 AI(custom message,非 user message)
302
+ const resumeSends = h.piCalls.filter((c) => c.kind === "sendContext");
303
+ expect(resumeSends).toHaveLength(1);
304
+ expect(resumeSends[0]?.customType).toBe("goal-context");
305
+
306
+ // 恢复后:下一次 agent_end 正常发 continuation(新周期额度)。
307
+ // resume 触发消息同为 followUp custom message(改造后形态),按 [GOAL] 前缀
308
+ // 区分 continuation 本体(continuationPrompt 专属前缀)
306
309
  await runTurn(h, session, { tokenDelta: 200 });
307
- expect(followUpSends(h)).toHaveLength(1);
310
+ const continuations = followUpSends(h).filter((c) => String(c.content).includes("[GOAL]"));
311
+ expect(continuations).toHaveLength(1);
308
312
  });
309
313
 
310
314
  it("未封顶的普通 active goal → resume 提示无需恢复(原语义不变)", async () => {
@@ -315,7 +319,7 @@ describe("/goal resume 恢复通道", () => {
315
319
  await handleGoalCommand(h.pi, session, "resume", h.ctx);
316
320
 
317
321
  expect(notifyTexts(h).some((t) => t.includes("no need to resume"))).toBe(true);
318
- expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(0);
322
+ expect(h.piCalls.filter((c) => c.kind === "sendContext")).toHaveLength(0);
319
323
  });
320
324
  });
321
325
 
@@ -447,3 +451,34 @@ describe("MF-6③ timer 清理面:session_start / before_agent_start 取消旧
447
451
  expect(followUpSends(h)).toHaveLength(4);
448
452
  });
449
453
  });
454
+
455
+ // ── A8:退避通道激活(sendContextMessage 端口 triggerTurn 改造)──
456
+ //
457
+ // 设计 docs/design/send-user-message-to-custom-message.md(已删除,git 可追溯;决策见 ADR-0068)
458
+ // §2.2 端口改造 + §3.1 A8:
459
+ // 端口改造前 backoff timer 到期伪装用户消息投递;改造后
460
+ // deliverContinuation 经端口发 custom message 且 options 含 triggerTurn: true——
461
+ // 非 streaming idle 场景真实开轮(改造前 append 不开轮 = 死消息)。
462
+ // isIdle=false 拦截见 MF-6①;封顶记账守卫见「主判据」describe;此处锁定到期
463
+ // 发出路径的 options 契约与记账恰好 +1。
464
+ describe("A8:退避 timer 到期 → triggerTurn: true 开轮发出", () => {
465
+ it("isIdle 守卫通过后发出:custom message 四要素齐备,记账恰好 +1", async () => {
466
+ const { h, session } = await enterBackoff();
467
+
468
+ vi.advanceTimersByTime(20_000); // 2×base 到期,isIdle 默认 true → 守卫放行
469
+
470
+ const sends = followUpSends(h);
471
+ expect(sends).toHaveLength(5); // 4 立即 + 1 到期发出
472
+ const fired = sends[4]!;
473
+ // message 形态:customType + display:false(custom message,非用户气泡)
474
+ expect(fired.customType).toBe("goal-context");
475
+ expect(fired.display).toBe(false);
476
+ // options:deliverAs + triggerTurn(A8 核心断言——退避通道真实开轮)
477
+ const opts = fired.payload as { deliverAs?: string; triggerTurn?: boolean };
478
+ expect(opts.deliverAs).toBe("followUp");
479
+ expect(opts.triggerTurn).toBe(true);
480
+ // budget 记账不重复触发:主判据只在实发时 +1,本次到期恰好一次
481
+ expect(session.state!.continuationsSent).toBe(5);
482
+ expect(session.continuationTimer).toBeNull(); // 无残留待发 timer
483
+ });
484
+ });
@@ -27,12 +27,14 @@ import { createGoalSession } from "../session";
27
27
  // ── Fake pi / ctx ────────────────────────────────────
28
28
 
29
29
  interface RecordedCall {
30
- kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser";
30
+ kind: "appendState" | "appendHistory" | "notify" | "sendContext";
31
31
  text?: string;
32
32
  level?: string;
33
33
  content?: string;
34
- deliverAs?: string;
35
34
  customType?: string;
35
+ display?: boolean;
36
+ deliverAs?: string;
37
+ triggerTurn?: boolean;
36
38
  payload?: unknown;
37
39
  }
38
40
 
@@ -60,12 +62,17 @@ function makeHarness(entries: SessionEntryLike[] = []): FakeHarness {
60
62
  payload: data,
61
63
  });
62
64
  },
63
- sendMessage(message: unknown, _options?: unknown): void {
64
- const msg = message as { customType?: string; content?: string };
65
- piCalls.push({ kind: "sendContext", content: msg.content, customType: msg.customType });
66
- },
67
- sendUserMessage(content: string | unknown[], _options?: unknown): void {
68
- piCalls.push({ kind: "sendUser", content: typeof content === "string" ? content : undefined });
65
+ sendMessage(message: unknown, options?: unknown): void {
66
+ const msg = message as { customType?: string; content?: string; display?: boolean };
67
+ const opts = (options ?? {}) as { deliverAs?: string; triggerTurn?: boolean };
68
+ piCalls.push({
69
+ kind: "sendContext",
70
+ content: msg.content,
71
+ customType: msg.customType,
72
+ display: msg.display,
73
+ deliverAs: opts.deliverAs,
74
+ triggerTurn: opts.triggerTurn,
75
+ });
69
76
  },
70
77
  } as unknown as ExtensionAPI;
71
78
 
@@ -121,6 +128,11 @@ function notifyText(h: FakeHarness): string[] {
121
128
  .map((c) => c.text ?? "");
122
129
  }
123
130
 
131
+ /** 发出的 custom message 调用(经 ports.messaging.sendContextMessage → pi.sendMessage) */
132
+ function sendContextCalls(h: FakeHarness): RecordedCall[] {
133
+ return h.piCalls.filter((c) => c.kind === "sendContext");
134
+ }
135
+
124
136
  // ── /goal status ─────────────────────────────────────
125
137
 
126
138
  describe("handleGoalCommand — status", () => {
@@ -200,8 +212,14 @@ describe("handleGoalCommand — resume (FR-3 paused/blocked→active + G-014)",
200
212
  await handleGoalCommand(h.pi, session, "resume", h.ctx);
201
213
  expect(session.state!.status).toBe("active");
202
214
  expect(h.states).toHaveLength(1); // persist 恰好 1 次
203
- // FR-8.12: resume 后触发 AI
204
- expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(1);
215
+ // FR-8.12: resume 后触发 AI(custom message 四要素:customType/display/deliverAs/triggerTurn)
216
+ const sends = sendContextCalls(h);
217
+ expect(sends).toHaveLength(1);
218
+ expect(sends[0]?.customType).toBe("goal-context");
219
+ expect(sends[0]?.display).toBe(false);
220
+ expect(sends[0]?.deliverAs).toBe("followUp");
221
+ expect(sends[0]?.triggerTurn).toBe(true);
222
+ expect(sends[0]?.content).toContain("Goal resumed");
205
223
  });
206
224
 
207
225
  it("resume 重置 timeStartedAt=now(FR-3.2 重启计时器)", async () => {
@@ -482,7 +500,7 @@ describe("parseGoalArgs — 子命令路由", () => {
482
500
  });
483
501
  });
484
502
 
485
- // ── /goal set(提示词触发器:sendUserMessage 让 AI 调 goal_control create)──
503
+ // ── /goal set(提示词触发器:custom message 让 AI 调 goal_control create)──
486
504
 
487
505
  describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终态)", () => {
488
506
  it("无旧 goal → 发触发消息(不直接创建 state)", async () => {
@@ -492,11 +510,15 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
492
510
  // 提示词触发器:不直接 createGoal,state 仍为 null(由 AI 后续 toolcall 创建)
493
511
  expect(session.state).toBeNull();
494
512
  expect(h.states).toHaveLength(0); // 不写 state
495
- // 发送 sendUserMessage 引导 AI 调 create
496
- const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
497
- expect(sendUserCalls).toHaveLength(1);
498
- expect(sendUserCalls[0]?.content).toContain("my objective");
499
- expect(sendUserCalls[0]?.content).toContain("goal_control");
513
+ // 发 custom message 引导 AI 调 create(四要素 + content 含 objective 与工具名)
514
+ const sends = sendContextCalls(h);
515
+ expect(sends).toHaveLength(1);
516
+ expect(sends[0]?.customType).toBe("goal-context");
517
+ expect(sends[0]?.display).toBe(false);
518
+ expect(sends[0]?.deliverAs).toBe("followUp");
519
+ expect(sends[0]?.triggerTurn).toBe(true);
520
+ expect(sends[0]?.content).toContain("my objective");
521
+ expect(sends[0]?.content).toContain("goal_control");
500
522
  });
501
523
 
502
524
  const SET_REJECT_STATUSES: GoalRuntimeState["status"][] = ["active", "paused"];
@@ -515,7 +537,7 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
515
537
  expect(notifyText(h).join("\n")).toContain("clear");
516
538
  expect(session.state!.status).toBe(status); // 状态不变
517
539
  expect(session.state!.objective).toBe(`old ${status} goal`); // 旧 goal 保留
518
- expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(0); // 不触发 AI
540
+ expect(sendContextCalls(h)).toHaveLength(0); // 不触发 AI
519
541
  },
520
542
  );
521
543
 
@@ -527,7 +549,7 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
527
549
  await handleGoalCommand(h.pi, session, "new objective", h.ctx);
528
550
  expect(h.history.length).toBe(historyBefore); // 不写 history(触发器不直接创建)
529
551
  // 终态旧 goal 不挡触发器,发消息让 AI 创建
530
- expect(h.piCalls.filter((c) => c.kind === "sendUser")).toHaveLength(1);
552
+ expect(sendContextCalls(h)).toHaveLength(1);
531
553
  });
532
554
 
533
555
  it("空 objective → usage 提示", async () => {
@@ -547,18 +569,18 @@ describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终
547
569
  await handleGoalCommand(h.pi, session, "obj --tokens 0", h.ctx);
548
570
  // 提示词触发器不直接创建 state
549
571
  expect(session.state).toBeNull();
550
- const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
551
- expect(sendUserCalls).toHaveLength(1);
552
- expect(sendUserCalls[0]?.content).toContain("obj");
572
+ const sends = sendContextCalls(h);
573
+ expect(sends).toHaveLength(1);
574
+ expect(sends[0]?.content).toContain("obj");
553
575
  });
554
576
 
555
577
  it("--tokens N → 触发消息含 budget 值", async () => {
556
578
  const h = makeHarness();
557
579
  const session = createGoalSession();
558
580
  await handleGoalCommand(h.pi, session, "obj --tokens 5000", h.ctx);
559
- const sendUserCalls = h.piCalls.filter((c) => c.kind === "sendUser");
560
- expect(sendUserCalls).toHaveLength(1);
561
- expect(sendUserCalls[0]?.content).toContain("5000");
581
+ const sends = sendContextCalls(h);
582
+ expect(sends).toHaveLength(1);
583
+ expect(sends[0]?.content).toContain("5000");
562
584
  });
563
585
  });
564
586
 
@@ -43,7 +43,6 @@ function makeFakePorts(): ServicePorts & {
43
43
  } as UiPort,
44
44
  messaging: {
45
45
  sendContextMessage: () => {},
46
- sendUserMessage: () => {},
47
46
  },
48
47
  session: {
49
48
  getEntries: () => [],
@@ -21,7 +21,7 @@ import { createGoalSession } from "../session";
21
21
  // ── Fake pi / ctx ────────────────────────────────────
22
22
 
23
23
  interface RecordedCall {
24
- kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "sendUser" | "setStatus" | "setWidget";
24
+ kind: "appendState" | "appendHistory" | "notify" | "sendContext" | "setStatus" | "setWidget";
25
25
  payload?: unknown;
26
26
  text?: string;
27
27
  level?: string;
@@ -50,13 +50,6 @@ function makeFakePi(): { pi: ExtensionAPI; calls: RecordedCall[]; states: unknow
50
50
  payload: _options,
51
51
  });
52
52
  },
53
- sendUserMessage(content: string | unknown[], options?: unknown): void {
54
- calls.push({
55
- kind: "sendUser",
56
- content: typeof content === "string" ? content : undefined,
57
- payload: options,
58
- });
59
- },
60
53
  } as unknown as ExtensionAPI;
61
54
  return { pi, calls, states, history };
62
55
  }
@@ -119,8 +112,8 @@ describe("handleAgentEnd — FR-6.7 ESC 守卫", () => {
119
112
  await handleAgentEnd(pi, session, ctx);
120
113
  const all = allCalls(piCalls, ctxCalls);
121
114
 
122
- // 不发 continuation(无 sendContext/sendUser)
123
- expect(all.filter((c) => c.kind === "sendContext" || c.kind === "sendUser")).toHaveLength(0);
115
+ // 不发 continuation(无 sendContext custom message)
116
+ expect(all.filter((c) => c.kind === "sendContext")).toHaveLength(0);
124
117
  // 不做 budget 检查(无 notify 关于 budget)
125
118
  expect(all.filter((c) => c.kind === "notify")).toHaveLength(0);
126
119
  // goal 保持 active(status 不变)
@@ -60,7 +60,6 @@ function makeFakePorts(): ServicePorts & {
60
60
  } as UiPort,
61
61
  messaging: {
62
62
  sendContextMessage: () => {},
63
- sendUserMessage: () => {},
64
63
  },
65
64
  session: {
66
65
  getEntries: () => [],
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 覆盖场景:
5
5
  * - RPC 模式 create/complete/report_blocked → details 无 __gui__ 字段(状态展示改由
6
- * handle* 内 updateWidget 经 guiSetWidget 推送 M17 对话流 widget 面板)
6
+ * handle* 内 updateWidget 经 guiSetWidget 推送 composer 任务托盘的协议 widget 区)
7
7
  * - RPC 模式 + session.state = null → 前置 handler throw(分支不可达)
8
8
  * - 非 RPC 模式(tui/json/print)→ details 同样无 __gui__,content 文本正常
9
9
  *
@@ -11,12 +11,22 @@
11
11
  * 不 mock handleCreate:走真实 createGoal 让 session.state 含完整字段(slug/objective/budget),
12
12
  * 避免 hand-rolled state 与生产路径不一致。pi/ctx 用最小 fake(对齐 index.test.ts makeFactoryFixture)。
13
13
  */
14
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import { describe, expect, it } from "vitest";
14
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
+ import { describe, expect, it, vi, beforeEach } from "vitest";
16
16
 
17
17
  import { registerGoalControlTool, type GoalControlDetails } from "../adapters/goal-control-adapter";
18
18
  import { createGoalSession } from "../session";
19
19
 
20
+ // ── mock:logger + updateWidget(UI 通道降级用例断言面;其余用例下两者为 no-op,
21
+ // 与真实路径行为等价——fixture 的 setWidget/notify 本就是 no-op)──
22
+ const loggerWarnSpy = vi.hoisted(() => vi.fn());
23
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
24
+ getLogger: () => ({ warn: loggerWarnSpy, debug: vi.fn(), error: vi.fn() }),
25
+ }));
26
+ vi.mock("../projection/widget", () => ({ updateWidget: vi.fn() }));
27
+
28
+ import { updateWidget } from "../projection/widget";
29
+
20
30
  // ── Types ─────────────────────────────────────────────
21
31
 
22
32
  type ExecuteResult = {
@@ -33,6 +43,7 @@ interface CapturedTool {
33
43
  onUpdate: unknown,
34
44
  ctx: ExtensionContext,
35
45
  ) => Promise<ExecuteResult>;
46
+ renderCall: (args: Record<string, unknown>, theme: Theme) => unknown;
36
47
  }
37
48
 
38
49
  // ── Fake pi + ctx(最小化,对齐 index.test.ts 的 makeFactoryFixture)──
@@ -58,7 +69,6 @@ function makeFixture(mode: "rpc" | "tui" | "json" | "print" = "rpc"): FakeFixtur
58
69
  registerMessageRenderer: () => {},
59
70
  appendEntry: () => {},
60
71
  sendMessage: () => {},
61
- sendUserMessage: () => {},
62
72
  } as unknown as ExtensionAPI;
63
73
 
64
74
  const ctx = {
@@ -215,3 +225,105 @@ describe("goal_control execute — 非 RPC 模式无 __gui__", () => {
215
225
  expect(result.content[0].text).toContain("Goal created");
216
226
  });
217
227
  });
228
+
229
+ // ── 辅助 UI 通道降级(create/complete/blocked 的 updateWidget/notify 在核心副作用
230
+ // 之后调用,UI 故障不得翻转工具结果)──────────────────
231
+
232
+ describe("goal_control execute — 辅助 UI 通道失败降级", () => {
233
+ beforeEach(() => {
234
+ vi.mocked(updateWidget).mockReset();
235
+ loggerWarnSpy.mockClear();
236
+ });
237
+
238
+ /** 注册 tool 并同时持有 session 引用(断言持久化后的内存 state)。 */
239
+ function captureToolWithSession(pi: ExtensionAPI): { tool: CapturedTool; session: ReturnType<typeof createGoalSession> } {
240
+ let captured: CapturedTool | undefined;
241
+ const capturePi = {
242
+ ...pi,
243
+ registerTool(tool: CapturedTool): void {
244
+ captured = tool;
245
+ },
246
+ } as unknown as ExtensionAPI;
247
+ const session = createGoalSession();
248
+ registerGoalControlTool(capturePi, session);
249
+ if (!captured) throw new Error("registerGoalControlTool did not register a tool");
250
+ return { tool: captured, session };
251
+ }
252
+
253
+ it("create + updateWidget 抛错 → 工具调用仍成功返回、状态已创建(warn 留痕非静默)", async () => {
254
+ vi.mocked(updateWidget).mockImplementation(() => {
255
+ throw new Error("widget channel broken");
256
+ });
257
+ const { pi, ctx } = makeFixture("rpc");
258
+ const { tool, session } = captureToolWithSession(pi);
259
+ const result = await tool.execute(
260
+ "call-ui-fail",
261
+ { action: "create", slug: "ui-fail", objective: "survive ui failure", successCriteria: ["tests pass"] },
262
+ undefined,
263
+ undefined,
264
+ ctx,
265
+ );
266
+ // 工具成功返回(UI 异常未上抛翻转结果)
267
+ expect(result.details.action).toBe("create");
268
+ expect(result.details.status).toBe("active");
269
+ expect(result.content[0].text).toContain("Goal created");
270
+ // 核心副作用不受影响:state 已落盘到 session(内存态可见,persist 已在 createGoal 内完成)
271
+ expect(session.state?.status).toBe("active");
272
+ expect(session.state?.objective).toBe("survive ui failure");
273
+ // 降级留痕:warn 而非静默
274
+ expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
275
+ expect(loggerWarnSpy).toHaveBeenCalledWith(
276
+ expect.stringContaining("goal_control create"),
277
+ expect.objectContaining({ detail: expect.objectContaining({ err: "widget channel broken" }) }),
278
+ );
279
+ });
280
+
281
+ it("complete + updateWidget 抛错 → 工具调用仍成功返回(终态已持久化)", async () => {
282
+ const { pi, ctx } = makeFixture("rpc");
283
+ const { tool, session } = captureToolWithSession(pi);
284
+ await createViaHandler(tool, pi, ctx, { slug: "ui-comp", objective: "to complete" });
285
+ vi.mocked(updateWidget).mockImplementation(() => {
286
+ throw new Error("widget channel broken");
287
+ });
288
+ const result = await tool.execute(
289
+ "call-ui-comp",
290
+ { action: "complete", evidence: "tests green" },
291
+ undefined,
292
+ undefined,
293
+ ctx,
294
+ );
295
+ expect(result.details.status).toBe("complete");
296
+ expect(session.state?.status).toBe("complete");
297
+ expect(loggerWarnSpy).toHaveBeenCalledWith(
298
+ expect.stringContaining("goal_control complete"),
299
+ expect.objectContaining({ detail: expect.objectContaining({ err: "widget channel broken" }) }),
300
+ );
301
+ });
302
+ });
303
+
304
+ // ── renderCall 非法 args 安全渲染(TUI 渲染先于 schema 校验)──
305
+
306
+ const stubTheme = {
307
+ fg: (_color: string, text: string) => text,
308
+ bold: (text: string) => text,
309
+ } as unknown as Theme;
310
+
311
+ /** 注册 → 捕获 tool → 以给定 args 调 renderCall,返回渲染文本(width 足够宽避免 wrap)。 */
312
+ function renderedCallText(args: unknown): string {
313
+ const { pi } = makeFixture("rpc");
314
+ const tool = captureTool(pi);
315
+ const node = tool.renderCall(args as Record<string, unknown>, stubTheme) as {
316
+ render: (width: number) => string[];
317
+ };
318
+ return node.render(400).join("\n");
319
+ }
320
+
321
+ describe("renderCall — 非法 args 安全渲染", () => {
322
+ it("args 为 null → 占位文本,不抛错", () => {
323
+ expect(renderedCallText(null)).toContain("(invalid args)");
324
+ });
325
+
326
+ it("action 非字符串 → 不抛错,落 report_blocked 回落分支(与原行为一致)", () => {
327
+ expect(renderedCallText({ action: 42 })).toContain("report_blocked");
328
+ });
329
+ });
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 覆盖:
5
5
  * - 内容根 = group(透明组合容器):stats-line(status/turn[+tokens])+ list-tree(criteria)
6
- * - meta:title=slug、状态点语义(done/failed/idle/running)、token 进度(取整 + 百分比 + 阈值 severity)
6
+ * - meta:title=slug、状态点语义(done/failed/idle/running)、token 进度(取整 + 百分比 + 阈值 severity)、
7
+ * 托盘 icon(显式 'target' key)
7
8
  * - successCriteria 逐行 list-tree(不压扁/不截断/无 icon/不编号)
8
9
  * - token 展示走 formatTokens(无 budget 分支)
9
10
  */
@@ -60,12 +61,23 @@ describe("buildGoalGui(v1.1 meta head 架构)", () => {
60
61
  }),
61
62
  );
62
63
  expect(gui.meta).toEqual({
64
+ icon: "target",
63
65
  title: "fix-auth",
64
66
  status: "running",
65
67
  progress: { current: 4200, total: 10000, label: "42%", severity: "ok" },
66
68
  });
67
69
  });
68
70
 
71
+ it("meta.icon 推显式 'target' key(协议形状 string;badge 不推,宿主从 progress.label 派生)", () => {
72
+ const gui = buildGoalGui(makeState({ slug: "fix-auth", budget: { tokenBudget: 10000 } }));
73
+ // 协议形状:string(宿主按 lucide 名解析)或 { paths }(自定义形状)。
74
+ // goal 只用 key 形态——与宿主内置 widgetKey 映射('goal'→Target)同款,
75
+ // 宿主改映射表也不会换掉 goal 图标。
76
+ expect(gui.meta!.icon).toBe("target");
77
+ // badge 留空:托盘缺省从 progress.label 派生同值百分比,推一份会变成双真相源
78
+ expect(gui.meta!.badge).toBeUndefined();
79
+ });
80
+
69
81
  it("tokensUsed 浮点 → meta.progress.current 取整(1454.84…1 不进 UI)", () => {
70
82
  const gui = buildGoalGui(
71
83
  makeState({ tokensUsed: 1454.8400000000001, budget: { tokenBudget: 5000 } }),
@@ -104,7 +116,7 @@ describe("buildGoalGui(v1.1 meta head 架构)", () => {
104
116
  tokensUsed: 3000,
105
117
  }),
106
118
  );
107
- expect(gui.meta).toEqual({ title: expect.any(String), status: "running" });
119
+ expect(gui.meta).toEqual({ icon: "target", title: expect.any(String), status: "running" });
108
120
  const stats = findChildComp(gui, "stats-line");
109
121
  const items = stats.props.items as { label?: string; value?: string }[];
110
122
  expect(items.find((i) => i.label === "tokens")).toMatchObject({ value: "3k" });
@@ -38,15 +38,13 @@ interface FactoryFixture {
38
38
  ctx: ExtensionContext;
39
39
  states: unknown[];
40
40
  history: unknown[];
41
- sendUser: unknown[];
42
41
  commands: Record<string, { description?: string }>;
43
42
  }
44
43
 
45
- /** 工厂所需的最小 pi:registerCommand/registerTool/on/registerMessageRenderer + appendEntry/sendMessage/sendUserMessage */
44
+ /** 工厂所需的最小 pi:registerCommand/registerTool/on/registerMessageRenderer + appendEntry/sendMessage */
46
45
  function makeFactoryFixture(): FactoryFixture {
47
46
  const states: unknown[] = [];
48
47
  const history: unknown[] = [];
49
- const sendUser: unknown[] = [];
50
48
  const commands: Record<string, { description?: string }> = {};
51
49
  const pi = {
52
50
  registerCommand: (name: string, opts: { description?: string }) => {
@@ -60,9 +58,6 @@ function makeFactoryFixture(): FactoryFixture {
60
58
  else states.push(data);
61
59
  },
62
60
  sendMessage: () => {},
63
- sendUserMessage(content: unknown): void {
64
- sendUser.push(content);
65
- },
66
61
  } as unknown as ExtensionAPI;
67
62
 
68
63
  const ctx = {
@@ -78,7 +73,7 @@ function makeFactoryFixture(): FactoryFixture {
78
73
  sessionManager: { getEntries: () => [], getBranch: () => undefined },
79
74
  } as unknown as ExtensionContext;
80
75
 
81
- return { pi, ctx, states, history, sendUser, commands };
76
+ return { pi, ctx, states, history, commands };
82
77
  }
83
78
 
84
79
  // ── goalInit slot(NFR-AC-8 / T1.8)──────────────────
@@ -181,7 +176,6 @@ function makeE1Fixture(
181
176
  appendFileSync(sessionFile, `${JSON.stringify({ type: "custom", customType, data })}\n`, "utf-8");
182
177
  },
183
178
  sendMessage: () => {},
184
- sendUserMessage: () => {},
185
179
  } as unknown as ExtensionAPI;
186
180
 
187
181
  const ctx = {
@@ -42,7 +42,6 @@ function makeFakePorts(): ServicePorts & {
42
42
  },
43
43
  messaging: {
44
44
  sendContextMessage: () => {},
45
- sendUserMessage: () => {},
46
45
  },
47
46
  session: {
48
47
  getEntries: () => [],
@@ -7,9 +7,17 @@ export function StringEnum<T extends readonly string[]>(values: T, _options?: Re
7
7
  return values[0] as T[number];
8
8
  }
9
9
 
10
- // Text: pi-tui 的渲染节点 stub(goal-control-adapter renderCall/renderResult 返回值)
10
+ // Text: pi-tui 的渲染节点 stub(goal-control-adapter renderCall/renderResult 返回值)。
11
+ // 承载 content 并提供最小 render(原样单行返回)——renderCall 的非法 args 安全渲染
12
+ // 测试经它断言渲染文本。
11
13
  export class Text {
12
- constructor(_content: unknown, _x: number, _y: number) {}
14
+ content: unknown;
15
+ constructor(content: unknown, _x: number, _y: number) {
16
+ this.content = content;
17
+ }
18
+ render(_width: number): string[] {
19
+ return [String(this.content)];
20
+ }
13
21
  }
14
22
 
15
23
  // Pi SDK types used in import chains — re-export as empty
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * 状态变更调 service(createGoal / finalizeAndPersist);
11
11
  * ports 桥接复用 adapters/ports.buildPorts(DRY:单一 ports 构造点);
12
- * FR-8.12: set/resume 后 sendUserMessage 触发 AI。
12
+ * FR-8.12: set/resume 后经 messaging.sendContextMessage(followUp)触发 AI。
13
13
  *
14
14
  * adapters 层可 import Pi 类型(桥接 Pi 和 service)。
15
15
  */
@@ -184,16 +184,17 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
184
184
  persistState(session, ports);
185
185
  updateWidget(session, ports.ui);
186
186
 
187
- // FR-8.12 并行模式:resume 后触发 AI 继续
187
+ // FR-8.12 并行模式:resume 后触发 AI 继续(custom message + followUp,
188
+ // 非 streaming 时经端口 triggerTurn 开轮)
188
189
  const blockerNote = state.lastBlockerReason
189
190
  ? `\n\nPrevious blocker: ${state.lastBlockerReason}. Try a different approach.`
190
191
  : "";
191
192
  const capNote = cappedActive
192
193
  ? "\n\nContinuation circuit breaker was reached — pursue the objective with concrete tool-backed progress."
193
194
  : "";
194
- pi.sendUserMessage(
195
+ ports.messaging.sendContextMessage(
195
196
  `Goal resumed. Continuing toward the objective.${blockerNote}${capNote}\n\nObjective: ${state.objective}`,
196
- { deliverAs: "followUp" },
197
+ "followUp",
197
198
  );
198
199
  }
199
200
 
@@ -347,7 +348,7 @@ function handleUpdate(
347
348
 
348
349
  /**
349
350
  * /goal <objective> 改为「提示词触发器」:不直接 createGoal,
350
- * 而是 sendUserMessage 引导 AI 调 goal_control create(slug 由 AI 生成)。
351
+ * 而是发 custom message(followUp)引导 AI 调 goal_control create(slug 由 AI 生成)。
351
352
  *
352
353
  * 这样 goal 创建的唯一路径是 goal_control toolcall(统一入口),
353
354
  * slug/objective/budget 都由 AI 在 toolcall 时决定。
@@ -400,6 +401,7 @@ function handleSet(
400
401
  `\nRaw objective from user: ${objective.trim()}${budgetLine}`;
401
402
 
402
403
  ctx.ui.notify(`Requesting goal start: ${objective.trim()}`, "info");
403
- // FR-8.12: 触发 AI(followUp)—— AI 消化后调 goal_control create
404
- pi.sendUserMessage(message, { deliverAs: "followUp" });
404
+ // FR-8.12: 触发 AI(custom message + followUp,非 streaming 时开轮)—— AI 消化后调 goal_control create
405
+ const ports = buildPorts(pi, ctx);
406
+ ports.messaging.sendContextMessage(message, "followUp");
405
407
  }
@@ -189,7 +189,7 @@ async function handleContinuation(
189
189
  );
190
190
 
191
191
  // pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
192
- // 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
192
+ // 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true},缺省 deliverAs 即 steer) 自然唤醒主 agent;
193
193
  // goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
194
194
  // _followUpMessages 队列 → _handlePostAgentRun 的 hasQueuedMessages() 返回 true → agent.continue()
195
195
  // 新 turn → 又 agent_end → 又 continuation。
@@ -16,7 +16,7 @@
16
16
  * - complete: finalizeAndPersist(state, "complete", ...)(内部已含 tickState → finalizeGoal → persist)
17
17
  * - report_blocked: 手动 tickState(status 仍 active 才累加当前运行段)→ transitionStatus(active→blocked) → persistState
18
18
  *
19
- * create 不调 sendUserMessage:toolcall 时 AI 已在 turn 中,返回结果后自行续跑
19
+ * create 不发消息注入:toolcall 时 AI 已在 turn 中,返回结果后自行续跑
20
20
  * (与 /goal set 的 followUp 触发区分;对齐 Codex create_goal 不自动续跑)。
21
21
  *
22
22
  * schema:扁平 Type.Object(OpenAI 兼容,C3)——parameters 顶层必须是 type:"object",
@@ -33,6 +33,7 @@
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 { getLogger } from "@zhushanwen/pi-extension-logger";
36
37
  import { type Static, Type } from "typebox";
37
38
 
38
39
  import { SHORT_ID_LENGTH } from "../constants";
@@ -112,6 +113,26 @@ export const GoalControlParams = Type.Object(
112
113
 
113
114
  export type GoalControlParamsT = Static<typeof GoalControlParams>;
114
115
 
116
+ const logger = getLogger("goal");
117
+
118
+ /**
119
+ * 辅助 UI 通道降级包装:updateWidget / notify 失败只 warn 留痕不上抛。
120
+ *
121
+ * 调用点全部位于核心副作用(createGoal / finalizeAndPersist / persistState)之后——
122
+ * 状态已落盘,UI 通道故障不能翻转工具结果(与 base-tool-enhance notify.ts 的接入点
123
+ * 降级同构)。错误串格式化内联(Error message / String)而非复用
124
+ * @zhushanwen/pi-ext-guards 的 toErrorMessage——不为一个一行 helper 引入运行时依赖。
125
+ */
126
+ function runUiChannelSafe(action: GoalControlDetails["action"], goalId: string, fn: () => void): void {
127
+ try {
128
+ fn();
129
+ } catch (err) {
130
+ logger.warn(`goal_control ${action}: ui channel failed; persisted state unaffected`, {
131
+ detail: { goalId, err: err instanceof Error ? err.message : String(err) },
132
+ });
133
+ }
134
+ }
135
+
115
136
  // ── Details(renderResult 数据来源)──────────────────
116
137
 
117
138
  export interface GoalControlDetails {
@@ -130,7 +151,7 @@ export interface GoalControlDetails {
130
151
  * slug:AI 生成的短标识,仅 widget 标题 + history 用,不注入 prompt。真 optional。
131
152
  * objective:完整描述,注入每轮 context prompt(保证方向感)。
132
153
  *
133
- * 全解耦:不读 todo/plan。toolcall 时 AI 已在 turn 中,**不**调 sendUserMessage
154
+ * 全解耦:不读 todo/plan。toolcall 时 AI 已在 turn 中,**不**发消息注入
134
155
  * (AI 返回后自行续跑,对齐 Codex create_goal)。
135
156
  *
136
157
  * 守卫用 D25 严格语义:非终态 active/paused/blocked 全挡,提示用 /goal resume 或
@@ -212,14 +233,16 @@ export function handleCreate(
212
233
  // createGoal 内部 active 守卫兜底(理论上上面守卫已挡;防御性)
213
234
  throw new Error("Goal already active. Cannot create a new one.");
214
235
  }
215
- updateWidget(session, ports.ui);
216
236
 
217
237
  const state = session.state!;
218
238
  // slug fallback:未提供时用 goalId 截断作标题(与 buildGoalGui 一致,避免 [undefined])
219
239
  const slug = state.slug ?? state.goalId.slice(0, SHORT_ID_LENGTH);
220
240
  const budgetNotice: string[] = [];
221
241
  if (budget.tokenBudget) budgetNotice.push(`Token budget: ${budget.tokenBudget}`);
222
- ports.ui.notify([`Goal created [${slug}]: ${objective}`, ...budgetNotice].join("\n"), "info");
242
+ runUiChannelSafe("create", state.goalId, () => {
243
+ updateWidget(session, ports.ui);
244
+ ports.ui.notify([`Goal created [${slug}]: ${objective}`, ...budgetNotice].join("\n"), "info");
245
+ });
223
246
 
224
247
  return { action: "create", goalId: state.goalId, status: state.status, slug };
225
248
  }
@@ -251,8 +274,10 @@ export function handleComplete(
251
274
 
252
275
  // FR-3.3: 唯一终态序列入口(内部:tickState → finalizeGoal(transition+history) → persist)
253
276
  finalizeAndPersist(state, "complete", ports);
254
- updateWidget(session, ports.ui);
255
- ports.ui.notify(`Goal completed: ${state.objective}`, "info");
277
+ runUiChannelSafe("complete", state.goalId, () => {
278
+ updateWidget(session, ports.ui);
279
+ ports.ui.notify(`Goal completed: ${state.objective}`, "info");
280
+ });
256
281
 
257
282
  return { action: "complete", goalId: state.goalId, status: state.status };
258
283
  }
@@ -289,8 +314,10 @@ export function handleReportBlocked(
289
314
  state.status = transitionStatus(state.status, "blocked");
290
315
 
291
316
  persistState(session, ports);
292
- updateWidget(session, ports.ui);
293
- ports.ui.notify(`Goal blocked: ${reason}`, "warning");
317
+ runUiChannelSafe("report_blocked", state.goalId, () => {
318
+ updateWidget(session, ports.ui);
319
+ ports.ui.notify(`Goal blocked: ${reason}`, "warning");
320
+ });
294
321
 
295
322
  return { action: "report_blocked", goalId: state.goalId, status: state.status };
296
323
  }
@@ -376,12 +403,23 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
376
403
  : `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
377
404
 
378
405
  // 状态展示不再进 tool result(GUI 渲染字段已移除):GUI 由 handle* 内的 updateWidget
379
- // 经 guiSetWidget 推送(M17 对话流 widget 面板)。
406
+ // 经 setWidgetDual 推送(GUI 臂 = guiSetWidget/marker 通道,低层原语不单独调用;
407
+ // 渲染终点 = composer 任务托盘的协议 widget 区)。
380
408
  return { content: [{ type: "text", text }], details };
381
409
  },
382
410
 
383
411
  renderCall(args: Record<string, unknown>, theme: Theme): Text {
384
- const action = args.action as string;
412
+ // TUI 渲染先于 schema 校验(args 是未经 schema 收窄的原始形态):非对象整体
413
+ // 走安全占位(不抛错);action/slug 逐个 typeof 守卫替代裸断言——非法 action
414
+ // 落 report_blocked 回落分支(与原行为一致)
415
+ if (typeof args !== "object" || args === null) {
416
+ return new Text(
417
+ theme.fg("toolTitle", theme.bold("goal_control ")) + theme.fg("muted", "(invalid args)"),
418
+ 0,
419
+ 0,
420
+ );
421
+ }
422
+ const action = typeof args.action === "string" ? args.action : "";
385
423
  const slug = typeof args.slug === "string" ? args.slug : "";
386
424
  const actionLabel =
387
425
  action === "create"
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - persistence: pi.appendEntry 映射到 appendState / appendHistory(type 字符串区分)
7
7
  * - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + theme(fg/bold 适配 ThemeLike)
8
- * - messaging: pi.sendMessage 映射到 sendContextMessage / sendUserMessage
8
+ * - messaging: pi.sendMessage(custom message 形态)映射到 sendContextMessage
9
9
  * - session: ctx.sessionManager.getEntries
10
10
  *
11
11
  * port/ctx 双通道惯例:port 对象仅用于传入 service/session 的实参;
@@ -74,6 +74,8 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
74
74
  };
75
75
 
76
76
  const messaging: MessagingPort = {
77
+ // triggerTurn: true——非 streaming 时真实开轮(不设则 pi 只 append entry 不开轮,
78
+ // 消息成死文);streaming 时走 deliverAs 队列(steer/followUp),triggerTurn 不额外生效。
77
79
  sendContextMessage: (content, deliverAs, customType): void => {
78
80
  pi.sendMessage(
79
81
  {
@@ -81,12 +83,9 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
81
83
  content,
82
84
  display: false,
83
85
  },
84
- { deliverAs },
86
+ { deliverAs, triggerTurn: true },
85
87
  );
86
88
  },
87
- sendUserMessage: (content, deliverAs): void => {
88
- pi.sendUserMessage(content, { deliverAs });
89
- },
90
89
  };
91
90
 
92
91
  const session: SessionPort = {
@@ -91,7 +91,7 @@ describe("createGoalState", () => {
91
91
 
92
92
  describe("E1 守卫:src 内禁止 .status 裸赋值(必须走 transitionStatus 查表)", () => {
93
93
  it("扫描 src/**/*.ts(排除 __tests__):0 命中", () => {
94
- // 正则逐字来自设计 docs/design/ext-simplify-03-goal.md §5.4「E1 守卫设计」,不得自行修改:
94
+ // 正则逐字来自设计定稿,不得自行修改:
95
95
  // (?![=>]) 排除 ==/===/=>(比较与箭头函数形态);(?!\s*transitionStatus\b) 放行白名单查表赋值。
96
96
  // 扫描粒度 = 整文件内容(非逐行):\s* 必须能跨行吃换行,才能排除
97
97
  // `state.status =\n transitionStatus(...)` 这类跨行白名单赋值形态,逐行实现会误报。
package/src/index.ts CHANGED
@@ -125,7 +125,7 @@ export default function goalExtension(pi: ExtensionAPI) {
125
125
  * 通道形态(C-ext-06 globalThis slot 惯例):裸函数直挂——plan 侧使用点以 typeof 守卫
126
126
  * 读取 `globalThis[Symbol.for("@zhushanwen/pi-goal.goalInit")]`(plan/src/compact.ts)。
127
127
  * 不挂 pi API 对象:pi 0.84.4 为每个扩展创建独立 ExtensionAPI,挂 pi 对象的字段
128
- * 跨扩展不可见(设计 docs/design/goal-bridge-cross-extension.md §2.3)。
128
+ * 跨扩展不可见。
129
129
  *
130
130
  * @param objective 目标描述
131
131
  * @param budget 预算配置,传 undefined 用默认值
package/src/ports.ts CHANGED
@@ -70,10 +70,11 @@ export interface UiPort {
70
70
  // ── MessagingPort ────────────────────────────────────
71
71
 
72
72
  export interface MessagingPort {
73
- /** 发送 custom message(goal-context 等) */
73
+ /**
74
+ * 发送 custom message(goal-context 等)。实现侧固定 display:false +
75
+ * triggerTurn:true(非 streaming 真实开轮,streaming 走 deliverAs 队列)。
76
+ */
74
77
  sendContextMessage(content: string, deliverAs: "steer" | "followUp", customType?: string): void;
75
- /** 发送 user message(触发 AI 开始工作,FR-8.12) */
76
- sendUserMessage(content: string, deliverAs: "steer" | "followUp"): void;
77
78
  }
78
79
 
79
80
  // ── SessionPort ──────────────────────────────────────
@@ -284,12 +284,17 @@ describe("updateWidget dual payload(gui 臂)", () => {
284
284
  updateWidget(session, ui);
285
285
  const widgetCall = calls.find((c) => c.method === "setWidget" && c.args[0] === "goal");
286
286
  const payload = widgetCall!.args[1] as {
287
- gui: { component: { type: string }; meta: { title: string; progress?: { total: number } } };
287
+ gui: {
288
+ component: { type: string };
289
+ meta: { title: string; progress?: { total: number }; icon?: string | { paths: string[] } };
290
+ };
288
291
  text: string[];
289
292
  };
290
293
  // v1.1:gui 臂整个 GuiRenderResult(group 组合根 + meta 宿主元数据,head 由壳层渲染)
291
294
  expect(payload.gui.component.type).toBe("group");
292
295
  expect(payload.gui.meta.progress).toMatchObject({ total: 10000 });
296
+ // 托盘 icon 随 dual payload 的 gui 臂到达宿主(协议形状 = string key)
297
+ expect(payload.gui.meta.icon).toBe("target");
293
298
  // text 臂同步构造(TUI 渲染行)
294
299
  expect(payload.text.length).toBeGreaterThan(0);
295
300
  });
@@ -301,9 +306,11 @@ describe("updateWidget dual payload(gui 臂)", () => {
301
306
  updateWidget(session, ui);
302
307
  const widgetCall = calls.find((c) => c.method === "setWidget" && c.args[0] === "goal");
303
308
  const payload = widgetCall!.args[1] as {
304
- gui: { component: { type: string }; meta: { title: string; progress?: unknown } };
309
+ gui: { component: { type: string }; meta: { title: string; progress?: unknown; icon?: unknown } };
305
310
  };
306
311
  expect(payload.gui.component.type).toBe("group");
307
312
  expect(payload.gui.meta.progress).toBeUndefined();
313
+ // icon 与预算无关,两种形态都推(托盘不依赖宿主 widgetKey 映射兜底)
314
+ expect(payload.gui.meta.icon).toBe("target");
308
315
  });
309
316
  });
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * 与 projection/widget.ts 的分工:
8
8
  * - widget.ts:TUI 模式渲染(ANSI 字符串,经 ctx.ui.setWidget)
9
- * - gui.ts:RPC 模式渲染(结构化 GuiComponent 描述符,经 guiSetWidget 推送给 M17 对话流 widget 面板)
9
+ * - gui.ts:RPC 模式渲染(结构化 GuiComponent 描述符,经 setWidgetDual 推送——GUI 臂 =
10
+ * guiSetWidget/marker 通道,低层原语不单独调用;渲染终点 = composer 任务托盘的协议 widget 区)
10
11
  *
11
12
  * 预算阈值经 engine/budget.ts 的 getBudgetSeverity 单源化(H4):
12
13
  * buildGoalGui(percent→severity)与 widget.getBudgetColor(percent→color)共用阈值。
@@ -70,7 +71,7 @@ function metaStatus(status: GoalStatus): WidgetMeta["status"] {
70
71
  /**
71
72
  * 构造 goal 的 GUI 渲染描述符(v1.1 meta head 架构)。
72
73
  *
73
- * - meta(标题=slug / 状态点 / token 进度)由宿主壳层渲染成唯一 head;
74
+ * - meta(托盘 icon / 标题=slug / 状态点 / token 进度)由宿主壳层渲染成唯一 head;
74
75
  * 有预算时进度计数用百分比 + 预算阈值 severity(70/90 warn/danger 单源)。
75
76
  * - 内容根 = group(透明组合容器):stats-line(status/turn,无预算时补 token
76
77
  * 绝对值)+ list-tree(criteria 逐行,无 icon——所有行同 icon 是无信息量装饰,
@@ -114,6 +115,11 @@ export function buildGoalGui(state: GoalRuntimeState): GuiRenderResult {
114
115
  }
115
116
 
116
117
  const meta: WidgetMeta = {
118
+ // 点名 lucide key "target"(与宿主内置 widgetKey 映射 "goal"→Target 同图标):
119
+ // 宿主映射只是兜底,extension 显式声明后,宿主调整自己的映射表也不会换掉 goal 的图标。
120
+ // 不推 badge:托盘 badge 缺省由 progress.label 派生("42%"),推一份同值字符串会把
121
+ // 百分比格式变成两个真相源。
122
+ icon: "target",
117
123
  title: slug,
118
124
  status: metaStatus(state.status),
119
125
  ...(hasBudget
package/src/service.ts CHANGED
@@ -124,7 +124,7 @@ export function persistAndUpdate(
124
124
  * - goalInit slot(index.ts,跨扩展编程式入口)
125
125
  *
126
126
  * 注:/goal <objective> 命令路径已改为提示词触发器——不直接调本函数,
127
- * 而是 sendUserMessage 让 AI 调 goal_control create(slug + successCriteria 由 AI 生成)。
127
+ * 而是发 custom message 让 AI 调 goal_control create(slug + successCriteria 由 AI 生成)。
128
128
  *
129
129
  * @param slug AI 生成的短标识(optional,仅 widget 标题 + history 用)
130
130
  * @param successCriteria 成功标准(optional,由 AI 推导或外部传入;注入 prompt 指导完成验证)