@zhushanwen/pi-goal 0.13.0 → 0.14.1
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 +3 -3
- package/package.json +3 -3
- package/src/__tests__/circuit-breaker.test.ts +1 -1
- package/src/__tests__/criteria-array-adapter.test.ts +4 -4
- package/src/__tests__/criteria-array-migration.test.ts +0 -2
- package/src/__tests__/deserialize-state.test.ts +28 -6
- package/src/__tests__/event-adapter.test.ts +1 -1
- package/src/__tests__/goal-control-adapter.test.ts +5 -5
- package/src/__tests__/goal-control-rpc.test.ts +1 -1
- package/src/__tests__/ports.test.ts +1 -1
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/service.test.ts +5 -5
- package/src/__tests__/session.test.ts +0 -2
- package/src/__tests__/stale-checker.test.ts +2 -2
- package/src/adapters/command-adapter.ts +2 -4
- package/src/adapters/event-handlers/agent-end.ts +18 -1
- package/src/adapters/event-handlers/before-agent-start.ts +2 -2
- package/src/adapters/event-handlers/message-end.ts +2 -14
- package/src/adapters/goal-control-adapter.ts +1 -1
- package/src/adapters/ports.ts +17 -23
- package/src/engine/__tests__/budget.test.ts +7 -7
- package/src/engine/__tests__/goal.test.ts +40 -1
- package/src/engine/budget.ts +7 -13
- package/src/engine/goal.ts +0 -2
- package/src/engine/types.ts +0 -2
- package/src/index.ts +17 -57
- package/src/persistence.ts +4 -5
- package/src/ports.ts +13 -3
- package/src/projection/__tests__/prompts.test.ts +19 -11
- package/src/projection/__tests__/widget.test.ts +9 -6
- package/src/projection/prompts.ts +8 -28
- package/src/projection/widget.ts +6 -38
- package/src/service.ts +6 -6
- package/src/adapters/event-adapter.ts +0 -17
- package/src/adapters/event-handlers/shared.ts +0 -27
package/README.md
CHANGED
|
@@ -52,8 +52,8 @@ goal/
|
|
|
52
52
|
├── service.ts # 协调层 — createGoal / finalizeAndPersist / applyEvent
|
|
53
53
|
├── persistence.ts # serialize/deserialize + GoalHistoryEntry
|
|
54
54
|
├── session.ts # 运行时句柄 + 状态重建(append-only 只读最新一条,无 entry GC)
|
|
55
|
-
├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types)
|
|
56
|
-
├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter /
|
|
57
|
-
│ # + event-handlers
|
|
55
|
+
├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types/liveness)
|
|
56
|
+
├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter / ports /
|
|
57
|
+
│ # success-criteria + event-handlers/)
|
|
58
58
|
└── projection/ # 渲染(widget / prompts / gui)
|
|
59
59
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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,10 @@
|
|
|
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.
|
|
32
|
+
"@zhushanwen/pi-pending-notifications": "0.7.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@xyz-agent/extension-protocol": "0.
|
|
35
|
+
"@xyz-agent/extension-protocol": "0.10.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependenciesMeta": {
|
|
38
38
|
"@earendil-works/pi-tui": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 另覆盖:停发通知含 /goal resume 恢复指引 + goal 保持 active;/goal resume
|
|
12
12
|
* 恢复通道重置计数;未封顶 active goal 的 resume 语义不变。
|
|
13
13
|
*
|
|
14
|
-
* fake timers + fake pi/ctx(不 import Pi SDK 真实实现,harness 对齐 event-adapter.test.ts)。
|
|
14
|
+
* fake timers + fake pi/ctx(不 import Pi SDK 真实实现,harness 对齐 __tests__/event-adapter.test.ts)。
|
|
15
15
|
*/
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -38,8 +38,10 @@ function makeFakePorts(): ServicePorts & {
|
|
|
38
38
|
hasUI: true,
|
|
39
39
|
isGui: false,
|
|
40
40
|
setGuiWidget: () => {},
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
theme: {
|
|
42
|
+
fg: (_color: string, text: string) => text,
|
|
43
|
+
bold: (text: string) => text,
|
|
44
|
+
},
|
|
43
45
|
} as UiPort,
|
|
44
46
|
messaging: {
|
|
45
47
|
sendContextMessage: () => {},
|
|
@@ -47,8 +49,6 @@ function makeFakePorts(): ServicePorts & {
|
|
|
47
49
|
},
|
|
48
50
|
session: {
|
|
49
51
|
getEntries: () => [],
|
|
50
|
-
getContextUsage: () => null,
|
|
51
|
-
signal: undefined,
|
|
52
52
|
},
|
|
53
53
|
};
|
|
54
54
|
}
|
|
@@ -24,9 +24,7 @@ function makeLegacyData(successCriteria: unknown): Record<string, unknown> {
|
|
|
24
24
|
timeStartedAt: 1000,
|
|
25
25
|
timeUsedSeconds: 0,
|
|
26
26
|
budget: {},
|
|
27
|
-
lastProgressTurn: 0,
|
|
28
27
|
budgetLimitSteeringSent: false,
|
|
29
|
-
objectiveUpdatedAt: 1000,
|
|
30
28
|
lastBlockerReason: null,
|
|
31
29
|
tokenWarning70Sent: false,
|
|
32
30
|
tokenWarning90Sent: false,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FR-5/FR-7.3: deserializeState —
|
|
2
|
+
* FR-5/FR-7.3: deserializeState — 严格解析(必填缺失 throw;旧格式容忍面见 persistence.ts 各迁移点注释)
|
|
3
3
|
*
|
|
4
4
|
* round-trip property 覆盖全字段 serialize→deserialize 双向一致性,替代手写还原用例。
|
|
5
5
|
* 保留:throw 路径(缺必填)+ 向后兼容(缺 optional)+ 旧 tasks 字段忽略。
|
|
@@ -32,9 +32,7 @@ const goalStateArb = fc.record({
|
|
|
32
32
|
timeStartedAt: fc.integer({ min: 0 }),
|
|
33
33
|
timeUsedSeconds: fc.nat(),
|
|
34
34
|
budget: fc.record({ tokenBudget: fc.option(fc.integer({ min: 0 })) }),
|
|
35
|
-
lastProgressTurn: fc.nat(),
|
|
36
35
|
budgetLimitSteeringSent: fc.boolean(),
|
|
37
|
-
objectiveUpdatedAt: fc.integer({ min: 0 }),
|
|
38
36
|
lastBlockerReason: fc.option(fc.string(), { nil: null }), // string | null
|
|
39
37
|
tokenWarning70Sent: fc.boolean(),
|
|
40
38
|
tokenWarning90Sent: fc.boolean(),
|
|
@@ -59,9 +57,7 @@ const FULL_DATA: Record<string, unknown> = {
|
|
|
59
57
|
timeStartedAt: 1000,
|
|
60
58
|
timeUsedSeconds: 0,
|
|
61
59
|
budget: {},
|
|
62
|
-
lastProgressTurn: 0,
|
|
63
60
|
budgetLimitSteeringSent: false,
|
|
64
|
-
objectiveUpdatedAt: 1000,
|
|
65
61
|
lastBlockerReason: null,
|
|
66
62
|
tokenWarning70Sent: false,
|
|
67
63
|
tokenWarning90Sent: false,
|
|
@@ -70,7 +66,7 @@ const FULL_DATA: Record<string, unknown> = {
|
|
|
70
66
|
};
|
|
71
67
|
|
|
72
68
|
describe("deserializeState — round-trip", () => {
|
|
73
|
-
// ⭐ serialize→deserialize 深相等:覆盖全
|
|
69
|
+
// ⭐ serialize→deserialize 深相等:覆盖全 21 字段双向一致性(含 optional 有/无两态)
|
|
74
70
|
it.prop([goalStateArb])("deserializeState(serializeState(s)) 深相等 s", (s) => {
|
|
75
71
|
const rt = deserializeState(serializeState(s) as unknown as Record<string, unknown>);
|
|
76
72
|
expect(rt).toEqual(s);
|
|
@@ -111,6 +107,32 @@ describe("deserializeState — 严格解析(缺必填 throw)", () => {
|
|
|
111
107
|
});
|
|
112
108
|
});
|
|
113
109
|
|
|
110
|
+
describe("deserializeState — legacy entry 含已删字段(P-m14-1,D2 白名单兼容)", () => {
|
|
111
|
+
// ext-simplify-03 D2:lastProgressTurn/objectiveUpdatedAt 已从 GoalRuntimeState 一步删除。
|
|
112
|
+
// 0.13.0 形状 entry 仍携带这两个字段——deserializeState 是白名单模式(只对已知 key 逐个
|
|
113
|
+
// req()/可选解析,从不遍历 entry 全部 key,与 tasks 字段先例同一机制),entry 多出的
|
|
114
|
+
// 字段天然被忽略:不 throw、state 完整重建(G3:旧数据加载兼容)。
|
|
115
|
+
const LEGACY_0_13: Record<string, unknown> = {
|
|
116
|
+
...FULL_DATA,
|
|
117
|
+
lastProgressTurn: 3,
|
|
118
|
+
objectiveUpdatedAt: 2000,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
it("0.13.0 形状 entry(含已删两字段)→ 不 throw,返回完整 state", () => {
|
|
122
|
+
const state = deserializeState(LEGACY_0_13);
|
|
123
|
+
expect(state.goalId).toBe("g1");
|
|
124
|
+
expect(state.objective).toBe("test");
|
|
125
|
+
expect(state.status).toBe("active");
|
|
126
|
+
expect(state.budgetLimitSteeringSent).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("已删字段被白名单忽略:返回对象不含 lastProgressTurn/objectiveUpdatedAt", () => {
|
|
130
|
+
const state = deserializeState(LEGACY_0_13);
|
|
131
|
+
expect(state).not.toHaveProperty("lastProgressTurn");
|
|
132
|
+
expect(state).not.toHaveProperty("objectiveUpdatedAt");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
114
136
|
describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)", () => {
|
|
115
137
|
it("缺 completedAtTurnIndex → undefined", () => {
|
|
116
138
|
expect(deserializeState(FULL_DATA).completedAtTurnIndex).toBeUndefined();
|
|
@@ -52,9 +52,11 @@ function makeFakePorts(): ServicePorts & {
|
|
|
52
52
|
notifications.push({ text, level });
|
|
53
53
|
},
|
|
54
54
|
hasUI: true,
|
|
55
|
-
// widget
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// widget 渲染读 UiPort.theme(ports.ts 的 uiPort 同样以 theme 嵌套成员提供)
|
|
56
|
+
theme: {
|
|
57
|
+
fg: (_color: string, text: string) => text,
|
|
58
|
+
bold: (text: string) => text,
|
|
59
|
+
},
|
|
58
60
|
} as UiPort,
|
|
59
61
|
messaging: {
|
|
60
62
|
sendContextMessage: () => {},
|
|
@@ -62,8 +64,6 @@ function makeFakePorts(): ServicePorts & {
|
|
|
62
64
|
},
|
|
63
65
|
session: {
|
|
64
66
|
getEntries: () => [],
|
|
65
|
-
getContextUsage: () => null,
|
|
66
|
-
signal: undefined,
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
69
|
}
|
|
@@ -47,7 +47,7 @@ interface FakeFixture {
|
|
|
47
47
|
* 构造最小 fake pi / ctx。ctx.mode 由调用方覆盖(默认 "rpc")。
|
|
48
48
|
*
|
|
49
49
|
* ports.ts 的 buildPorts 读 ctx.ui.theme.fg/bold、ctx.ui.setWidget/setStatus/notify、
|
|
50
|
-
* ctx.sessionManager.getEntries、ctx.
|
|
50
|
+
* ctx.sessionManager.getEntries、ctx.hasUI——全部 mock。
|
|
51
51
|
*/
|
|
52
52
|
function makeFixture(mode: "rpc" | "tui" | "json" | "print" = "rpc"): FakeFixture {
|
|
53
53
|
const notifications: Array<{ text: string; level: string }> = [];
|
|
@@ -46,7 +46,7 @@ describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
|
|
|
46
46
|
const captured = getCaptured();
|
|
47
47
|
expect(captured).toBeDefined();
|
|
48
48
|
expect(captured).toHaveLength(1);
|
|
49
|
-
// marker 前缀(host 侧
|
|
49
|
+
// marker 前缀(host 侧 EventAdapter 据此解码成 extension:widgetGui)
|
|
50
50
|
expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
|
|
51
51
|
// 解码还原 GuiComponent
|
|
52
52
|
const json = captured![0].slice(GUI_WIDGET_MARKER.length);
|
|
@@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest";
|
|
|
16
16
|
import { Value } from "typebox/value";
|
|
17
17
|
|
|
18
18
|
import { GoalControlParams } from "../adapters/goal-control-adapter";
|
|
19
|
+
import { createGoalState } from "../engine/goal";
|
|
20
|
+
import { serializeState } from "../persistence";
|
|
19
21
|
|
|
20
22
|
describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
|
|
21
23
|
// 顶层合规:序列化形态顶层必须有 type:"object",不得有 anyOf(否则被严格网关 400 拒绝)
|
|
@@ -52,3 +54,16 @@ describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
|
|
|
52
54
|
expect(Value.Check(GoalControlParams, params)).toBe(false);
|
|
53
55
|
});
|
|
54
56
|
});
|
|
57
|
+
|
|
58
|
+
// ── 持久化 schema:serializeState 输出(P-m14-2,ext-simplify-03 D2)──
|
|
59
|
+
|
|
60
|
+
describe("persisted goal-state schema — serializeState 输出(P-m14-2)", () => {
|
|
61
|
+
// D2 一步删除 lastProgressTurn/objectiveUpdatedAt 后,新写入的 goal-state entry
|
|
62
|
+
// 不得再携带这两个 write-only 字段。serializeState 是浅拷贝透传({ ...state, budget: {...} }),
|
|
63
|
+
// 字段从 GoalRuntimeState 删除后输出自动少两键——此处断言删除完整(fixture 残留即红)。
|
|
64
|
+
it("新写入 entry(serializeState(createGoalState))不含已删两字段", () => {
|
|
65
|
+
const serialized = serializeState(createGoalState("obj"));
|
|
66
|
+
expect(serialized).not.toHaveProperty("lastProgressTurn");
|
|
67
|
+
expect(serialized).not.toHaveProperty("objectiveUpdatedAt");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -34,9 +34,11 @@ function makeFakePorts(): ServicePorts & {
|
|
|
34
34
|
setStatus: () => {},
|
|
35
35
|
notify: () => {},
|
|
36
36
|
hasUI: true,
|
|
37
|
-
// ThemeLike
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// ThemeLike 嵌套成员(UiPort.theme 显式声明,updateWidget 直读 uiPort.theme)
|
|
38
|
+
theme: {
|
|
39
|
+
fg: (_color: string, text: string) => text,
|
|
40
|
+
bold: (text: string) => text,
|
|
41
|
+
},
|
|
40
42
|
},
|
|
41
43
|
messaging: {
|
|
42
44
|
sendContextMessage: () => {},
|
|
@@ -44,8 +46,6 @@ function makeFakePorts(): ServicePorts & {
|
|
|
44
46
|
},
|
|
45
47
|
session: {
|
|
46
48
|
getEntries: () => [],
|
|
47
|
-
getContextUsage: () => null,
|
|
48
|
-
signal: undefined,
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* agent-end stale-check 辅助函数测试(TC-3)
|
|
3
3
|
*
|
|
4
4
|
* 直接测试 makeStaleChecker 的快照语义,
|
|
5
5
|
* 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
|
|
10
|
-
import { makeStaleChecker } from "../adapters/event-handlers/
|
|
10
|
+
import { makeStaleChecker } from "../adapters/event-handlers/agent-end";
|
|
11
11
|
import { createGoalState } from "../engine/goal";
|
|
12
12
|
import type { GoalRuntimeState } from "../engine/types";
|
|
13
13
|
import { createGoalSession } from "../session";
|
|
@@ -154,7 +154,7 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
|
|
|
154
154
|
return;
|
|
155
155
|
}
|
|
156
156
|
if (state.status !== "active") {
|
|
157
|
-
state.status = "active";
|
|
157
|
+
state.status = transitionStatus(state.status, "active");
|
|
158
158
|
state.timeStartedAt = Date.now();
|
|
159
159
|
}
|
|
160
160
|
// W5:resume = 新激活周期——熔断计数清零。主判据总次数(continuationsSent)只经
|
|
@@ -284,7 +284,7 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
|
|
|
284
284
|
|
|
285
285
|
/**
|
|
286
286
|
* FR-8.4 G-002:重塑(reset)。重置 objective/budget flags/
|
|
287
|
-
* currentTurnIndex
|
|
287
|
+
* currentTurnIndex,保留 goalId。
|
|
288
288
|
* active 状态下向 AI 注入 objective-updated steering。
|
|
289
289
|
*/
|
|
290
290
|
function handleUpdate(
|
|
@@ -313,9 +313,7 @@ function handleUpdate(
|
|
|
313
313
|
const oldObjective = state.objective;
|
|
314
314
|
// FR-8.4 G-002: 重塑(重置,保留 goalId)
|
|
315
315
|
state.objective = newObjective;
|
|
316
|
-
state.objectiveUpdatedAt = Date.now();
|
|
317
316
|
state.currentTurnIndex = 0;
|
|
318
|
-
state.lastProgressTurn = 0;
|
|
319
317
|
state.budgetLimitSteeringSent = false;
|
|
320
318
|
state.tokenWarning70Sent = false;
|
|
321
319
|
state.tokenWarning90Sent = false;
|
|
@@ -43,7 +43,6 @@ import { serializeState } from "../../persistence";
|
|
|
43
43
|
import { cancelContinuationTimer, type GoalSession } from "../../session";
|
|
44
44
|
import type { ServicePorts } from "../../service";
|
|
45
45
|
import { buildPorts } from "../ports";
|
|
46
|
-
import { makeStaleChecker } from "./shared";
|
|
47
46
|
|
|
48
47
|
export async function handleAgentEnd(
|
|
49
48
|
pi: ExtensionAPI,
|
|
@@ -353,3 +352,21 @@ function deliverContinuation(ports: ServicePorts, session: GoalSession): void {
|
|
|
353
352
|
ports.messaging.sendContextMessage(continuationPrompt(state), "followUp");
|
|
354
353
|
ports.persistence.appendState(serializeState(state));
|
|
355
354
|
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 构造 stale-check 闭包(FR-8.2 G-020):入口快照 goalId,后续判断是否被新 goal 覆盖。
|
|
358
|
+
*
|
|
359
|
+
* 用法(agent_end):
|
|
360
|
+
* ```ts
|
|
361
|
+
* const checkStale = makeStaleChecker(session);
|
|
362
|
+
* // ... 长流程 ...
|
|
363
|
+
* if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
|
|
364
|
+
* ```
|
|
365
|
+
*
|
|
366
|
+
* 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
|
|
367
|
+
* 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
|
|
368
|
+
*/
|
|
369
|
+
export function makeStaleChecker(session: GoalSession): () => boolean {
|
|
370
|
+
const snapshotGoalId = session.state?.goalId;
|
|
371
|
+
return () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
372
|
+
}
|
|
@@ -20,7 +20,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
20
20
|
import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
|
|
21
21
|
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
22
22
|
import { contextInjectionPrompt } from "../../projection/prompts";
|
|
23
|
-
import {
|
|
23
|
+
import { renderTerminalStatusLine } from "../../projection/widget";
|
|
24
24
|
import type { GoalSession } from "../../session";
|
|
25
25
|
import { cancelContinuationTimer, clearGoalSession } from "../../session";
|
|
26
26
|
import { buildPorts } from "../ports";
|
|
@@ -92,7 +92,7 @@ function handleTerminalStateBeforeAgent(
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
// 折叠 status bar(终态显示)
|
|
95
|
-
const statusText = renderTerminalStatusLine(state,
|
|
95
|
+
const statusText = renderTerminalStatusLine(state, buildPorts(pi, ctx).ui.theme);
|
|
96
96
|
if (statusText && ctx.hasUI) ctx.ui.setStatus("goal", statusText);
|
|
97
97
|
if (ctx.hasUI) ctx.ui.setWidget("goal", undefined);
|
|
98
98
|
}
|
|
@@ -7,27 +7,15 @@
|
|
|
7
7
|
* 不 persist / updateWidget。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ExtensionContext, MessageEndEvent } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
12
|
import { applyEvent } from "../../service";
|
|
13
13
|
import type { GoalSession } from "../../session";
|
|
14
14
|
|
|
15
|
-
export interface MessageEndLikeEvent {
|
|
16
|
-
message: {
|
|
17
|
-
role: string;
|
|
18
|
-
usage?: {
|
|
19
|
-
input?: number;
|
|
20
|
-
output?: number;
|
|
21
|
-
cacheRead?: number;
|
|
22
|
-
totalTokens?: number;
|
|
23
|
-
};
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
15
|
export async function handleMessageEnd(
|
|
28
16
|
session: GoalSession,
|
|
29
17
|
ctx: ExtensionContext,
|
|
30
|
-
event:
|
|
18
|
+
event: MessageEndEvent,
|
|
31
19
|
): Promise<void> {
|
|
32
20
|
if (!session.state) return;
|
|
33
21
|
// FR-6.7 ESC 守卫
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* executionMode: "sequential"——状态变更 tool,不可与同批其他 tool 并行执行。
|
|
30
30
|
*
|
|
31
|
-
* 错误处理:用 throw new Error(
|
|
31
|
+
* 错误处理:用 throw new Error(extension-conventions.md Tool 设计规范),不返回错误成功模式。
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
package/src/adapters/ports.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ports 桥接 — Pi → ServicePorts 适配(adapters 层)
|
|
3
3
|
*
|
|
4
|
-
* 单一 ports 构造点(DRY):command-adapter /
|
|
4
|
+
* 单一 ports 构造点(DRY):command-adapter / goal-control-adapter / event-handlers / index 共用。
|
|
5
5
|
*
|
|
6
6
|
* - persistence: pi.appendEntry 映射到 appendState / appendHistory(type 字符串区分)
|
|
7
|
-
* - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI +
|
|
7
|
+
* - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + isGui + theme(fg/bold 适配 ThemeLike)
|
|
8
8
|
* - messaging: pi.sendMessage 映射到 sendContextMessage / sendUserMessage
|
|
9
|
-
* - session: ctx.sessionManager.getEntries
|
|
9
|
+
* - session: ctx.sessionManager.getEntries
|
|
10
|
+
*
|
|
11
|
+
* port/ctx 双通道惯例:port 对象仅用于传入 service/session 的实参;
|
|
12
|
+
* handler 自用的 UI/日志直呼 ctx(如 ctx.ui.notify、ctx.ui.setWidget)。
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import type { ExtensionAPI, ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
@@ -22,10 +25,6 @@ import type { ServicePorts } from "../service";
|
|
|
22
25
|
*
|
|
23
26
|
* persistence 的 appendState 用 ENTRY_TYPE,appendHistory 用 HISTORY_ENTRY_TYPE,
|
|
24
27
|
* 与 serializeState / makeHistoryEntry 的输出对齐(session.ts reconstructGoalState 据此识别)。
|
|
25
|
-
*
|
|
26
|
-
* UiPort 接口未声明 fg/bold(D-22:只声明机器可检查的能力边界),
|
|
27
|
-
* 构造满足 UiPort & ThemeLike 的对象后整体断言为 UiPort(多出的 fg/bold 运行时存在,
|
|
28
|
-
* projection/widget.ts 的 asTheme 用 `as unknown as ThemeLike` 单步断言取出)。
|
|
29
28
|
*/
|
|
30
29
|
export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePorts {
|
|
31
30
|
const persistence: PersistencePort = {
|
|
@@ -40,7 +39,7 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
40
39
|
},
|
|
41
40
|
};
|
|
42
41
|
|
|
43
|
-
const uiPort = {
|
|
42
|
+
const uiPort: UiPort = {
|
|
44
43
|
setWidget(name: string, content: string[] | string | undefined): void {
|
|
45
44
|
// SDK setWidget 重载只接受 string[] | undefined(或 factory)。本地 UiPort 允许单 string,
|
|
46
45
|
// 此处归一化:string 包一层成 [string],再透传。
|
|
@@ -50,7 +49,7 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
50
49
|
setGuiWidget(name: string, result: GuiRenderResult | undefined): void {
|
|
51
50
|
// GUI 协议 widget:guiSetWidget 在 RPC 模式用 marker 编码 GuiRenderResult JSON
|
|
52
51
|
// (component + meta 宿主元数据)进 string[],复用 ctx.ui.setWidget 通道;
|
|
53
|
-
// host 侧
|
|
52
|
+
// host 侧 EventAdapter(xyz-agent runtime 的 pi 事件适配层)检测 marker 解码还原。guiSetWidget 无 isGui 守卫
|
|
54
53
|
// (helpers.ts 仅查 ctx.ui?.setWidget 存在性),isGui 判定在 updateWidget
|
|
55
54
|
// 外层(projection/widget.ts)。
|
|
56
55
|
//
|
|
@@ -74,16 +73,18 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
74
73
|
// 断言同 setGuiWidget:custom 泛型签名静态不兼容,mode 单字段运行时可靠。
|
|
75
74
|
return isGuiCapable(ctx as GuiContext);
|
|
76
75
|
},
|
|
77
|
-
// ThemeLike
|
|
76
|
+
// ThemeLike 适配:透传 ctx.ui.theme 的 fg/bold。
|
|
78
77
|
// Theme.fg 只接受 ThemeColor 字面量 union(SDK 契约);projection 层保证传入 union 内字面量,
|
|
79
78
|
// string → ThemeColor 是宽到窄单步断言(无需 unknown 中转)。
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
theme: {
|
|
80
|
+
fg(color: string, text: string): string {
|
|
81
|
+
return ctx.ui.theme.fg(color as ThemeColor, text);
|
|
82
|
+
},
|
|
83
|
+
bold(text: string): string {
|
|
84
|
+
return ctx.ui.theme.bold(text);
|
|
85
|
+
},
|
|
85
86
|
},
|
|
86
|
-
}
|
|
87
|
+
};
|
|
87
88
|
|
|
88
89
|
const messaging: MessagingPort = {
|
|
89
90
|
sendContextMessage: (content, deliverAs, customType): void => {
|
|
@@ -103,13 +104,6 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
103
104
|
|
|
104
105
|
const session: SessionPort = {
|
|
105
106
|
getEntries: () => ctx.sessionManager.getEntries(),
|
|
106
|
-
getContextUsage: () => {
|
|
107
|
-
const usage = ctx.getContextUsage();
|
|
108
|
-
return usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow } : null;
|
|
109
|
-
},
|
|
110
|
-
get signal(): AbortSignal | undefined {
|
|
111
|
-
return ctx.signal;
|
|
112
|
-
},
|
|
113
107
|
};
|
|
114
108
|
|
|
115
109
|
return { persistence, ui: uiPort, messaging, session };
|
|
@@ -71,21 +71,21 @@ describe("checkBudgetOnTurnEnd — token 阈值", () => {
|
|
|
71
71
|
it("token < 70% → 无预警", () => {
|
|
72
72
|
expect(checkBudgetOnTurnEnd(makeState({ tokensUsed: 600, budget: { tokenBudget: 1000 } })).warnings).toEqual([]);
|
|
73
73
|
});
|
|
74
|
-
it("token >= 70% 未发 → warning70
|
|
74
|
+
it("token >= 70% 未发 → warning70", () => {
|
|
75
75
|
const s = makeState({ tokensUsed: 700, budget: { tokenBudget: 1000 } });
|
|
76
|
-
expect(checkBudgetOnTurnEnd(s).warnings).toContainEqual({ type: "warning70"
|
|
76
|
+
expect(checkBudgetOnTurnEnd(s).warnings).toContainEqual({ type: "warning70" });
|
|
77
77
|
});
|
|
78
78
|
it("token >= 70% 已发 → 不重复", () => {
|
|
79
79
|
const s = makeState({ tokensUsed: 750, tokenWarning70Sent: true, budget: { tokenBudget: 1000 } });
|
|
80
|
-
expect(checkBudgetOnTurnEnd(s).warnings).not.toContainEqual({ type: "warning70"
|
|
80
|
+
expect(checkBudgetOnTurnEnd(s).warnings).not.toContainEqual({ type: "warning70" });
|
|
81
81
|
});
|
|
82
82
|
it("token >= 90% 未发 steering → shouldSendSteering", () => {
|
|
83
83
|
const s = makeState({ tokensUsed: 950, budget: { tokenBudget: 1000 } });
|
|
84
84
|
expect(checkBudgetOnTurnEnd(s).shouldSendSteering).toBe(true);
|
|
85
85
|
});
|
|
86
|
-
it("token >= 100% 已发 steering → terminal exceeded
|
|
86
|
+
it("token >= 100% 已发 steering → terminal exceeded", () => {
|
|
87
87
|
const s = makeState({ tokensUsed: 1000, budgetLimitSteeringSent: true, budget: { tokenBudget: 1000 } });
|
|
88
|
-
expect(checkBudgetOnTurnEnd(s).terminal).toEqual({ type: "exceeded"
|
|
88
|
+
expect(checkBudgetOnTurnEnd(s).terminal).toEqual({ type: "exceeded" });
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -96,9 +96,9 @@ describe("checkBudgetOnResume", () => {
|
|
|
96
96
|
it("无预算 → null", () => {
|
|
97
97
|
expect(checkBudgetOnResume(makeState())).toBeNull();
|
|
98
98
|
});
|
|
99
|
-
it("token 超额 → exceeded
|
|
99
|
+
it("token 超额 → exceeded", () => {
|
|
100
100
|
const s = makeState({ tokensUsed: 1000, budget: { tokenBudget: 1000 } });
|
|
101
|
-
expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded"
|
|
101
|
+
expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded" });
|
|
102
102
|
});
|
|
103
103
|
it("未超额 → null", () => {
|
|
104
104
|
const s = makeState({ tokensUsed: 500, budget: { tokenBudget: 1000 } });
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { describe, expect } from "vitest";
|
|
8
8
|
import { it, fc } from "@fast-check/vitest";
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
9
12
|
|
|
10
13
|
import { createGoalState, isActiveStatus, isTerminalStatus, transitionStatus } from "../goal";
|
|
11
14
|
import { VALID_TRANSITIONS } from "../types";
|
|
@@ -53,7 +56,6 @@ describe("createGoalState", () => {
|
|
|
53
56
|
tokensUsed: 0,
|
|
54
57
|
timeUsedSeconds: 0,
|
|
55
58
|
currentTurnIndex: 0,
|
|
56
|
-
lastProgressTurn: 0,
|
|
57
59
|
lastTurnTokensUsed: 0,
|
|
58
60
|
budgetLimitSteeringSent: false,
|
|
59
61
|
tokenWarning70Sent: false,
|
|
@@ -84,3 +86,40 @@ describe("createGoalState", () => {
|
|
|
84
86
|
});
|
|
85
87
|
});
|
|
86
88
|
});
|
|
89
|
+
|
|
90
|
+
// ── E1 守卫:状态变更唯一执行点(.status 裸赋值扫描)──
|
|
91
|
+
|
|
92
|
+
describe("E1 守卫:src 内禁止 .status 裸赋值(必须走 transitionStatus 查表)", () => {
|
|
93
|
+
it("扫描 src/**/*.ts(排除 __tests__):0 命中", () => {
|
|
94
|
+
// 正则逐字来自设计 docs/design/ext-simplify-03-goal.md §5.4「E1 守卫设计」,不得自行修改:
|
|
95
|
+
// (?![=>]) 排除 ==/===/=>(比较与箭头函数形态);(?!\s*transitionStatus\b) 放行白名单查表赋值。
|
|
96
|
+
// 扫描粒度 = 整文件内容(非逐行):\s* 必须能跨行吃换行,才能排除
|
|
97
|
+
// `state.status =\n transitionStatus(...)` 这类跨行白名单赋值形态,逐行实现会误报。
|
|
98
|
+
const re = /\.status\s*=(?![=>])(?!\s*transitionStatus\b)/g;
|
|
99
|
+
const srcDir = fileURLToPath(new URL("../../", import.meta.url));
|
|
100
|
+
const offenders: string[] = [];
|
|
101
|
+
const walk = (dir: string): void => {
|
|
102
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
103
|
+
if (dirent.name === "__tests__") continue;
|
|
104
|
+
const abs = join(dir, dirent.name);
|
|
105
|
+
if (dirent.isDirectory()) {
|
|
106
|
+
walk(abs);
|
|
107
|
+
} else if (dirent.isFile() && dirent.name.endsWith(".ts")) {
|
|
108
|
+
const content = readFileSync(abs, "utf8");
|
|
109
|
+
let match: RegExpExecArray | null;
|
|
110
|
+
while ((match = re.exec(content)) !== null) {
|
|
111
|
+
// 行号 = 命中起点之前的换行数 + 1
|
|
112
|
+
const line = content.slice(0, match.index).split("\n").length;
|
|
113
|
+
offenders.push(`${abs}:${line}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walk(srcDir);
|
|
119
|
+
expect(
|
|
120
|
+
offenders,
|
|
121
|
+
`检测到 ${offenders.length} 处 .status 裸赋值(状态变更必须走 transitionStatus 查表,见设计 §5.4 E1 / VALID_TRANSITIONS engine/types.ts):\n`
|
|
122
|
+
+ offenders.join("\n"),
|
|
123
|
+
).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/engine/budget.ts
CHANGED
|
@@ -35,20 +35,14 @@ export interface TickResult {
|
|
|
35
35
|
timeStartedAt: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
export type BudgetDimension = "token";
|
|
40
|
-
|
|
41
|
-
export type BudgetDecision =
|
|
42
|
-
| { type: "warning70"; dimension: BudgetDimension }
|
|
43
|
-
| { type: "warning90"; dimension: BudgetDimension };
|
|
38
|
+
export type BudgetDecision = { type: "warning70" } | { type: "warning90" };
|
|
44
39
|
|
|
45
40
|
export interface BudgetCheckResult {
|
|
46
|
-
terminal: { type: "exceeded"
|
|
41
|
+
terminal: { type: "exceeded" } | null;
|
|
47
42
|
warnings: BudgetDecision[];
|
|
48
43
|
shouldSendSteering: boolean;
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
|
|
52
46
|
// ── token 累加(FR-8.6)──────────────────────────────
|
|
53
47
|
|
|
54
48
|
export function accumulateTokens(currentTokensUsed: number, usage: TokenUsage): number {
|
|
@@ -130,15 +124,15 @@ export function checkBudgetOnTurnEnd(state: GoalRuntimeState): BudgetCheckResult
|
|
|
130
124
|
if (state.budget.tokenBudget) {
|
|
131
125
|
const tokenPct = state.tokensUsed / state.budget.tokenBudget;
|
|
132
126
|
if (tokenPct >= 1 && state.budgetLimitSteeringSent) {
|
|
133
|
-
result.terminal = { type: "exceeded"
|
|
127
|
+
result.terminal = { type: "exceeded" };
|
|
134
128
|
return result;
|
|
135
129
|
}
|
|
136
130
|
if (tokenPct >= BUDGET_RATIO_HIGH && !state.budgetLimitSteeringSent) {
|
|
137
131
|
result.shouldSendSteering = true;
|
|
138
132
|
} else if (tokenPct >= BUDGET_RATIO_HIGH && !state.tokenWarning90Sent) {
|
|
139
|
-
result.warnings.push({ type: "warning90"
|
|
133
|
+
result.warnings.push({ type: "warning90" });
|
|
140
134
|
} else if (tokenPct >= BUDGET_RATIO_LOW && !state.tokenWarning70Sent) {
|
|
141
|
-
result.warnings.push({ type: "warning70"
|
|
135
|
+
result.warnings.push({ type: "warning70" });
|
|
142
136
|
}
|
|
143
137
|
}
|
|
144
138
|
|
|
@@ -147,9 +141,9 @@ export function checkBudgetOnTurnEnd(state: GoalRuntimeState): BudgetCheckResult
|
|
|
147
141
|
|
|
148
142
|
// ── resume 预算重检 ──────────────────────────────────
|
|
149
143
|
|
|
150
|
-
export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded"
|
|
144
|
+
export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded" } | null {
|
|
151
145
|
if (state.budget.tokenBudget && state.tokensUsed >= state.budget.tokenBudget) {
|
|
152
|
-
return { type: "exceeded"
|
|
146
|
+
return { type: "exceeded" };
|
|
153
147
|
}
|
|
154
148
|
return null;
|
|
155
149
|
}
|
package/src/engine/goal.ts
CHANGED
|
@@ -50,9 +50,7 @@ export function createGoalState(
|
|
|
50
50
|
timeStartedAt: now,
|
|
51
51
|
timeUsedSeconds: 0,
|
|
52
52
|
budget: { ...DEFAULT_BUDGET, ...budgetOverrides },
|
|
53
|
-
lastProgressTurn: 0,
|
|
54
53
|
budgetLimitSteeringSent: false,
|
|
55
|
-
objectiveUpdatedAt: now,
|
|
56
54
|
lastBlockerReason: null,
|
|
57
55
|
tokenWarning70Sent: false,
|
|
58
56
|
tokenWarning90Sent: false,
|
package/src/engine/types.ts
CHANGED
|
@@ -71,9 +71,7 @@ export interface GoalRuntimeState {
|
|
|
71
71
|
timeStartedAt: number;
|
|
72
72
|
timeUsedSeconds: number;
|
|
73
73
|
budget: BudgetConfig;
|
|
74
|
-
lastProgressTurn: number;
|
|
75
74
|
budgetLimitSteeringSent: boolean;
|
|
76
|
-
objectiveUpdatedAt: number;
|
|
77
75
|
lastBlockerReason: string | null;
|
|
78
76
|
// token 维度预警 flag(time budget 已移除,仅 token 70/90 两个 flag)
|
|
79
77
|
tokenWarning70Sent: boolean;
|
package/src/index.ts
CHANGED
|
@@ -9,69 +9,29 @@
|
|
|
9
9
|
* - ports.ts:机器可检查的能力边界
|
|
10
10
|
* - adapters/ports.ts:Pi → ServicePorts 桥接(单一构造点)
|
|
11
11
|
* - service.ts:事件入口协调器(applyEvent)
|
|
12
|
-
* - adapters/:Pi 桥接(command-adapter / event-
|
|
12
|
+
* - adapters/:Pi 桥接(command-adapter / event-handlers / ports)
|
|
13
13
|
* - projection/:渲染(widget / prompts)
|
|
14
14
|
*
|
|
15
15
|
* FR-4.2/D-16:ctx 必填,移除 lastCtx 模块级可变状态。
|
|
16
16
|
* FR-6.4:移除 hasPendingInjection。
|
|
17
|
-
* FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted
|
|
17
|
+
* FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted 守卫,在各 event handler)。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext,
|
|
20
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, MessageEndEvent } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { Text } from "@earendil-works/pi-tui";
|
|
22
22
|
|
|
23
23
|
import { handleGoalCommand } from "./adapters/command-adapter";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
handleTurnEnd,
|
|
31
|
-
type MessageEndLikeEvent,
|
|
32
|
-
} from "./adapters/event-adapter";
|
|
24
|
+
import { handleAgentEnd } from "./adapters/event-handlers/agent-end";
|
|
25
|
+
import { handleBeforeAgentStart } from "./adapters/event-handlers/before-agent-start";
|
|
26
|
+
import { handleMessageEnd } from "./adapters/event-handlers/message-end";
|
|
27
|
+
import { handleSessionShutdown } from "./adapters/event-handlers/session-shutdown";
|
|
28
|
+
import { handleSessionStart } from "./adapters/event-handlers/session-start";
|
|
29
|
+
import { handleTurnEnd } from "./adapters/event-handlers/turn-end";
|
|
33
30
|
import { registerGoalControlTool } from "./adapters/goal-control-adapter";
|
|
34
31
|
import { buildPorts } from "./adapters/ports";
|
|
35
32
|
import { createGoal } from "./service";
|
|
36
33
|
import { createGoalSession, type GoalSession } from "./session";
|
|
37
34
|
|
|
38
|
-
// ── Local Interfaces (Like*Event — 避免 any on Pi callback/event signatures) ────
|
|
39
|
-
|
|
40
|
-
interface BeforeAgentStartLikeEvent {
|
|
41
|
-
type: "before_agent_start";
|
|
42
|
-
prompt: string;
|
|
43
|
-
systemPrompt: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface TurnEndLikeEvent {
|
|
47
|
-
type: "turn_end";
|
|
48
|
-
turnIndex: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface AgentEndLikeEvent {
|
|
52
|
-
type: "agent_end";
|
|
53
|
-
messages: unknown[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface SessionStartLikeEvent {
|
|
57
|
-
type: "session_start";
|
|
58
|
-
reason: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface SessionShutdownLikeEvent {
|
|
62
|
-
type: "session_shutdown";
|
|
63
|
-
reason: "quit" | "reload" | "new" | "resume" | "fork";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface LikeCustomMessage {
|
|
67
|
-
customType: string;
|
|
68
|
-
content: string | unknown;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface LikeMessageRenderOptions {
|
|
72
|
-
expanded: boolean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
35
|
// ── Extension Factory ─────────────────────────────────
|
|
76
36
|
|
|
77
37
|
export default function goalExtension(pi: ExtensionAPI) {
|
|
@@ -106,32 +66,32 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
106
66
|
|
|
107
67
|
registerGoalControlTool(pi, session);
|
|
108
68
|
|
|
109
|
-
// ── Events(全部委托 adapters/event-
|
|
69
|
+
// ── Events(全部委托 adapters/event-handlers)────────
|
|
110
70
|
|
|
111
|
-
pi.on("before_agent_start", async (_event
|
|
71
|
+
pi.on("before_agent_start", async (_event, ctx: ExtensionContext) => {
|
|
112
72
|
return handleBeforeAgentStart(pi, session, ctx);
|
|
113
73
|
});
|
|
114
74
|
|
|
115
|
-
pi.on("turn_end", async (_event
|
|
75
|
+
pi.on("turn_end", async (_event, ctx: ExtensionContext) => {
|
|
116
76
|
await handleTurnEnd(pi, session, ctx);
|
|
117
77
|
});
|
|
118
78
|
|
|
119
|
-
pi.on("message_end", async (event:
|
|
79
|
+
pi.on("message_end", async (event: MessageEndEvent, ctx: ExtensionContext) => {
|
|
120
80
|
await handleMessageEnd(session, ctx, event);
|
|
121
81
|
});
|
|
122
82
|
|
|
123
|
-
pi.on("agent_end", async (_event
|
|
83
|
+
pi.on("agent_end", async (_event, ctx: ExtensionContext) => {
|
|
124
84
|
await handleAgentEnd(pi, session, ctx);
|
|
125
85
|
});
|
|
126
86
|
|
|
127
|
-
pi.on("session_start", async (_event
|
|
87
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
128
88
|
await handleSessionStart(pi, session, ctx);
|
|
129
89
|
});
|
|
130
90
|
|
|
131
91
|
// MF-R2-1 根修:session_shutdown 在 runner invalidate 之前触发(此刻 pi/ctx
|
|
132
92
|
// 仍可用),取消待发的退避 continuation timer——覆盖 reload / new / resume /
|
|
133
93
|
// fork / quit 全部失效路径,旧 timer 不再携 stale ctx 闭包存活到到期
|
|
134
|
-
pi.on("session_shutdown", async (_event
|
|
94
|
+
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
135
95
|
await handleSessionShutdown(session);
|
|
136
96
|
});
|
|
137
97
|
|
|
@@ -141,7 +101,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
141
101
|
for (const customType of goalMessageTypes) {
|
|
142
102
|
pi.registerMessageRenderer(
|
|
143
103
|
customType,
|
|
144
|
-
(message
|
|
104
|
+
(message, _options, theme) => {
|
|
145
105
|
const prefix =
|
|
146
106
|
message.customType === "goal-context-exceeded"
|
|
147
107
|
? theme.fg("error", "[GOAL Budget] ")
|
package/src/persistence.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 持久化层 — serialize/deserialize + history entry 构造
|
|
3
3
|
*
|
|
4
|
-
* FR-5
|
|
5
|
-
*
|
|
4
|
+
* FR-5:必填字段缺失直接 throw;旧格式容忍面(time_limited → budget_limited 迁移、
|
|
5
|
+
* successCriteria 旧格式归一、W5 熔断计数缺省归零、slug/completedAtTurnIndex 可选解析、
|
|
6
|
+
* tasks 及未知字段白名单忽略)见 deserializeState 各迁移点注释。零 Pi 依赖。
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { GoalRuntimeState, GoalStatus } from "./engine/types";
|
|
@@ -22,7 +23,7 @@ export function serializeState(state: GoalRuntimeState): GoalRuntimeState {
|
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
// ── deserialize(FR-5
|
|
26
|
+
// ── deserialize(FR-5 严格解析,缺必填字段 throw)──────────
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* 旧持久化状态归一化(唯一迁移点)。
|
|
@@ -95,9 +96,7 @@ export function deserializeState(data: Record<string, unknown>): GoalRuntimeStat
|
|
|
95
96
|
timeStartedAt: req("timeStartedAt"),
|
|
96
97
|
timeUsedSeconds: req("timeUsedSeconds"),
|
|
97
98
|
budget: req("budget"),
|
|
98
|
-
lastProgressTurn: req("lastProgressTurn"),
|
|
99
99
|
budgetLimitSteeringSent: req("budgetLimitSteeringSent"),
|
|
100
|
-
objectiveUpdatedAt: req("objectiveUpdatedAt"),
|
|
101
100
|
lastBlockerReason: req("lastBlockerReason"),
|
|
102
101
|
tokenWarning70Sent: req("tokenWarning70Sent"),
|
|
103
102
|
tokenWarning90Sent: req("tokenWarning90Sent"),
|
package/src/ports.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface PersistencePort {
|
|
|
35
35
|
appendHistory(entry: GoalHistoryEntry): void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ── ThemeLike ────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
|
|
42
|
+
* adapter 层负责把 Pi 的 theme(fg 接收 ThemeColor)适配到此签名(fg 接收 string)。
|
|
43
|
+
*/
|
|
44
|
+
export interface ThemeLike {
|
|
45
|
+
fg: (color: string, text: string) => string;
|
|
46
|
+
bold: (text: string) => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
// ── UiPort ───────────────────────────────────────────
|
|
39
50
|
|
|
40
51
|
export interface UiPort {
|
|
@@ -54,6 +65,8 @@ export interface UiPort {
|
|
|
54
65
|
readonly hasUI: boolean;
|
|
55
66
|
/** 是否 GUI 渲染模式(RPC → GuiComponent;TUI → 原生文本行) */
|
|
56
67
|
readonly isGui: boolean;
|
|
68
|
+
/** 终端主题能力(fg/bold 着色),widget/status 文本行渲染消费(E3 显式声明) */
|
|
69
|
+
readonly theme: ThemeLike;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
// ── MessagingPort ────────────────────────────────────
|
|
@@ -75,7 +88,4 @@ export interface SessionEntryLike {
|
|
|
75
88
|
|
|
76
89
|
export interface SessionPort {
|
|
77
90
|
getEntries(): SessionEntryLike[];
|
|
78
|
-
// tokens 与 Pi SDK 的 ContextUsage.tokens 对齐:number | null(null = 未知,如压缩后未到下次 LLM 响应)。
|
|
79
|
-
getContextUsage(): { tokens: number | null; contextWindow: number } | null;
|
|
80
|
-
readonly signal: AbortSignal | undefined;
|
|
81
91
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* projection/prompts.ts 测试 — prompt 生成函数 +
|
|
2
|
+
* projection/prompts.test.ts 测试 — prompt 生成函数 + budget 片段格式化
|
|
3
3
|
*
|
|
4
4
|
* 覆盖:
|
|
5
|
-
* -
|
|
5
|
+
* - formatBudgetPercent / formatBudgetLine
|
|
6
6
|
* - escapeXmlText(XML 注入防护)
|
|
7
7
|
* - continuationPrompt / budgetLimitPrompt / objectiveUpdatedPrompt / contextInjectionPrompt
|
|
8
8
|
*
|
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
contextInjectionPrompt,
|
|
20
20
|
continuationPrompt,
|
|
21
21
|
escapeXmlText,
|
|
22
|
-
|
|
22
|
+
formatBudgetLine,
|
|
23
|
+
formatBudgetPercent,
|
|
23
24
|
objectiveUpdatedPrompt,
|
|
24
25
|
} from "../prompts";
|
|
25
26
|
|
|
@@ -32,31 +33,38 @@ function makeState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
|
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
// ──
|
|
36
|
+
// ── budget 片段格式化 ────────────────────────────────
|
|
36
37
|
|
|
37
|
-
describe("
|
|
38
|
-
it("
|
|
38
|
+
describe("formatBudgetPercent", () => {
|
|
39
|
+
it("有预算 → Token 百分比", () => {
|
|
39
40
|
const state = makeState({
|
|
40
41
|
budget: { tokenBudget: 1000 },
|
|
41
42
|
tokensUsed: 500,
|
|
42
43
|
});
|
|
43
|
-
const out =
|
|
44
|
+
const out = formatBudgetPercent(state);
|
|
44
45
|
expect(out).toContain("Token: 50%");
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
it("
|
|
48
|
+
it("无预算 → 空字符串", () => {
|
|
48
49
|
const state = makeState();
|
|
49
|
-
expect(
|
|
50
|
+
expect(formatBudgetPercent(state)).toBe("");
|
|
50
51
|
});
|
|
52
|
+
});
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
describe("formatBudgetLine", () => {
|
|
55
|
+
it("有预算 → 剩余/总量格式", () => {
|
|
53
56
|
const state = makeState({
|
|
54
57
|
budget: { tokenBudget: 1000 },
|
|
55
58
|
tokensUsed: 300,
|
|
56
59
|
});
|
|
57
|
-
const out =
|
|
60
|
+
const out = formatBudgetLine(state);
|
|
58
61
|
expect(out).toContain("Tokens: 700/1000");
|
|
59
62
|
});
|
|
63
|
+
|
|
64
|
+
it("无预算 → 空字符串", () => {
|
|
65
|
+
const state = makeState();
|
|
66
|
+
expect(formatBudgetLine(state)).toBe("");
|
|
67
|
+
});
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
// ── escapeXmlText(XML 注入防护)──────────────────────
|
|
@@ -14,14 +14,13 @@ import { describe, expect, it } from "vitest";
|
|
|
14
14
|
|
|
15
15
|
import { createGoalState } from "../../engine/goal";
|
|
16
16
|
import type { GoalRuntimeState, GoalStatus } from "../../engine/types";
|
|
17
|
-
import type { UiPort } from "../../ports";
|
|
17
|
+
import type { ThemeLike, UiPort } from "../../ports";
|
|
18
18
|
import { createGoalSession } from "../../session";
|
|
19
19
|
import { OBJECTIVE_DISPLAY_LIMIT, OBJECTIVE_TRUNCATE_KEEP } from "../../constants";
|
|
20
20
|
import {
|
|
21
21
|
renderStatusLine,
|
|
22
22
|
renderTerminalStatusLine,
|
|
23
23
|
renderWidgetLines,
|
|
24
|
-
type ThemeLike,
|
|
25
24
|
toSingleLine,
|
|
26
25
|
updateWidget,
|
|
27
26
|
} from "../widget";
|
|
@@ -221,8 +220,10 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
|
221
220
|
calls.push({ method: "setStatus", args: [name, text] });
|
|
222
221
|
},
|
|
223
222
|
notify() {},
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
theme: {
|
|
224
|
+
fg: (_color: string, text: string) => text,
|
|
225
|
+
bold: (text: string) => text,
|
|
226
|
+
},
|
|
226
227
|
} as UiPort;
|
|
227
228
|
return { ui, calls };
|
|
228
229
|
}
|
|
@@ -300,8 +301,10 @@ describe("updateWidget GUI 协议分支(isGui=true)", () => {
|
|
|
300
301
|
calls.push({ method: "setStatus", args: [name, text] });
|
|
301
302
|
},
|
|
302
303
|
notify() {},
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
theme: {
|
|
305
|
+
fg: (_color: string, text: string) => text,
|
|
306
|
+
bold: (text: string) => text,
|
|
307
|
+
},
|
|
305
308
|
} as UiPort;
|
|
306
309
|
return { ui, calls };
|
|
307
310
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Steering prompt 模板(projection 层)
|
|
3
3
|
*
|
|
4
|
-
* 类型 import 自 engine/types.ts。
|
|
4
|
+
* 类型 import 自 engine/types.ts。
|
|
5
5
|
*
|
|
6
6
|
* 设计原则(来自 Codex 调研):
|
|
7
7
|
* - Completion audit: 逐项证据验证,intent/partial progress 不是 evidence
|
|
@@ -42,31 +42,10 @@ function successCriteriaBlock(state: GoalRuntimeState): string {
|
|
|
42
42
|
return `<successCriteria>\n${items}\n</successCriteria>\n`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// ──
|
|
45
|
+
// ── budget 片段格式化 ────────────────────────────────
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* FR-3.4 唯一 budget 格式化收敛出口。
|
|
51
|
-
*
|
|
52
|
-
* 2 种输出样式:
|
|
53
|
-
* - "percent" → ` (Token: N%)`(contextInjectionPrompt 用)
|
|
54
|
-
* - "line" → ` | Tokens: remaining/total`(continuationPrompt 用)
|
|
55
|
-
*
|
|
56
|
-
* @param state runtime state(读 budget / tokensUsed)
|
|
57
|
-
* @param style 输出形式
|
|
58
|
-
*/
|
|
59
|
-
export function formatBudget(
|
|
60
|
-
state: GoalRuntimeState,
|
|
61
|
-
style: BudgetFormatStyle,
|
|
62
|
-
): string {
|
|
63
|
-
if (style === "percent") {
|
|
64
|
-
return formatBudgetPercent(state);
|
|
65
|
-
}
|
|
66
|
-
return formatBudgetLine(state);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function formatBudgetPercent(state: GoalRuntimeState): string {
|
|
47
|
+
/** ` (Token: N%)`(contextInjectionPrompt 用)。无预算返回空串。 */
|
|
48
|
+
export function formatBudgetPercent(state: GoalRuntimeState): string {
|
|
70
49
|
if (state.budget.tokenBudget) {
|
|
71
50
|
const pct = Math.round((state.tokensUsed / state.budget.tokenBudget) * PERCENT_FACTOR);
|
|
72
51
|
return ` (Token: ${pct}%)`;
|
|
@@ -74,7 +53,8 @@ function formatBudgetPercent(state: GoalRuntimeState): string {
|
|
|
74
53
|
return "";
|
|
75
54
|
}
|
|
76
55
|
|
|
77
|
-
|
|
56
|
+
/** ` | Tokens: remaining/total`(continuationPrompt 用)。无预算返回空串。 */
|
|
57
|
+
export function formatBudgetLine(state: GoalRuntimeState): string {
|
|
78
58
|
if (state.budget.tokenBudget) {
|
|
79
59
|
const remaining = Math.max(state.budget.tokenBudget - state.tokensUsed, 0);
|
|
80
60
|
return ` | Tokens: ${remaining}/${state.budget.tokenBudget}`;
|
|
@@ -86,7 +66,7 @@ function formatBudgetLine(state: GoalRuntimeState): string {
|
|
|
86
66
|
|
|
87
67
|
export function continuationPrompt(state: GoalRuntimeState): string {
|
|
88
68
|
const objective = escapeXmlText(state.objective);
|
|
89
|
-
const budgetLine =
|
|
69
|
+
const budgetLine = formatBudgetLine(state);
|
|
90
70
|
const criteria = successCriteriaBlock(state);
|
|
91
71
|
|
|
92
72
|
return (
|
|
@@ -190,7 +170,7 @@ export function objectiveUpdatedPrompt(
|
|
|
190
170
|
*/
|
|
191
171
|
export function contextInjectionPrompt(state: GoalRuntimeState): string {
|
|
192
172
|
const objective = escapeXmlText(state.objective);
|
|
193
|
-
const budgetInfo =
|
|
173
|
+
const budgetInfo = formatBudgetPercent(state);
|
|
194
174
|
const criteria = successCriteriaBlock(state);
|
|
195
175
|
|
|
196
176
|
return (
|
package/src/projection/widget.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Widget 渲染逻辑(projection 层)— 状态栏和侧边栏面板
|
|
3
3
|
*
|
|
4
4
|
* 设计要点:
|
|
5
|
-
* - 不 import Pi 类型(
|
|
6
|
-
* - 类型 import 自 engine/types.ts
|
|
5
|
+
* - 不 import Pi 类型(theme 能力经 ports.ts 的 ThemeLike 抽象,fg 接收 string)
|
|
6
|
+
* - 类型 import 自 engine/types.ts 与 ports.ts(ThemeLike 上移至接口层)
|
|
7
7
|
* - 工具函数 import 自 engine/budget.ts
|
|
8
8
|
* - 时间计算基于 state.timeUsedSeconds(不含 Date.now() 副作用段)
|
|
9
9
|
* - updateWidget(session, uiPort) 含 FR-6.6 hasUI 守卫
|
|
@@ -26,19 +26,10 @@ import {
|
|
|
26
26
|
import { getBudgetColor, getTokenUsagePercent } from "../engine/budget";
|
|
27
27
|
import { isTerminalStatus } from "../engine/goal";
|
|
28
28
|
import type { GoalRuntimeState } from "../engine/types";
|
|
29
|
-
import type { UiPort } from "../ports";
|
|
29
|
+
import type { ThemeLike, UiPort } from "../ports";
|
|
30
30
|
import type { GoalSession } from "../session";
|
|
31
31
|
import { buildGoalGui } from "./gui";
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
|
|
35
|
-
* adapter 层负责把 Pi 的 theme(fg 接收 ThemeColor)适配到此签名(fg 接收 string)。
|
|
36
|
-
*/
|
|
37
|
-
export interface ThemeLike {
|
|
38
|
-
fg: (color: string, text: string) => string;
|
|
39
|
-
bold: (text: string) => string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
33
|
/**
|
|
43
34
|
* 将多行文本压缩为单行,用于 widget 渲染。
|
|
44
35
|
* 多行 content 泄漏到 widget 会导致 markdown 表格/标题等破坏布局。
|
|
@@ -195,29 +186,6 @@ export function renderWidgetLines(state: GoalRuntimeState, th: ThemeLike): strin
|
|
|
195
186
|
|
|
196
187
|
// ── updateWidget(FR-6.6 hasUI 守卫)──
|
|
197
188
|
|
|
198
|
-
/**
|
|
199
|
-
* 从 UiPort 取出 ThemeLike。
|
|
200
|
-
*
|
|
201
|
-
* ports.ts 的 UiPort 故意不暴露 theme(保持抽象最小)。adapter 层在
|
|
202
|
-
* 构造 UiPort 实现时,把 Pi 的 ctx.ui.theme 的 fg/bold 方法挂到对象上,
|
|
203
|
-
* 使该实现同时满足 UiPort 与 ThemeLike 形状。projection 层通过此单步断言取出。
|
|
204
|
-
*/
|
|
205
|
-
function asTheme(uiPort: UiPort): ThemeLike {
|
|
206
|
-
// UiPort 刻意不声明 fg/bold(D-22:只声明机器可检查的能力边界),与 ThemeLike 无类型重叠,
|
|
207
|
-
// 必须 unknown 中转——这是架构契约断言:ports.ts 构造 UiPort 实现时已把 ctx.ui.theme 的
|
|
208
|
-
// fg/bold 挂到对象上(buildPorts 的 uiPort 对象含全部字段),运行时必然存在。
|
|
209
|
-
// eslint-disable-next-line taste/no-unsafe-cast
|
|
210
|
-
return uiPort as unknown as ThemeLike;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* 导出 asTheme:单一 theme 提取断言点(TS-1)。
|
|
215
|
-
*
|
|
216
|
-
* adapter 层(event-adapter handleTerminalStateBeforeAgent)复用本函数,
|
|
217
|
-
* 避免重复 `ctx.ui.theme as unknown as ThemeLike` 断言。
|
|
218
|
-
*/
|
|
219
|
-
export { asTheme };
|
|
220
|
-
|
|
221
189
|
/**
|
|
222
190
|
* 刷新 widget + status bar。
|
|
223
191
|
*
|
|
@@ -243,7 +211,7 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
|
243
211
|
|
|
244
212
|
// 终态折叠为单行 status bar
|
|
245
213
|
if (isTerminalStatus(session.state.status)) {
|
|
246
|
-
const statusText = renderTerminalStatusLine(session.state,
|
|
214
|
+
const statusText = renderTerminalStatusLine(session.state, uiPort.theme);
|
|
247
215
|
if (statusText) {
|
|
248
216
|
uiPort.setStatus("goal", statusText);
|
|
249
217
|
}
|
|
@@ -252,11 +220,11 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
|
252
220
|
return;
|
|
253
221
|
}
|
|
254
222
|
|
|
255
|
-
uiPort.setStatus("goal", renderStatusLine(session.state,
|
|
223
|
+
uiPort.setStatus("goal", renderStatusLine(session.state, uiPort.theme));
|
|
256
224
|
if (isGui) {
|
|
257
225
|
// 复用 projection/gui.ts 的 buildGoalGui(整个 GuiRenderResult:component + meta 宿主元数据)
|
|
258
226
|
uiPort.setGuiWidget("goal", buildGoalGui(session.state));
|
|
259
227
|
} else {
|
|
260
|
-
uiPort.setWidget("goal", renderWidgetLines(session.state,
|
|
228
|
+
uiPort.setWidget("goal", renderWidgetLines(session.state, uiPort.theme));
|
|
261
229
|
}
|
|
262
230
|
}
|
package/src/service.ts
CHANGED
|
@@ -60,7 +60,7 @@ export function tickState(state: GoalRuntimeState): void {
|
|
|
60
60
|
* 这类「active → 非活跃」的转换,调用方必须在改 status **之前**先调本函数
|
|
61
61
|
* (或先调 {@link tickState} 捕获最后运行段)。
|
|
62
62
|
*
|
|
63
|
-
* 导出供 command-adapter /
|
|
63
|
+
* 导出供 command-adapter / goal-control-adapter 复用(DRY:所有路径共用同一持久化语义)。
|
|
64
64
|
*/
|
|
65
65
|
export function persistState(session: GoalSession, ports: ServicePorts): void {
|
|
66
66
|
if (!session.state) return;
|
|
@@ -154,9 +154,9 @@ export function createGoal(
|
|
|
154
154
|
/**
|
|
155
155
|
* 唯一终态序列入口(FR-3.3 / AC-3)。
|
|
156
156
|
*
|
|
157
|
-
* 收口所有 active→terminal
|
|
158
|
-
* command-adapter /
|
|
159
|
-
* writeHistory + appendState` 序列。
|
|
157
|
+
* 收口所有 active→terminal 转换的完整副作用序列——service(budget 终态)与
|
|
158
|
+
* command-adapter(clear / resume 超预算)、goal-control-adapter(complete)共用
|
|
159
|
+
* 同一 `transitionStatus + completedAtTurnIndex + writeHistory + appendState` 序列。
|
|
160
160
|
*
|
|
161
161
|
* 序列(严格顺序):
|
|
162
162
|
* 1. tickState(state)(FR-6.5:转 terminal 前累加当前运行段——此时 status 仍为 active)
|
|
@@ -237,10 +237,10 @@ function toMessageEndData(eventData: unknown): MessageEndEventData | null {
|
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
239
|
* 路径 B 入口。异步事件,无返回值(副作用直接 mutate session.state)。
|
|
240
|
-
* 并发保护(isProcessing / stale-check)在 event-
|
|
240
|
+
* 并发保护(isProcessing / stale-check)在 event-handlers(agent-end.ts),不在此层。
|
|
241
241
|
*
|
|
242
242
|
* 本函数作为简单事件的统一入口(message_end / turn_end)。
|
|
243
|
-
* 复杂事件(before_agent_start / agent_end / session_start)由 event-
|
|
243
|
+
* 复杂事件(before_agent_start / agent_end / session_start)由 event-handlers
|
|
244
244
|
* 直接实现,调 engine 纯函数 + service 辅助函数。
|
|
245
245
|
*
|
|
246
246
|
* H3:不再返回 effect 数组。turn_end 的 updateWidget 副作用由调用方
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Event adapter — 薄路由(#4 拆分后)。
|
|
3
|
-
*
|
|
4
|
-
* 6 个事件 handler + 1 个共享辅助拆在 event-handlers/,本文件只做 re-export。
|
|
5
|
-
* handler 逻辑见各自文件。pi.on 注册在 index.ts(保持不变)。
|
|
6
|
-
*
|
|
7
|
-
* 行为等价(#4 验收):handler 逻辑原样搬迁,仅 persistAndUpdate 签名 ports 化
|
|
8
|
-
* (见 service.ts)+ turn_end 的 buildPorts 调用从两次收敛为一次。
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export { handleAgentEnd } from "./event-handlers/agent-end";
|
|
12
|
-
export { handleBeforeAgentStart } from "./event-handlers/before-agent-start";
|
|
13
|
-
export type { MessageEndLikeEvent } from "./event-handlers/message-end";
|
|
14
|
-
export { handleMessageEnd } from "./event-handlers/message-end";
|
|
15
|
-
export { handleSessionShutdown } from "./event-handlers/session-shutdown";
|
|
16
|
-
export { handleSessionStart } from "./event-handlers/session-start";
|
|
17
|
-
export { handleTurnEnd } from "./event-handlers/turn-end";
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* event-handlers 共享辅助(adapters/event-handlers 层)。
|
|
3
|
-
*
|
|
4
|
-
* - makeStaleChecker:FR-8.2 G-020 goalId snapshot,agent_end 入口构造、每个副作用前 checkStale
|
|
5
|
-
*
|
|
6
|
-
* handleAgentEnd 直接操纵 session.isProcessing(历史实现),无需 acquire/release 封装。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { GoalSession } from "../../session";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 构造 stale-check 闭包:入口快照 goalId,后续判断是否被新 goal 覆盖。
|
|
13
|
-
*
|
|
14
|
-
* 用法(agent_end):
|
|
15
|
-
* ```ts
|
|
16
|
-
* const checkStale = makeStaleChecker(session);
|
|
17
|
-
* // ... 长流程 ...
|
|
18
|
-
* if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
|
|
19
|
-
* ```
|
|
20
|
-
*
|
|
21
|
-
* 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
|
|
22
|
-
* 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
|
|
23
|
-
*/
|
|
24
|
-
export function makeStaleChecker(session: GoalSession): () => boolean {
|
|
25
|
-
const snapshotGoalId = session.state?.goalId;
|
|
26
|
-
return () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
27
|
-
}
|