@zhushanwen/pi-goal 0.7.1 → 0.8.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.
Files changed (40) hide show
  1. package/package.json +4 -2
  2. package/src/__tests__/command-adapter.test.ts +130 -81
  3. package/src/__tests__/deserialize-state.test.ts +79 -45
  4. package/src/__tests__/event-adapter.test.ts +26 -39
  5. package/src/__tests__/goal-control-adapter.test.ts +7 -69
  6. package/src/__tests__/goal-control-prompt.test.ts +45 -46
  7. package/src/__tests__/goal-control-rpc.test.ts +8 -49
  8. package/src/__tests__/gui.test.ts +32 -93
  9. package/src/__tests__/index.test.ts +19 -37
  10. package/src/__tests__/schema.test.ts +38 -0
  11. package/src/__tests__/service.test.ts +20 -52
  12. package/src/__tests__/session.test.ts +17 -56
  13. package/src/__tests__/stale-checker.test.ts +9 -40
  14. package/src/adapters/command-adapter.ts +46 -47
  15. package/src/adapters/event-adapter.ts +0 -1
  16. package/src/adapters/event-handlers/agent-end.ts +28 -31
  17. package/src/adapters/event-handlers/before-agent-start.ts +7 -8
  18. package/src/adapters/event-handlers/shared.ts +1 -20
  19. package/src/adapters/event-handlers/turn-end.ts +5 -9
  20. package/src/adapters/goal-control-adapter.ts +103 -225
  21. package/src/adapters/ports.ts +3 -0
  22. package/src/commands.ts +2 -8
  23. package/src/constants.ts +0 -1
  24. package/src/engine/__tests__/budget.test.ts +53 -95
  25. package/src/engine/__tests__/goal.test.ts +62 -102
  26. package/src/engine/budget.ts +50 -49
  27. package/src/engine/goal.ts +0 -2
  28. package/src/engine/types.ts +3 -10
  29. package/src/index.ts +3 -16
  30. package/src/persistence.ts +18 -9
  31. package/src/ports.ts +0 -2
  32. package/src/projection/__tests__/format.test.ts +161 -0
  33. package/src/projection/__tests__/prompts.test.ts +48 -77
  34. package/src/projection/__tests__/widget.test.ts +34 -50
  35. package/src/projection/gui.ts +126 -0
  36. package/src/projection/prompts.ts +32 -101
  37. package/src/projection/widget.ts +12 -31
  38. package/src/service.ts +17 -40
  39. package/src/__tests__/stubs/typebox.ts +0 -18
  40. package/src/adapters/event-handlers/agent-start.ts +0 -17
@@ -260,7 +260,6 @@ describe("handleAgentEnd — continuation", () => {
260
260
  (s as { component?: string } | undefined)?.component === "goal:agent-end" &&
261
261
  /continuation deferred/.test(String((s as { message?: string } | undefined)?.message ?? "")),
262
262
  );
263
- expect(deferredLog).toBeDefined();
264
263
  const data = (deferredLog as { data?: { activePending?: number; ids?: string[] } } | undefined)?.data;
265
264
  expect(data?.activePending).toBe(2); // 多元素计数(替代原 >=1 弱断言)
266
265
  expect(data?.ids).toEqual(["bg-1", "bg-2"]); // ids 透出
@@ -390,15 +389,13 @@ describe("handleBeforeAgentStart", () => {
390
389
 
391
390
  const result = await handleBeforeAgentStart(pi, session, ctx);
392
391
 
393
- expect(result).toBeDefined();
394
392
  expect(result!.message.customType).toBe("goal-context");
395
393
  expect(result!.message.content).toContain("[GOAL mode activated]");
396
394
  expect(result!.message.display).toBe(false);
397
395
  });
398
396
 
399
- // 全解耦后:planAvailable 恒 true(不再运行时探测 pi.__planStart)。
400
- // contextInjectionPrompt 恒定注入 plan mode 建议段落,AI 自行判断是否用。
401
- it("恒定注入 plan mode 建议段落(全解耦:不再探测 plan extension)", async () => {
397
+ // contextInjection 精简(planAvailable 参数已删,plan 提示收敛到 continuationPrompt)
398
+ it("context injection 不含 plan mode 提示(收敛到 continuation)", async () => {
402
399
  const { pi } = makeFakePi();
403
400
  const { ctx } = makeFakeCtx();
404
401
  const session = createGoalSession();
@@ -406,9 +403,10 @@ describe("handleBeforeAgentStart", () => {
406
403
 
407
404
  const result = await handleBeforeAgentStart(pi, session, ctx);
408
405
 
409
- expect(result).toBeDefined();
410
406
  expect(result!.message.customType).toBe("goal-context");
411
- expect(result!.message.content).toContain("plan mode");
407
+ // plan 引导段已删(收敛到 continuationPrompt,agent_end 发)
408
+ expect(result!.message.content).not.toContain("plan mode");
409
+ expect(result!.message.content).not.toContain("__planStart");
412
410
  });
413
411
 
414
412
  // FR-8.1 G-007: AUTO_CLEAR_TURNS=2
@@ -444,7 +442,7 @@ describe("handleBeforeAgentStart", () => {
444
442
  expect(result).toBeUndefined();
445
443
  expect(session.state).not.toBeNull(); // 未清理
446
444
  // setStatus 触发(折叠为终态单行)
447
- expect(all.filter((c) => c.kind === "setStatus" && c.key === "goal").length).toBeGreaterThan(0);
445
+ expect(all.filter((c) => c.kind === "setStatus" && c.key === "goal")).toHaveLength(1);
448
446
  });
449
447
 
450
448
  // ADR-002 context usage 提示(CONTEXT_USAGE_RATIO_LIMIT=0.85,保持 active)
@@ -456,7 +454,6 @@ describe("handleBeforeAgentStart", () => {
456
454
 
457
455
  const result = await handleBeforeAgentStart(pi, session, ctx);
458
456
 
459
- expect(result).toBeDefined();
460
457
  expect(result!.message.customType).toBe("goal-context-exceeded");
461
458
  // ADR-002:goal 保持 active(不转 paused)
462
459
  expect(session.state!.status).toBe("active");
@@ -487,34 +484,24 @@ describe("handleBeforeAgentStart", () => {
487
484
 
488
485
  // ── pending-notifications(W2 跨扩展集成)───────────────────
489
486
 
490
- it("有活跃 pending entries → 注入 content 不含 pending 提示(pendingHint 已移除)", async () => {
491
- const { pi } = makeFakePi();
492
- const { ctx } = makeFakeCtx({
493
- entries: [
494
- { type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
495
- { type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
496
- ],
497
- });
498
- const session = createGoalSession();
499
- session.state = makeRunningState();
500
-
501
- const result = await handleBeforeAgentStart(pi, session, ctx);
502
-
503
- expect(result).toBeDefined();
504
- expect(result!.message.customType).toBe("goal-context");
505
- // pendingHint 已删除:pending 感知交给 LLM 调 pending_notifications tool(mandatory)
506
- expect(result!.message.content).not.toContain("pending async operation");
507
- });
508
-
509
- it("无 pending entries → 正常注入(不含 pending 提示)", async () => {
510
- const { pi } = makeFakePi();
511
- const { ctx } = makeFakeCtx(); // 默认 getEntries: () => []
512
- const session = createGoalSession();
513
- session.state = makeRunningState();
514
-
515
- const result = await handleBeforeAgentStart(pi, session, ctx);
516
-
517
- expect(result).toBeDefined();
518
- expect(result!.message.content).not.toContain("pending async operation");
519
- });
487
+ // pendingHint 已删除(pending 感知交给 LLM 调 pending_notifications tool),
488
+ // 无论是否有活跃 pending entries,content 都不含 pending 提示——轻量回归保护。
489
+ const PENDING_HINT_CASES: Array<[string, unknown[]]> = [
490
+ ["有活跃 pending", [{ type: "custom", customType: "pending:register", data: { id: "wf-1" } }]],
491
+ ["无 pending", []],
492
+ ];
493
+ it.each(PENDING_HINT_CASES)(
494
+ "pendingHint 已移除:%s → content 不含 pending 提示",
495
+ async (_label, entries) => {
496
+ const { pi } = makeFakePi();
497
+ const { ctx } = makeFakeCtx({ entries });
498
+ const session = createGoalSession();
499
+ session.state = makeRunningState();
500
+
501
+ const result = await handleBeforeAgentStart(pi, session, ctx);
502
+
503
+ expect(result!.message.customType).toBe("goal-context");
504
+ expect(result!.message.content).not.toContain("pending async operation");
505
+ },
506
+ );
520
507
  });
@@ -17,7 +17,7 @@ import {
17
17
  handleCreate,
18
18
  handleReportBlocked,
19
19
  } from "../adapters/goal-control-adapter";
20
- import { createGoalState, transitionStatus } from "../engine/goal";
20
+ import { createGoalState } from "../engine/goal";
21
21
  import type { GoalRuntimeState } from "../engine/types";
22
22
  import type { UiPort } from "../ports";
23
23
  import type { ServicePorts } from "../service";
@@ -106,14 +106,6 @@ describe("handleComplete — active 守卫 + evidence + finalizeAndPersist", ()
106
106
  handleComplete({ action: "complete", evidence: " " }, session, makeFakePorts()),
107
107
  ).toThrow(/evidence/);
108
108
  });
109
-
110
- it("completedTasks 写入 history", () => {
111
- const session = createGoalSession();
112
- session.state = activeState();
113
- const ports = makeFakePorts();
114
- handleComplete({ action: "complete", evidence: "done", completedTasks: 5 }, session, ports);
115
- expect((ports.history[0] as { completedTasks?: number }).completedTasks).toBe(5);
116
- });
117
109
  });
118
110
 
119
111
  // ── handleCreate ─────────────────────────────────────
@@ -143,27 +135,13 @@ describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal"
143
135
  expect(ports.notifications[0]?.text).toContain("ship feature X");
144
136
  });
145
137
 
146
- it("objective 空 → throw(即使有 slug)", () => {
138
+ it("objective 空 → throw", () => {
147
139
  const session = createGoalSession();
148
140
  expect(() =>
149
- handleCreate({ action: "create", slug: "x", objective: " " }, session, makeFakePorts()),
141
+ handleCreate({ action: "create", slug: "x", objective: " ", successCriteria: "done" }, session, makeFakePorts()),
150
142
  ).toThrow(/objective/);
151
143
  });
152
144
 
153
- it("slug 空 → throw(即使有 objective)", () => {
154
- const session = createGoalSession();
155
- expect(() =>
156
- handleCreate({ action: "create", slug: " ", objective: "do thing" }, session, makeFakePorts()),
157
- ).toThrow(/slug/);
158
- });
159
-
160
- it("slug 缺省 → throw", () => {
161
- const session = createGoalSession();
162
- expect(() =>
163
- handleCreate({ action: "create", objective: "do thing" }, session, makeFakePorts()),
164
- ).toThrow(/slug/);
165
- });
166
-
167
145
  it("successCriteria 空 → throw(即使有 slug + objective)", () => {
168
146
  const session = createGoalSession();
169
147
  expect(() =>
@@ -175,33 +153,10 @@ describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal"
175
153
  ).toThrow(/successCriteria/);
176
154
  });
177
155
 
178
- it("已有 active goal → throw(D25 非终态守卫)", () => {
179
- const session = createGoalSession();
180
- session.state = activeState({ status: "active" });
181
- expect(() =>
182
- handleCreate(
183
- { action: "create", slug: "new", objective: "new obj", successCriteria: "done" },
184
- session,
185
- makeFakePorts(),
186
- ),
187
- ).toThrow(/already active/i);
188
- });
189
-
190
- it("已有 paused goal → throw(paused 也是非终态)", () => {
191
- const session = createGoalSession();
192
- session.state = activeState({ status: "paused" });
193
- expect(() =>
194
- handleCreate(
195
- { action: "create", slug: "new", objective: "new obj", successCriteria: "done" },
196
- session,
197
- makeFakePorts(),
198
- ),
199
- ).toThrow(/already active/i);
200
- });
201
-
202
- it("已有 blocked goal → throw(blocked 也是非终态)", () => {
156
+ const NON_TERMINAL_STATUSES: GoalRuntimeState["status"][] = ["active", "paused", "blocked"];
157
+ it.each(NON_TERMINAL_STATUSES)("已有 %s goal → throw(D25 非终态守卫)", (status) => {
203
158
  const session = createGoalSession();
204
- session.state = activeState({ status: "blocked" });
159
+ session.state = activeState({ status });
205
160
  expect(() =>
206
161
  handleCreate(
207
162
  { action: "create", slug: "new", objective: "new obj", successCriteria: "done" },
@@ -240,29 +195,17 @@ describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal"
240
195
  ).toThrow(/tokenBudget/);
241
196
  });
242
197
 
243
- it("timeBudgetMinutes <= 0 → throw", () => {
244
- const session = createGoalSession();
245
- expect(() =>
246
- handleCreate(
247
- { action: "create", slug: "x", objective: "x", successCriteria: "done", timeBudgetMinutes: -5 },
248
- session,
249
- makeFakePorts(),
250
- ),
251
- ).toThrow(/timeBudgetMinutes/);
252
- });
253
-
254
198
  it("合法 budget → 合并进新 state", () => {
255
199
  const session = createGoalSession();
256
200
  const ports = makeFakePorts();
257
201
 
258
202
  handleCreate(
259
- { action: "create", slug: "x", objective: "x", successCriteria: "done", tokenBudget: 8000, timeBudgetMinutes: 30 },
203
+ { action: "create", slug: "x", objective: "x", successCriteria: "done", tokenBudget: 8000 },
260
204
  session,
261
205
  ports,
262
206
  );
263
207
 
264
208
  expect(session.state!.budget.tokenBudget).toBe(8000);
265
- expect(session.state!.budget.timeBudgetMinutes).toBe(30);
266
209
  });
267
210
  });
268
211
 
@@ -307,9 +250,4 @@ describe("handleReportBlocked — active 守卫 + tick + transition + persist",
307
250
  handleReportBlocked({ action: "report_blocked", reason: "" }, session, makeFakePorts()),
308
251
  ).toThrow(/reason/);
309
252
  });
310
-
311
- it("active→blocked 是合法转换", () => {
312
- // transitionStatus 自身已由 engine 测试覆盖,此处验证集成不破坏
313
- expect(transitionStatus("active", "blocked")).toBe("blocked");
314
- });
315
253
  });
@@ -1,17 +1,14 @@
1
1
  // 提示词质量回归:goal_control tool description + runtime 错误文案必须是
2
2
  // "可纠正的行为约束器",而非干巴巴的错误。
3
3
  //
4
- // agent(LLM)唯一能看到的 tool 元信息就是 description + 报错文案。弱模型
5
- // (如 step-3.7-flash)会在首次调用时省略条件必填字段(create 漏 slug、
6
- // complete 漏 evidence——complete 需模型主动生成内容,省略动因最强)。
7
- // description 补完整 JSON 正例 + 结构反例,runtime throw 补 "Correct:" 纠正
8
- // 正例,能让弱模型在首轮或报错后无需猜测即可对齐。
4
+ // agent(LLM)唯一能看到的 tool 元信息就是 description + 报错文案。description 用中文
5
+ // 重写(动作 create/complete/report_blocked + 规则 + §2.5 终态语义),删除英文 Examples/Don't
6
+ // 段;runtime throw 保留 .trim() 空串校验 + 'Correct:' 纠正正例(缺失必填已前移到 schema
7
+ // discriminated union 层拒绝,C3)。
9
8
  //
10
- // 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把正例/反例
11
- // 或纠错文案删掉。读源码而非 import,避免 mock 链(goal-control-adapter.ts
12
- // 依赖 pi-ai/typebox/pi-tui/ExtensionAPI 等值导入)。
13
- //
14
- // 模式参考:subagent-workflow/src/interface/__tests__/subagent-tool-prompt.test.ts。
9
+ // 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把中文语义/纠错文案/
10
+ // union 结构删掉。读源码而非 import,避免 mock 链(goal-control-adapter.ts 依赖
11
+ // pi-ai/typebox/pi-tui/ExtensionAPI 等值导入)。
15
12
 
16
13
  import { readFileSync } from "node:fs";
17
14
  import { dirname, join } from "node:path";
@@ -34,62 +31,64 @@ function extractDescription(src: string): string {
34
31
 
35
32
  const DESCRIPTION = extractDescription(ADAPTER_SRC);
36
33
 
37
- describe("goal_control description — 含完整 JSON 正例(弱模型首轮误用防御)", () => {
38
- it("含 create 正例子串", () => {
39
- // create 是弱模型最易漏 slug 的 action;完整 JSON 正例给出可直接复用的形态。
40
- expect(DESCRIPTION).toContain('{"action":"create"');
41
- });
42
-
43
- it("含 complete 正例子串", () => {
44
- // complete 需模型主动生成 evidence,省略动因最强;正例锁定必填字段。
45
- expect(DESCRIPTION).toContain('{"action":"complete"');
34
+ describe("goal_control description — 中文化 + 动作覆盖", () => {
35
+ it("含管理目标总述 + complete 须满足每条 successCriteria 条件", () => {
36
+ expect(DESCRIPTION).toContain("管理当前会话的目标");
37
+ expect(DESCRIPTION).toContain("满足每条 successCriteria 条件");
46
38
  });
39
+ });
47
40
 
48
- it("含 report_blocked 正例子串", () => {
49
- expect(DESCRIPTION).toContain('{"action":"report_blocked"');
41
+ describe("goal_control description — §2.5 终态语义", () => {
42
+ it("create 失败提示 / complete 报告 token / pause-resume-budget 归用户 / 不反复报告阻塞", () => {
43
+ expect(DESCRIPTION).toContain("请让用户运行 /goal resume 或 /goal clear");
44
+ expect(DESCRIPTION).toContain("报告最终 token 用量");
45
+ expect(DESCRIPTION).toContain("pause/resume 和 budget 变更由用户经 /goal 命令控制");
46
+ expect(DESCRIPTION).toContain("不要反复报告同一阻塞");
50
47
  });
51
48
  });
52
49
 
53
- describe("goal_control description — 含参数结构反例(Don't 段)", () => {
54
- it("含 Don't 段", () => {
55
- expect(DESCRIPTION).toMatch(/Don't/);
50
+ describe("goal_control description — 删除 Examples/Don't 英文段", () => {
51
+ it("不含 Examples JSON 正例段 + 不含 Don't 段", () => {
52
+ // Examples 段已删,description 不再含裸 JSON 正例
53
+ expect(DESCRIPTION).not.toContain('{"action":"create"');
54
+ expect(DESCRIPTION).not.toMatch(/Don't/);
56
55
  });
56
+ });
57
57
 
58
- it("Don't 段含 evidence 结构反例关键词(complete 漏 evidence)", () => {
59
- // complete 漏 evidence 是核心失败模式之一;反例文案必须提及 evidence。
60
- expect(DESCRIPTION).toMatch(/evidence/);
58
+ describe("goal_control schema — discriminated union(C3)", () => {
59
+ it("Type.Union + 各分支 additionalProperties:false + 无 timeBudgetMinutes", () => {
60
+ expect(ADAPTER_SRC).toMatch(/Type\.Union\(/);
61
+ expect(ADAPTER_SRC).toContain("additionalProperties: false");
62
+ expect(ADAPTER_SRC).not.toMatch(/timeBudgetMinutes/);
61
63
  });
62
64
  });
63
65
 
64
- describe("goal_control promptGuidelines — complete/report_blocked 主动触发引导(防不主动收尾)", () => {
65
- // promptGuidelines 进 system prompt guidelines 段(强信号),是修复 complete/report_blocked
66
- // 「该主动调但不调」的核心载体。用源码断言锁定两条正向触发引导,防止后续重构删掉。
67
- it("源码含 promptGuidelines 字段", () => {
66
+ describe("goal_control promptGuidelines — complete/report_blocked 主动触发引导", () => {
67
+ it("含 promptGuidelines 字段 + complete/report_blocked 主动触发信号", () => {
68
68
  expect(ADAPTER_SRC).toMatch(/promptGuidelines:\s*\[/);
69
- });
70
-
71
- it("promptGuidelines 含 complete 主动触发信号(proactively + objective is actually achieved)", () => {
72
69
  expect(ADAPTER_SRC).toMatch(/complete:.*proactively call.*objective is actually achieved/s);
73
- });
74
-
75
- it("promptGuidelines 含 report_blocked 主动触发信号(proactively + ≥3 approaches)", () => {
76
70
  expect(ADAPTER_SRC).toMatch(/report_blocked:.*proactively call.*≥3 distinct alternative approaches/s);
71
+ expect(ADAPTER_SRC).toContain("do not repeatedly report the same blocker");
77
72
  });
78
73
  });
79
74
 
80
- describe("goal_control runtime 错误文案 — 含 'Correct:' 纠正正例(≥4 处)", () => {
81
- it("源码含 ≥4 处 'Correct:' 纠错文案(4 条 required throw 各一带正例)", () => {
82
- // objective/slug/evidence/reason 四条必填 throw 各应带完整 JSON 正例,
75
+ describe("goal_control runtime 错误文案 — 含 'Correct:' 纠正正例", () => {
76
+ it("≥4 处 'Correct:'(4 条空串 throw 各一带正例)+ objective/evidence 报错带正例", () => {
77
+ // objective/successCriteria/evidence/reason 四条空串 throw 各应带完整 JSON 正例,
83
78
  // 让弱模型在报错后无需猜测即可纠正。读整份源码统计出现次数。
84
79
  const matches = ADAPTER_SRC.match(/Correct:/g) || [];
85
80
  expect(matches.length).toBeGreaterThanOrEqual(4);
81
+ expect(ADAPTER_SRC).toMatch(/'objective' must not be empty[\s\S]*?Correct:/);
82
+ expect(ADAPTER_SRC).toMatch(/'evidence' must not be empty[\s\S]*?Correct:/);
86
83
  });
84
+ });
87
85
 
88
- it("objective 报错带 Correct 正例", () => {
89
- expect(ADAPTER_SRC).toMatch(/'objective' is required[\s\S]*?Correct:/);
90
- });
91
-
92
- it("evidence 报错带 Correct 正例", () => {
93
- expect(ADAPTER_SRC).toMatch(/'evidence' is required[\s\S]*?Correct:/);
86
+ describe("goal_control 描述修正(A1/slug optional)", () => {
87
+ it("无 'at least 3 approaches' + reason throw 含 'what you tried' + slug 真 optional", () => {
88
+ // A1: reason schema desc/throw 已删强制 ≥3 声明
89
+ expect(ADAPTER_SRC).not.toMatch(/at least 3 approaches/);
90
+ expect(ADAPTER_SRC).toMatch(/'reason' must not be empty[\s\S]*?what you tried/);
91
+ expect(ADAPTER_SRC).toContain("可选。");
92
+ expect(ADAPTER_SRC).not.toMatch(/'slug' is required/);
94
93
  });
95
94
  });
@@ -104,7 +104,7 @@ function createViaHandler(
104
104
  tool: CapturedTool,
105
105
  pi: ExtensionAPI,
106
106
  ctx: ExtensionContext,
107
- params: { slug: string; objective: string; tokenBudget?: number; timeBudgetMinutes?: number },
107
+ params: { slug: string; objective: string; tokenBudget?: number },
108
108
  ): Promise<ExecuteResult> {
109
109
  return tool.execute(
110
110
  "call-1",
@@ -127,7 +127,6 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
127
127
  objective: "rpc card goal",
128
128
  tokenBudget: 10000,
129
129
  });
130
- expect(result.details.__gui__).toBeDefined();
131
130
  const gui = result.details.__gui__!;
132
131
  expect(gui.component.type).toBe("card");
133
132
  // card body 含 progress-bar(tokenBudget)+ stats-line
@@ -145,7 +144,6 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
145
144
  slug: "rpc-stats",
146
145
  objective: "rpc stats goal",
147
146
  });
148
- expect(result.details.__gui__).toBeDefined();
149
147
  const gui = result.details.__gui__!;
150
148
  expect(gui.component.type).toBe("stats-line");
151
149
  // stats-line items 含 goal/status/turn/tokens
@@ -182,20 +180,6 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
182
180
  ).rejects.toThrow(/not active/i);
183
181
  });
184
182
 
185
- it("TUI 模式 → __gui__ 不附加(即使有 state)", async () => {
186
- const { pi, ctx } = makeFixture("tui");
187
- const tool = captureTool(pi);
188
- const result = await createViaHandler(tool, pi, ctx, {
189
- slug: "tui-no-gui",
190
- objective: "tui goal",
191
- tokenBudget: 5000,
192
- });
193
- // TUI 模式不注入 __gui__
194
- expect(result.details.__gui__).toBeUndefined();
195
- // 但 content 仍正常
196
- expect(result.content[0]!.text).toContain("Goal created");
197
- });
198
-
199
183
  it("RPC + complete 动作 → __gui__ 也附加(用终态 state 渲染)", async () => {
200
184
  // 验证 __gui__ 注入不限于 create:complete 后 session.state 仍非 null(status=complete)
201
185
  const { pi, ctx } = makeFixture("rpc");
@@ -210,7 +194,6 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
210
194
  undefined,
211
195
  ctx,
212
196
  );
213
- expect(result.details.__gui__).toBeDefined();
214
197
  expect(result.details.status).toBe("complete");
215
198
  const gui = result.details.__gui__!;
216
199
  // 无 budget → stats-line
@@ -231,7 +214,6 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
231
214
  undefined,
232
215
  ctx,
233
216
  );
234
- expect(result.details.__gui__).toBeDefined();
235
217
  expect(result.details.status).toBe("blocked");
236
218
  // 无 budget → stats-line;blocked status severity=danger
237
219
  const gui = result.details.__gui__!;
@@ -241,44 +223,21 @@ describe("goal_control execute — RPC __gui__ 注入分支", () => {
241
223
  expect(statusItem!.severity).toBe("danger");
242
224
  });
243
225
 
244
- it("RPC + 有 time budget → __gui__ card 含 time progress-bar", async () => {
245
- const { pi, ctx } = makeFixture("rpc");
246
- const tool = captureTool(pi);
247
- const result = await createViaHandler(tool, pi, ctx, {
248
- slug: "time-budget",
249
- objective: "time boxed",
250
- timeBudgetMinutes: 30,
251
- });
252
- const gui = result.details.__gui__!;
253
- expect(gui.component.type).toBe("card");
254
- const body = gui.component.props.body as Array<{ type: string; props: { label?: string } }>;
255
- const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time");
256
- expect(timeBar).toBeDefined();
257
- });
258
226
  });
259
227
 
260
- // ── 边界:非 RPC 模式(json/print)也不注入 __gui__ ──
228
+ // ── 边界:非 RPC 模式(tui/json/print)也不注入 __gui__ ──
261
229
 
262
230
  describe("goal_control execute — 非 RPC 模式不注入 __gui__", () => {
263
- it("json 模式 → __gui__ 不附加", async () => {
264
- const { pi, ctx } = makeFixture("json");
231
+ const NON_RPC_MODES: Array<"tui" | "json" | "print"> = ["tui", "json", "print"];
232
+ it.each(NON_RPC_MODES)("%s 模式 → __gui__ 不附加(content 仍正常)", async (mode) => {
233
+ const { pi, ctx } = makeFixture(mode);
265
234
  const tool = captureTool(pi);
266
235
  const result = await createViaHandler(tool, pi, ctx, {
267
- slug: "json-mode",
268
- objective: "json goal",
269
- tokenBudget: 1000,
270
- });
271
- expect(result.details.__gui__).toBeUndefined();
272
- });
273
-
274
- it("print 模式 → __gui__ 不附加", async () => {
275
- const { pi, ctx } = makeFixture("print");
276
- const tool = captureTool(pi);
277
- const result = await createViaHandler(tool, pi, ctx, {
278
- slug: "print-mode",
279
- objective: "print goal",
236
+ slug: `${mode}-mode`,
237
+ objective: `${mode} goal`,
280
238
  tokenBudget: 1000,
281
239
  });
282
240
  expect(result.details.__gui__).toBeUndefined();
241
+ expect(result.content[0]!.text).toContain("Goal created");
283
242
  });
284
243
  });
@@ -9,9 +9,9 @@
9
9
  */
10
10
  import { describe, expect, it } from "vitest";
11
11
 
12
- import { buildGoalGui } from "../adapters/goal-control-adapter";
12
+ import { buildGoalGui } from "../projection/gui";
13
13
  import { createGoalState } from "../engine/goal";
14
- import type { GoalRuntimeState } from "../engine/types";
14
+ import type { GoalRuntimeState, GoalStatus } from "../engine/types";
15
15
 
16
16
  function makeState(overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState {
17
17
  return {
@@ -20,6 +20,15 @@ function makeState(overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState
20
20
  };
21
21
  }
22
22
 
23
+ /** 从 card 分支的 body 中按 type 取组件(hasBudget 分支只有一个 progress-bar / 一个 stats-line 组合)。 */
24
+ function findBodyComp(
25
+ gui: ReturnType<typeof buildGoalGui>,
26
+ type: string,
27
+ ): { type: string; props: Record<string, unknown> } {
28
+ const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
29
+ return body.find((c) => c.type === type)!;
30
+ }
31
+
23
32
  describe("buildGoalGui", () => {
24
33
  it("有 tokenBudget → card 含 progress-bar + stats-line", () => {
25
34
  const gui = buildGoalGui(
@@ -31,36 +40,21 @@ describe("buildGoalGui", () => {
31
40
  );
32
41
  expect(gui.v).toBe(1);
33
42
  expect(gui.component.type).toBe("card");
34
- const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
35
- // body[0] = progress-bar(tokens), body[1] = stats-line
36
- const tokenBar = body.find((c) => c.type === "progress-bar")!;
43
+ const tokenBar = findBodyComp(gui, "progress-bar");
37
44
  expect(tokenBar.props).toMatchObject({ current: 4200, total: 10000, severity: "ok" });
38
- const stats = body.find((c) => c.type === "stats-line")!;
45
+ const stats = findBodyComp(gui, "stats-line");
39
46
  expect(stats.props.items).toContainEqual(expect.objectContaining({ label: "status", value: "active" }));
40
47
  });
41
48
 
42
- it("token 消耗 ≥90% → severity danger", () => {
43
- const gui = buildGoalGui(
44
- makeState({
45
- tokensUsed: 9500,
46
- budget: { tokenBudget: 10000 },
47
- }),
48
- );
49
- const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
50
- const tokenBar = body.find((c) => c.type === "progress-bar")!;
51
- expect(tokenBar.props.severity).toBe("danger");
52
- });
53
-
54
- it("token 消耗 ≥70% → severity warn", () => {
55
- const gui = buildGoalGui(
56
- makeState({
57
- tokensUsed: 7500,
58
- budget: { tokenBudget: 10000 },
59
- }),
60
- );
61
- const body = gui.component.props.body as { type: string; props: Record<string, unknown> }[];
62
- const tokenBar = body.find((c) => c.type === "progress-bar")!;
63
- expect(tokenBar.props.severity).toBe("warn");
49
+ it.each([
50
+ ["9500/10000 ≥90%", 9500, "danger"],
51
+ ["9000/10000 =90%", 9000, "danger"],
52
+ ["7500/10000 ≥70%", 7500, "warn"],
53
+ ["7000/10000 =70%", 7000, "warn"],
54
+ ])("token 消耗 %s → severity %s(S#14 边界 ≥)", (_label, used, severity) => {
55
+ const gui = buildGoalGui(makeState({ tokensUsed: used, budget: { tokenBudget: 10000 } }));
56
+ const tokenBar = findBodyComp(gui, "progress-bar");
57
+ expect(tokenBar.props.severity).toBe(severity);
64
58
  });
65
59
 
66
60
  it("无 budget → stats-line 摘要", () => {
@@ -101,18 +95,6 @@ describe("buildGoalGui", () => {
101
95
  expect(gui.component.props.variant).toBe("success");
102
96
  });
103
97
 
104
- it("time 进度条:≥90% → danger", () => {
105
- const gui = buildGoalGui(
106
- makeState({
107
- timeUsedSeconds: 5 * 60 + 1, // 5.01 min / 5 min budget
108
- budget: { timeBudgetMinutes: 5 },
109
- }),
110
- );
111
- const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
112
- const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time")!;
113
- expect(timeBar.props.severity).toBe("danger");
114
- });
115
-
116
98
  it("slug 缺省 → header 用 goalId 前 8 字符", () => {
117
99
  const state = makeState({ slug: undefined, budget: { tokenBudget: 10000 } });
118
100
  const gui = buildGoalGui(state);
@@ -121,59 +103,16 @@ describe("buildGoalGui", () => {
121
103
 
122
104
  // ── S#2: statusSeverity 完整覆盖 ──
123
105
 
124
- it("budget_limited 状态 → status severity danger(S#2)", () => {
125
- const gui = buildGoalGui(makeState({ status: "budget_limited", budget: { tokenBudget: 10000 } }));
126
- const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
127
- const stats = body.find((c) => c.type === "stats-line")!;
128
- const statusItem = stats.props.items.find((i) => i.label === "status")!;
129
- expect(statusItem.severity).toBe("danger");
130
- });
131
-
132
- it("time_limited 状态 → status severity danger(S#2)", () => {
133
- const gui = buildGoalGui(makeState({ status: "time_limited", budget: { tokenBudget: 10000 } }));
134
- const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
135
- const stats = body.find((c) => c.type === "stats-line")!;
136
- const statusItem = stats.props.items.find((i) => i.label === "status")!;
137
- expect(statusItem.severity).toBe("danger");
138
- });
139
-
140
- it("cancelled 状态 → status severity danger(S#2)", () => {
141
- const gui = buildGoalGui(makeState({ status: "cancelled", budget: { tokenBudget: 10000 } }));
142
- const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
143
- const stats = body.find((c) => c.type === "stats-line")!;
144
- const statusItem = stats.props.items.find((i) => i.label === "status")!;
145
- expect(statusItem.severity).toBe("danger");
146
- });
147
-
148
- it("paused 状态 → status severity warn(S#2)", () => {
149
- const gui = buildGoalGui(makeState({ status: "paused", budget: { tokenBudget: 10000 } }));
150
- const body = gui.component.props.body as { type: string; props: { items: Array<{ label: string; severity: string }> } }[];
151
- const stats = body.find((c) => c.type === "stats-line")!;
152
- const statusItem = stats.props.items.find((i) => i.label === "status")!;
153
- expect(statusItem.severity).toBe("warn");
154
- });
155
-
156
- // ── S#14: 阈值边界精确值 ──
157
-
158
- it("token 消耗正好 90% → severity danger(边界 ≥,S#14)", () => {
159
- const gui = buildGoalGui(makeState({ tokensUsed: 9000, budget: { tokenBudget: 10000 } }));
160
- const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
161
- const tokenBar = body.find((c) => c.type === "progress-bar" && c.props.label === "tokens")!;
162
- expect(tokenBar.props.severity).toBe("danger");
163
- });
164
-
165
- it("token 消耗正好 70% → severity warn(边界 ≥,S#14)", () => {
166
- const gui = buildGoalGui(makeState({ tokensUsed: 7000, budget: { tokenBudget: 10000 } }));
167
- const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
168
- const tokenBar = body.find((c) => c.type === "progress-bar" && c.props.label === "tokens")!;
169
- expect(tokenBar.props.severity).toBe("warn");
170
- });
171
-
172
- it("time 消耗正好 70% → severity warn(边界 ≥,S#14)", () => {
173
- const gui = buildGoalGui(makeState({ timeUsedSeconds: 7 * 60, budget: { timeBudgetMinutes: 10 } }));
174
- const body = gui.component.props.body as { type: string; props: { label?: string; severity?: string } }[];
175
- const timeBar = body.find((c) => c.type === "progress-bar" && c.props.label === "time")!;
176
- expect(timeBar.props.severity).toBe("warn");
106
+ it.each([
107
+ ["budget_limited", "danger"],
108
+ ["cancelled", "danger"],
109
+ ["paused", "warn"],
110
+ ])("status %s → stats-line status severity %s(S#2)", (status, severity) => {
111
+ const gui = buildGoalGui(makeState({ status: status as GoalStatus, budget: { tokenBudget: 10000 } }));
112
+ const stats = findBodyComp(gui, "stats-line");
113
+ const items = stats.props.items as Array<{ label: string; severity?: string }>;
114
+ const statusItem = items.find((i) => i.label === "status")!;
115
+ expect(statusItem.severity).toBe(severity);
177
116
  });
178
117
 
179
118
  // ── I#1: tokenBudget=0 口径统一 ──