@zhushanwen/pi-goal 0.3.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 +1 -1
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +65 -94
- 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 +5 -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 +8 -23
- package/src/constants.ts +1 -8
- 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 -355
- 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/__tests__/is-task-done.test.ts +0 -43
- package/src/__tests__/validate-update-tasks.test.ts +0 -172
- package/src/action-handlers.ts +0 -412
- package/src/agent-end-handler.ts +0 -306
- package/src/before-agent-start-handler.ts +0 -180
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -299
- package/src/state.ts +0 -274
- package/src/templates.ts +0 -274
- package/src/tool-handler.ts +0 -291
- package/src/widget.ts +0 -225
package/src/commands.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* /goal 命令定义和参数解析
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import type { BudgetConfig } from "./
|
|
5
|
+
import { UPDATE_PREFIX_LENGTH } from "./constants";
|
|
6
|
+
import type { BudgetConfig } from "./engine/types";
|
|
7
7
|
|
|
8
8
|
export interface GoalCommandArgs {
|
|
9
|
-
action: "set" | "status" | "pause" | "resume" | "clear" | "
|
|
9
|
+
action: "set" | "status" | "pause" | "resume" | "clear" | "update" | "history";
|
|
10
10
|
objective?: string;
|
|
11
11
|
budget?: Partial<BudgetConfig>;
|
|
12
12
|
}
|
|
@@ -19,18 +19,15 @@ export function parseGoalArgs(raw: string): GoalCommandArgs {
|
|
|
19
19
|
if (trimmed === "" || trimmed === "status") {
|
|
20
20
|
return { action: "status" };
|
|
21
21
|
}
|
|
22
|
-
if (trimmed === "pause") {
|
|
23
|
-
return { action: "pause" };
|
|
24
|
-
}
|
|
25
22
|
if (trimmed === "resume") {
|
|
26
23
|
return { action: "resume" };
|
|
27
24
|
}
|
|
25
|
+
if (trimmed === "pause") {
|
|
26
|
+
return { action: "pause" };
|
|
27
|
+
}
|
|
28
28
|
if (trimmed === "clear") {
|
|
29
29
|
return { action: "clear" };
|
|
30
30
|
}
|
|
31
|
-
if (trimmed === "abort") {
|
|
32
|
-
return { action: "abort" };
|
|
33
|
-
}
|
|
34
31
|
if (trimmed === "history") {
|
|
35
32
|
return { action: "history" };
|
|
36
33
|
}
|
|
@@ -44,9 +41,9 @@ export function parseGoalArgs(raw: string): GoalCommandArgs {
|
|
|
44
41
|
return { action: "update" };
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
// /goal <objective> [--tokens N] [--timeout N]
|
|
44
|
+
// /goal <objective> [--tokens N] [--timeout N]
|
|
48
45
|
// 只匹配已知 flag,避免误删 objective 中的 -- 文本
|
|
49
|
-
const knownFlags = /--(?:tokens|timeout
|
|
46
|
+
const knownFlags = /--(?:tokens|timeout)\s+\d+/g;
|
|
50
47
|
const objective = fullRaw.replace(knownFlags, "").trim();
|
|
51
48
|
const budget: Partial<BudgetConfig> = {};
|
|
52
49
|
|
|
@@ -62,18 +59,6 @@ export function parseGoalArgs(raw: string): GoalCommandArgs {
|
|
|
62
59
|
if (!isNaN(val) && val > 0) budget.timeBudgetMinutes = val;
|
|
63
60
|
}
|
|
64
61
|
|
|
65
|
-
const maxTurnsMatch = fullRaw.match(/--max-turns\s+(\d+)/);
|
|
66
|
-
if (maxTurnsMatch) {
|
|
67
|
-
const val = parseInt(maxTurnsMatch[1]!, 10);
|
|
68
|
-
if (!isNaN(val)) budget.maxTurns = Math.max(1, Math.min(val, MAX_TURNS_CAP));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const maxStallMatch = fullRaw.match(/--max-stall\s+(\d+)/);
|
|
72
|
-
if (maxStallMatch) {
|
|
73
|
-
const val = parseInt(maxStallMatch[1]!, 10);
|
|
74
|
-
if (!isNaN(val)) budget.maxStallTurns = Math.max(1, Math.min(val, MAX_STALL_CAP));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
62
|
if (!objective) {
|
|
78
63
|
return { action: "status" };
|
|
79
64
|
}
|
package/src/constants.ts
CHANGED
|
@@ -23,8 +23,6 @@ export const BUDGET_PERCENT_LOW = 70; // widget 颜色变黄
|
|
|
23
23
|
|
|
24
24
|
// ── 长度/数量上限 ───────────────────────────────────
|
|
25
25
|
|
|
26
|
-
export const MAX_TURNS_CAP = 100; // maxTurns 上限
|
|
27
|
-
export const MAX_STALL_CAP = 20; // maxStallTurns 上限
|
|
28
26
|
export const UPDATE_PREFIX_LENGTH = 7; // "update ".length
|
|
29
27
|
|
|
30
28
|
// ── 百分比换算因子 ──────────────────────────────────
|
|
@@ -36,14 +34,9 @@ export const PERCENT_FACTOR = 100;
|
|
|
36
34
|
export const PROGRESS_BAR_DEFAULT_WIDTH = 10;
|
|
37
35
|
export const OBJECTIVE_DISPLAY_LIMIT = 80;
|
|
38
36
|
export const OBJECTIVE_TRUNCATE_KEEP = 77; // DISPLAY_LIMIT - 3 for "..."
|
|
37
|
+
export const TOKEN_K_THRESHOLD = 1000; // token 数 ≥ 此值时缩写为 k 单位(12000 → 12k)
|
|
39
38
|
|
|
40
39
|
// ── 停滞/清理阈值 ────────────────────────────────────
|
|
41
40
|
|
|
42
|
-
export const TASK_STALL_TURN_THRESHOLD = 10; // task/subtask 停滞提醒阈值(turn 数)
|
|
43
41
|
export const AUTO_CLEAR_TURNS = 2; // 终态后自动清理轮数
|
|
44
42
|
export const MAX_HISTORY_ENTRIES = 20; // goal-history entry GC 上限
|
|
45
|
-
|
|
46
|
-
export const VERIFY_METHOD_WIDGET_LEN = 30; // widget 中验证方法显示的截断长度
|
|
47
|
-
export const EXT_INIT_TASK_DESC_MAX = 60; // external init 中 task description 截断上限
|
|
48
|
-
export const TASK_DESC_MAX_LENGTH = 80; // task description 截断上限(normalizeDescription)
|
|
49
|
-
export const ELLIPSIS_LENGTH = 3;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/budget.ts 测试
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
accumulateTokens,
|
|
8
|
+
checkBudgetOnResume,
|
|
9
|
+
checkBudgetOnTurnEnd,
|
|
10
|
+
getBudgetColor,
|
|
11
|
+
getTimeUsagePercent,
|
|
12
|
+
getTokenUsagePercent,
|
|
13
|
+
tick,
|
|
14
|
+
} from "../budget";
|
|
15
|
+
import type { GoalRuntimeState } from "../types";
|
|
16
|
+
|
|
17
|
+
const makeState = (overrides: Partial<GoalRuntimeState> = {}): GoalRuntimeState => ({
|
|
18
|
+
goalId: "test",
|
|
19
|
+
objective: "test",
|
|
20
|
+
status: "active",
|
|
21
|
+
tokensUsed: 0,
|
|
22
|
+
timeStartedAt: 1000,
|
|
23
|
+
timeUsedSeconds: 0,
|
|
24
|
+
budget: {},
|
|
25
|
+
lastProgressTurn: 0,
|
|
26
|
+
budgetLimitSteeringSent: false,
|
|
27
|
+
objectiveUpdatedAt: 1000,
|
|
28
|
+
lastBlockerReason: null,
|
|
29
|
+
tokenWarning70Sent: false,
|
|
30
|
+
tokenWarning90Sent: false,
|
|
31
|
+
timeWarning70Sent: false,
|
|
32
|
+
timeWarning90Sent: false,
|
|
33
|
+
lastTurnTokensUsed: 0,
|
|
34
|
+
currentTurnIndex: 0,
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── accumulateTokens(FR-8.6)─────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("accumulateTokens", () => {
|
|
41
|
+
it("input/output 有 → max(input-cacheRead,0)+output", () => {
|
|
42
|
+
expect(accumulateTokens(1000, { input: 100, output: 50, cacheRead: 20 })).toBe(1130);
|
|
43
|
+
});
|
|
44
|
+
it("cacheRead > input → max=0", () => {
|
|
45
|
+
expect(accumulateTokens(1000, { input: 50, output: 30, cacheRead: 100 })).toBe(1030);
|
|
46
|
+
});
|
|
47
|
+
it("input=0 output=0 → fallback totalTokens", () => {
|
|
48
|
+
expect(accumulateTokens(1000, { totalTokens: 200 })).toBe(1200);
|
|
49
|
+
});
|
|
50
|
+
it("全空 → 不累加", () => {
|
|
51
|
+
expect(accumulateTokens(1000, {})).toBe(1000);
|
|
52
|
+
});
|
|
53
|
+
it("无 cacheRead → 视为 0", () => {
|
|
54
|
+
expect(accumulateTokens(0, { input: 100, output: 50 })).toBe(150);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── tick(FR-6.5 纯函数)──────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe("tick", () => {
|
|
61
|
+
// timeStartedAt 是 Date.now() 毫秒时间戳;timeUsedSeconds 是累计秒数
|
|
62
|
+
it("isRunning=true → 累加 (now-start)/1000 到 timeUsedSeconds", () => {
|
|
63
|
+
expect(tick(1000000, 0, 1600000, true)).toEqual({ timeUsedSeconds: 600, timeStartedAt: 1600000 });
|
|
64
|
+
});
|
|
65
|
+
it("isRunning=true → 叠加已有 timeUsedSeconds", () => {
|
|
66
|
+
expect(tick(1000000, 100, 1600000, true)).toEqual({ timeUsedSeconds: 700, timeStartedAt: 1600000 });
|
|
67
|
+
});
|
|
68
|
+
it("isRunning=false → 不累加,但重置 timeStartedAt=now", () => {
|
|
69
|
+
expect(tick(1000000, 500, 2000000, false)).toEqual({ timeUsedSeconds: 500, timeStartedAt: 2000000 });
|
|
70
|
+
});
|
|
71
|
+
it("纯函数:相同输入相同输出", () => {
|
|
72
|
+
expect(tick(1000000, 50, 2000000, true)).toEqual(tick(1000000, 50, 2000000, true));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── checkBudgetOnTurnEnd(FR-6.2 维度独立)────────────
|
|
77
|
+
|
|
78
|
+
describe("checkBudgetOnTurnEnd — 无预算", () => {
|
|
79
|
+
it("无 token/time budget → ok", () => {
|
|
80
|
+
const r = checkBudgetOnTurnEnd(makeState(), 0);
|
|
81
|
+
expect(r.terminal).toBeNull();
|
|
82
|
+
expect(r.warnings).toEqual([]);
|
|
83
|
+
expect(r.shouldSendSteering).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("checkBudgetOnTurnEnd — token 阈值", () => {
|
|
88
|
+
it("token < 70% → 无预警", () => {
|
|
89
|
+
const s = makeState({ tokensUsed: 600, budget: { tokenBudget: 1000 } });
|
|
90
|
+
expect(checkBudgetOnTurnEnd(s, 0).warnings).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
it("token >= 70% 未发 → warning70 token", () => {
|
|
93
|
+
const s = makeState({ tokensUsed: 700, budget: { tokenBudget: 1000 } });
|
|
94
|
+
expect(checkBudgetOnTurnEnd(s, 0).warnings).toContainEqual({ type: "warning70", dimension: "token" });
|
|
95
|
+
});
|
|
96
|
+
it("token >= 70% 已发 → 不重复", () => {
|
|
97
|
+
const s = makeState({ tokensUsed: 750, tokenWarning70Sent: true, budget: { tokenBudget: 1000 } });
|
|
98
|
+
expect(checkBudgetOnTurnEnd(s, 0).warnings).not.toContainEqual({ type: "warning70", dimension: "token" });
|
|
99
|
+
});
|
|
100
|
+
it("token >= 90% 未发 steering → shouldSendSteering", () => {
|
|
101
|
+
const s = makeState({ tokensUsed: 950, budget: { tokenBudget: 1000 } });
|
|
102
|
+
expect(checkBudgetOnTurnEnd(s, 0).shouldSendSteering).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
it("token >= 100% 已发 steering → terminal exceeded token", () => {
|
|
105
|
+
const s = makeState({ tokensUsed: 1000, budgetLimitSteeringSent: true, budget: { tokenBudget: 1000 } });
|
|
106
|
+
expect(checkBudgetOnTurnEnd(s, 0).terminal).toEqual({ type: "exceeded", dimension: "token" });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("checkBudgetOnTurnEnd — time 阈值", () => {
|
|
111
|
+
it("time < 70% → 无预警", () => {
|
|
112
|
+
const s = makeState({ budget: { timeBudgetMinutes: 10 } });
|
|
113
|
+
expect(checkBudgetOnTurnEnd(s, 300).warnings).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
it("time >= 70% → warning70 time", () => {
|
|
116
|
+
const s = makeState({ budget: { timeBudgetMinutes: 10 } });
|
|
117
|
+
expect(checkBudgetOnTurnEnd(s, 420).warnings).toContainEqual({ type: "warning70", dimension: "time" });
|
|
118
|
+
});
|
|
119
|
+
it("time >= 100% → terminal exceeded time", () => {
|
|
120
|
+
const s = makeState({ budget: { timeBudgetMinutes: 10 } });
|
|
121
|
+
expect(checkBudgetOnTurnEnd(s, 600).terminal).toEqual({ type: "exceeded", dimension: "time" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("checkBudgetOnTurnEnd — FR-6.2 维度独立(核心 bug 修复)", () => {
|
|
126
|
+
it("token 已发 70%,time 到 70% 也独立发", () => {
|
|
127
|
+
const s = makeState({
|
|
128
|
+
tokensUsed: 750, tokenWarning70Sent: true,
|
|
129
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
130
|
+
});
|
|
131
|
+
const r = checkBudgetOnTurnEnd(s, 450); // time 75%
|
|
132
|
+
expect(r.warnings).toContainEqual({ type: "warning70", dimension: "time" });
|
|
133
|
+
expect(r.warnings).not.toContainEqual({ type: "warning70", dimension: "token" });
|
|
134
|
+
});
|
|
135
|
+
it("两个维度同时到 70% → 两个 warning70 都发", () => {
|
|
136
|
+
const s = makeState({
|
|
137
|
+
tokensUsed: 750,
|
|
138
|
+
budget: { tokenBudget: 1000, timeBudgetMinutes: 10 },
|
|
139
|
+
});
|
|
140
|
+
const r = checkBudgetOnTurnEnd(s, 450);
|
|
141
|
+
expect(r.warnings).toContainEqual({ type: "warning70", dimension: "token" });
|
|
142
|
+
expect(r.warnings).toContainEqual({ type: "warning70", dimension: "time" });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── checkBudgetOnResume ──────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe("checkBudgetOnResume", () => {
|
|
149
|
+
it("无预算 → null", () => expect(checkBudgetOnResume(makeState())).toBeNull());
|
|
150
|
+
it("token 超额 → exceeded token", () => {
|
|
151
|
+
const s = makeState({ tokensUsed: 1000, budget: { tokenBudget: 1000 } });
|
|
152
|
+
expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded", dimension: "token" });
|
|
153
|
+
});
|
|
154
|
+
it("time 超额 → exceeded time", () => {
|
|
155
|
+
const s = makeState({ timeUsedSeconds: 700, budget: { timeBudgetMinutes: 10 } });
|
|
156
|
+
expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded", dimension: "time" });
|
|
157
|
+
});
|
|
158
|
+
it("未超额 → null", () => {
|
|
159
|
+
const s = makeState({ tokensUsed: 500, budget: { tokenBudget: 1000 } });
|
|
160
|
+
expect(checkBudgetOnResume(s)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// ── 百分比 + 颜色 ────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("getTokenUsagePercent / getTimeUsagePercent", () => {
|
|
168
|
+
it("无 tokenBudget → 0", () => expect(getTokenUsagePercent(makeState())).toBe(0));
|
|
169
|
+
it("50% token", () => {
|
|
170
|
+
expect(getTokenUsagePercent(makeState({ tokensUsed: 500, budget: { tokenBudget: 1000 } }))).toBe(50);
|
|
171
|
+
});
|
|
172
|
+
it("无 timeBudgetMinutes → 0", () => expect(getTimeUsagePercent(makeState(), 100)).toBe(0));
|
|
173
|
+
it("50% time", () => {
|
|
174
|
+
expect(getTimeUsagePercent(makeState({ budget: { timeBudgetMinutes: 10 } }), 300)).toBe(50);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("getBudgetColor", () => {
|
|
179
|
+
it(">=90 → error", () => expect(getBudgetColor(90)).toBe("error"));
|
|
180
|
+
it(">=70 → warning", () => expect(getBudgetColor(70)).toBe("warning"));
|
|
181
|
+
it("<70 → muted", () => expect(getBudgetColor(69)).toBe("muted"));
|
|
182
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/goal.ts 测试 — Goal 7 态状态机 + createGoalState
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createGoalState,
|
|
8
|
+
isActiveStatus,
|
|
9
|
+
isTerminalStatus,
|
|
10
|
+
transitionStatus,
|
|
11
|
+
} from "../goal";
|
|
12
|
+
import type { GoalStatus } from "../types";
|
|
13
|
+
|
|
14
|
+
const TERMINAL: GoalStatus[] = ["complete", "budget_limited", "time_limited", "cancelled"];
|
|
15
|
+
const NON_TERMINAL: GoalStatus[] = ["active", "paused", "blocked"]; // paused #2 新增
|
|
16
|
+
const ALL = [...NON_TERMINAL, ...TERMINAL];
|
|
17
|
+
|
|
18
|
+
// ── isTerminalStatus / isActiveStatus ─────────────────
|
|
19
|
+
|
|
20
|
+
describe("isTerminalStatus", () => {
|
|
21
|
+
for (const s of TERMINAL) {
|
|
22
|
+
it(`${s} → terminal`, () => expect(isTerminalStatus(s)).toBe(true));
|
|
23
|
+
}
|
|
24
|
+
for (const s of NON_TERMINAL) {
|
|
25
|
+
it(`${s} → NOT terminal`, () => expect(isTerminalStatus(s)).toBe(false));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("isActiveStatus", () => {
|
|
30
|
+
it("active → true", () => expect(isActiveStatus("active")).toBe(true));
|
|
31
|
+
for (const s of ALL) {
|
|
32
|
+
if (s === "active") continue;
|
|
33
|
+
it(`${s} → false`, () => expect(isActiveStatus(s)).toBe(false));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── transitionStatus(查表,非法转换 throw)──────────────
|
|
38
|
+
|
|
39
|
+
describe("transitionStatus — 合法转换返回 next", () => {
|
|
40
|
+
const legalCases: Array<[GoalStatus, GoalStatus]> = [
|
|
41
|
+
["active", "paused"],
|
|
42
|
+
["active", "blocked"],
|
|
43
|
+
["active", "complete"],
|
|
44
|
+
["active", "budget_limited"],
|
|
45
|
+
["active", "time_limited"],
|
|
46
|
+
["active", "cancelled"],
|
|
47
|
+
["paused", "active"],
|
|
48
|
+
["paused", "cancelled"],
|
|
49
|
+
["blocked", "active"],
|
|
50
|
+
["blocked", "cancelled"],
|
|
51
|
+
];
|
|
52
|
+
for (const [from, to] of legalCases) {
|
|
53
|
+
it(`${from} → ${to} 返回 ${to}`, () => {
|
|
54
|
+
expect(transitionStatus(from, to)).toBe(to);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("transitionStatus — 非法转换 throw", () => {
|
|
60
|
+
// 终态不可转任何状态(VALID_TRANSITIONS 表为空)
|
|
61
|
+
for (const terminal of TERMINAL) {
|
|
62
|
+
for (const target of ALL) {
|
|
63
|
+
it(`terminal ${terminal} → ${target} throw`, () => {
|
|
64
|
+
expect(() => transitionStatus(terminal, target)).toThrow();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 非终态的非法路径(不在 VALID_TRANSITIONS 表内)
|
|
69
|
+
const illegalNonTerminal: Array<[GoalStatus, GoalStatus]> = [
|
|
70
|
+
["active", "active"], // 自转不在表内
|
|
71
|
+
["paused", "paused"],
|
|
72
|
+
["paused", "blocked"],
|
|
73
|
+
["paused", "complete"],
|
|
74
|
+
["blocked", "blocked"],
|
|
75
|
+
["blocked", "paused"],
|
|
76
|
+
["blocked", "complete"],
|
|
77
|
+
];
|
|
78
|
+
for (const [from, to] of illegalNonTerminal) {
|
|
79
|
+
it(`${from} → ${to} throw`, () => {
|
|
80
|
+
expect(() => transitionStatus(from, to)).toThrow();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── createGoalState 初始值 ───────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("createGoalState — 初始值", () => {
|
|
88
|
+
it("status = active", () => expect(createGoalState("obj").status).toBe("active"));
|
|
89
|
+
it("objective 透传", () => expect(createGoalState("my obj").objective).toBe("my obj"));
|
|
90
|
+
it("tokensUsed = 0", () => expect(createGoalState("obj").tokensUsed).toBe(0));
|
|
91
|
+
it("timeUsedSeconds = 0", () => expect(createGoalState("obj").timeUsedSeconds).toBe(0));
|
|
92
|
+
it("goalId 非空", () => {
|
|
93
|
+
expect(createGoalState("obj").goalId).toBeTruthy();
|
|
94
|
+
expect(typeof createGoalState("obj").goalId).toBe("string");
|
|
95
|
+
});
|
|
96
|
+
it("两个 createGoalState 生成不同 goalId", () => {
|
|
97
|
+
expect(createGoalState("obj").goalId).not.toBe(createGoalState("obj").goalId);
|
|
98
|
+
});
|
|
99
|
+
it("currentTurnIndex = 0", () => expect(createGoalState("obj").currentTurnIndex).toBe(0));
|
|
100
|
+
it("completedAtTurnIndex = undefined", () => {
|
|
101
|
+
expect(createGoalState("obj").completedAtTurnIndex).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
// FR-6.2: 4 个独立预警 flag
|
|
104
|
+
it("tokenWarning70Sent = false", () => expect(createGoalState("obj").tokenWarning70Sent).toBe(false));
|
|
105
|
+
it("tokenWarning90Sent = false", () => expect(createGoalState("obj").tokenWarning90Sent).toBe(false));
|
|
106
|
+
it("timeWarning70Sent = false", () => expect(createGoalState("obj").timeWarning70Sent).toBe(false));
|
|
107
|
+
it("timeWarning90Sent = false", () => expect(createGoalState("obj").timeWarning90Sent).toBe(false));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("createGoalState — budget 合并", () => {
|
|
111
|
+
it("无 overrides 用 DEFAULT_BUDGET", () => {
|
|
112
|
+
const s = createGoalState("obj");
|
|
113
|
+
expect(s.budget).toEqual({});
|
|
114
|
+
});
|
|
115
|
+
it("tokenBudget override", () => {
|
|
116
|
+
expect(createGoalState("obj", { tokenBudget: 10000 }).budget.tokenBudget).toBe(10000);
|
|
117
|
+
});
|
|
118
|
+
it("timeBudgetMinutes override", () => {
|
|
119
|
+
expect(createGoalState("obj", { timeBudgetMinutes: 30 }).budget.timeBudgetMinutes).toBe(30);
|
|
120
|
+
});
|
|
121
|
+
it("多字段 override", () => {
|
|
122
|
+
const s = createGoalState("obj", { tokenBudget: 5000, timeBudgetMinutes: 30 });
|
|
123
|
+
expect(s.budget.tokenBudget).toBe(5000);
|
|
124
|
+
expect(s.budget.timeBudgetMinutes).toBe(30);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget 决策引擎 — 纯函数
|
|
3
|
+
*
|
|
4
|
+
* 零 Pi 依赖。import from "./types"。
|
|
5
|
+
*
|
|
6
|
+
* FR-6.5: tick 是纯函数(不调 Date.now,不查 status)
|
|
7
|
+
* FR-6.2: checkBudgetOnTurnEnd 用 4 个独立 flag
|
|
8
|
+
* FR-8.6: accumulateTokens token 累加算法
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { GoalRuntimeState } from "./types";
|
|
12
|
+
|
|
13
|
+
// ── 常量(engine 内部,保持自洽)──────────────────────
|
|
14
|
+
|
|
15
|
+
const RATIO_HIGH = 0.9;
|
|
16
|
+
const RATIO_LOW = 0.7;
|
|
17
|
+
const PERCENT_FACTOR = 100;
|
|
18
|
+
const SECONDS_PER_MINUTE = 60;
|
|
19
|
+
const MS_PER_SECOND = 1000;
|
|
20
|
+
|
|
21
|
+
// ── 类型 ────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface TokenUsage {
|
|
24
|
+
input?: number;
|
|
25
|
+
output?: number;
|
|
26
|
+
cacheRead?: number;
|
|
27
|
+
totalTokens?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TickResult {
|
|
31
|
+
timeUsedSeconds: number;
|
|
32
|
+
timeStartedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type BudgetDecision =
|
|
36
|
+
| { type: "warning70"; dimension: "token" | "time" }
|
|
37
|
+
| { type: "warning90"; dimension: "token" | "time" };
|
|
38
|
+
|
|
39
|
+
export interface BudgetCheckResult {
|
|
40
|
+
terminal: { type: "exceeded"; dimension: "token" | "time" } | null;
|
|
41
|
+
warnings: BudgetDecision[];
|
|
42
|
+
shouldSendSteering: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// ── token 累加(FR-8.6)──────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function accumulateTokens(currentTokensUsed: number, usage: TokenUsage): number {
|
|
49
|
+
const input = usage.input ?? 0;
|
|
50
|
+
const output = usage.output ?? 0;
|
|
51
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
52
|
+
if (input > 0 || output > 0) {
|
|
53
|
+
return currentTokensUsed + Math.max(input - cacheRead, 0) + output;
|
|
54
|
+
}
|
|
55
|
+
return currentTokensUsed + (usage.totalTokens ?? 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 时间累计(FR-6.5 纯函数)──────────────────────────
|
|
59
|
+
|
|
60
|
+
export function tick(
|
|
61
|
+
timeStartedAt: number,
|
|
62
|
+
timeUsedSeconds: number,
|
|
63
|
+
now: number,
|
|
64
|
+
isRunning: boolean,
|
|
65
|
+
): TickResult {
|
|
66
|
+
if (isRunning && timeStartedAt > 0) {
|
|
67
|
+
const elapsed = (now - timeStartedAt) / MS_PER_SECOND;
|
|
68
|
+
return { timeUsedSeconds: timeUsedSeconds + elapsed, timeStartedAt: now };
|
|
69
|
+
}
|
|
70
|
+
return { timeUsedSeconds, timeStartedAt: now };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── 百分比计算 ───────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function getTokenUsagePercent(state: GoalRuntimeState): number {
|
|
76
|
+
if (!state.budget.tokenBudget || state.budget.tokenBudget <= 0) return 0;
|
|
77
|
+
return (state.tokensUsed / state.budget.tokenBudget) * PERCENT_FACTOR;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getTimeUsagePercent(state: GoalRuntimeState, timeUsedSeconds: number): number {
|
|
81
|
+
if (!state.budget.timeBudgetMinutes || state.budget.timeBudgetMinutes <= 0) return 0;
|
|
82
|
+
const budgetSeconds = state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE;
|
|
83
|
+
return (timeUsedSeconds / budgetSeconds) * PERCENT_FACTOR;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getBudgetColor(percent: number): "error" | "warning" | "muted" {
|
|
87
|
+
if (percent >= RATIO_HIGH * PERCENT_FACTOR) return "error";
|
|
88
|
+
if (percent >= RATIO_LOW * PERCENT_FACTOR) return "warning";
|
|
89
|
+
return "muted";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── turn end 预算检查(FR-6.2 维度独立)───────────────
|
|
93
|
+
|
|
94
|
+
export function checkBudgetOnTurnEnd(state: GoalRuntimeState, timeUsedSeconds: number): BudgetCheckResult {
|
|
95
|
+
const result: BudgetCheckResult = { terminal: null, warnings: [], shouldSendSteering: false };
|
|
96
|
+
|
|
97
|
+
// token 维度
|
|
98
|
+
// 注:token 终态需 budgetLimitSteeringSent=true(90% steering 已发),time 维度无此 gate。
|
|
99
|
+
// 这是有意设计——token 有 90% steering 中间态(给 agent 收尾机会),需 steering 已发才确认
|
|
100
|
+
// 「agent 已被提醒但未收尾」→ 终态合理;time 维度无 steering 中间态,超额直接终态。
|
|
101
|
+
if (state.budget.tokenBudget) {
|
|
102
|
+
const tokenPct = state.tokensUsed / state.budget.tokenBudget;
|
|
103
|
+
if (tokenPct >= 1 && state.budgetLimitSteeringSent) {
|
|
104
|
+
result.terminal = { type: "exceeded", dimension: "token" };
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
if (tokenPct >= RATIO_HIGH && !state.budgetLimitSteeringSent) {
|
|
108
|
+
result.shouldSendSteering = true;
|
|
109
|
+
} else if (tokenPct >= RATIO_HIGH && !state.tokenWarning90Sent) {
|
|
110
|
+
result.warnings.push({ type: "warning90", dimension: "token" });
|
|
111
|
+
} else if (tokenPct >= RATIO_LOW && !state.tokenWarning70Sent) {
|
|
112
|
+
result.warnings.push({ type: "warning70", dimension: "token" });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// time 维度(FR-6.2: 独立 flag,不被 token 吞)
|
|
117
|
+
if (state.budget.timeBudgetMinutes) {
|
|
118
|
+
const budgetSeconds = state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE;
|
|
119
|
+
if (timeUsedSeconds >= budgetSeconds) {
|
|
120
|
+
result.terminal = { type: "exceeded", dimension: "time" };
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
const timePct = timeUsedSeconds / budgetSeconds;
|
|
124
|
+
if (timePct >= RATIO_HIGH && !state.timeWarning90Sent) {
|
|
125
|
+
result.warnings.push({ type: "warning90", dimension: "time" });
|
|
126
|
+
} else if (timePct >= RATIO_LOW && !state.timeWarning70Sent) {
|
|
127
|
+
result.warnings.push({ type: "warning70", dimension: "time" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── resume 预算重检 ──────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded"; dimension: "token" | "time" } | null {
|
|
137
|
+
if (state.budget.tokenBudget && state.tokensUsed >= state.budget.tokenBudget) {
|
|
138
|
+
return { type: "exceeded", dimension: "token" };
|
|
139
|
+
}
|
|
140
|
+
if (state.budget.timeBudgetMinutes) {
|
|
141
|
+
if (state.timeUsedSeconds >= state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE) {
|
|
142
|
+
return { type: "exceeded", dimension: "time" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal 聚合状态机 — 纯函数
|
|
3
|
+
*
|
|
4
|
+
* 零 Pi 依赖。import from "./types"。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { BudgetConfig, GoalRuntimeState, GoalStatus } from "./types";
|
|
8
|
+
import { DEFAULT_BUDGET, TERMINAL_GOAL_STATUSES, VALID_TRANSITIONS } from "./types";
|
|
9
|
+
|
|
10
|
+
export function isTerminalStatus(status: GoalStatus): boolean {
|
|
11
|
+
return TERMINAL_GOAL_STATUSES.has(status);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isActiveStatus(status: GoalStatus): boolean {
|
|
15
|
+
return status === "active";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 安全的状态转换。查 VALID_TRANSITIONS 表,非法转换 throw。
|
|
20
|
+
*
|
|
21
|
+
* 调用方必须在终态转换前自行检查 isTerminalStatus(如 finalizeAndPersist),
|
|
22
|
+
* 因为终态→终态不是合法转换(表为空)。对 active→终态、active→paused/blocked、
|
|
23
|
+
* paused/blocked→active/cancelled 这些合法路径直接返回 next。
|
|
24
|
+
*/
|
|
25
|
+
export function transitionStatus(current: GoalStatus, next: GoalStatus): GoalStatus {
|
|
26
|
+
const allowed = VALID_TRANSITIONS[current];
|
|
27
|
+
if (allowed && allowed.includes(next)) {
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Invalid goal state transition: ${current} → ${next}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 创建初始 GoalRuntimeState。纯数据构造,无副作用。
|
|
35
|
+
*/
|
|
36
|
+
export function createGoalState(
|
|
37
|
+
objective: string,
|
|
38
|
+
budgetOverrides?: Partial<BudgetConfig>,
|
|
39
|
+
slug?: string,
|
|
40
|
+
): GoalRuntimeState {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
return {
|
|
43
|
+
goalId: crypto.randomUUID(),
|
|
44
|
+
objective,
|
|
45
|
+
slug,
|
|
46
|
+
status: "active",
|
|
47
|
+
tokensUsed: 0,
|
|
48
|
+
timeStartedAt: now,
|
|
49
|
+
timeUsedSeconds: 0,
|
|
50
|
+
budget: { ...DEFAULT_BUDGET, ...budgetOverrides },
|
|
51
|
+
lastProgressTurn: 0,
|
|
52
|
+
budgetLimitSteeringSent: false,
|
|
53
|
+
objectiveUpdatedAt: now,
|
|
54
|
+
lastBlockerReason: null,
|
|
55
|
+
tokenWarning70Sent: false,
|
|
56
|
+
tokenWarning90Sent: false,
|
|
57
|
+
timeWarning70Sent: false,
|
|
58
|
+
timeWarning90Sent: false,
|
|
59
|
+
lastTurnTokensUsed: 0,
|
|
60
|
+
currentTurnIndex: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal 运行时组合状态类型 — engine 层共享类型定义
|
|
3
|
+
*
|
|
4
|
+
* 零 Pi 依赖。
|
|
5
|
+
*
|
|
6
|
+
* FR-6.2 修复:预警 flag 按 token/time 维度独立(4 个独立 flag),
|
|
7
|
+
* 取代旧版 budgetWarning70Sent/budgetWarning90Sent 共享 flag。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Goal 状态枚举 ────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type GoalStatus =
|
|
13
|
+
| "active"
|
|
14
|
+
| "paused"
|
|
15
|
+
| "blocked"
|
|
16
|
+
| "complete"
|
|
17
|
+
| "budget_limited"
|
|
18
|
+
| "time_limited"
|
|
19
|
+
| "cancelled";
|
|
20
|
+
|
|
21
|
+
export const TERMINAL_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set([
|
|
22
|
+
"complete",
|
|
23
|
+
"budget_limited",
|
|
24
|
+
"time_limited",
|
|
25
|
+
"cancelled",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 显式状态转换表(system-architecture §5)。终态映射空数组——不可逆。
|
|
30
|
+
* transitionStatus 据此查表,非法转换 throw。新增状态时必须更新此表(forcing function)。
|
|
31
|
+
*/
|
|
32
|
+
export const VALID_TRANSITIONS: Record<GoalStatus, GoalStatus[]> = {
|
|
33
|
+
active: ["paused", "blocked", "complete", "budget_limited", "time_limited", "cancelled"],
|
|
34
|
+
paused: ["active", "cancelled"],
|
|
35
|
+
blocked: ["active", "cancelled"],
|
|
36
|
+
complete: [],
|
|
37
|
+
budget_limited: [],
|
|
38
|
+
time_limited: [],
|
|
39
|
+
cancelled: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ── 预算配置 ────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface BudgetConfig {
|
|
45
|
+
tokenBudget?: number;
|
|
46
|
+
timeBudgetMinutes?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_BUDGET: BudgetConfig = {};
|
|
50
|
+
|
|
51
|
+
// ── 运行时状态(也是持久化格式)─────────────────────
|
|
52
|
+
|
|
53
|
+
export interface GoalRuntimeState {
|
|
54
|
+
goalId: string;
|
|
55
|
+
objective: string;
|
|
56
|
+
/**
|
|
57
|
+
* AI 生成的短标识(kebab-case 风格),仅用于 widget 状态栏标题与 history。
|
|
58
|
+
* 不注入 prompt(prompt 仍读 objective,保证方向感)。
|
|
59
|
+
* 由 goal_control create 时 AI 提供;/goal 命令路径走提示词触发器由 AI toolcall 生成。
|
|
60
|
+
* optional:旧持久化数据无此字段,widget fallback 到 objective 截断。
|
|
61
|
+
*/
|
|
62
|
+
slug?: string;
|
|
63
|
+
status: GoalStatus;
|
|
64
|
+
tokensUsed: number;
|
|
65
|
+
timeStartedAt: number;
|
|
66
|
+
timeUsedSeconds: number;
|
|
67
|
+
budget: BudgetConfig;
|
|
68
|
+
lastProgressTurn: number;
|
|
69
|
+
budgetLimitSteeringSent: boolean;
|
|
70
|
+
objectiveUpdatedAt: number;
|
|
71
|
+
lastBlockerReason: string | null;
|
|
72
|
+
// FR-6.2: 4 个独立预警 flag
|
|
73
|
+
tokenWarning70Sent: boolean;
|
|
74
|
+
tokenWarning90Sent: boolean;
|
|
75
|
+
timeWarning70Sent: boolean;
|
|
76
|
+
timeWarning90Sent: boolean;
|
|
77
|
+
lastTurnTokensUsed: number;
|
|
78
|
+
currentTurnIndex: number;
|
|
79
|
+
completedAtTurnIndex?: number;
|
|
80
|
+
}
|