@zhushanwen/pi-todo 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -24
- package/package.json +4 -1
- package/src/__tests__/gui.test.ts +39 -0
- package/src/__tests__/steer.test.ts +254 -0
- package/src/__tests__/todo.test.ts +4 -26
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +7 -12
- package/src/index.ts +17 -18
- package/src/model.ts +39 -21
- package/src/render.ts +2 -6
- package/src/tool.ts +67 -197
package/README.md
CHANGED
|
@@ -1,45 +1,108 @@
|
|
|
1
1
|
# todo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
轻量三态任务清单 — `pending` / `in_progress` / `completed`。支持 session 持久化、状态栏、双列 widget、`/todos` TUI 视图,以及延迟 steer 驱动任务推进。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 设计定位
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
| 维度 | todo | goal |
|
|
8
|
+
|------|------|------|
|
|
9
|
+
| 状态机 | **刻意无约束**,任意状态自由流转(含反向) | 7 态状态机 + 强制任务分解 |
|
|
10
|
+
| 持久化 | 复用 Pi 的 toolResult entry(不调用 appendEntry) | appendEntry 主动写入 |
|
|
11
|
+
| 定位 | 多步骤工作的临时进度追踪 | 持久化目标驱动循环 |
|
|
12
|
+
|
|
13
|
+
`in_progress` 非强制,`pending → completed` 直接跳转合法。
|
|
12
14
|
|
|
13
15
|
## 安装
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
# symlink 方式(开发推荐)
|
|
17
|
-
ln -s /path/to/xyz-pi-extensions-workspace/main/packages/todo \
|
|
18
|
-
~/.pi/agent/extensions/todo
|
|
19
|
-
|
|
20
|
-
# npm 方式(正式)
|
|
21
18
|
pi install npm:@zhushanwen/pi-todo
|
|
22
19
|
```
|
|
23
20
|
|
|
24
|
-
##
|
|
21
|
+
## todo tool
|
|
22
|
+
|
|
23
|
+
### Action 与参数
|
|
24
|
+
|
|
25
|
+
| action | 参数 | 必填 | 行为 |
|
|
26
|
+
|--------|------|------|------|
|
|
27
|
+
| `list` | — | — | 返回全部 todo |
|
|
28
|
+
| `add` | `texts: string[]` | 是 | 批量追加,自动分配连续 ID,初始 `status=pending` |
|
|
29
|
+
| `update` | `id` + `status` 或 `text`;**或** `updates: Array<{id, status?, text?}>` | `id` 必填 | `updates[]` **优先于** single 的 `id/status/text` |
|
|
30
|
+
| `delete` | `ids: number[]` | 是 | 批量删除;**部分 id 缺失则整体拒绝**(原子性) |
|
|
31
|
+
| `clear` | — | — | 清空全部,重置 `nextId=1` 和完成态标记 |
|
|
32
|
+
|
|
33
|
+
- `status` 枚举:`pending` / `in_progress` / `completed`
|
|
34
|
+
- `add` 不接受 `status`(恒为 pending),不存在 `verifyTexts`(那是 goal 的概念)
|
|
35
|
+
|
|
36
|
+
### 错误处理约定
|
|
37
|
+
|
|
38
|
+
handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见 CLAUDE.md「Tool 设计」)。常见错误:
|
|
39
|
+
|
|
40
|
+
| 触发 | 错误信息 |
|
|
41
|
+
|------|---------|
|
|
42
|
+
| `add` 缺 `texts` | `add requires texts parameter (non-empty array)` |
|
|
43
|
+
| `update` 缺 `id` | `update requires id parameter` |
|
|
44
|
+
| `update` 缺 `status` 和 `text` | `update requires at least status or text parameter` |
|
|
45
|
+
| `update` `text` 空串 | `text cannot be empty string` |
|
|
46
|
+
| `update` `status` 非法 | `status only accepts pending / in_progress / completed` |
|
|
47
|
+
| `update`/`delete` id 不存在 | `Todo #N not found` |
|
|
48
|
+
| `delete` 缺 `ids` | `delete requires ids parameter (non-empty array)` |
|
|
49
|
+
|
|
50
|
+
## Steer 机制(延迟注入)
|
|
51
|
+
|
|
52
|
+
todo 的核心驱动力是「延迟一拍」的 steer:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
agent_end 设置 pendingSteerMessage
|
|
56
|
+
→ 下一 turn 的 before_agent_start 消费(用户不可见,display:false)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
四个子机制(handlers.ts,阈值常量硬编码):
|
|
60
|
+
|
|
61
|
+
| 机制 | 触发 | 行为 |
|
|
62
|
+
|------|------|------|
|
|
63
|
+
| **auto-clear** | 全部 completed 后再过 2 轮 | 自动清空 todos + 重置标记 |
|
|
64
|
+
| **completion-steer** | 首次全部 completed | 注入「检查交付质量」steer(一次性,`completionSteered` 防重) |
|
|
65
|
+
| **stall 检测** | 无 todo 活动达 5 轮 | 注入极简 reminder(仅下一个任务),整个 session 只触发一次 |
|
|
66
|
+
| **reminder** | 无 todo 活动达 2 轮 | 温和 reminder |
|
|
67
|
+
|
|
68
|
+
`agent_end` 内短路顺序:completion-steer **不短路**(继续往下),auto-clear / stall / reminder 各自短路 return。详见 `ARCHITECTURE.md`。
|
|
69
|
+
|
|
70
|
+
## 持久化机制
|
|
71
|
+
|
|
72
|
+
todo 扩展**自己不调用 `appendEntry`**。状态快照随 Pi 框架自动记录的 toolResult entry 落盘:
|
|
73
|
+
|
|
74
|
+
1. 每次 todo tool 调用,`execute` 返回的 `details.todos` / `details.nextId` 被 Pi 自动序列化为一条 `toolResult` entry
|
|
75
|
+
2. `session_start` / `session_tree` 时,`reconstructState` 回放**最后一条** todo toolResult 重建状态
|
|
76
|
+
3. 回放后 splice 掉更早的 todo toolResult(entry GC,从后往前删避免索引漂移)
|
|
77
|
+
4. 向后兼容:`migrateTodo` 把旧五态(`verifying→in_progress`、`failed→pending`)和极旧的 `done:boolean` 降级映射到三态
|
|
78
|
+
|
|
79
|
+
## 三层渲染
|
|
25
80
|
|
|
26
|
-
|
|
81
|
+
| 层 | 触发 | 规则 |
|
|
82
|
+
|----|------|------|
|
|
83
|
+
| **status line** | 每次 tool execute / session 恢复 | 空列表不显示;全完成 `✓ c/t`(绿);否则 `☑ c/t` |
|
|
84
|
+
| **widget**(侧边) | 有 todo 时 | ≤8 项单列;≥9 项双列(规避 Pi 的 10 行 widget 截断) |
|
|
85
|
+
| **tool result** | tool 返回时 | collapsed 显示前 5 项 + `... N more`;expanded 全显示 |
|
|
27
86
|
|
|
28
|
-
|
|
29
|
-
|--------|------|
|
|
30
|
-
| `list` | 查看所有 todo |
|
|
31
|
-
| `add` | 批量添加 todo |
|
|
32
|
-
| `update` | 更新 todo(状态/文本) |
|
|
33
|
-
| `delete` | 批量删除 |
|
|
34
|
-
| `clear` | 清空所有 |
|
|
87
|
+
## 命令
|
|
35
88
|
|
|
36
|
-
|
|
89
|
+
`/todos` — 进入只读 TUI 视图(`TodoListComponent`,固定双列布局)。Escape / Ctrl+C 关闭。需 interactive mode。
|
|
37
90
|
|
|
38
91
|
## 文件结构
|
|
39
92
|
|
|
40
93
|
```
|
|
41
94
|
todo/
|
|
42
|
-
├── index.ts
|
|
95
|
+
├── index.ts # 工厂入口(re-export src/index.ts)
|
|
96
|
+
├── PLAN.md # [SUPERSEDED] v2 历史计划,保留作决策记录
|
|
97
|
+
├── ARCHITECTURE.md # 架构详图(文件依赖 + steer 时序 + 事件流)
|
|
43
98
|
└── src/
|
|
44
|
-
|
|
99
|
+
├── index.ts # 工厂入口(创建 state + 注册 tool/command/event)
|
|
100
|
+
├── state.ts # TodoSessionState 会话状态接口 + 工厂
|
|
101
|
+
├── model.ts # 纯函数数据层(类型/迁移/addTodos/updateTodos/format/buildGui)
|
|
102
|
+
├── tool.ts # todo tool 注册 — 5 action + execute dispatcher
|
|
103
|
+
├── handlers.ts # 5 事件处理器 + reconstructState + steer 四机制
|
|
104
|
+
├── render.ts # status line / widget / tool result 三层渲染
|
|
105
|
+
├── component.ts # /todos 的 TodoListComponent TUI 组件
|
|
106
|
+
├── commands.ts # /todos 命令注册
|
|
107
|
+
└── __tests__/ # 单测(model 纯函数 + widget 布局 + agent_end 数据条件)
|
|
45
108
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"vitest": "^4.1.8"
|
|
26
26
|
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@xyz-agent/extension-protocol": "^0.2.0"
|
|
29
|
+
},
|
|
27
30
|
"peerDependencies": {
|
|
28
31
|
"@mariozechner/pi-coding-agent": "*",
|
|
29
32
|
"@earendil-works/pi-tui": "*",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildGui, type Todo } from "../model";
|
|
4
|
+
|
|
5
|
+
describe("buildGui", () => {
|
|
6
|
+
it("maps 4 statuses to list-tree with correct icons", () => {
|
|
7
|
+
const todos: Todo[] = [
|
|
8
|
+
{ id: 1, text: "pending task", status: "pending" },
|
|
9
|
+
{ id: 2, text: "active task", status: "in_progress" },
|
|
10
|
+
{ id: 3, text: "done task", status: "completed" },
|
|
11
|
+
{ id: 4, text: "cancelled task", status: "cancelled" },
|
|
12
|
+
];
|
|
13
|
+
const gui = buildGui(todos);
|
|
14
|
+
expect(gui.v).toBe(1);
|
|
15
|
+
expect(gui.component.type).toBe("list-tree");
|
|
16
|
+
const items = gui.component.props.items;
|
|
17
|
+
expect(items).toHaveLength(4);
|
|
18
|
+
// pending → dot, no status(guiResult 的 stripUndefined 删除 undefined 键)
|
|
19
|
+
expect(items[0]).toMatchObject({ icon: "dot", label: "#1: pending task", depth: 0 });
|
|
20
|
+
expect(items[0]).not.toHaveProperty("status");
|
|
21
|
+
// in_progress → circle, running
|
|
22
|
+
expect(items[1]).toMatchObject({ icon: "circle", label: "#2: active task", status: "running", depth: 0 });
|
|
23
|
+
// completed → check, done
|
|
24
|
+
expect(items[2]).toMatchObject({ icon: "check", label: "#3: done task", status: "done", depth: 0 });
|
|
25
|
+
// cancelled → cross, failed
|
|
26
|
+
expect(items[3]).toMatchObject({ icon: "cross", label: "#4: cancelled task", status: "failed", depth: 0 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("empty todos → empty list-tree", () => {
|
|
30
|
+
const gui = buildGui([]);
|
|
31
|
+
expect(gui.component.props.items).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("isVerification todo still maps correctly", () => {
|
|
35
|
+
const todos: Todo[] = [{ id: 1, text: "verify", status: "pending", isVerification: true }];
|
|
36
|
+
const gui = buildGui(todos);
|
|
37
|
+
expect(gui.component.props.items[0]).toMatchObject({ icon: "dot", label: "#1: verify" });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildBeforeAgentStartMessage,
|
|
5
|
+
buildMinimalReminder,
|
|
6
|
+
handleAutoClear,
|
|
7
|
+
handleCompletionSteer,
|
|
8
|
+
handleReminder,
|
|
9
|
+
handleStallDetection,
|
|
10
|
+
reconstructState,
|
|
11
|
+
} from "../handlers";
|
|
12
|
+
import type { Todo } from "../model";
|
|
13
|
+
import { createTodoSessionState, type TodoSessionState } from "../state";
|
|
14
|
+
|
|
15
|
+
// ── helpers ─────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function makeState(todos: Todo[], overrides: Partial<TodoSessionState> = {}): TodoSessionState {
|
|
18
|
+
const s = createTodoSessionState();
|
|
19
|
+
s.todos = todos;
|
|
20
|
+
Object.assign(s, overrides);
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function todoEntry(todos: Todo[], nextId: number) {
|
|
25
|
+
return {
|
|
26
|
+
type: "message",
|
|
27
|
+
message: { role: "toolResult", toolName: "todo", details: { todos, nextId } },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeCtx(entries: unknown[]) {
|
|
32
|
+
return {
|
|
33
|
+
sessionManager: { getEntries: () => entries },
|
|
34
|
+
} as unknown as Parameters<typeof reconstructState>[1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── completion steer ────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("handleCompletionSteer", () => {
|
|
40
|
+
it("sets one-shot steer when all completed", () => {
|
|
41
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }]);
|
|
42
|
+
expect(handleCompletionSteer(s)).toBe(true);
|
|
43
|
+
expect(s.completionSteered).toBe(true);
|
|
44
|
+
expect(s.pendingSteerMessage).toContain("交付质量");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not steer twice (single-shot lock)", () => {
|
|
48
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], { completionSteered: true });
|
|
49
|
+
expect(handleCompletionSteer(s)).toBe(false);
|
|
50
|
+
expect(s.pendingSteerMessage).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not steer when not all completed", () => {
|
|
54
|
+
const s = makeState([
|
|
55
|
+
{ id: 1, text: "a", status: "completed" },
|
|
56
|
+
{ id: 2, text: "b", status: "pending" },
|
|
57
|
+
]);
|
|
58
|
+
expect(handleCompletionSteer(s)).toBe(false);
|
|
59
|
+
expect(s.completionSteered).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not steer on empty list", () => {
|
|
63
|
+
expect(handleCompletionSteer(makeState([]))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── auto-clear ──────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe("handleAutoClear", () => {
|
|
70
|
+
it("does not handle when not all completed, and resets anchor", () => {
|
|
71
|
+
const s = makeState([{ id: 1, text: "a", status: "pending" }], { allCompletedAtCount: 3 });
|
|
72
|
+
expect(handleAutoClear(s)).toEqual({ handled: false, cleared: false });
|
|
73
|
+
expect(s.allCompletedAtCount).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("anchors on first all-completed round without clearing", () => {
|
|
77
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
|
|
78
|
+
expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
|
|
79
|
+
expect(s.allCompletedAtCount).toBe(5);
|
|
80
|
+
expect(s.todos).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not clear before AUTO_CLEAR_DELAY_ROUNDS (2) elapse", () => {
|
|
84
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], {
|
|
85
|
+
userMessageCount: 5, allCompletedAtCount: 4,
|
|
86
|
+
});
|
|
87
|
+
expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("clears and resets flags after delay elapses", () => {
|
|
91
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], {
|
|
92
|
+
userMessageCount: 6, allCompletedAtCount: 4, completionSteered: true,
|
|
93
|
+
});
|
|
94
|
+
expect(handleAutoClear(s)).toEqual({ handled: true, cleared: true });
|
|
95
|
+
expect(s.todos).toEqual([]);
|
|
96
|
+
expect(s.nextId).toBe(1);
|
|
97
|
+
expect(s.allCompletedAtCount).toBeNull();
|
|
98
|
+
expect(s.completionSteered).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── stall detection ─────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("handleStallDetection", () => {
|
|
105
|
+
it("fires once when idle exceeds STALL_THRESHOLD (5)", () => {
|
|
106
|
+
const s = makeState([{ id: 1, text: "task", status: "pending" }], {
|
|
107
|
+
userMessageCount: 10, lastTodoCallCount: 5,
|
|
108
|
+
});
|
|
109
|
+
expect(handleStallDetection(s)).toBe(true);
|
|
110
|
+
expect(s.stallNotified).toBe(true);
|
|
111
|
+
expect(s.pendingSteerMessage).toContain("#1");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not fire twice (single-shot lock)", () => {
|
|
115
|
+
const s = makeState([{ id: 1, text: "task", status: "pending" }], {
|
|
116
|
+
userMessageCount: 10, lastTodoCallCount: 5, stallNotified: true,
|
|
117
|
+
});
|
|
118
|
+
expect(handleStallDetection(s)).toBe(false);
|
|
119
|
+
expect(s.pendingSteerMessage).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not fire below threshold", () => {
|
|
123
|
+
const s = makeState([{ id: 1, text: "task", status: "pending" }], {
|
|
124
|
+
userMessageCount: 8, lastTodoCallCount: 5,
|
|
125
|
+
});
|
|
126
|
+
expect(handleStallDetection(s)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── reminder ────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe("handleReminder", () => {
|
|
133
|
+
it("fires when idle exceeds REMINDER_INTERVAL (2)", () => {
|
|
134
|
+
const s = makeState([{ id: 1, text: "task", status: "pending" }], {
|
|
135
|
+
userMessageCount: 5, lastTodoCallCount: 3,
|
|
136
|
+
});
|
|
137
|
+
expect(handleReminder(s)).toBe(true);
|
|
138
|
+
expect(s.pendingSteerMessage).toContain("#1");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not fire within interval", () => {
|
|
142
|
+
const s = makeState([{ id: 1, text: "task", status: "pending" }], {
|
|
143
|
+
userMessageCount: 4, lastTodoCallCount: 3,
|
|
144
|
+
});
|
|
145
|
+
expect(handleReminder(s)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── reminder / context builders ─────────────────────
|
|
150
|
+
|
|
151
|
+
describe("buildMinimalReminder", () => {
|
|
152
|
+
it("mentions the next pending task", () => {
|
|
153
|
+
const s = makeState([
|
|
154
|
+
{ id: 1, text: "done", status: "completed" },
|
|
155
|
+
{ id: 2, text: "next", status: "pending" },
|
|
156
|
+
]);
|
|
157
|
+
expect(buildMinimalReminder(s)).toContain("#2 next");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns empty string when no pending", () => {
|
|
161
|
+
expect(buildMinimalReminder(makeState([{ id: 1, text: "done", status: "completed" }]))).toBe("");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("buildBeforeAgentStartMessage", () => {
|
|
166
|
+
it("injects hidden context for pending tasks only", () => {
|
|
167
|
+
const s = makeState([
|
|
168
|
+
{ id: 1, text: "a", status: "pending" },
|
|
169
|
+
{ id: 2, text: "b", status: "completed" },
|
|
170
|
+
]);
|
|
171
|
+
const m = buildBeforeAgentStartMessage(s);
|
|
172
|
+
expect(m).toBeDefined();
|
|
173
|
+
expect(m!.message.display).toBe(false);
|
|
174
|
+
expect(m!.message.customType).toBe("todo-context");
|
|
175
|
+
expect(m!.message.content).toContain("#1: a");
|
|
176
|
+
expect(m!.message.content).not.toContain("#2");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns undefined when list empty", () => {
|
|
180
|
+
expect(buildBeforeAgentStartMessage(makeState([]))).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns undefined when all completed", () => {
|
|
184
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }]);
|
|
185
|
+
expect(buildBeforeAgentStartMessage(s)).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── reconstructState ────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe("reconstructState", () => {
|
|
192
|
+
it("leaves state empty when no todo entry", () => {
|
|
193
|
+
const entries = [{ type: "message", message: { role: "user", content: "hi" } }];
|
|
194
|
+
const s = createTodoSessionState();
|
|
195
|
+
reconstructState(s, makeCtx(entries));
|
|
196
|
+
expect(s.todos).toEqual([]);
|
|
197
|
+
expect(s.nextId).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("replays the latest todo entry snapshot", () => {
|
|
201
|
+
const entries = [todoEntry([{ id: 1, text: "a", status: "pending" }], 2)];
|
|
202
|
+
const s = createTodoSessionState();
|
|
203
|
+
reconstructState(s, makeCtx(entries));
|
|
204
|
+
expect(s.todos).toHaveLength(1);
|
|
205
|
+
expect(s.todos[0].text).toBe("a");
|
|
206
|
+
expect(s.nextId).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses the last todo entry and GCs older ones (splice from tail)", () => {
|
|
210
|
+
const entries = [
|
|
211
|
+
todoEntry([{ id: 1, text: "old", status: "pending" }], 2),
|
|
212
|
+
todoEntry([{ id: 5, text: "new", status: "completed" }], 6),
|
|
213
|
+
];
|
|
214
|
+
const s = createTodoSessionState();
|
|
215
|
+
reconstructState(s, makeCtx(entries));
|
|
216
|
+
expect(s.todos[0].id).toBe(5);
|
|
217
|
+
expect(s.nextId).toBe(6);
|
|
218
|
+
expect(entries).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("migrates legacy status on replay", () => {
|
|
222
|
+
const legacy = [{ id: 1, text: "a", status: "failed" }] as unknown as Todo[];
|
|
223
|
+
const entries = [todoEntry(legacy, 2)];
|
|
224
|
+
const s = createTodoSessionState();
|
|
225
|
+
reconstructState(s, makeCtx(entries));
|
|
226
|
+
expect(s.todos[0].status).toBe("pending");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── agent_end integration (短路顺序) ────────────────
|
|
231
|
+
|
|
232
|
+
describe("agent_end short-circuit order", () => {
|
|
233
|
+
it("completion steer fires before auto-clear (completion does not short-circuit)", () => {
|
|
234
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
|
|
235
|
+
// 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear(短路)
|
|
236
|
+
expect(handleCompletionSteer(s)).toBe(true);
|
|
237
|
+
expect(s.pendingSteerMessage).toContain("交付质量");
|
|
238
|
+
expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
|
|
239
|
+
expect(s.allCompletedAtCount).toBe(5);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("after delay, auto-clear clears but the one-shot steer stays queued", () => {
|
|
243
|
+
// 竞态点:completion steer 早已置位,auto-clear 现在清空 todos
|
|
244
|
+
const s = makeState([{ id: 1, text: "a", status: "completed" }], {
|
|
245
|
+
userMessageCount: 7, allCompletedAtCount: 5,
|
|
246
|
+
completionSteered: true, pendingSteerMessage: "<queued>",
|
|
247
|
+
});
|
|
248
|
+
expect(handleCompletionSteer(s)).toBe(false); // 已 steered,不重复
|
|
249
|
+
expect(handleAutoClear(s).cleared).toBe(true);
|
|
250
|
+
expect(s.todos).toEqual([]);
|
|
251
|
+
// pendingSteerMessage 仍保留,由下一 turn before_agent_start 消费(此时 todos 已空)
|
|
252
|
+
expect(s.pendingSteerMessage).toBe("<queued>");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
addTodos,
|
|
6
|
-
buildRender,
|
|
7
6
|
formatTodoLine,
|
|
8
7
|
migrateTodo,
|
|
9
8
|
type Todo,
|
|
@@ -201,15 +200,15 @@ describe("handleSingleUpdate FR-6 guards (tool single path)", () => {
|
|
|
201
200
|
it("FR-6: cancelled todo + status → cannot restore", () => {
|
|
202
201
|
const state = createTodoSessionState();
|
|
203
202
|
state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
expect(() => handleSingleUpdate(state, { action: "update", id: 1, status: "pending" }))
|
|
204
|
+
.toThrow("#1 is cancelled (cannot restore)");
|
|
206
205
|
});
|
|
207
206
|
|
|
208
207
|
it("FR-6: verification todo + status=cancelled → cannot cancel", () => {
|
|
209
208
|
const state = createTodoSessionState();
|
|
210
209
|
state.todos = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
expect(() => handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" }))
|
|
211
|
+
.toThrow("#2 is verification todo (cannot cancel)");
|
|
213
212
|
});
|
|
214
213
|
});
|
|
215
214
|
|
|
@@ -269,27 +268,6 @@ describe("formatTodoLine", () => {
|
|
|
269
268
|
});
|
|
270
269
|
});
|
|
271
270
|
|
|
272
|
-
// ── buildRender ─────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
describe("buildRender", () => {
|
|
275
|
-
it("should calculate summary correctly", () => {
|
|
276
|
-
const todos: Todo[] = [
|
|
277
|
-
{ id: 1, text: "a", status: "completed" },
|
|
278
|
-
{ id: 2, text: "b", status: "pending" },
|
|
279
|
-
{ id: 3, text: "c", status: "in_progress" },
|
|
280
|
-
];
|
|
281
|
-
const render = buildRender(todos);
|
|
282
|
-
expect(render).toBeDefined();
|
|
283
|
-
expect(render!.summary).toBe("1/3 completed");
|
|
284
|
-
expect(render!.data.items).toHaveLength(3);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("should handle empty list", () => {
|
|
288
|
-
const render = buildRender([]);
|
|
289
|
-
expect(render!.summary).toBe("0/0 completed");
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
|
|
293
271
|
// ── widget 渲染布局 ────────────────────────────────
|
|
294
272
|
|
|
295
273
|
const mockTheme = {
|