@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-adapter 并发保护辅助函数测试(TC-3)
|
|
3
|
+
*
|
|
4
|
+
* 直接测试 makeStaleChecker / acquireProcessing / releaseProcessing 的快照语义,
|
|
5
|
+
* 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
|
|
6
|
+
* 后续任何新 goal 均视为 stale。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { acquireProcessing, makeStaleChecker, releaseProcessing } from "../adapters/event-handlers/shared";
|
|
11
|
+
import { createGoalState } from "../engine/goal";
|
|
12
|
+
import { createGoalSession } from "../session";
|
|
13
|
+
|
|
14
|
+
describe("makeStaleChecker", () => {
|
|
15
|
+
it("snapshot 时 state=null → 后续新 goal 视为 stale", () => {
|
|
16
|
+
const session = createGoalSession();
|
|
17
|
+
// state 为 null 时 snapshot goalId = undefined
|
|
18
|
+
const checkStale = makeStaleChecker(session);
|
|
19
|
+
// 后续出现新 goal(goalId 有值)
|
|
20
|
+
session.state = createGoalState("new goal");
|
|
21
|
+
expect(checkStale()).toBe(true); // undefined !== "goal-xxx" → stale
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("snapshot 时有 goal → 同 goalId 返回 false(未 stale)", () => {
|
|
25
|
+
const session = createGoalSession();
|
|
26
|
+
session.state = createGoalState("current");
|
|
27
|
+
const snapshotId = session.state.goalId;
|
|
28
|
+
const checkStale = makeStaleChecker(session);
|
|
29
|
+
// 后续 mutate 但 goalId 不变
|
|
30
|
+
session.state.currentTurnIndex = 99;
|
|
31
|
+
expect(checkStale()).toBe(false);
|
|
32
|
+
expect(session.state.goalId).toBe(snapshotId); // 确认 goalId 未变
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("snapshot 时有 goal → goalId 变更后视为 stale(被新 goal 覆盖)", () => {
|
|
36
|
+
const session = createGoalSession();
|
|
37
|
+
session.state = createGoalState("old goal");
|
|
38
|
+
const checkStale = makeStaleChecker(session);
|
|
39
|
+
expect(checkStale()).toBe(false); // 初始未 stale
|
|
40
|
+
// 模拟新 goal 覆盖(createGoalState 生成新 goalId)
|
|
41
|
+
session.state = createGoalState("new goal overwrote");
|
|
42
|
+
expect(checkStale()).toBe(true); // goalId 变了 → stale
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("state 被清空(null)后 → 视为 stale", () => {
|
|
46
|
+
const session = createGoalSession();
|
|
47
|
+
session.state = createGoalState("then cleared");
|
|
48
|
+
const checkStale = makeStaleChecker(session);
|
|
49
|
+
expect(checkStale()).toBe(false);
|
|
50
|
+
session.state = null; // clearGoalSession 清空
|
|
51
|
+
expect(checkStale()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("acquireProcessing / releaseProcessing", () => {
|
|
56
|
+
it("首次 acquire → true 并设置 isProcessing", () => {
|
|
57
|
+
const session = createGoalSession();
|
|
58
|
+
expect(session.isProcessing).toBe(false);
|
|
59
|
+
expect(acquireProcessing(session)).toBe(true);
|
|
60
|
+
expect(session.isProcessing).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("已占用时再 acquire → false(防重入)", () => {
|
|
64
|
+
const session = createGoalSession();
|
|
65
|
+
expect(acquireProcessing(session)).toBe(true); // 首次成功
|
|
66
|
+
expect(acquireProcessing(session)).toBe(false); // 重入拒绝
|
|
67
|
+
expect(acquireProcessing(session)).toBe(false); // 再入仍拒绝
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("release 后可再次 acquire", () => {
|
|
71
|
+
const session = createGoalSession();
|
|
72
|
+
acquireProcessing(session);
|
|
73
|
+
expect(session.isProcessing).toBe(true);
|
|
74
|
+
releaseProcessing(session);
|
|
75
|
+
expect(session.isProcessing).toBe(false);
|
|
76
|
+
expect(acquireProcessing(session)).toBe(true); // 释放后可重新获取
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi SDK stub for vitest — 提供运行时 mock,避免 import 真实的 Pi 包
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// StringEnum: 返回 values 的 union type 的 runtime 值(简化为第一个元素)
|
|
6
|
+
export function StringEnum<T extends readonly string[]>(values: T, _options?: Record<string, unknown>): T[number] {
|
|
7
|
+
return values[0] as T[number];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Text: pi-tui 的渲染节点 stub(goal-control-adapter renderCall/renderResult 返回值)
|
|
11
|
+
export class Text {
|
|
12
|
+
constructor(_content: unknown, _x: number, _y: number) {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Pi SDK types used in import chains — re-export as empty
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typebox stub for vitest — 只需要 Type.Object/String/Array/Number/Optional/StringEnum 等
|
|
3
|
+
* 被 tool-handler.ts import 链拉入,测试中不实际调用 schema 验证
|
|
4
|
+
*/
|
|
5
|
+
export const Type = {
|
|
6
|
+
Object: (_schema: unknown) => ({}),
|
|
7
|
+
String: (_opts?: unknown) => ({}),
|
|
8
|
+
Number: (_opts?: unknown) => ({}),
|
|
9
|
+
Array: (_item: unknown, _opts?: unknown) => ([]),
|
|
10
|
+
Optional: (_item: unknown) => ({}),
|
|
11
|
+
Boolean: (_opts?: unknown) => ({}),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Static<_T> = Record<string, unknown>;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /goal 命令适配器 — 子命令 handler(adapters 层)
|
|
3
|
+
*
|
|
4
|
+
* 子命令:set / status / pause / resume / clear / update / history
|
|
5
|
+
* (/goal abort 随 task CRUD 一并废弃)。
|
|
6
|
+
*
|
|
7
|
+
* FR-3: pause(active→paused)与 resume(paused/blocked→active)对称设计——
|
|
8
|
+
* 两者都是非终态「停止」状态,用户控制续跑节奏。
|
|
9
|
+
*
|
|
10
|
+
* 状态变更调 service(createGoal / finalizeAndPersist);
|
|
11
|
+
* ports 桥接复用 adapters/ports.buildPorts(DRY:单一 ports 构造点);
|
|
12
|
+
* FR-8.12: set/resume 后 sendUserMessage 触发 AI。
|
|
13
|
+
*
|
|
14
|
+
* adapters 层可 import Pi 类型(桥接 Pi 和 service)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import { parseGoalArgs } from "../commands";
|
|
20
|
+
import {
|
|
21
|
+
MAX_HISTORY_ENTRIES,
|
|
22
|
+
OBJECTIVE_DISPLAY_LIMIT,
|
|
23
|
+
OBJECTIVE_TRUNCATE_KEEP,
|
|
24
|
+
SECONDS_PER_MINUTE,
|
|
25
|
+
} from "../constants";
|
|
26
|
+
import { checkBudgetOnResume } from "../engine/budget";
|
|
27
|
+
import { isActiveStatus, isTerminalStatus, transitionStatus } from "../engine/goal";
|
|
28
|
+
import type { BudgetConfig } from "../engine/types";
|
|
29
|
+
import { objectiveUpdatedPrompt } from "../projection/prompts";
|
|
30
|
+
import { updateWidget } from "../projection/widget";
|
|
31
|
+
import { finalizeAndPersist, persistState, tickState } from "../service";
|
|
32
|
+
import type { GoalSession } from "../session";
|
|
33
|
+
import { clearGoalSession } from "../session";
|
|
34
|
+
import { buildPorts } from "./ports";
|
|
35
|
+
|
|
36
|
+
// ── Orchestrator ──────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* /goal 命令分发器。按 parseGoalArgs 结果路由到子命令 handler。
|
|
40
|
+
*
|
|
41
|
+
* 调用方(index.ts 的 command handler)把原始 args 透传到这里。
|
|
42
|
+
*/
|
|
43
|
+
export async function handleGoalCommand(
|
|
44
|
+
pi: ExtensionAPI,
|
|
45
|
+
session: GoalSession,
|
|
46
|
+
args: string | undefined,
|
|
47
|
+
ctx: ExtensionContext,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const parsed = parseGoalArgs(args ?? "");
|
|
50
|
+
switch (parsed.action) {
|
|
51
|
+
case "status":
|
|
52
|
+
return handleStatus(session, ctx);
|
|
53
|
+
case "pause":
|
|
54
|
+
return handlePause(pi, session, ctx);
|
|
55
|
+
case "resume":
|
|
56
|
+
return handleResume(pi, session, ctx);
|
|
57
|
+
case "history":
|
|
58
|
+
return handleHistory(ctx);
|
|
59
|
+
case "clear":
|
|
60
|
+
return handleClear(pi, session, ctx);
|
|
61
|
+
case "update":
|
|
62
|
+
return handleUpdate(pi, session, parsed.objective, ctx);
|
|
63
|
+
case "set":
|
|
64
|
+
return handleSet(pi, session, parsed.objective ?? "", parsed.budget, ctx);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── /goal status ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function handleStatus(session: GoalSession, ctx: ExtensionContext): void {
|
|
71
|
+
if (!session.state) {
|
|
72
|
+
ctx.ui.notify("Goal mode not active. Use /goal <objective> to start.", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const state = session.state;
|
|
76
|
+
// 回归修复:active goal 显示实时耗时(tick 累加当前运行段后再读)
|
|
77
|
+
if (isActiveStatus(state.status)) {
|
|
78
|
+
tickState(state);
|
|
79
|
+
}
|
|
80
|
+
const timeMins = Math.floor(state.timeUsedSeconds / SECONDS_PER_MINUTE);
|
|
81
|
+
const timeSecs = Math.floor(state.timeUsedSeconds % SECONDS_PER_MINUTE);
|
|
82
|
+
const lines: Array<string | null> = [
|
|
83
|
+
state.slug ? `Slug: ${state.slug}` : null,
|
|
84
|
+
`Objective: ${state.objective}`,
|
|
85
|
+
`Status: ${state.status}`,
|
|
86
|
+
`Turn: ${state.currentTurnIndex}`,
|
|
87
|
+
`Time elapsed: ${timeMins}m${timeSecs}s`,
|
|
88
|
+
state.budget.tokenBudget ? `Token: ${state.tokensUsed}/${state.budget.tokenBudget}` : null,
|
|
89
|
+
`Goal ID: ${state.goalId}`,
|
|
90
|
+
];
|
|
91
|
+
ctx.ui.notify(lines.filter(Boolean).join("\n"), "info");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── /goal pause(FR-3 用户暂停)──────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* FR-3: active → paused(用户叫停续跑)。
|
|
98
|
+
*
|
|
99
|
+
* 对称设计(与 blocked 行为对称):两者都是非终态「停止」状态,区别只在触发主体
|
|
100
|
+
* (paused = 用户,blocked = agent)。都不续跑、不 budget 检查、不注入 context。
|
|
101
|
+
*
|
|
102
|
+
* 先 tickState 捕获最后运行段(status 仍 active 才累加),再 transitionStatus。
|
|
103
|
+
* 复用 handleReportBlocked 的 tick-before-transition 模式(见 goal-control-adapter.ts)。
|
|
104
|
+
*/
|
|
105
|
+
function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
106
|
+
if (!session.state) {
|
|
107
|
+
ctx.ui.notify("Goal mode not active.", "warning");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const state = session.state;
|
|
111
|
+
if (!isActiveStatus(state.status)) {
|
|
112
|
+
ctx.ui.notify(
|
|
113
|
+
`Goal is not active (status: ${state.status}). Only an active goal can be paused.`,
|
|
114
|
+
"warning",
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// 先 tickState 累加当前运行段(此时 status 仍为 active)
|
|
119
|
+
tickState(state);
|
|
120
|
+
state.status = transitionStatus(state.status, "paused");
|
|
121
|
+
|
|
122
|
+
const ports = buildPorts(pi, ctx);
|
|
123
|
+
persistState(session, ports);
|
|
124
|
+
updateWidget(session, ports.ui);
|
|
125
|
+
ctx.ui.notify("Goal paused. Use /goal resume to continue.", "info");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── /goal resume ──────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
131
|
+
if (!session.state) {
|
|
132
|
+
ctx.ui.notify("Goal mode not active.", "warning");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const state = session.state;
|
|
136
|
+
if (isTerminalStatus(state.status)) {
|
|
137
|
+
ctx.ui.notify(`Goal is in terminal state (${state.status}), cannot resume.`, "warning");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// FR-3: resume 支持 paused→active 和 blocked→active(两者对称,都做 budget 重检 + 触发 AI)
|
|
141
|
+
if (state.status !== "paused" && state.status !== "blocked") {
|
|
142
|
+
ctx.ui.notify("Goal is not paused or blocked, no need to resume.", "info");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
state.status = "active";
|
|
146
|
+
state.timeStartedAt = Date.now();
|
|
147
|
+
|
|
148
|
+
const ports = buildPorts(pi, ctx);
|
|
149
|
+
|
|
150
|
+
// FR-8.3 G-014: resume 时 budget 重检
|
|
151
|
+
const resumeCheck = checkBudgetOnResume(state);
|
|
152
|
+
if (resumeCheck) {
|
|
153
|
+
const dim = resumeCheck.dimension;
|
|
154
|
+
// FR-8.7: 走 finalizeAndPersist 写 history(含 tick + transition + history + appendState),
|
|
155
|
+
// 勿用 transitionStatus + persistState(不写 history,goal 会从 /goal history 凭空消失)
|
|
156
|
+
finalizeAndPersist(state, dim === "token" ? "budget_limited" : "time_limited", 0, ports);
|
|
157
|
+
updateWidget(session, ports.ui);
|
|
158
|
+
ctx.ui.notify(
|
|
159
|
+
`${dim === "token" ? "Token" : "Time"} budget exhausted, cannot resume. Use /goal clear to reset.`,
|
|
160
|
+
"warning",
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
persistState(session, ports);
|
|
165
|
+
updateWidget(session, ports.ui);
|
|
166
|
+
|
|
167
|
+
// FR-8.12 并行模式:resume 后触发 AI 继续
|
|
168
|
+
const blockerNote = state.lastBlockerReason
|
|
169
|
+
? `\n\nPrevious blocker: ${state.lastBlockerReason}. Try a different approach.`
|
|
170
|
+
: "";
|
|
171
|
+
pi.sendUserMessage(
|
|
172
|
+
`Goal resumed. Continuing toward the objective.${blockerNote}\n\nObjective: ${state.objective}`,
|
|
173
|
+
{ deliverAs: "followUp" },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── /goal history ─────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
interface GoalHistoryData {
|
|
180
|
+
goalId: string;
|
|
181
|
+
objective: string;
|
|
182
|
+
status: string;
|
|
183
|
+
completedTasks: number;
|
|
184
|
+
totalTasks: number;
|
|
185
|
+
elapsedSeconds: number;
|
|
186
|
+
timestamp: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleHistory(ctx: ExtensionContext): void {
|
|
190
|
+
const entries = ctx.sessionManager.getEntries();
|
|
191
|
+
const historyEntries = entries.filter(
|
|
192
|
+
(e): e is CustomEntry<GoalHistoryData> =>
|
|
193
|
+
e.type === "custom" && (e as CustomEntry).customType === "goal-history",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (historyEntries.length === 0) {
|
|
197
|
+
ctx.ui.notify("No goal history", "info");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// FR-8.1 G-006: session append-only(Pi getEntries 返回 filter-copy,无法 splice 旧 entry)。
|
|
201
|
+
// 显示侧截断为最近 MAX_HISTORY_ENTRIES 条,避免历史无界增长刷屏。
|
|
202
|
+
const recent = historyEntries.slice(-MAX_HISTORY_ENTRIES);
|
|
203
|
+
const sorted = [...recent].reverse();
|
|
204
|
+
const lines: string[] = ["Goal history:\n"];
|
|
205
|
+
sorted.forEach((entry, i) => {
|
|
206
|
+
const h = entry.data;
|
|
207
|
+
if (!h) return;
|
|
208
|
+
const icon =
|
|
209
|
+
h.status === "complete"
|
|
210
|
+
? "✓"
|
|
211
|
+
: h.status === "cancelled"
|
|
212
|
+
? "✗"
|
|
213
|
+
: h.status === "budget_limited"
|
|
214
|
+
? "⊗"
|
|
215
|
+
: h.status === "time_limited"
|
|
216
|
+
? "⏱"
|
|
217
|
+
: "?";
|
|
218
|
+
// GAP-5: 标题优先用 slug(紧凑),无 slug fallback objective 截断(旧 entry 兼容)
|
|
219
|
+
const title = h.slug ?? (h.objective.length > OBJECTIVE_DISPLAY_LIMIT
|
|
220
|
+
? `${h.objective.slice(0, OBJECTIVE_TRUNCATE_KEEP)}...`
|
|
221
|
+
: h.objective);
|
|
222
|
+
const mins = Math.floor(h.elapsedSeconds / SECONDS_PER_MINUTE);
|
|
223
|
+
const secs = Math.floor(h.elapsedSeconds % SECONDS_PER_MINUTE);
|
|
224
|
+
lines.push(`${i + 1}. ${icon} ${title}`);
|
|
225
|
+
lines.push(` ${mins}m${secs}s | ${h.status}`);
|
|
226
|
+
});
|
|
227
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── /goal clear(强制清)──────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* FR-6.3:强制清。直接 cancelled + clearSession。
|
|
234
|
+
*/
|
|
235
|
+
function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
236
|
+
if (!session.state) {
|
|
237
|
+
ctx.ui.notify("Goal mode not active.", "info");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const ports = buildPorts(pi, ctx);
|
|
241
|
+
// transitionStatus 查表终态不可转(engine/goal.ts):已终态 goal 直接 clearSession
|
|
242
|
+
if (!isTerminalStatus(session.state.status)) {
|
|
243
|
+
// FR-3.3: 唯一终态序列入口(tick + finalizeGoal + persist)
|
|
244
|
+
finalizeAndPersist(session.state, "cancelled", 0, ports);
|
|
245
|
+
}
|
|
246
|
+
// FR-8.7: cancelled → 立即 clearSession
|
|
247
|
+
clearGoalSession(session, ports.ui);
|
|
248
|
+
ctx.ui.notify("Goal cleared.", "info");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── /goal update(重塑)──────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* FR-8.4 G-002:重塑(reset)。重置 objective/budget flags/
|
|
255
|
+
* currentTurnIndex/lastProgressTurn,保留 goalId。
|
|
256
|
+
* active 状态下向 AI 注入 objective-updated steering。
|
|
257
|
+
*/
|
|
258
|
+
function handleUpdate(
|
|
259
|
+
pi: ExtensionAPI,
|
|
260
|
+
session: GoalSession,
|
|
261
|
+
newObjective: string | undefined,
|
|
262
|
+
ctx: ExtensionContext,
|
|
263
|
+
): void {
|
|
264
|
+
if (!session.state) {
|
|
265
|
+
ctx.ui.notify("Goal mode not active.", "warning");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!newObjective) {
|
|
269
|
+
ctx.ui.notify("Usage: /goal update <new-objective>", "warning");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const state = session.state;
|
|
273
|
+
const oldObjective = state.objective;
|
|
274
|
+
// FR-8.4 G-002: 重塑(重置,保留 goalId)
|
|
275
|
+
state.objective = newObjective;
|
|
276
|
+
state.objectiveUpdatedAt = Date.now();
|
|
277
|
+
state.currentTurnIndex = 0;
|
|
278
|
+
state.lastProgressTurn = 0;
|
|
279
|
+
state.budgetLimitSteeringSent = false;
|
|
280
|
+
state.tokenWarning70Sent = false;
|
|
281
|
+
state.tokenWarning90Sent = false;
|
|
282
|
+
state.timeWarning70Sent = false;
|
|
283
|
+
state.timeWarning90Sent = false;
|
|
284
|
+
// GAP-6: update 是重塑,旧 slug 已不匹配新 objective → 置空(widget fallback objective 截断)
|
|
285
|
+
state.slug = undefined;
|
|
286
|
+
// FR-6.5: 持久化重塑后的状态(persistState 按当前 status tick 累加)+ FR-6.1 widget 刷新
|
|
287
|
+
const updatePorts = buildPorts(pi, ctx);
|
|
288
|
+
persistState(session, updatePorts);
|
|
289
|
+
updateWidget(session, updatePorts.ui);
|
|
290
|
+
ctx.ui.notify(`Objective updated:\nPrevious: ${oldObjective}\nNew: ${newObjective}`, "info");
|
|
291
|
+
|
|
292
|
+
if (isActiveStatus(state.status)) {
|
|
293
|
+
const ports = buildPorts(pi, ctx);
|
|
294
|
+
ports.messaging.sendContextMessage(objectiveUpdatedPrompt(state, oldObjective), "steer");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── /goal set(提示词触发器)─────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* /goal <objective> 改为「提示词触发器」:不直接 createGoal,
|
|
302
|
+
* 而是 sendUserMessage 引导 AI 调 goal_control create(slug 由 AI 生成)。
|
|
303
|
+
*
|
|
304
|
+
* 这样 goal 创建的唯一路径是 goal_control toolcall(统一入口),
|
|
305
|
+
* slug/objective/budget 都由 AI 在 toolcall 时决定。
|
|
306
|
+
*
|
|
307
|
+
* D25 守卫仍在此处预检:非终态旧 goal(active/paused/blocked)→ 拒绝,
|
|
308
|
+
* 提示 /goal resume 或 /goal clear(避免 AI 在已有未完成 goal 时重复创建)。
|
|
309
|
+
*
|
|
310
|
+
* budget flag(--tokens/--timeout)写入消息体,让 AI 原样传给 create。
|
|
311
|
+
*/
|
|
312
|
+
function handleSet(
|
|
313
|
+
pi: ExtensionAPI,
|
|
314
|
+
session: GoalSession,
|
|
315
|
+
objective: string,
|
|
316
|
+
budgetOverrides: Partial<BudgetConfig> | undefined,
|
|
317
|
+
ctx: ExtensionContext,
|
|
318
|
+
): void {
|
|
319
|
+
if (!objective || !objective.trim()) {
|
|
320
|
+
ctx.ui.notify("Usage: /goal <objective> [--tokens N] [--timeout N]", "warning");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// #11 / D25: 非终态旧 goal(active/paused/blocked)→ 拒绝(不覆盖、不写 history)
|
|
325
|
+
if (session.state && !isTerminalStatus(session.state.status)) {
|
|
326
|
+
ctx.ui.notify(
|
|
327
|
+
"Goal already active. Use /goal resume to continue or /goal clear to reset.",
|
|
328
|
+
"warning",
|
|
329
|
+
);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// budget 校验(非法值在此拦截,不进入消息体)
|
|
334
|
+
if (budgetOverrides?.tokenBudget !== undefined && budgetOverrides.tokenBudget <= 0) {
|
|
335
|
+
ctx.ui.notify("Token budget must be greater than 0.", "warning");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (budgetOverrides?.timeBudgetMinutes !== undefined && budgetOverrides.timeBudgetMinutes <= 0) {
|
|
339
|
+
ctx.ui.notify("Time budget must be greater than 0.", "warning");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 构造引导 AI 创建 goal 的消息(含 objective + 可选 budget)
|
|
344
|
+
const budgetHints: string[] = [];
|
|
345
|
+
if (budgetOverrides?.tokenBudget) budgetHints.push(`tokenBudget: ${budgetOverrides.tokenBudget}`);
|
|
346
|
+
if (budgetOverrides?.timeBudgetMinutes)
|
|
347
|
+
budgetHints.push(`timeBudgetMinutes: ${budgetOverrides.timeBudgetMinutes}`);
|
|
348
|
+
const budgetLine = budgetHints.length > 0 ? `\nBudget: ${budgetHints.join(", ")}` : "";
|
|
349
|
+
|
|
350
|
+
const message =
|
|
351
|
+
`Start a new goal with the objective below. Call goal_control(action="create") with:\n` +
|
|
352
|
+
`- slug: a short kebab-case identifier you generate for this goal\n` +
|
|
353
|
+
`- objective: the full objective text\n` +
|
|
354
|
+
(budgetHints.length > 0 ? `- pass through the budget values below as-is\n` : "") +
|
|
355
|
+
`\nObjective: ${objective.trim()}${budgetLine}`;
|
|
356
|
+
|
|
357
|
+
ctx.ui.notify(`Requesting goal start: ${objective.trim()}`, "info");
|
|
358
|
+
// FR-8.12: 触发 AI(followUp)—— AI 消化后调 goal_control create
|
|
359
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
360
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event adapter — 薄路由(#4 拆分后)。
|
|
3
|
+
*
|
|
4
|
+
* 6 个事件 handler 拆到 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 { handleAgentStart } from "./event-handlers/agent-start";
|
|
13
|
+
export { handleBeforeAgentStart } from "./event-handlers/before-agent-start";
|
|
14
|
+
export type { MessageEndLikeEvent } from "./event-handlers/message-end";
|
|
15
|
+
export { handleMessageEnd } from "./event-handlers/message-end";
|
|
16
|
+
export { handleSessionStart } from "./event-handlers/session-start";
|
|
17
|
+
export { handleTurnEnd } from "./event-handlers/turn-end";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 6: agent_end(FR-8.7 + ESC 守卫 + 并发保护)。
|
|
3
|
+
*
|
|
4
|
+
* 关键约定:
|
|
5
|
+
* - FR-8.2 G-021 防重入:session.isProcessing 入口加锁,finally 释放
|
|
6
|
+
* - FR-8.2 G-020 stale 快照:入口 makeStaleChecker snapshot goalId,每个副作用前 checkStale
|
|
7
|
+
* - FR-6.7 ESC 守卫(最关键):ctx.signal?.aborted → 不发 continuation、不做 budget 检查、
|
|
8
|
+
* 不做任何状态变更,goal 保持 active,等用户下次输入恢复
|
|
9
|
+
*
|
|
10
|
+
* #8 后流程:budget 预警/steering → continuation 去抖。
|
|
11
|
+
* agent_end 只做提醒(warning + steering),不做终态转换。
|
|
12
|
+
* 终态转换不在 agent_end——由 persistAndUpdate 兜底(#5 范围,单一检查点)。
|
|
13
|
+
*
|
|
14
|
+
* 全解耦:不再做 allTasksDone followUp(原依赖 pi.__todoGetList,跨 ext 失效)。
|
|
15
|
+
* todo 是否全完成由 AI 自行判断(prompt 软建议)。
|
|
16
|
+
*
|
|
17
|
+
* ESC 路径:aborted 时直接 return(goal 保持 active)。注意 ESC 守卫在终态/非 active
|
|
18
|
+
* 检查之后——终态 goal 仍走终态 notify(不被 ESC 影响),非 active 状态直接返回。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { checkBudgetOnTurnEnd } from "../../engine/budget";
|
|
24
|
+
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
25
|
+
import {
|
|
26
|
+
budgetLimitPrompt,
|
|
27
|
+
continuationPrompt,
|
|
28
|
+
} from "../../projection/prompts";
|
|
29
|
+
import { persistAndUpdate, tickState } from "../../service";
|
|
30
|
+
import type { GoalSession } from "../../session";
|
|
31
|
+
import { buildPorts } from "../ports";
|
|
32
|
+
import { makeStaleChecker } from "./shared";
|
|
33
|
+
|
|
34
|
+
export async function handleAgentEnd(
|
|
35
|
+
pi: ExtensionAPI,
|
|
36
|
+
session: GoalSession,
|
|
37
|
+
ctx: ExtensionContext,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
if (!session.state || session.isProcessing) return;
|
|
40
|
+
session.isProcessing = true;
|
|
41
|
+
try {
|
|
42
|
+
const checkStale = makeStaleChecker(session);
|
|
43
|
+
if (checkStale()) return;
|
|
44
|
+
|
|
45
|
+
// 终态处理(complete / blocked)
|
|
46
|
+
if (session.state.status === "complete" || session.state.status === "blocked") {
|
|
47
|
+
await handleTerminalStateAgentEnd(pi, session, ctx, checkStale);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
51
|
+
|
|
52
|
+
// FR-6.7 ESC 守卫(最关键):aborted 时 goal 保持 active,不做任何副作用
|
|
53
|
+
if (ctx.signal?.aborted) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 预算检查(FR-6.2 维度独立)——先 tick 把当前运行段计入 timeUsedSeconds,
|
|
58
|
+
// 否则时间预算检测会比实际晚一轮(回归修复)
|
|
59
|
+
tickState(session.state);
|
|
60
|
+
const budgetResult = checkBudgetOnTurnEnd(session.state, session.state.timeUsedSeconds);
|
|
61
|
+
const budgetAction = await handleBudgetChecks(pi, session, ctx, budgetResult, checkStale);
|
|
62
|
+
if (budgetAction !== "continue") return;
|
|
63
|
+
|
|
64
|
+
// continuation 去抖(budget 预警未拦截时)
|
|
65
|
+
await handleContinuation(pi, session, ctx, checkStale);
|
|
66
|
+
} finally {
|
|
67
|
+
session.isProcessing = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 终态 agent_end:persist + notify(complete/blocked 各一条消息)。 */
|
|
72
|
+
async function handleTerminalStateAgentEnd(
|
|
73
|
+
pi: ExtensionAPI,
|
|
74
|
+
session: GoalSession,
|
|
75
|
+
ctx: ExtensionContext,
|
|
76
|
+
checkStale: () => boolean,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const state = session.state!;
|
|
79
|
+
if (persistAndUpdate(session, buildPorts(pi, ctx), checkStale)) return;
|
|
80
|
+
if (state.status === "complete") {
|
|
81
|
+
ctx.ui.notify(`Objective completed ✓ (${state.currentTurnIndex} turns)`, "info");
|
|
82
|
+
} else {
|
|
83
|
+
ctx.ui.notify(
|
|
84
|
+
"Goal blocked. Use /goal resume to continue or /goal clear to reset.",
|
|
85
|
+
"warning",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type BudgetAction = "continue" | "stop";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* FR-6.2 维度独立预算检查(#8 后只做提醒,不做终态):
|
|
94
|
+
* - 预警(warning70/warning90):set flag + notify(不阻塞 continuation)
|
|
95
|
+
* - 90% steering(shouldSendSteering):set flag + 发 budgetLimitPrompt(收尾),返回 "stop" 中断 continuation
|
|
96
|
+
*
|
|
97
|
+
* 终态转换(budget 耗尽)不在 agent_end,由 persistAndUpdate 兜底(#5 范围,单一检查点)。
|
|
98
|
+
*/
|
|
99
|
+
async function handleBudgetChecks(
|
|
100
|
+
pi: ExtensionAPI,
|
|
101
|
+
session: GoalSession,
|
|
102
|
+
ctx: ExtensionContext,
|
|
103
|
+
budgetResult: ReturnType<typeof checkBudgetOnTurnEnd>,
|
|
104
|
+
checkStale: () => boolean,
|
|
105
|
+
): Promise<BudgetAction> {
|
|
106
|
+
const state = session.state!;
|
|
107
|
+
|
|
108
|
+
// 发送预警(FR-6.2 维度独立:token/time 各有 70/90 flag)
|
|
109
|
+
for (const w of budgetResult.warnings) {
|
|
110
|
+
if (w.type === "warning90") {
|
|
111
|
+
if (w.dimension === "token") state.tokenWarning90Sent = true;
|
|
112
|
+
else state.timeWarning90Sent = true;
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
`${w.dimension === "token" ? "Token" : "Time"} budget 90% used — start wrapping up.`,
|
|
115
|
+
"warning",
|
|
116
|
+
);
|
|
117
|
+
} else if (w.type === "warning70") {
|
|
118
|
+
if (w.dimension === "token") state.tokenWarning70Sent = true;
|
|
119
|
+
else state.timeWarning70Sent = true;
|
|
120
|
+
ctx.ui.notify(
|
|
121
|
+
`${w.dimension === "token" ? "Token" : "Time"} budget 70% used — keep scope in check.`,
|
|
122
|
+
"info",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 90% steering → 收尾
|
|
128
|
+
if (budgetResult.shouldSendSteering) {
|
|
129
|
+
state.budgetLimitSteeringSent = true;
|
|
130
|
+
if (persistAndUpdate(session, buildPorts(pi, ctx), checkStale)) return "stop";
|
|
131
|
+
buildPorts(pi, ctx).messaging.sendContextMessage(
|
|
132
|
+
budgetLimitPrompt(state, "token", state.timeUsedSeconds),
|
|
133
|
+
"steer",
|
|
134
|
+
);
|
|
135
|
+
return "stop";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (checkStale()) return "stop";
|
|
139
|
+
return "continue";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* continuation 去抖(budget 预警未拦截时的尾部逻辑)。
|
|
144
|
+
*
|
|
145
|
+
* - continuation 去抖:tokenDelta=0(空 turn)不发,只 persist
|
|
146
|
+
* - 否则 persist + 发 continuationPrompt
|
|
147
|
+
*
|
|
148
|
+
* 注:maxTurnsReached / stall 自动终态分支随 #6 删除;budget terminal 分支随 #8 删除。
|
|
149
|
+
* 终态转换由 persistAndUpdate 兑底(#5 范围,单一检查点)。
|
|
150
|
+
*/
|
|
151
|
+
async function handleContinuation(
|
|
152
|
+
pi: ExtensionAPI,
|
|
153
|
+
session: GoalSession,
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
checkStale: () => boolean,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const state = session.state!;
|
|
158
|
+
if (checkStale()) return;
|
|
159
|
+
|
|
160
|
+
// FR-8.6: continuation 去抖(空 turn 不发)
|
|
161
|
+
const tokenDelta = state.tokensUsed - state.lastTurnTokensUsed;
|
|
162
|
+
state.lastTurnTokensUsed = state.tokensUsed;
|
|
163
|
+
if (tokenDelta <= 0) {
|
|
164
|
+
// 空 turn:只 persist,不发 continuation
|
|
165
|
+
persistAndUpdate(session, buildPorts(pi, ctx));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
persistAndUpdate(session, buildPorts(pi, ctx));
|
|
169
|
+
// 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited/time_limited 终态。
|
|
170
|
+
// 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
|
|
171
|
+
if (isTerminalStatus(state.status)) return;
|
|
172
|
+
// 发 continuation(FR-8.7: 去 debounce 后才发)
|
|
173
|
+
buildPorts(pi, ctx).messaging.sendContextMessage(
|
|
174
|
+
continuationPrompt(state, state.timeUsedSeconds),
|
|
175
|
+
"followUp",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 1: agent_start(adapters/event-handlers 层)。
|
|
3
|
+
*
|
|
4
|
+
* 委托 service.applyEvent("agent_start")。task 已移除,当前无副作用
|
|
5
|
+
* (#7 注入 todo 进度后可能重填基线逻辑)。
|
|
6
|
+
*
|
|
7
|
+
* 无 ESC 守卫(agent_start 是 agent 开始时的信号,此时无 aborted 可能)。
|
|
8
|
+
* 无 persist / updateWidget。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { applyEvent } from "../../service";
|
|
12
|
+
import type { GoalSession } from "../../session";
|
|
13
|
+
|
|
14
|
+
export async function handleAgentStart(session: GoalSession): Promise<void> {
|
|
15
|
+
if (!session.state) return;
|
|
16
|
+
applyEvent(session, "agent_start", undefined);
|
|
17
|
+
}
|