@zhushanwen/pi-todo 0.6.0 → 0.7.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/package.json +2 -2
- package/src/__tests__/gui.test.ts +2 -5
- package/src/__tests__/schema.test.ts +76 -0
- package/src/__tests__/steer.test.ts +27 -107
- package/src/__tests__/todo.test.ts +58 -94
- package/src/__tests__/tool-detectors.test.ts +12 -0
- package/src/__tests__/tool-prompt.test.ts +32 -52
- package/src/__tests__/tool-rpc.test.ts +3 -3
- package/src/handlers.ts +13 -64
- package/src/index.ts +2 -2
- package/src/model.ts +35 -42
- package/src/render.ts +5 -13
- package/src/state.ts +1 -5
- package/src/tool.ts +106 -85
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// 提示词质量回归:todo tool 的 description 与 runtime 纠错文案必须是
|
|
2
|
-
// "弱模型友好"
|
|
3
|
-
// id/ids
|
|
2
|
+
// "弱模型友好"的——条件必填字段在 schema 层强约束(见 schema.test.ts),双形陷阱
|
|
3
|
+
// (text/texts、id/ids)的 throw 要带 Correct 纠错正例让模型自我纠正。
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// 本测试用源码文本断言锁定这些约束,防止后续重构把纠错文案删掉或弱化。读源码而非
|
|
6
|
+
// import,避免 mock 链(tool.ts 依赖 typebox/ExtensionAPI/Theme 等值导入)。参考
|
|
7
|
+
// subagent-workflow 的 prompt 回归范式。
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
@@ -21,8 +21,6 @@ const TOOL_SRC = readFileSync(join(__dirname, "../tool.ts"), "utf-8");
|
|
|
21
21
|
*/
|
|
22
22
|
function extractDescriptionRegion(src: string): string {
|
|
23
23
|
// 锚定到 registerTodoTool 内的 tool description,而非 schema 字段的 description:
|
|
24
|
-
// (Type.String({ description: "..." }) 等 schema 字段会先被 indexOf 命中,导致
|
|
25
|
-
// 捕获区域含 schema 描述 + tool 描述——虽当前子串唯一能过但 fragile to restructuring)。
|
|
26
24
|
const regIdx = src.indexOf("registerTodoTool");
|
|
27
25
|
if (regIdx === -1) throw new Error("registerTodoTool not found in tool.ts");
|
|
28
26
|
const start = src.indexOf("description:", regIdx);
|
|
@@ -34,49 +32,36 @@ function extractDescriptionRegion(src: string): string {
|
|
|
34
32
|
|
|
35
33
|
const DESCRIPTION_REGION = extractDescriptionRegion(TOOL_SRC);
|
|
36
34
|
|
|
37
|
-
// ── description
|
|
35
|
+
// ── description 中文版(T7/TC9)──────────────────────
|
|
38
36
|
|
|
39
|
-
describe("todo description —
|
|
40
|
-
it("
|
|
41
|
-
|
|
42
|
-
expect(DESCRIPTION_REGION).toContain('{"action":"add","texts"');
|
|
37
|
+
describe("todo description — 中文版(动作 + 规则)", () => {
|
|
38
|
+
it("开篇是中文定位语", () => {
|
|
39
|
+
expect(DESCRIPTION_REGION).toContain("管理当前会话的 todo 列表");
|
|
43
40
|
});
|
|
44
41
|
|
|
45
|
-
it("update
|
|
46
|
-
expect(DESCRIPTION_REGION).toContain(
|
|
42
|
+
it("含「动作:」清单(list/add/update/delete 四动作)", () => {
|
|
43
|
+
expect(DESCRIPTION_REGION).toContain("动作:");
|
|
44
|
+
expect(DESCRIPTION_REGION).toContain("list: 查看全部 todo");
|
|
45
|
+
expect(DESCRIPTION_REGION).toContain("add: 批量添加 todo");
|
|
46
|
+
expect(DESCRIPTION_REGION).toContain("update: 按 id 更新 todo");
|
|
47
|
+
expect(DESCRIPTION_REGION).toContain("delete: 按 id 删除 todo");
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
it("
|
|
50
|
-
|
|
51
|
-
expect(DESCRIPTION_REGION).toContain('{"action":"update","updates"');
|
|
50
|
+
it("不含已删除的 clear 动作", () => {
|
|
51
|
+
expect(DESCRIPTION_REGION).not.toMatch(/clear/i);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("
|
|
55
|
-
|
|
56
|
-
expect(DESCRIPTION_REGION).toContain(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// ── description 必须给双形陷阱反例(消歧) ──────────
|
|
61
|
-
|
|
62
|
-
describe("todo description — 双形陷阱反例(消歧单复数)", () => {
|
|
63
|
-
it("Don't 段存在", () => {
|
|
64
|
-
expect(DESCRIPTION_REGION).toMatch(/Don't/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("反例标注:text 属于 update(add 用 texts)", () => {
|
|
68
|
-
// 反例必须含消歧词,告诉模型 text 属于 update 而非 add。
|
|
69
|
-
expect(DESCRIPTION_REGION).toContain("text is for update");
|
|
70
|
-
expect(DESCRIPTION_REGION).toContain("add uses texts");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("反例标注:id 属于 update(delete 用 ids)", () => {
|
|
74
|
-
expect(DESCRIPTION_REGION).toContain("id is for update");
|
|
75
|
-
expect(DESCRIPTION_REGION).toContain("delete uses ids");
|
|
54
|
+
it("含「规则:」三条核心行为准则", () => {
|
|
55
|
+
expect(DESCRIPTION_REGION).toContain("规则:");
|
|
56
|
+
expect(DESCRIPTION_REGION).toContain("同一时间只有一个 todo 处于 in_progress");
|
|
57
|
+
expect(DESCRIPTION_REGION).toContain("完成一个 todo 立即标记 completed");
|
|
58
|
+
expect(DESCRIPTION_REGION).toContain("未真正完成不得标记 completed");
|
|
76
59
|
});
|
|
77
60
|
|
|
78
|
-
it("
|
|
79
|
-
expect(DESCRIPTION_REGION).toContain("
|
|
61
|
+
it("已删除旧英文 Examples 段与 Don't 段", () => {
|
|
62
|
+
expect(DESCRIPTION_REGION).not.toContain("Available actions");
|
|
63
|
+
expect(DESCRIPTION_REGION).not.toContain("Don't");
|
|
64
|
+
expect(DESCRIPTION_REGION).not.toContain("Examples");
|
|
80
65
|
});
|
|
81
66
|
});
|
|
82
67
|
|
|
@@ -100,18 +85,13 @@ describe("todo runtime — throw 含 Correct 纠错正例", () => {
|
|
|
100
85
|
});
|
|
101
86
|
});
|
|
102
87
|
|
|
103
|
-
// ── promptSnippet / promptGuidelines
|
|
88
|
+
// ── promptSnippet / promptGuidelines(verification guidance)──
|
|
104
89
|
// DESCRIPTION_REGION 截取止于 promptSnippet:,不含这两个字段,故对完整 TOOL_SRC 断言。
|
|
105
90
|
|
|
106
|
-
describe("todo tool prompt —
|
|
107
|
-
it("promptSnippet
|
|
108
|
-
expect(TOOL_SRC).toContain(
|
|
109
|
-
|
|
110
|
-
);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("promptSnippet 声明多步骤工作场景", () => {
|
|
114
|
-
expect(TOOL_SRC).toContain("Use todo when breaking multi-step work into trackable items.");
|
|
91
|
+
describe("todo tool prompt — snippet & guidelines", () => {
|
|
92
|
+
it("promptSnippet 为中文精简版(验证步骤引导)", () => {
|
|
93
|
+
expect(TOOL_SRC).toContain("用 todo 跟踪多步骤工作");
|
|
94
|
+
expect(TOOL_SRC).toContain("验证步骤");
|
|
115
95
|
});
|
|
116
96
|
|
|
117
97
|
it("promptGuidelines 含 [验证任务] 条目(完成前确保验证通过)", () => {
|
|
@@ -119,7 +99,7 @@ describe("todo tool prompt — verification guidance", () => {
|
|
|
119
99
|
});
|
|
120
100
|
|
|
121
101
|
it("promptGuidelines 含 [自动闭合] / [批量优先] 核心条目", () => {
|
|
122
|
-
expect(TOOL_SRC).toContain("[自动闭合]
|
|
102
|
+
expect(TOOL_SRC).toContain("[自动闭合] 全部完成后自动清理");
|
|
123
103
|
expect(TOOL_SRC).toContain("[批量优先] 完成多项任务时使用 updates[] 批量更新");
|
|
124
104
|
});
|
|
125
105
|
});
|
|
@@ -267,7 +267,7 @@ describe("executeTodoAction — state isolation & snapshot", () => {
|
|
|
267
267
|
|
|
268
268
|
it("S-2: details.todos is a shallow array copy (splice-safe, element-shared)", async () => {
|
|
269
269
|
// executeTodoAction 用 [...state.todos] 做浅拷贝:数组独立、元素共享。
|
|
270
|
-
// add/
|
|
270
|
+
// add/delete 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
|
|
271
271
|
const { tool } = setup();
|
|
272
272
|
await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx());
|
|
273
273
|
const before = (await tool.execute(
|
|
@@ -278,8 +278,8 @@ describe("executeTodoAction — state isolation & snapshot", () => {
|
|
|
278
278
|
makeRpcCtx(),
|
|
279
279
|
)).details.todos;
|
|
280
280
|
expect(before).toHaveLength(2);
|
|
281
|
-
//
|
|
282
|
-
await tool.execute("id", { action: "
|
|
281
|
+
// delete 改 state.todos 数组,已发出的 before 快照仍为 2 项
|
|
282
|
+
await tool.execute("id", { action: "delete", ids: [1, 2] }, undefined, undefined, makeRpcCtx());
|
|
283
283
|
expect(before).toHaveLength(2);
|
|
284
284
|
});
|
|
285
285
|
});
|
package/src/handlers.ts
CHANGED
|
@@ -15,29 +15,16 @@ import type { TodoSessionState } from "./state";
|
|
|
15
15
|
|
|
16
16
|
/** 全部完成后保留的轮数,之后再自动 clear */
|
|
17
17
|
const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
18
|
-
/** Stall 检测阈值(无 todo 活动轮数 → stall 提醒) */
|
|
19
|
-
const STALL_THRESHOLD = 5;
|
|
20
|
-
/** 提醒间隔(上次 todo 调用后轮数 → 提醒) */
|
|
21
|
-
const REMINDER_INTERVAL = 2;
|
|
22
18
|
|
|
23
19
|
// ── 辅助函数 ────────────────────────────────────────
|
|
24
20
|
|
|
25
21
|
export type RefreshDisplayFn = (ctx: ExtensionContext) => void;
|
|
26
22
|
|
|
27
|
-
/** 未完成任务判定:pending / in_progress
|
|
23
|
+
/** 未完成任务判定:pending / in_progress */
|
|
28
24
|
function isPending(t: TodoDetails["todos"][number]): boolean {
|
|
29
25
|
return t.status === "pending" || t.status === "in_progress";
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
/** 构建极简提醒:只含下一个推荐任务 + 行动指令 */
|
|
33
|
-
export function buildMinimalReminder(state: TodoSessionState): string {
|
|
34
|
-
const pendingTodos = state.todos.filter(isPending);
|
|
35
|
-
if (pendingTodos.length === 0) return "";
|
|
36
|
-
|
|
37
|
-
const next = pendingTodos[0];
|
|
38
|
-
return `<todo_context>\n[TODO] 你有 ${pendingTodos.length} 个未完成任务已搁置。下一个必须处理:#${next.id} ${next.text}。完成后用 todo update 标记 completed,不要继续搁置。\n</todo_context>`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
28
|
export function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
|
|
42
29
|
if (state.todos.length === 0) return undefined;
|
|
43
30
|
|
|
@@ -59,18 +46,22 @@ export function buildBeforeAgentStartMessage(state: TodoSessionState): { message
|
|
|
59
46
|
|
|
60
47
|
// ── 状态重建 ────────────────────────────────────────
|
|
61
48
|
|
|
49
|
+
/**
|
|
50
|
+
* 回放最后一条 todo toolResult 重建 state(纯读,不修改 entries)。
|
|
51
|
+
*
|
|
52
|
+
* H1(C2 决策):pi 的 SessionManager.getEntries() 返回的是 filter-copy,原先的
|
|
53
|
+
* splice GC 段对副本操作无效,且修改传入 entries 是反模式。删除整段 splice,只保留
|
|
54
|
+
* 回放逻辑——找到最后一条有效 todo 快照,迁移脏数据后载入 state。
|
|
55
|
+
*/
|
|
62
56
|
export function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
|
|
63
57
|
state.todos = [];
|
|
64
58
|
state.nextId = 1;
|
|
65
59
|
state.userMessageCount = 0;
|
|
66
|
-
state.lastTodoCallCount = 0;
|
|
67
|
-
state.stallNotified = false;
|
|
68
60
|
state.allCompletedAtCount = null;
|
|
69
61
|
state.completionSteered = false;
|
|
70
62
|
state.pendingSteerMessage = null;
|
|
71
63
|
|
|
72
64
|
const entries = ctx.sessionManager.getEntries();
|
|
73
|
-
let latestIdx = -1;
|
|
74
65
|
|
|
75
66
|
for (let i = 0; i < entries.length; i++) {
|
|
76
67
|
const entry = entries[i];
|
|
@@ -93,25 +84,9 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
93
84
|
if (migrated.length > 0) {
|
|
94
85
|
state.todos = migrated;
|
|
95
86
|
state.nextId = details.nextId ?? Math.max(...migrated.map((t) => t.id)) + 1;
|
|
96
|
-
latestIdx = i;
|
|
97
87
|
}
|
|
98
88
|
}
|
|
99
89
|
}
|
|
100
|
-
|
|
101
|
-
if (latestIdx >= 0) {
|
|
102
|
-
const staleIndices: number[] = [];
|
|
103
|
-
for (let i = 0; i < latestIdx; i++) {
|
|
104
|
-
const entry = entries[i];
|
|
105
|
-
if (entry.type !== "message") continue;
|
|
106
|
-
const msg = entry.message;
|
|
107
|
-
if (msg.role === "toolResult" && msg.toolName === "todo") {
|
|
108
|
-
staleIndices.push(i);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
for (let j = staleIndices.length - 1; j >= 0; j--) {
|
|
112
|
-
entries.splice(staleIndices[j], 1);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
90
|
}
|
|
116
91
|
|
|
117
92
|
// ── agent_end 子函数 ────────────────────────────────
|
|
@@ -145,32 +120,6 @@ export function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
|
145
120
|
return true;
|
|
146
121
|
}
|
|
147
122
|
|
|
148
|
-
export function handleStallDetection(state: TodoSessionState): boolean {
|
|
149
|
-
if (
|
|
150
|
-
!state.stallNotified &&
|
|
151
|
-
state.userMessageCount - state.lastTodoCallCount >= STALL_THRESHOLD
|
|
152
|
-
) {
|
|
153
|
-
state.stallNotified = true;
|
|
154
|
-
const reminder = buildMinimalReminder(state);
|
|
155
|
-
if (reminder) {
|
|
156
|
-
state.pendingSteerMessage = reminder;
|
|
157
|
-
}
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function handleReminder(state: TodoSessionState): boolean {
|
|
164
|
-
if (state.userMessageCount - state.lastTodoCallCount >= REMINDER_INTERVAL) {
|
|
165
|
-
const reminder = buildMinimalReminder(state);
|
|
166
|
-
if (reminder) {
|
|
167
|
-
state.pendingSteerMessage = reminder;
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
123
|
// ── Event handler 注册入口 ──────────────────────────
|
|
175
124
|
|
|
176
125
|
export function registerTodoEventHandlers(
|
|
@@ -218,12 +167,12 @@ export function registerTodoEventHandlers(
|
|
|
218
167
|
// 全部 completed → 总检查 steer(仅一次)
|
|
219
168
|
handleCompletionSteer(state);
|
|
220
169
|
|
|
221
|
-
// auto-clear
|
|
170
|
+
// auto-clear(全部完成后延迟清理)
|
|
222
171
|
const ac = handleAutoClear(state);
|
|
223
|
-
if (ac.handled) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
172
|
+
if (ac.handled) {
|
|
173
|
+
if (ac.cleared) refreshDisplay(ctx);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
227
176
|
} catch (e) {
|
|
228
177
|
// best-effort:agent_end 事件处理器出错不阻断会话主流程,仅记录调试日志
|
|
229
178
|
console.debug("[todo] agent_end error:", e);
|
package/src/index.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* 文件职责:
|
|
9
9
|
* - state.ts: TodoSessionState 会话状态接口 + 工厂(闭包内创建,session 隔离)
|
|
10
10
|
* - model.ts: 纯函数数据层(Todo 类型、migrateTodo 兼容迁移、addTodos/updateTodos、format/buildGui)
|
|
11
|
-
* - tool.ts: todo tool 注册 —
|
|
11
|
+
* - tool.ts: todo tool 注册 — 4 个 action(list/add/update/delete)+ execute dispatcher
|
|
12
12
|
* - handlers.ts: 5 个事件处理器(session_start/session_tree/agent_start/before_agent_start/agent_end)
|
|
13
|
-
* + reconstructState(回放最后一条 todo toolResult)+ steer
|
|
13
|
+
* + reconstructState(回放最后一条 todo toolResult)+ steer 双机制(autoClear/completion)
|
|
14
14
|
* - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
|
|
15
15
|
* - component.ts: /todos 命令的 TodoListComponent TUI 视图(只读双列)
|
|
16
16
|
* - commands.ts: /todos 命令注册
|
package/src/model.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
|
|
3
|
-
*
|
|
4
|
-
* (cancelled 不可恢复)
|
|
3
|
+
* 三态: pending → in_progress → completed
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
|
|
@@ -11,24 +10,24 @@ import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@x
|
|
|
11
10
|
export interface Todo {
|
|
12
11
|
id: number;
|
|
13
12
|
text: string;
|
|
14
|
-
status: "pending" | "in_progress" | "completed"
|
|
13
|
+
status: "pending" | "in_progress" | "completed";
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export interface TodoDetails {
|
|
18
|
-
action: "list" | "add" | "update" | "delete"
|
|
17
|
+
action: "list" | "add" | "update" | "delete";
|
|
19
18
|
todos: Todo[];
|
|
20
19
|
nextId: number;
|
|
21
20
|
/** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
|
|
22
21
|
__gui__?: GuiRenderResult;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
export const VALID_STATUSES = ["pending", "in_progress", "completed"
|
|
24
|
+
export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
26
25
|
|
|
27
26
|
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
28
27
|
|
|
29
28
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
30
29
|
|
|
31
|
-
/** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
|
|
30
|
+
/** 旧格式迁移:verifying → in_progress,failed → pending,cancelled → completed(历史三态化降级),done:boolean → status */
|
|
32
31
|
export function migrateTodo(raw: unknown): Todo {
|
|
33
32
|
// raw 是任意旧格式数据(兼容 done:boolean 等历史结构),以 Record 方式安全访问字段
|
|
34
33
|
// 守卫:null/原始类型(typeof null === 'object',必须显式排除 null)→ 明确报错而非混淆的 TypeError
|
|
@@ -51,10 +50,12 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
51
50
|
status = done === true ? "completed" : "pending";
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
//
|
|
53
|
+
// 历史状态映射(先转 string 避免类型收窄后无法比较)
|
|
55
54
|
const rawStatus = record.status as string | undefined;
|
|
56
55
|
if (rawStatus === "verifying") status = "in_progress";
|
|
57
56
|
if (rawStatus === "failed") status = "pending";
|
|
57
|
+
// 三态化降级:历史 cancelled 项映射为 completed(不丢数据,且解除 every(completed) 死锁)
|
|
58
|
+
if (rawStatus === "cancelled") status = "completed";
|
|
58
59
|
|
|
59
60
|
return {
|
|
60
61
|
id: record.id as number,
|
|
@@ -71,7 +72,6 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
71
72
|
* pending → dot / 无 status
|
|
72
73
|
* in_progress → circle / running
|
|
73
74
|
* completed → check / done
|
|
74
|
-
* cancelled → cross / failed
|
|
75
75
|
*/
|
|
76
76
|
export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
77
77
|
const items: TreeItem[] = todos.map((t) => {
|
|
@@ -80,17 +80,13 @@ export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
|
80
80
|
? "check"
|
|
81
81
|
: t.status === "in_progress"
|
|
82
82
|
? "circle"
|
|
83
|
-
:
|
|
84
|
-
? "cross"
|
|
85
|
-
: "dot"; // pending
|
|
83
|
+
: "dot"; // pending
|
|
86
84
|
const status =
|
|
87
85
|
t.status === "in_progress"
|
|
88
86
|
? "running"
|
|
89
87
|
: t.status === "completed"
|
|
90
88
|
? "done"
|
|
91
|
-
:
|
|
92
|
-
? "failed"
|
|
93
|
-
: undefined; // pending 无 status
|
|
89
|
+
: undefined; // pending 无 status
|
|
94
90
|
return {
|
|
95
91
|
icon,
|
|
96
92
|
label: `#${t.id}: ${t.text}`,
|
|
@@ -110,32 +106,27 @@ export function getDisplayStatus(t: Todo): string {
|
|
|
110
106
|
export interface AddResult {
|
|
111
107
|
newTodos: Todo[];
|
|
112
108
|
newNextId: number;
|
|
113
|
-
|
|
114
|
-
resultText?: string;
|
|
109
|
+
resultText: string;
|
|
115
110
|
}
|
|
116
111
|
|
|
112
|
+
/**
|
|
113
|
+
* 批量新增 todo。
|
|
114
|
+
* texts 整体 trim;任一项 trim 后为空串则 throw(不再静默 filter 丢弃——
|
|
115
|
+
* 模型应学到传有效项,C1 决策)。
|
|
116
|
+
*/
|
|
117
117
|
export function addTodos(
|
|
118
118
|
currentTodos: Todo[],
|
|
119
119
|
currentNextId: number,
|
|
120
120
|
texts: string[],
|
|
121
121
|
): AddResult {
|
|
122
122
|
if (!texts || texts.length === 0) {
|
|
123
|
-
|
|
124
|
-
newTodos: currentTodos,
|
|
125
|
-
newNextId: currentNextId,
|
|
126
|
-
error: "texts required",
|
|
127
|
-
resultText: "Error: add requires texts parameter (non-empty array)",
|
|
128
|
-
};
|
|
123
|
+
throw new Error("add requires texts parameter (non-empty array)");
|
|
129
124
|
}
|
|
130
125
|
|
|
131
|
-
const trimmed = texts.map((t) => t.trim())
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
newNextId: currentNextId,
|
|
136
|
-
error: "all texts empty",
|
|
137
|
-
resultText: "Error: texts must contain at least one non-empty string",
|
|
138
|
-
};
|
|
126
|
+
const trimmed = texts.map((t) => t.trim());
|
|
127
|
+
// 任一项 trim 后空串 → throw(不静默 filter)
|
|
128
|
+
if (trimmed.some((t) => t.length === 0)) {
|
|
129
|
+
throw new Error("texts must not contain empty or whitespace-only items");
|
|
139
130
|
}
|
|
140
131
|
|
|
141
132
|
const startId = currentNextId;
|
|
@@ -169,6 +160,13 @@ export function updateTodos(
|
|
|
169
160
|
currentTodos: Todo[],
|
|
170
161
|
updates: Array<{ id: number; status?: string; text?: string }>,
|
|
171
162
|
): UpdateResult {
|
|
163
|
+
// text 校验统一(CT5):text 存在则 trim,空串 throw(不静默跳过)
|
|
164
|
+
for (const u of updates) {
|
|
165
|
+
if (u.text !== undefined && u.text.trim().length === 0) {
|
|
166
|
+
throw new Error(`update item id ${u.id}: text cannot be empty or whitespace-only`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
172
170
|
const ids = updates.map((u) => u.id);
|
|
173
171
|
if (new Set(ids).size !== ids.length) {
|
|
174
172
|
return {
|
|
@@ -200,14 +198,6 @@ export function updateTodos(
|
|
|
200
198
|
resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
|
|
201
199
|
};
|
|
202
200
|
}
|
|
203
|
-
// cancelled 不可恢复
|
|
204
|
-
if (todo.status === "cancelled" && u.status !== undefined) {
|
|
205
|
-
return {
|
|
206
|
-
updatedTodos: currentTodos,
|
|
207
|
-
error: `id ${u.id} is cancelled`,
|
|
208
|
-
resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
201
|
}
|
|
212
202
|
|
|
213
203
|
const updated = currentTodos.map((t) => {
|
|
@@ -215,7 +205,7 @@ export function updateTodos(
|
|
|
215
205
|
if (!u) return t;
|
|
216
206
|
const patch: Partial<Todo> = {};
|
|
217
207
|
if (u.status) patch.status = u.status as Todo["status"];
|
|
218
|
-
if (u.text) patch.text = u.text;
|
|
208
|
+
if (u.text !== undefined) patch.text = u.text.trim();
|
|
219
209
|
return { ...t, ...patch };
|
|
220
210
|
});
|
|
221
211
|
return {
|
|
@@ -232,8 +222,11 @@ export function formatTodoLine(t: Todo): string {
|
|
|
232
222
|
? "x"
|
|
233
223
|
: t.status === "in_progress"
|
|
234
224
|
? "~"
|
|
235
|
-
:
|
|
236
|
-
? "-"
|
|
237
|
-
: " ";
|
|
225
|
+
: " "; // pending
|
|
238
226
|
return `[${mark}] #${t.id}: ${t.text}`;
|
|
239
227
|
}
|
|
228
|
+
|
|
229
|
+
/** 把整张列表格式化为多行文本,每行复用 formatTodoLine(T3)。 */
|
|
230
|
+
export function formatTodoList(todos: Todo[]): string {
|
|
231
|
+
return todos.map((t) => formatTodoLine(t)).join("\n");
|
|
232
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -60,12 +60,9 @@ function renderWidgetItem(t: Todo, th: Theme): string {
|
|
|
60
60
|
? th.fg("success", "\u2713")
|
|
61
61
|
: t.status === "in_progress"
|
|
62
62
|
? th.fg("warning", "\u25cf")
|
|
63
|
-
:
|
|
64
|
-
? th.fg("error", "\u2715")
|
|
65
|
-
: th.fg("dim", "\u25cb");
|
|
63
|
+
: th.fg("dim", "\u25cb"); // pending
|
|
66
64
|
const id = th.fg("accent", `#${t.id}`);
|
|
67
|
-
const text =
|
|
68
|
-
t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
65
|
+
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
69
66
|
return `${mark} ${id} ${text}`;
|
|
70
67
|
}
|
|
71
68
|
|
|
@@ -151,11 +148,8 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
151
148
|
? theme.fg("success", "\u2713")
|
|
152
149
|
: status === "in_progress"
|
|
153
150
|
? theme.fg("warning", "\u25cf")
|
|
154
|
-
:
|
|
155
|
-
|
|
156
|
-
: theme.fg("dim", "\u25cb");
|
|
157
|
-
const itemText =
|
|
158
|
-
status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
151
|
+
: theme.fg("dim", "\u25cb"); // pending
|
|
152
|
+
const itemText = status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
159
153
|
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
160
154
|
}
|
|
161
155
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
@@ -185,8 +179,7 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
185
179
|
|
|
186
180
|
case "add":
|
|
187
181
|
case "update":
|
|
188
|
-
case "delete":
|
|
189
|
-
case "clear": {
|
|
182
|
+
case "delete": {
|
|
190
183
|
const text = r.content[0];
|
|
191
184
|
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
192
185
|
const listText = buildTodoListText(todoList, options, theme);
|
|
@@ -204,4 +197,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
204
197
|
}
|
|
205
198
|
}
|
|
206
199
|
}
|
|
207
|
-
|
package/src/state.ts
CHANGED
|
@@ -12,10 +12,8 @@ import type { Todo } from "./model";
|
|
|
12
12
|
export interface TodoSessionState {
|
|
13
13
|
todos: Todo[];
|
|
14
14
|
nextId: number;
|
|
15
|
-
//
|
|
15
|
+
// 用户消息轮数追踪(auto-clear 锚定用)
|
|
16
16
|
userMessageCount: number;
|
|
17
|
-
lastTodoCallCount: number;
|
|
18
|
-
stallNotified: boolean;
|
|
19
17
|
allCompletedAtCount: number | null;
|
|
20
18
|
/** 全部 completed 时已注入 steer,防止重复 */
|
|
21
19
|
completionSteered: boolean;
|
|
@@ -28,8 +26,6 @@ export function createTodoSessionState(): TodoSessionState {
|
|
|
28
26
|
todos: [],
|
|
29
27
|
nextId: 1,
|
|
30
28
|
userMessageCount: 0,
|
|
31
|
-
lastTodoCallCount: 0,
|
|
32
|
-
stallNotified: false,
|
|
33
29
|
allCompletedAtCount: null,
|
|
34
30
|
completionSteered: false,
|
|
35
31
|
pendingSteerMessage: null,
|