@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 +1 -1
- package/package.json +4 -3
- package/src/__tests__/circuit-breaker.test.ts +47 -12
- package/src/__tests__/command-adapter.test.ts +46 -24
- package/src/__tests__/criteria-array-adapter.test.ts +0 -1
- package/src/__tests__/event-adapter.test.ts +3 -10
- package/src/__tests__/goal-control-adapter.test.ts +0 -1
- package/src/__tests__/goal-control-rpc.test.ts +116 -4
- package/src/__tests__/gui.test.ts +14 -2
- package/src/__tests__/index.test.ts +2 -8
- package/src/__tests__/service.test.ts +0 -1
- package/src/__tests__/stubs/pi-sdk.ts +10 -2
- package/src/adapters/command-adapter.ts +9 -7
- package/src/adapters/event-handlers/agent-end.ts +1 -1
- package/src/adapters/goal-control-adapter.ts +48 -10
- package/src/adapters/ports.ts +4 -5
- package/src/engine/__tests__/goal.test.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ports.ts +4 -3
- package/src/projection/__tests__/widget.test.ts +9 -2
- package/src/projection/gui.ts +8 -2
- package/src/service.ts +1 -1
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
|
|
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.
|
|
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.
|
|
32
|
+
"@zhushanwen/pi-pending-notifications": "0.7.6"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zhushanwen/extension-protocol": "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"
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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 === "
|
|
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"
|
|
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,
|
|
64
|
-
const msg = message as { customType?: string; content?: string };
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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(提示词触发器:
|
|
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
|
-
//
|
|
496
|
-
const
|
|
497
|
-
expect(
|
|
498
|
-
expect(
|
|
499
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
551
|
-
expect(
|
|
552
|
-
expect(
|
|
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
|
|
560
|
-
expect(
|
|
561
|
-
expect(
|
|
581
|
+
const sends = sendContextCalls(h);
|
|
582
|
+
expect(sends).toHaveLength(1);
|
|
583
|
+
expect(sends[0]?.content).toContain("5000");
|
|
562
584
|
});
|
|
563
585
|
});
|
|
564
586
|
|
|
@@ -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" | "
|
|
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
|
|
123
|
-
expect(all.filter((c) => c.kind === "sendContext"
|
|
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 不变)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 覆盖场景:
|
|
5
5
|
* - RPC 模式 create/complete/report_blocked → details 无 __gui__ 字段(状态展示改由
|
|
6
|
-
* handle* 内 updateWidget 经 guiSetWidget 推送
|
|
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
|
|
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,
|
|
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 = {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
195
|
+
ports.messaging.sendContextMessage(
|
|
195
196
|
`Goal resumed. Continuing toward the objective.${blockerNote}${capNote}\n\nObjective: ${state.objective}`,
|
|
196
|
-
|
|
197
|
+
"followUp",
|
|
197
198
|
);
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -347,7 +348,7 @@ function handleUpdate(
|
|
|
347
348
|
|
|
348
349
|
/**
|
|
349
350
|
* /goal <objective> 改为「提示词触发器」:不直接 createGoal,
|
|
350
|
-
*
|
|
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
|
|
404
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
// 经
|
|
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
|
-
|
|
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"
|
package/src/adapters/ports.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
});
|
package/src/projection/gui.ts
CHANGED
|
@@ -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 描述符,经
|
|
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
|
|
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
|
-
*
|
|
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 指导完成验证)
|