@baique/pi-web-sky 0.1.50 → 0.1.52
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/running/route.js +1 -1
- package/.next/server/app/api/agent/running/route.js.nft.json +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
- package/.next/server/app/api/sessions/[id]/auto-name/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/minimap/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/task-scheduler/status/route.js +1 -1
- package/.next/server/app/api/task-scheduler/status/route.js.nft.json +1 -1
- package/.next/server/app/api/tasks/[id]/route.js +1 -1
- package/.next/server/app/api/tasks/[id]/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/page.js +7 -7
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +1 -1
- package/.next/server/chunks/402.js +5 -5
- package/.next/server/chunks/4229.js +1 -0
- package/.next/server/chunks/542.js +4 -3
- package/.next/server/chunks/6429.js +2 -1
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/7975.65ec9e5818297e19.js +1 -0
- package/.next/static/chunks/app/{layout-60b0775a416224b6.js → layout-af55652f9ad6e6b2.js} +1 -1
- package/.next/static/chunks/app/{page-1a7a0f82070b63a2.js → page-41f235aaff2b0dab.js} +4 -4
- package/.next/static/chunks/{webpack-655ec0127caa45cc.js → webpack-6a637257a0907061.js} +1 -1
- package/.next/trace +8 -42
- package/.next/trace-build +1 -1
- package/README.md +2 -2
- package/lib/i18n/messages/en.ts +0 -1
- package/lib/i18n/messages/zh-CN.ts +0 -1
- package/lib/rpc-manager.ts +4 -0
- package/lib/session-reader.ts +23 -14
- package/lib/todo-extension.test.mjs +392 -0
- package/lib/todo-extension.ts +316 -0
- package/lib/todo-store.test.mjs +135 -0
- package/lib/todo-store.ts +253 -0
- package/lib/types.ts +5 -9
- package/package.json +2 -1
- package/.next/static/chunks/7975.30b20872ab9c9f62.js +0 -1
- /package/.next/static/{GRbCBL7nrIAiM-iulM5Wc → 76HS-3QabmzJcJPpSaFEJ}/_buildManifest.js +0 -0
- /package/.next/static/{GRbCBL7nrIAiM-iulM5Wc → 76HS-3QabmzJcJPpSaFEJ}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话 TODO 内联扩展 —— 由 pi-web-sky 在创建 AgentSession 时注入
|
|
3
|
+
* (见 lib/rpc-manager.ts 的 `extensionFactories`),用户无需安装任何 pi 包。
|
|
4
|
+
*
|
|
5
|
+
* 职责:
|
|
6
|
+
* - 注册 `todo` 工具(契约与 `@zhushanwen/pi-todo` 一致:list/add/update/delete)
|
|
7
|
+
* - 每次变更把快照写入 custom entry `pi-todo.state`(顶栏面板的数据源)
|
|
8
|
+
* - `session_start` / `session_tree` 回放最后一条快照重建状态(会话隔离靠工厂闭包)
|
|
9
|
+
* - auto-clear:全部完成后再过 2 轮自动清空并复位 nextId(轮数从分支重算)
|
|
10
|
+
* - 节奏提醒:连续 4 轮没用 todo 工具且仍有未完成项时,在 `context` 事件里注入
|
|
11
|
+
* **瞬时**提醒(不落盘、不进会话文件、聊天界面不出现卡片);轮数在回放时
|
|
12
|
+
* 从分支续算,不受 AgentSession 空闲回收影响
|
|
13
|
+
*
|
|
14
|
+
* 刻意不做:`before_agent_start` 返回 custom_message / completion-steer 等落盘注入。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Type } from "typebox";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
dispatchTodoAction,
|
|
23
|
+
formatTodoLine,
|
|
24
|
+
hasOpenTodos,
|
|
25
|
+
parseTodoSnapshot,
|
|
26
|
+
TODO_STATE_CUSTOM_TYPE,
|
|
27
|
+
TODO_TOOL_NAME,
|
|
28
|
+
VALID_STATUSES,
|
|
29
|
+
type Todo,
|
|
30
|
+
type TodoActionParams,
|
|
31
|
+
type TodoSnapshot,
|
|
32
|
+
} from "./todo-store";
|
|
33
|
+
|
|
34
|
+
/** 全部完成后再保留的轮数,之后自动清空。 */
|
|
35
|
+
export const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
36
|
+
|
|
37
|
+
/** 连续多少轮没用 todo 工具才提醒一次。 */
|
|
38
|
+
export const REMINDER_INTERVAL = 4;
|
|
39
|
+
|
|
40
|
+
// ── auto-clear ──────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** `getBranch()` 的最小结构(只读三个字段,不依赖 SDK 的联合类型)。 */
|
|
43
|
+
export interface BranchEntry {
|
|
44
|
+
type: string;
|
|
45
|
+
customType?: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
message?: { role?: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `agent_end` 时求值:分支上最后一条快照处于「全部完成」,且其后已过去
|
|
52
|
+
* AUTO_CLEAR_DELAY_ROUNDS 条用户消息 → 清空。
|
|
53
|
+
*
|
|
54
|
+
* 为什么不记内存计数器:pi-web 的 AgentSession 空闲 10 分钟即销毁(见
|
|
55
|
+
* lib/rpc-manager.ts 的 idle timer),扩展闭包里的计数活不过两次交互的间隔,
|
|
56
|
+
* 真实使用中「2 轮」几乎永远数不满(实测:全完成后 7 轮过去仍未清空)。
|
|
57
|
+
* 分支是持久的,重算一次 O(分支长度),且 agent_end 本来就每轮只跑一次。
|
|
58
|
+
*/
|
|
59
|
+
export function shouldAutoClear(entries: readonly BranchEntry[]): boolean {
|
|
60
|
+
let userMessages = 0;
|
|
61
|
+
let snapshot: unknown;
|
|
62
|
+
let snapshotAt = 0;
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.type === "custom" && entry.customType === TODO_STATE_CUSTOM_TYPE) {
|
|
65
|
+
snapshot = entry.data;
|
|
66
|
+
snapshotAt = userMessages;
|
|
67
|
+
} else if (entry.message?.role === "user") {
|
|
68
|
+
userMessages++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = parseTodoSnapshot(snapshot);
|
|
73
|
+
if (!parsed || parsed.todos.length === 0 || hasOpenTodos(parsed.todos)) return false;
|
|
74
|
+
return userMessages - snapshotAt >= AUTO_CLEAR_DELAY_ROUNDS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 分支上「最后一次改 todo」(最后一条快照 entry)之后又过了几个模型回合。
|
|
79
|
+
*
|
|
80
|
+
* 只认快照、不认 `todo list`:`list` 不落盘,分支上看不到(同一实例内的即时重置
|
|
81
|
+
* 另外靠 `noteToolResult` 处理)。
|
|
82
|
+
*
|
|
83
|
+
* 计的是**模型回合**(assistant 消息)而不是 user 消息:提醒要盯的是「一次长任务里
|
|
84
|
+
* 模型自己跑偏了」,一条 user 消息下可能跑十几个回合,按 user 消息数永远数不满。
|
|
85
|
+
* 计数单位与 `onTurnStart` 一致(turn_start = 一次模型往返)。
|
|
86
|
+
*/
|
|
87
|
+
export function turnsSinceLastTodoUse(entries: readonly BranchEntry[]): number {
|
|
88
|
+
let turns = 0;
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.type === "custom" && entry.customType === TODO_STATE_CUSTOM_TYPE) {
|
|
91
|
+
turns = 0;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (entry.message?.role === "assistant") turns++;
|
|
95
|
+
}
|
|
96
|
+
return turns;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── 节奏提醒(纯函数,可单测)──────────────────────────
|
|
100
|
+
|
|
101
|
+
export interface ReminderState {
|
|
102
|
+
/** 已经过的对话轮数(`turn_start` 自增)。 */
|
|
103
|
+
currentTurn: number;
|
|
104
|
+
/** 上一次用 todo 工具、或上一次发出提醒时的轮数。 */
|
|
105
|
+
lastTodoToolUseTurn: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createReminderState(): ReminderState {
|
|
109
|
+
return { currentTurn: 0, lastTodoToolUseTurn: 0 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resetReminderState(state: ReminderState): void {
|
|
113
|
+
Object.assign(state, createReminderState());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function onTurnStart(state: ReminderState): void {
|
|
117
|
+
state.currentTurn++;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 用了 todo 工具就重置节奏(下次提醒重新从 0 起算)。 */
|
|
121
|
+
export function noteToolResult(state: ReminderState, toolName: string): void {
|
|
122
|
+
if (toolName === TODO_TOOL_NAME) state.lastTodoToolUseTurn = state.currentTurn;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `context` 事件时求值:距上次 todo 工具使用已过 REMINDER_INTERVAL 轮且有未完成项 → 取走
|
|
127
|
+
* 一次提醒配额。
|
|
128
|
+
*
|
|
129
|
+
* 求值点必须在 `context`(每轮 LLM 请求前都到),不能只在 `tool_result` 里 arm:
|
|
130
|
+
* 纯文本轮(模型没调任何工具)不发 `tool_result`,那样的回合永远不会触发提醒。
|
|
131
|
+
* 取走配额即把计时器推到当前轮 —— 这保证一个周期只提醒一次。
|
|
132
|
+
*
|
|
133
|
+
* 叫 take 而不是 should:它写 `state.lastTodoToolUseTurn`,是有副作用的取配额动作。
|
|
134
|
+
*/
|
|
135
|
+
export function takeReminderSlot(state: ReminderState, hasOpenWork: boolean): boolean {
|
|
136
|
+
if (!hasOpenWork) return false;
|
|
137
|
+
if (state.currentTurn - state.lastTodoToolUseTurn < REMINDER_INTERVAL) return false;
|
|
138
|
+
state.lastTodoToolUseTurn = state.currentTurn;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 由当前 open todo 构造瞬时提醒正文;没有未完成项时返回 null(调用方不得注入)。 */
|
|
143
|
+
export function buildReminder(todos: readonly Todo[]): string | null {
|
|
144
|
+
const open = todos.filter((t) => t.status !== "completed");
|
|
145
|
+
if (open.length === 0) return null;
|
|
146
|
+
|
|
147
|
+
const inProgress = open.filter((t) => t.status === "in_progress");
|
|
148
|
+
const focus = inProgress.length > 0
|
|
149
|
+
? `#${inProgress[0].id}("${inProgress[0].content}")仍标记为 in_progress。这一步如果已经做完,立刻用 todo update 把它标成 completed,并在同一次调用里把下一项 pending 置为 in_progress,不要留下过期的 [~]。`
|
|
150
|
+
: "还有 pending 项但没有任何 in_progress。准备继续动手前,先用 todo update 把其中一项置为 in_progress。";
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
"<system-reminder>",
|
|
154
|
+
`todo 工具已连续 ${REMINDER_INTERVAL} 轮没有被使用,但仍有未完成任务:`,
|
|
155
|
+
"",
|
|
156
|
+
...open.map(formatTodoLine),
|
|
157
|
+
"",
|
|
158
|
+
focus,
|
|
159
|
+
"",
|
|
160
|
+
"只在与本轮工作相关时行动;这是一条温和提醒,不适用就忽略。绝不要向用户提及这条提醒,也不要为了回应它而新造任务。",
|
|
161
|
+
"</system-reminder>",
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 工具 schema(扁平 Type.Object,OpenAI 兼容)──────────
|
|
166
|
+
|
|
167
|
+
// 顶层必须是 type:"object"(严格网关会 400 掉顶层 union)。字段全 Optional,
|
|
168
|
+
// 必填与 text/texts、id/ids 双形陷阱交给 dispatchTodoAction 运行时校验。
|
|
169
|
+
const TodoParams = Type.Object(
|
|
170
|
+
{
|
|
171
|
+
action: Type.Union(
|
|
172
|
+
[Type.Literal("list"), Type.Literal("add"), Type.Literal("update"), Type.Literal("delete")],
|
|
173
|
+
{ description: "list | add | update | delete" },
|
|
174
|
+
),
|
|
175
|
+
text: Type.Optional(Type.String({ description: "新文本,仅 update 使用(trim 后不可为空)" })),
|
|
176
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "待添加的 todo 文本数组(仅 add 使用)" })),
|
|
177
|
+
id: Type.Optional(Type.Number({ description: "要更新的 todo id" })),
|
|
178
|
+
ids: Type.Optional(Type.Array(Type.Number(), { description: "要删除的 todo id 数组" })),
|
|
179
|
+
status: Type.Optional(StringEnum(VALID_STATUSES, { description: "pending | in_progress | completed" })),
|
|
180
|
+
updates: Type.Optional(
|
|
181
|
+
Type.Array(
|
|
182
|
+
Type.Object(
|
|
183
|
+
{
|
|
184
|
+
id: Type.Number({ description: "要更新的 todo id" }),
|
|
185
|
+
status: Type.Optional(StringEnum(VALID_STATUSES)),
|
|
186
|
+
text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
|
|
187
|
+
},
|
|
188
|
+
{ additionalProperties: false },
|
|
189
|
+
),
|
|
190
|
+
{ description: "批量更新数组(优先于单条 id/status/text)" },
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
{ additionalProperties: false },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// ── 扩展 ─────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function createTodoExtension(): InlineExtension {
|
|
200
|
+
return {
|
|
201
|
+
name: "pi-web-todo",
|
|
202
|
+
hidden: true,
|
|
203
|
+
factory: (pi: ExtensionAPI) => {
|
|
204
|
+
// 闭包内状态 → 每个 AgentSession 一份,天然会话隔离。
|
|
205
|
+
const state: TodoSnapshot = { todos: [], nextId: 1 };
|
|
206
|
+
const reminder = createReminderState();
|
|
207
|
+
|
|
208
|
+
const persist = (): void => {
|
|
209
|
+
pi.appendEntry(TODO_STATE_CUSTOM_TYPE, { todos: state.todos, nextId: state.nextId });
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 回放当前分支上最后一条快照(切分支/新会话时重建),并把提醒节奏续算到分支上
|
|
214
|
+
* 已过去的回合数 —— 闭包计数器活不过 AgentSession 的 10 分钟空闲回收
|
|
215
|
+
* (见 lib/rpc-manager.ts),不续算的话「隔几分钟问一句」永远数不到 4 轮。
|
|
216
|
+
*/
|
|
217
|
+
const reconstruct = (ctx: ExtensionContext): void => {
|
|
218
|
+
const branch = ctx.sessionManager.getBranch() as BranchEntry[];
|
|
219
|
+
state.todos = [];
|
|
220
|
+
state.nextId = 1;
|
|
221
|
+
resetReminderState(reminder);
|
|
222
|
+
for (const entry of branch) {
|
|
223
|
+
if (entry.type !== "custom" || entry.customType !== TODO_STATE_CUSTOM_TYPE) continue;
|
|
224
|
+
const restored = parseTodoSnapshot(entry.data);
|
|
225
|
+
if (restored) {
|
|
226
|
+
state.todos = restored.todos;
|
|
227
|
+
state.nextId = restored.nextId;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
reminder.currentTurn = turnsSinceLastTodoUse(branch);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
|
|
234
|
+
pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
|
|
235
|
+
|
|
236
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
237
|
+
// 没有未完成项时 shouldAutoClear 必为 false(末尾快照就是 state),
|
|
238
|
+
// 提前返回省掉一次全分支遍历 —— 大多数会话根本没有 todo。
|
|
239
|
+
if (state.todos.length === 0 || hasOpenTodos(state.todos)) return;
|
|
240
|
+
if (!shouldAutoClear(ctx.sessionManager.getBranch() as BranchEntry[])) return;
|
|
241
|
+
state.todos = [];
|
|
242
|
+
state.nextId = 1;
|
|
243
|
+
persist();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── 节奏提醒 ──────────────────────────────────
|
|
247
|
+
pi.on("turn_start", async () => onTurnStart(reminder));
|
|
248
|
+
|
|
249
|
+
pi.on("tool_result", async (event) => {
|
|
250
|
+
noteToolResult(reminder, String((event as { toolName?: unknown }).toolName ?? ""));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 瞬时注入:只改这一次请求的 messages,不进会话文件、不出现在聊天流。
|
|
254
|
+
pi.on("context", async (event) => {
|
|
255
|
+
if (!takeReminderSlot(reminder, hasOpenTodos(state.todos))) return undefined;
|
|
256
|
+
const text = buildReminder(state.todos);
|
|
257
|
+
if (!text) return undefined;
|
|
258
|
+
return {
|
|
259
|
+
messages: [
|
|
260
|
+
...event.messages,
|
|
261
|
+
{ role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() },
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── 工具 ────────────────────────────────────
|
|
267
|
+
pi.registerTool({
|
|
268
|
+
name: TODO_TOOL_NAME,
|
|
269
|
+
label: "Todo",
|
|
270
|
+
description:
|
|
271
|
+
"管理当前会话的 todo 列表。"
|
|
272
|
+
+ "\n\n动作:"
|
|
273
|
+
+ "\n- list: 查看全部 todo"
|
|
274
|
+
+ "\n- add: 批量添加 todo(texts 数组)"
|
|
275
|
+
+ "\n- update: 按 id 更新 todo——status 和/或 text;批量用 updates[]"
|
|
276
|
+
+ "\n- delete: 按 id 删除 todo(ids 数组)"
|
|
277
|
+
+ "\n\n规则:"
|
|
278
|
+
+ "\n- 同一时间只有一个 todo 处于 in_progress"
|
|
279
|
+
+ "\n- 完成一个 todo 立即标记 completed,不要攒到最后批量标记"
|
|
280
|
+
+ "\n- 未真正完成不得标记 completed:被阻塞或测试失败时保持 in_progress",
|
|
281
|
+
promptSnippet: "用 todo 跟踪多步骤工作;记得为验证步骤(测试、类型检查)单独建 todo。",
|
|
282
|
+
promptGuidelines: [
|
|
283
|
+
"[Usage] 多步骤工作(3+步)时使用,AI 自发创建,无需用户触发",
|
|
284
|
+
"[验证任务] 为测试 / 类型检查等验证步骤单独建 todo,完成前确保验证通过",
|
|
285
|
+
"[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
|
|
286
|
+
"[自动闭合] 全部完成后自动清理,无需手动 delete",
|
|
287
|
+
"[Not for] 单步操作、简单对话",
|
|
288
|
+
],
|
|
289
|
+
executionMode: "sequential",
|
|
290
|
+
parameters: TodoParams,
|
|
291
|
+
|
|
292
|
+
async execute(_toolCallId, params, signal) {
|
|
293
|
+
if (signal?.aborted) throw new Error("Todo call aborted by signal.");
|
|
294
|
+
|
|
295
|
+
const result = dispatchTodoAction(params as TodoActionParams, state);
|
|
296
|
+
const mutated = result.action !== "list";
|
|
297
|
+
if (mutated) {
|
|
298
|
+
state.todos = result.snapshot.todos;
|
|
299
|
+
state.nextId = result.snapshot.nextId;
|
|
300
|
+
persist();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const text = mutated
|
|
304
|
+
? `${result.resultText}\n${state.todos.map((t) => formatTodoLine(t)).join("\n") || "No todos"}`
|
|
305
|
+
: result.resultText;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text" as const, text }],
|
|
309
|
+
// 前端靠这份 details 做增量刷新(lib → hooks/useAgentSession.ts 的 tool_execution_end)。
|
|
310
|
+
details: { action: result.action, todos: [...state.todos], nextId: state.nextId },
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
addTodos,
|
|
7
|
+
deleteTodos,
|
|
8
|
+
dispatchTodoAction,
|
|
9
|
+
formatTodoList,
|
|
10
|
+
hasOpenTodos,
|
|
11
|
+
parseTodoSnapshot,
|
|
12
|
+
updateTodos,
|
|
13
|
+
} = await createJiti(import.meta.url).import("./todo-store.ts");
|
|
14
|
+
|
|
15
|
+
const empty = () => ({ todos: [], nextId: 1 });
|
|
16
|
+
|
|
17
|
+
test("addTodos appends with consecutive ids from nextId", () => {
|
|
18
|
+
const { snapshot, resultText } = addTodos(empty(), [" first ", "second"]);
|
|
19
|
+
assert.deepEqual(snapshot.todos, [
|
|
20
|
+
{ id: 1, content: "first", status: "pending" },
|
|
21
|
+
{ id: 2, content: "second", status: "pending" },
|
|
22
|
+
]);
|
|
23
|
+
assert.equal(snapshot.nextId, 3);
|
|
24
|
+
assert.equal(resultText, "Added 2 todos (#1-#2)");
|
|
25
|
+
|
|
26
|
+
// ids 不复用:删掉 #1 再 add 仍从 nextId 继续
|
|
27
|
+
const afterDelete = deleteTodos(snapshot, [1]);
|
|
28
|
+
const regrown = addTodos(afterDelete.snapshot, ["third"]);
|
|
29
|
+
assert.deepEqual(regrown.snapshot.todos.map((t) => t.id), [2, 3]);
|
|
30
|
+
assert.equal(regrown.snapshot.nextId, 4);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("addTodos rejects empty items instead of silently dropping them", () => {
|
|
34
|
+
assert.throws(() => addTodos(empty(), []), /non-empty array/);
|
|
35
|
+
assert.throws(() => addTodos(empty(), ["ok", " "]), /cannot be empty or whitespace-only/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("updateTodos patches status and text, and is atomic on unknown ids", () => {
|
|
39
|
+
const { snapshot } = addTodos(empty(), ["a", "b", "c"]);
|
|
40
|
+
|
|
41
|
+
const single = updateTodos(snapshot, [{ id: 2, status: "in_progress" }]);
|
|
42
|
+
assert.equal(single.snapshot.todos[1].status, "in_progress");
|
|
43
|
+
assert.equal(single.resultText, "Updated 1 todo(s)");
|
|
44
|
+
|
|
45
|
+
const batch = updateTodos(single.snapshot, [
|
|
46
|
+
{ id: 1, status: "completed" },
|
|
47
|
+
{ id: 2, status: "completed" },
|
|
48
|
+
{ id: 3, text: "c2" },
|
|
49
|
+
]);
|
|
50
|
+
assert.deepEqual(batch.snapshot.todos.map((t) => t.status), ["completed", "completed", "pending"]);
|
|
51
|
+
assert.equal(batch.snapshot.todos[2].content, "c2");
|
|
52
|
+
|
|
53
|
+
// 原子性:一条非法 → 整体不动
|
|
54
|
+
assert.throws(() => updateTodos(batch.snapshot, [{ id: 1, status: "completed" }, { id: 99, status: "completed" }]), /Todo #99 not found/);
|
|
55
|
+
assert.throws(() => updateTodos(batch.snapshot, [{ id: 1, status: "done" }]), /status only accepts/);
|
|
56
|
+
assert.throws(() => updateTodos(batch.snapshot, [{ id: 1 }]), /neither status nor text/);
|
|
57
|
+
assert.throws(() => updateTodos(batch.snapshot, [{ id: 1, status: "completed" }, { id: 1, text: "dup" }]), /duplicate id 1/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("deleteTodos rejects the whole batch when any id is missing", () => {
|
|
61
|
+
const { snapshot } = addTodos(empty(), ["a", "b", "c"]);
|
|
62
|
+
assert.throws(() => deleteTodos(snapshot, [1, 99]), /Todo #99 not found/);
|
|
63
|
+
assert.deepEqual(deleteTodos(snapshot, [1, 3]).snapshot.todos.map((t) => t.id), [2]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("dispatchTodoAction validates required fields and singular/plural shape traps", () => {
|
|
67
|
+
const snapshot = addTodos(empty(), ["a"]).snapshot;
|
|
68
|
+
|
|
69
|
+
// add:双形陷阱
|
|
70
|
+
assert.throws(() => dispatchTodoAction({ action: "add", text: "x" }, snapshot), /You passed singular "text"/);
|
|
71
|
+
assert.throws(() => dispatchTodoAction({ action: "add", text: "x", texts: ["y"] }, snapshot), /do not also pass singular "text"/);
|
|
72
|
+
assert.equal(dispatchTodoAction({ action: "add", texts: ["b"] }, snapshot).action, "add");
|
|
73
|
+
|
|
74
|
+
// update:id 必填;updates[] 优先
|
|
75
|
+
assert.throws(() => dispatchTodoAction({ action: "update", status: "completed" }, snapshot), /update requires id parameter/);
|
|
76
|
+
const batched = dispatchTodoAction({
|
|
77
|
+
action: "update",
|
|
78
|
+
id: 1,
|
|
79
|
+
status: "completed",
|
|
80
|
+
updates: [{ id: 1, status: "in_progress" }],
|
|
81
|
+
}, snapshot);
|
|
82
|
+
assert.equal(batched.snapshot.todos[0].status, "in_progress", "updates[] 优先于单条字段");
|
|
83
|
+
|
|
84
|
+
// delete:双形陷阱 + 必填
|
|
85
|
+
assert.throws(() => dispatchTodoAction({ action: "delete", id: 1 }, snapshot), /You passed singular "id"/);
|
|
86
|
+
assert.throws(() => dispatchTodoAction({ action: "delete" }, snapshot), /delete requires ids parameter/);
|
|
87
|
+
|
|
88
|
+
// list:不变更(execute 以 action !== "list" 判定)
|
|
89
|
+
const listed = dispatchTodoAction({ action: "list" }, snapshot);
|
|
90
|
+
assert.equal(listed.action, "list");
|
|
91
|
+
assert.equal(listed.resultText, "[ ] #1: a");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("formatTodoList marks three states and falls back to No todos", () => {
|
|
95
|
+
assert.equal(formatTodoList([]), "No todos");
|
|
96
|
+
assert.equal(hasOpenTodos([]), false);
|
|
97
|
+
const todos = [
|
|
98
|
+
{ id: 1, content: "done", status: "completed" },
|
|
99
|
+
{ id: 2, content: "doing", status: "in_progress" },
|
|
100
|
+
{ id: 3, content: "waiting", status: "pending" },
|
|
101
|
+
];
|
|
102
|
+
assert.equal(formatTodoList(todos), "[x] #1: done\n[~] #2: doing\n[ ] #3: waiting");
|
|
103
|
+
assert.equal(hasOpenTodos(todos), true);
|
|
104
|
+
assert.equal(hasOpenTodos([todos[0]]), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("parseTodoSnapshot restores a snapshot and degrades dirty entries", () => {
|
|
108
|
+
const parsed = parseTodoSnapshot({
|
|
109
|
+
todos: [
|
|
110
|
+
{ id: 1, content: "keep", status: "pending" },
|
|
111
|
+
null,
|
|
112
|
+
{ id: 2, content: "no status" },
|
|
113
|
+
{ id: 3, content: "bad status", status: "done" },
|
|
114
|
+
{ id: 4, content: "", status: "pending" },
|
|
115
|
+
{ id: 5, content: "keep too", status: "completed" },
|
|
116
|
+
],
|
|
117
|
+
nextId: 9,
|
|
118
|
+
});
|
|
119
|
+
assert.deepEqual(parsed, {
|
|
120
|
+
todos: [
|
|
121
|
+
{ id: 1, content: "keep", status: "pending" },
|
|
122
|
+
{ id: 5, content: "keep too", status: "completed" },
|
|
123
|
+
],
|
|
124
|
+
nextId: 9,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 全脏 → 空列表(仍可回放)但保留 nextId(防 id 撞车);非快照数据 → null
|
|
128
|
+
assert.deepEqual(parseTodoSnapshot({ todos: [null], nextId: 3 }), { todos: [], nextId: 3 });
|
|
129
|
+
assert.equal(parseTodoSnapshot({ todos: "nope" }), null);
|
|
130
|
+
assert.equal(parseTodoSnapshot(null), null);
|
|
131
|
+
// nextId 落后于现存 id 时取 max+1(防脏数据导致 id 撞车)
|
|
132
|
+
assert.equal(parseTodoSnapshot({ todos: [{ id: 7, content: "x", status: "pending" }], nextId: 2 }).nextId, 8);
|
|
133
|
+
// auto-clear 落盘的空快照必须解析成空列表而不是 null
|
|
134
|
+
assert.deepEqual(parseTodoSnapshot({ todos: [], nextId: 1 }), { todos: [], nextId: 1 });
|
|
135
|
+
});
|