@zhushanwen/pi-goal 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/package.json +8 -4
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +80 -0
- package/src/__tests__/event-adapter.test.ts +339 -0
- package/src/__tests__/goal-control-adapter.test.ts +303 -0
- package/src/__tests__/index.test.ts +119 -0
- package/src/__tests__/service.test.ts +317 -0
- package/src/__tests__/session.test.ts +220 -0
- package/src/__tests__/stale-checker.test.ts +78 -0
- package/src/__tests__/stubs/pi-sdk.ts +16 -0
- package/src/__tests__/stubs/typebox.ts +14 -0
- package/src/adapters/command-adapter.ts +360 -0
- package/src/adapters/event-adapter.ts +17 -0
- package/src/adapters/event-handlers/agent-end.ts +177 -0
- package/src/adapters/event-handlers/agent-start.ts +17 -0
- package/src/adapters/event-handlers/before-agent-start.ts +115 -0
- package/src/adapters/event-handlers/message-end.ts +37 -0
- package/src/adapters/event-handlers/session-start.ts +24 -0
- package/src/adapters/event-handlers/shared.ts +46 -0
- package/src/adapters/event-handlers/turn-end.ts +37 -0
- package/src/adapters/goal-control-adapter.ts +296 -0
- package/src/adapters/ports.ts +89 -0
- package/src/commands.ts +7 -19
- package/src/constants.ts +1 -3
- package/src/engine/__tests__/budget.test.ts +182 -0
- package/src/engine/__tests__/goal.test.ts +126 -0
- package/src/engine/budget.ts +146 -0
- package/src/engine/goal.ts +62 -0
- package/src/engine/types.ts +80 -0
- package/src/index.ts +110 -333
- package/src/persistence.ts +83 -0
- package/src/ports.ts +68 -0
- package/src/projection/__tests__/prompts.test.ts +245 -0
- package/src/projection/__tests__/widget.test.ts +255 -0
- package/src/projection/prompts.ts +250 -0
- package/src/projection/widget.ts +254 -0
- package/src/service.ts +276 -0
- package/src/session.ts +114 -0
- package/src/action-handlers.ts +0 -341
- package/src/agent-end-handler.ts +0 -304
- package/src/before-agent-start-handler.ts +0 -182
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -280
- package/src/state.ts +0 -250
- package/src/templates.ts +0 -232
- package/src/tool-handler.ts +0 -252
- package/src/widget.ts +0 -186
package/src/ports.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports — 能力抽象接口
|
|
3
|
+
*
|
|
4
|
+
* D-22: ports 的核心价值是机器可检查的边界(engine/ 禁止 import Pi),
|
|
5
|
+
* 不是"可替换的 adapter"。service 层通过这些接口访问 Pi 能力,
|
|
6
|
+
* adapter 层提供实现(包装 ctx / pi)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { GoalRuntimeState } from "./engine/types";
|
|
10
|
+
|
|
11
|
+
// ── GoalHistoryEntry(DTO,非 aggregate,D-09)─────────
|
|
12
|
+
|
|
13
|
+
export interface GoalHistoryEntry {
|
|
14
|
+
goalId: string;
|
|
15
|
+
objective: string;
|
|
16
|
+
/** widget/history 标题用(fallback objective 截断)。旧 entry 无此字段。 */
|
|
17
|
+
slug?: string;
|
|
18
|
+
status: string;
|
|
19
|
+
completedTasks: number;
|
|
20
|
+
totalTasks: number;
|
|
21
|
+
elapsedSeconds: number;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── PersistencePort ──────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface PersistencePort {
|
|
28
|
+
/** 写入 goal-state entry(最新 1 条,GC 由 session 层管) */
|
|
29
|
+
appendState(state: GoalRuntimeState): void;
|
|
30
|
+
/** 写入 goal-history 归档 entry */
|
|
31
|
+
appendHistory(entry: GoalHistoryEntry): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── UiPort ───────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface UiPort {
|
|
37
|
+
/** 设置 widget(undefined = 清除)。hasUI=false 时 adapter 跳过(FR-6.6) */
|
|
38
|
+
setWidget(name: string, content: string[] | string | undefined): void;
|
|
39
|
+
/** 设置 status bar */
|
|
40
|
+
setStatus(name: string, text: string | undefined): void;
|
|
41
|
+
/** 弹通知 */
|
|
42
|
+
notify(text: string, level: "info" | "warning" | "error"): void;
|
|
43
|
+
/** 是否有 UI(headless/RPC mode 为 false) */
|
|
44
|
+
readonly hasUI: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── MessagingPort ────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface MessagingPort {
|
|
50
|
+
/** 发送 custom message(goal-context 等) */
|
|
51
|
+
sendContextMessage(content: string, deliverAs: "steer" | "followUp", customType?: string): void;
|
|
52
|
+
/** 发送 user message(触发 AI 开始工作,FR-8.12) */
|
|
53
|
+
sendUserMessage(content: string, deliverAs: "steer" | "followUp"): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── SessionPort ──────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface SessionEntryLike {
|
|
59
|
+
type: string;
|
|
60
|
+
customType?: string;
|
|
61
|
+
data?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SessionPort {
|
|
65
|
+
getEntries(): SessionEntryLike[];
|
|
66
|
+
getContextUsage(): { tokens?: number; contextWindow?: number } | null;
|
|
67
|
+
readonly signal: AbortSignal | undefined;
|
|
68
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projection/prompts.ts 测试 — prompt 生成函数 + formatBudget 4 样式
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - formatBudget 4 种 style(percent/line/remaining/report)
|
|
6
|
+
* - escapeXmlText(XML 注入防护)
|
|
7
|
+
* - continuationPrompt / budgetLimitPrompt / objectiveUpdatedPrompt / contextInjectionPrompt
|
|
8
|
+
*
|
|
9
|
+
* 全解耦后 stalenessReminderPrompt 已删(原依赖 pi.__todoGetList,跨 ext 失效)。
|
|
10
|
+
*
|
|
11
|
+
* 纯函数测试,不 import Pi SDK。
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { createGoalState } from "../../engine/goal";
|
|
16
|
+
import type { GoalRuntimeState } from "../../engine/types";
|
|
17
|
+
import {
|
|
18
|
+
budgetLimitPrompt,
|
|
19
|
+
contextInjectionPrompt,
|
|
20
|
+
continuationPrompt,
|
|
21
|
+
formatBudget,
|
|
22
|
+
objectiveUpdatedPrompt,
|
|
23
|
+
} from "../prompts";
|
|
24
|
+
|
|
25
|
+
// ── 辅助 ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function makeState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
|
|
28
|
+
return {
|
|
29
|
+
...createGoalState("test objective"),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── formatBudget 4 样式(FR-3.4 唯一收敛出口)────────
|
|
35
|
+
|
|
36
|
+
describe("formatBudget — 4 styles (FR-3.4)", () => {
|
|
37
|
+
it("percent: Token + Time 百分比", () => {
|
|
38
|
+
const state = makeState({
|
|
39
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
40
|
+
tokensUsed: 500,
|
|
41
|
+
});
|
|
42
|
+
const out = formatBudget(state, 300, "percent"); // 300s = 5min / 10min = 50%
|
|
43
|
+
expect(out).toContain("Token: 50%");
|
|
44
|
+
expect(out).toContain("Time: 50%");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("percent: 无预算 → 空字符串", () => {
|
|
48
|
+
const state = makeState();
|
|
49
|
+
expect(formatBudget(state, 0, "percent")).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("line: 剩余/总量格式", () => {
|
|
53
|
+
const state = makeState({
|
|
54
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
55
|
+
tokensUsed: 300,
|
|
56
|
+
});
|
|
57
|
+
const out = formatBudget(state, 120, "line"); // 120s = 2min used, 8min remaining
|
|
58
|
+
expect(out).toContain("Tokens: 700/1000");
|
|
59
|
+
expect(out).toContain("Time: 8m/10m");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("remaining: used/total (N remaining) 格式", () => {
|
|
63
|
+
const state = makeState({
|
|
64
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
65
|
+
tokensUsed: 400,
|
|
66
|
+
});
|
|
67
|
+
const out = formatBudget(state, 60, "remaining"); // 60s=1min used, 9min remaining
|
|
68
|
+
expect(out).toContain("Token: 400/1000 (600 remaining)");
|
|
69
|
+
expect(out).toContain("Time: 1m/10m (9m remaining)");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("report: 多行 usage + duration", () => {
|
|
73
|
+
const state = makeState({
|
|
74
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
75
|
+
tokensUsed: 700,
|
|
76
|
+
});
|
|
77
|
+
const out = formatBudget(state, 125, "report"); // 125s = 2m5s
|
|
78
|
+
expect(out).toContain("Token usage: 700/1000");
|
|
79
|
+
expect(out).toContain("Duration: 2m5s");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("report: 无 token 预算 → 只有 duration", () => {
|
|
83
|
+
const state = makeState();
|
|
84
|
+
const out = formatBudget(state, 65, "report"); // 65s = 1m5s
|
|
85
|
+
expect(out).toBe("Duration: 1m5s");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("remaining clamp: 超预算不出现负数", () => {
|
|
89
|
+
const state = makeState({
|
|
90
|
+
budget: { tokenBudget: 100, timeBudgetMinutes: 1 },
|
|
91
|
+
tokensUsed: 150, // 超 tokenBudget
|
|
92
|
+
});
|
|
93
|
+
const out = formatBudget(state, 120, "remaining"); // 120s 超 1min budget
|
|
94
|
+
expect(out).toContain("(0 remaining)"); // 不出现负数
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── escapeXmlText(XML 注入防护)──────────────────────
|
|
99
|
+
|
|
100
|
+
describe("XML escaping in prompts", () => {
|
|
101
|
+
it("objective 中的 <>& 被转义(continuationPrompt)", () => {
|
|
102
|
+
const state = makeState({ objective: "<script>alert('x')</script> & data" });
|
|
103
|
+
const out = continuationPrompt(state, 0);
|
|
104
|
+
expect(out).toContain("<script>");
|
|
105
|
+
expect(out).toContain("& data");
|
|
106
|
+
expect(out).not.toContain("<script>");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("objectiveUpdatedPrompt 转义新旧 objective", () => {
|
|
110
|
+
const state = makeState({ objective: "new <b>x</b>" });
|
|
111
|
+
const out = objectiveUpdatedPrompt(state, "old <i>y</i> & z");
|
|
112
|
+
expect(out).toContain("<b>x</b>");
|
|
113
|
+
expect(out).toContain("<i>y</i>");
|
|
114
|
+
expect(out).toContain("& z");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── continuationPrompt ───────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("continuationPrompt", () => {
|
|
121
|
+
it("含 objective + Turn + Completion audit 段落", () => {
|
|
122
|
+
const state = makeState({ currentTurnIndex: 3 });
|
|
123
|
+
const out = continuationPrompt(state, 0);
|
|
124
|
+
expect(out).toContain("Turn 3");
|
|
125
|
+
expect(out).toContain("test objective");
|
|
126
|
+
expect(out).toContain("Completion audit");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("对标 Codex 三约束:Completion audit / Fidelity / Blocked 完整", () => {
|
|
130
|
+
const state = makeState();
|
|
131
|
+
const out = continuationPrompt(state, 0);
|
|
132
|
+
// Completion audit: 逐项证据验证
|
|
133
|
+
expect(out).toContain("Completion audit");
|
|
134
|
+
expect(out).toContain("Evidence must prove completion");
|
|
135
|
+
expect(out).toContain("are NOT evidence");
|
|
136
|
+
// Fidelity: 不缩小目标范围
|
|
137
|
+
expect(out).toContain("Fidelity");
|
|
138
|
+
expect(out).toContain("requested end state");
|
|
139
|
+
expect(out).toContain("narrower or safer solution");
|
|
140
|
+
// Blocked: 不首次就放弃
|
|
141
|
+
expect(out).toContain("Blocked");
|
|
142
|
+
expect(out).toContain("Do not report blocked the first time");
|
|
143
|
+
expect(out).toContain("try alternative approaches first");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("软建议 complete 前完成所有 todo(FR-6,全解耦后非强制)", () => {
|
|
147
|
+
const state = makeState();
|
|
148
|
+
const out = continuationPrompt(state, 0);
|
|
149
|
+
expect(out).toContain("Recommend finishing all todos");
|
|
150
|
+
expect(out).toContain("verification todos");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("含 plan audit 软提醒(FR-7/D27 prompt 驱动)", () => {
|
|
154
|
+
const state = makeState();
|
|
155
|
+
const out = continuationPrompt(state, 0);
|
|
156
|
+
expect(out).toContain("plan.md step was executed");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── budgetLimitPrompt ────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("budgetLimitPrompt", () => {
|
|
163
|
+
it("token 维度 → TOKEN budget 提示", () => {
|
|
164
|
+
const state = makeState({
|
|
165
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
166
|
+
tokensUsed: 950,
|
|
167
|
+
});
|
|
168
|
+
const out = budgetLimitPrompt(state, "token", 60);
|
|
169
|
+
expect(out).toContain("TOKEN budget");
|
|
170
|
+
expect(out).toContain("Tokens used: 950 / 1000");
|
|
171
|
+
expect(out).toContain("wrap up immediately");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("time 维度 → time budget 提示", () => {
|
|
175
|
+
const state = makeState({
|
|
176
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
177
|
+
});
|
|
178
|
+
const out = budgetLimitPrompt(state, "time", 540); // 540s = 9m
|
|
179
|
+
expect(out).toContain("time budget");
|
|
180
|
+
expect(out).toContain("Time elapsed: 9m0s / 10 min");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── objectiveUpdatedPrompt ───────────────────────────
|
|
185
|
+
|
|
186
|
+
describe("objectiveUpdatedPrompt", () => {
|
|
187
|
+
it("显示新旧 objective + 指令", () => {
|
|
188
|
+
const state = makeState({ objective: "new obj" });
|
|
189
|
+
const out = objectiveUpdatedPrompt(state, "old obj");
|
|
190
|
+
expect(out).toContain("Objective updated");
|
|
191
|
+
expect(out).toContain("Previous objective: old obj");
|
|
192
|
+
expect(out).toContain("new obj");
|
|
193
|
+
expect(out).toContain("supersedes");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── contextInjectionPrompt ───────────────────────────
|
|
198
|
+
|
|
199
|
+
describe("contextInjectionPrompt", () => {
|
|
200
|
+
it("包含 objective/status/turn + 预算百分比 + 规则", () => {
|
|
201
|
+
const state = makeState({
|
|
202
|
+
status: "active",
|
|
203
|
+
currentTurnIndex: 2,
|
|
204
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
205
|
+
tokensUsed: 200,
|
|
206
|
+
});
|
|
207
|
+
const out = contextInjectionPrompt(state, 60);
|
|
208
|
+
expect(out).toContain("GOAL mode activated");
|
|
209
|
+
expect(out).toContain("Status: active");
|
|
210
|
+
expect(out).toContain("Turn: 2");
|
|
211
|
+
expect(out).toContain("Token: 20%"); // 200/1000
|
|
212
|
+
expect(out).toContain("test objective");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("要求建 todo(含 verification todo,FR-6/#10)", () => {
|
|
216
|
+
const state = makeState();
|
|
217
|
+
const out = contextInjectionPrompt(state, 0);
|
|
218
|
+
expect(out).toContain("todo tool");
|
|
219
|
+
expect(out).toContain("verification todos");
|
|
220
|
+
expect(out).toContain("isVerification intent");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("无 goal_manager 引用(#1 清理后)", () => {
|
|
224
|
+
const state = makeState();
|
|
225
|
+
const out = contextInjectionPrompt(state, 0);
|
|
226
|
+
expect(out).not.toContain("goal_manager");
|
|
227
|
+
expect(out).not.toContain("create_tasks");
|
|
228
|
+
expect(out).not.toContain("add_subtasks");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("planAvailable=false(默认)→ 不建议 plan mode(避免建议不存在的工具,FR-7)", () => {
|
|
232
|
+
const state = makeState();
|
|
233
|
+
const out = contextInjectionPrompt(state, 0, false);
|
|
234
|
+
expect(out).not.toContain("plan mode");
|
|
235
|
+
expect(out).not.toContain("__planStart");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("planAvailable=true → 注入 plan mode 建议段落(FR-7 LLM 自主判断复杂度)", () => {
|
|
239
|
+
const state = makeState();
|
|
240
|
+
const out = contextInjectionPrompt(state, 0, true);
|
|
241
|
+
expect(out).toContain("plan mode");
|
|
242
|
+
expect(out).toContain("__planStart");
|
|
243
|
+
expect(out).toContain("Complex tasks");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* widget.ts 测试 — projection 层渲染(TC-2)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - toSingleLine: 多行压缩
|
|
6
|
+
* - renderStatusLine: 状态后缀 + cancelled 短路 + 预算/停滞指标
|
|
7
|
+
* - renderWidgetLines: cancelled 短路 + objective + 预算进度条
|
|
8
|
+
* - renderTerminalStatusLine: 终态单行 + cancelled 短路
|
|
9
|
+
* - updateWidget: FR-6.6 hasUI 守卫 + 终态折叠 + cancelled 清除
|
|
10
|
+
*
|
|
11
|
+
* 用 passthrough ThemeLike(fg/bold 返回原文),断言渲染逻辑而非颜色。
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { createGoalState } from "../../engine/goal";
|
|
16
|
+
import type { GoalRuntimeState } from "../../engine/types";
|
|
17
|
+
import type { UiPort } from "../../ports";
|
|
18
|
+
import { createGoalSession } from "../../session";
|
|
19
|
+
import {
|
|
20
|
+
renderStatusLine,
|
|
21
|
+
renderTerminalStatusLine,
|
|
22
|
+
renderWidgetLines,
|
|
23
|
+
type ThemeLike,
|
|
24
|
+
toSingleLine,
|
|
25
|
+
updateWidget,
|
|
26
|
+
} from "../widget";
|
|
27
|
+
|
|
28
|
+
// ── Passthrough theme(断言渲染逻辑,不含颜色干扰)────
|
|
29
|
+
|
|
30
|
+
const theme: ThemeLike = {
|
|
31
|
+
fg: (_color: string, text: string) => text,
|
|
32
|
+
bold: (text: string) => text,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── 辅助 ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function makeState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
|
|
38
|
+
return {
|
|
39
|
+
...createGoalState("test objective"),
|
|
40
|
+
status: "active",
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── toSingleLine ─────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("toSingleLine", () => {
|
|
48
|
+
it("多行 → 空格分隔单行 + 去首尾空白(不折叠连续空格)", () => {
|
|
49
|
+
expect(toSingleLine("hello\nworld")).toBe("hello world");
|
|
50
|
+
expect(toSingleLine("a\nb\nc")).toBe("a b c");
|
|
51
|
+
expect(toSingleLine(" trimmed ")).toBe("trimmed");
|
|
52
|
+
});
|
|
53
|
+
it("单行原样返回(trim)", () => {
|
|
54
|
+
expect(toSingleLine(" single ")).toBe("single");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── renderStatusLine ─────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe("renderStatusLine", () => {
|
|
61
|
+
it("cancelled → 空字符串(短路)", () => {
|
|
62
|
+
expect(renderStatusLine(makeState({ status: "cancelled" }), theme)).toBe("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("active 状态:标题用 slug(fallback objective)+ turn 计数", () => {
|
|
66
|
+
const text = renderStatusLine(makeState({ status: "active", currentTurnIndex: 3 }), theme);
|
|
67
|
+
// 无 slug 时 fallback 到 objective 截断
|
|
68
|
+
expect(text).toContain("◆ test objective");
|
|
69
|
+
expect(text).toContain("Turn 3"); // currentTurnIndex
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("active 状态有 slug → 标题用 slug", () => {
|
|
73
|
+
const text = renderStatusLine(
|
|
74
|
+
makeState({ status: "active", slug: "refactor-auth", currentTurnIndex: 2 }),
|
|
75
|
+
theme,
|
|
76
|
+
);
|
|
77
|
+
expect(text).toContain("◆ refactor-auth");
|
|
78
|
+
expect(text).not.toContain("test objective"); // slug 优先,objective 不显示
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("无预算 → 显示已消耗绝对值(token + time)", () => {
|
|
82
|
+
const text = renderStatusLine(
|
|
83
|
+
makeState({ status: "active", tokensUsed: 12000, timeUsedSeconds: 90 }),
|
|
84
|
+
theme,
|
|
85
|
+
);
|
|
86
|
+
expect(text).toContain("12k tokens"); // formatTokens 缩写
|
|
87
|
+
expect(text).toContain("1m30s"); // formatMinutes
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("blocked → 含 ⊘ Blocked 后缀", () => {
|
|
91
|
+
const text = renderStatusLine(makeState({ status: "blocked" }), theme);
|
|
92
|
+
expect(text).toContain("⊘ Blocked");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("paused → 含 ⏸ Paused 后缀", () => {
|
|
96
|
+
const text = renderStatusLine(makeState({ status: "paused" }), theme);
|
|
97
|
+
expect(text).toContain("⏸ Paused");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("complete → 含 ✓ Completed 后缀", () => {
|
|
101
|
+
const text = renderStatusLine(makeState({ status: "complete" }), theme);
|
|
102
|
+
expect(text).toContain("✓ Completed");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("budget_limited → 含 ⊗ Token budget exhausted 后缀", () => {
|
|
106
|
+
const text = renderStatusLine(makeState({ status: "budget_limited" }), theme);
|
|
107
|
+
expect(text).toContain("⊗ Token budget exhausted");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("time_limited → 含 ⏱ Time budget exhausted 后缀", () => {
|
|
111
|
+
const text = renderStatusLine(makeState({ status: "time_limited" }), theme);
|
|
112
|
+
expect(text).toContain("⏱ Time budget exhausted");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("tokenBudget > 0 → 显示 token 百分比", () => {
|
|
116
|
+
const text = renderStatusLine(
|
|
117
|
+
makeState({
|
|
118
|
+
status: "active",
|
|
119
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 0 },
|
|
120
|
+
tokensUsed: 500,
|
|
121
|
+
}),
|
|
122
|
+
theme,
|
|
123
|
+
);
|
|
124
|
+
expect(text).toContain("50% tokens");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── renderTerminalStatusLine ─────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("renderTerminalStatusLine", () => {
|
|
131
|
+
it("cancelled → 空字符串", () => {
|
|
132
|
+
expect(renderTerminalStatusLine(makeState({ status: "cancelled" }), theme)).toBe("");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("complete → 含 ✓ Completed", () => {
|
|
136
|
+
const text = renderTerminalStatusLine(makeState({ status: "complete" }), theme);
|
|
137
|
+
expect(text).toContain("✓ Completed");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("budget_limited → 含 ⊗ Token budget exhausted", () => {
|
|
141
|
+
const text = renderTerminalStatusLine(makeState({ status: "budget_limited" }), theme);
|
|
142
|
+
expect(text).toContain("⊗ Token budget exhausted");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── renderWidgetLines ────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe("renderWidgetLines", () => {
|
|
149
|
+
it("cancelled → 空数组", () => {
|
|
150
|
+
expect(renderWidgetLines(makeState({ status: "cancelled" }), theme)).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("active → 不含 objective 全文行(精简,slug 作标题)", () => {
|
|
154
|
+
const lines = renderWidgetLines(makeState({ status: "active" }), theme);
|
|
155
|
+
// GAP-8: 移除 Objective 全文行(slug 已作标题)
|
|
156
|
+
expect(lines.some((l) => l.includes("Objective:"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("active 无 slug → 标题 fallback objective 截断", () => {
|
|
160
|
+
const lines = renderWidgetLines(makeState({ status: "active" }), theme);
|
|
161
|
+
expect(lines[0]).toContain("◆ test objective");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("tokenBudget + timeBudget → 含进度条行(used/total 格式)", () => {
|
|
165
|
+
const lines = renderWidgetLines(
|
|
166
|
+
makeState({
|
|
167
|
+
status: "active",
|
|
168
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 30 },
|
|
169
|
+
tokensUsed: 250,
|
|
170
|
+
timeUsedSeconds: 540, // 9 min
|
|
171
|
+
}),
|
|
172
|
+
theme,
|
|
173
|
+
);
|
|
174
|
+
// 新格式:进度条 + used/total(缩写)
|
|
175
|
+
expect(lines.some((l) => l.includes("Token:") && l.includes("250/1k"))).toBe(true);
|
|
176
|
+
expect(lines.some((l) => l.includes("Time:") && l.includes("9m/30min"))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("无预算 → 显示已消耗绝对值行(no budget)", () => {
|
|
180
|
+
const lines = renderWidgetLines(
|
|
181
|
+
makeState({ status: "active", tokensUsed: 5000, timeUsedSeconds: 120 }),
|
|
182
|
+
theme,
|
|
183
|
+
);
|
|
184
|
+
expect(lines.some((l) => l.includes("5k used (no budget)"))).toBe(true);
|
|
185
|
+
expect(lines.some((l) => l.includes("2m elapsed (no budget)"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── updateWidget(FR-6.6 hasUI 守卫)─────────────────
|
|
190
|
+
|
|
191
|
+
describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
192
|
+
interface RecordedCall {
|
|
193
|
+
method: "setWidget" | "setStatus";
|
|
194
|
+
args: unknown[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function makeUiPort(hasUI: boolean): { ui: UiPort; calls: RecordedCall[] } {
|
|
198
|
+
const calls: RecordedCall[] = [];
|
|
199
|
+
const ui = {
|
|
200
|
+
hasUI,
|
|
201
|
+
setWidget(name: string, content: unknown) {
|
|
202
|
+
calls.push({ method: "setWidget", args: [name, content] });
|
|
203
|
+
},
|
|
204
|
+
setStatus(name: string, text: unknown) {
|
|
205
|
+
calls.push({ method: "setStatus", args: [name, text] });
|
|
206
|
+
},
|
|
207
|
+
notify() {},
|
|
208
|
+
fg: (_color: string, text: string) => text,
|
|
209
|
+
bold: (text: string) => text,
|
|
210
|
+
} as UiPort;
|
|
211
|
+
return { ui, calls };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
it("hasUI=false → 不调 setWidget/setStatus(headless 守卫)", () => {
|
|
215
|
+
const { ui, calls } = makeUiPort(false);
|
|
216
|
+
const session = createGoalSession();
|
|
217
|
+
session.state = makeState({ status: "active" });
|
|
218
|
+
updateWidget(session, ui);
|
|
219
|
+
expect(calls).toHaveLength(0); // FR-6.6
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("session.state=null → 清除 widget + status", () => {
|
|
223
|
+
const { ui, calls } = makeUiPort(true);
|
|
224
|
+
updateWidget(createGoalSession(), ui);
|
|
225
|
+
expect(calls.some((c) => c.method === "setWidget" && c.args[1] === undefined)).toBe(true);
|
|
226
|
+
expect(calls.some((c) => c.method === "setStatus" && c.args[1] === undefined)).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("cancelled → 清除 widget + status", () => {
|
|
230
|
+
const { ui, calls } = makeUiPort(true);
|
|
231
|
+
const session = createGoalSession();
|
|
232
|
+
session.state = makeState({ status: "cancelled" });
|
|
233
|
+
updateWidget(session, ui);
|
|
234
|
+
expect(calls.some((c) => c.method === "setWidget" && c.args[1] === undefined)).toBe(true);
|
|
235
|
+
expect(calls.some((c) => c.method === "setStatus" && c.args[1] === undefined)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("终态(非 cancelled)→ setStatus 终态单行 + setWidget undefined", () => {
|
|
239
|
+
const { ui, calls } = makeUiPort(true);
|
|
240
|
+
const session = createGoalSession();
|
|
241
|
+
session.state = makeState({ status: "complete" });
|
|
242
|
+
updateWidget(session, ui);
|
|
243
|
+
expect(calls.some((c) => c.method === "setStatus" && typeof c.args[1] === "string")).toBe(true);
|
|
244
|
+
expect(calls.some((c) => c.method === "setWidget" && c.args[1] === undefined)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("active → setStatus + setWidget(正常渲染)", () => {
|
|
248
|
+
const { ui, calls } = makeUiPort(true);
|
|
249
|
+
const session = createGoalSession();
|
|
250
|
+
session.state = makeState({ status: "active" });
|
|
251
|
+
updateWidget(session, ui);
|
|
252
|
+
expect(calls.some((c) => c.method === "setStatus" && typeof c.args[1] === "string")).toBe(true);
|
|
253
|
+
expect(calls.some((c) => c.method === "setWidget" && Array.isArray(c.args[1]))).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|