@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
package/.next/trace-build
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"name":"run-webpack","duration":
|
|
1
|
+
[{"name":"run-webpack","duration":11305789,"timestamp":133422986049,"id":14,"parentId":1,"tags":{},"startTime":1789257371342,"traceId":"07fc277e914bb46c"},{"name":"run-typescript","duration":3037260,"timestamp":133434293349,"id":769,"parentId":1,"tags":{},"startTime":1789257382649,"traceId":"07fc277e914bb46c"},{"name":"static-check","duration":2360372,"timestamp":133437344563,"id":772,"parentId":1,"tags":{},"startTime":1789257385700,"traceId":"07fc277e914bb46c"},{"name":"static-generation","duration":3586432,"timestamp":133439883416,"id":916,"parentId":1,"tags":{},"startTime":1789257388239,"traceId":"07fc277e914bb46c"},{"name":"collect-build-traces","duration":23722920,"timestamp":133439707049,"id":913,"parentId":1,"tags":{},"startTime":1789257388063,"traceId":"07fc277e914bb46c"},{"name":"telemetry-flush","duration":36,"timestamp":133463432717,"id":925,"parentId":1,"tags":{},"startTime":1789257411789,"traceId":"07fc277e914bb46c"},{"name":"next-build","duration":40586333,"timestamp":133422846426,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789257371202,"traceId":"07fc277e914bb46c"}]
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ pi-web-sky
|
|
|
68
68
|
|
|
69
69
|
- **消息钉**:把任意消息钉成置顶浮窗,全局拖拽、自由缩放,关键上下文边干活边看。
|
|
70
70
|
- **编辑回滚**:任意用户消息(首条、已被自动压缩的、连续多条)都可编辑回滚,语义与官方一致。
|
|
71
|
-
-
|
|
71
|
+
- **内置 todo**:会话自带的 `todo` 工具(建/改/删三态任务清单)跟踪多步工作,顶栏右上角面板实时显示进度,全部完成后再过两轮自动清空——装好即用,无需再装 todo 插件。
|
|
72
72
|
- **信息工具条**:模型名/字数/统计/复制/时间下沉到气泡外底部,长文阅读不被头部遮挡。
|
|
73
73
|
- **瞬时通知**:扩展/错误的提示就地显示、自动消退,运行状态在输入框顶栏播报。
|
|
74
74
|
|
|
@@ -90,7 +90,7 @@ npm run lint
|
|
|
90
90
|
npm test # 单元测试
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
`npm run dev` 与 `next build` 共用 `.next`,但互不干扰,可在开发中直接构建。
|
|
94
94
|
|
|
95
95
|
## 维护
|
|
96
96
|
|
package/lib/i18n/messages/en.ts
CHANGED
|
@@ -20,7 +20,6 @@ export const enLocale: LocalePlugin = {
|
|
|
20
20
|
"history.unsaved": "History is available after the session is saved",
|
|
21
21
|
"history.label": "History",
|
|
22
22
|
"nav.stats": "Stats",
|
|
23
|
-
"nav.todo": "TODO",
|
|
24
23
|
"title.generating": "Generating...",
|
|
25
24
|
"title.updated": "Title updated",
|
|
26
25
|
"title.failed": "Generation failed",
|
package/lib/rpc-manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createProjectCommandBashOperations,
|
|
13
13
|
preferUserBashExtension,
|
|
14
14
|
} from "./project-command-env";
|
|
15
|
+
import { createTodoExtension } from "./todo-extension";
|
|
15
16
|
import { cacheSessionPath, invalidateSessionListCache } from "./session-reader";
|
|
16
17
|
import { projectIdentityKey } from "./project-identity";
|
|
17
18
|
import { resolveProject } from "./worktree";
|
|
@@ -1679,6 +1680,9 @@ export async function startRpcSession(
|
|
|
1679
1680
|
cwd: sessionCwd,
|
|
1680
1681
|
settings: settingsManager,
|
|
1681
1682
|
}),
|
|
1683
|
+
// 内建会话 TODO(工具 + pi-todo.state 快照),装 pi-web-sky 即自带,
|
|
1684
|
+
// 无需用户安装任何 pi 包。工具重名时用户扩展优先(见 pi-todo 文档)。
|
|
1685
|
+
createTodoExtension(),
|
|
1682
1686
|
],
|
|
1683
1687
|
extensionsOverride: preferUserBashExtension,
|
|
1684
1688
|
},
|
package/lib/session-reader.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
import { closeSync, existsSync, openSync, readSync } from "fs";
|
|
8
8
|
import { readdir } from "fs/promises";
|
|
9
9
|
import { join as joinPath, normalize as normalizePath } from "path";
|
|
10
|
-
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext
|
|
10
|
+
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext } from "./types";
|
|
11
|
+
import { parseTodoSnapshot, TODO_STATE_CUSTOM_TYPE, type Todo } from "./todo-store";
|
|
11
12
|
import type { SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { getDb } from "./sqlite-db";
|
|
13
14
|
import { normalizeToolCalls } from "./normalize";
|
|
@@ -529,18 +530,20 @@ export function getSessionEntries(filePath: string): SessionEntry[] {
|
|
|
529
530
|
return entries as unknown as SessionEntry[];
|
|
530
531
|
}
|
|
531
532
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
533
|
+
/**
|
|
534
|
+
* 取活动分支上最后一条 `pi-todo.state` 快照。解析规则完全交给
|
|
535
|
+
* `todo-store.parseTodoSnapshot`(与扩展共用一套校验:id/content/status 全合法才收),
|
|
536
|
+
* 否则面板与工具会对同一份脏数据给出不同答案。
|
|
537
|
+
*
|
|
538
|
+
* 调用方必须传入**活动分支**(`sliceActiveBranch` / `getBranch`)而不是全量 entries:
|
|
539
|
+
* 全量里混着别的分支(fork / 旧分支)的快照,面板会显示不属于当前会话的 todo。
|
|
540
|
+
*/
|
|
541
|
+
export function extractTodosFromEntries(entries: SessionEntry[]): Todo[] {
|
|
542
|
+
let todos: Todo[] = [];
|
|
537
543
|
for (const entry of entries) {
|
|
538
|
-
if (entry.type !== "custom" || entry.customType !==
|
|
539
|
-
const
|
|
540
|
-
if (
|
|
541
|
-
todos = data.todos.filter((t: unknown): t is TodoItem => (
|
|
542
|
-
isRecord(t) && typeof t.content === "string"
|
|
543
|
-
));
|
|
544
|
+
if (entry.type !== "custom" || entry.customType !== TODO_STATE_CUSTOM_TYPE) continue;
|
|
545
|
+
const parsed = parseTodoSnapshot((entry as { data?: unknown }).data);
|
|
546
|
+
if (parsed) todos = parsed.todos;
|
|
544
547
|
}
|
|
545
548
|
return todos;
|
|
546
549
|
}
|
|
@@ -583,8 +586,14 @@ export function buildSessionContext(
|
|
|
583
586
|
}
|
|
584
587
|
}
|
|
585
588
|
|
|
586
|
-
// Collect the latest pi-todo.state snapshot from the active branch
|
|
587
|
-
|
|
589
|
+
// Collect the latest pi-todo.state snapshot from the **whole active branch** —
|
|
590
|
+
// not from compaction-truncated contextEntries: compaction trims the LLM
|
|
591
|
+
// context, not the UI's data source. Reading the truncated list made the
|
|
592
|
+
// top-bar todo panel go empty mid-task while /todos (which walks getEntries)
|
|
593
|
+
// still showed items — two sources disagreeing.
|
|
594
|
+
const todos = extractTodosFromEntries(
|
|
595
|
+
sliceActiveBranch(entries, leafId ?? null, entries.length) as unknown as SessionEntry[],
|
|
596
|
+
);
|
|
588
597
|
|
|
589
598
|
return {
|
|
590
599
|
messages,
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
const jiti = createJiti(import.meta.url);
|
|
8
|
+
const {
|
|
9
|
+
AUTO_CLEAR_DELAY_ROUNDS,
|
|
10
|
+
REMINDER_INTERVAL,
|
|
11
|
+
buildReminder,
|
|
12
|
+
createReminderState,
|
|
13
|
+
createTodoExtension,
|
|
14
|
+
noteToolResult,
|
|
15
|
+
onTurnStart,
|
|
16
|
+
shouldAutoClear,
|
|
17
|
+
takeReminderSlot,
|
|
18
|
+
turnsSinceLastTodoUse,
|
|
19
|
+
} = await jiti.import("./todo-extension.ts");
|
|
20
|
+
const { TODO_STATE_CUSTOM_TYPE } = await jiti.import("./todo-store.ts");
|
|
21
|
+
|
|
22
|
+
const EXTENSION_SOURCE = readFileSync(
|
|
23
|
+
fileURLToPath(new URL("./todo-extension.ts", import.meta.url)),
|
|
24
|
+
"utf8",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// ── 测试替身 ─────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function stateEntry(data) {
|
|
30
|
+
return { type: "custom", customType: TODO_STATE_CUSTOM_TYPE, data };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function userEntry() {
|
|
34
|
+
return { type: "message", message: { role: "user" } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assistantEntry() {
|
|
38
|
+
return { type: "message", message: { role: "assistant", content: [] } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 装配一个内联扩展实例。
|
|
43
|
+
* 分支数组在 pi 与 ctx 之间共享:`appendEntry` 真的往分支追加 entry(与真实
|
|
44
|
+
* sessionManager 一致)—— auto-clear 与回放都从分支推导,替身必须照做。
|
|
45
|
+
*/
|
|
46
|
+
function mount(branch = []) {
|
|
47
|
+
const tools = new Map();
|
|
48
|
+
const handlers = new Map();
|
|
49
|
+
const appended = [];
|
|
50
|
+
const pi = {
|
|
51
|
+
registerTool: (definition) => tools.set(definition.name, definition),
|
|
52
|
+
on: (event, handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
|
|
53
|
+
appendEntry: (customType, data) => {
|
|
54
|
+
appended.push({ customType, data });
|
|
55
|
+
branch.push(stateEntry(data));
|
|
56
|
+
},
|
|
57
|
+
tools,
|
|
58
|
+
appended,
|
|
59
|
+
/** 往分支末尾追加 `count` 条用户消息(= 又过了 count 轮)。 */
|
|
60
|
+
rounds: (count) => {
|
|
61
|
+
for (let i = 0; i < count; i++) branch.push(userEntry());
|
|
62
|
+
},
|
|
63
|
+
/** 往分支末尾追加 `count` 个模型回合(= 又跑了 count 次模型往返)。 */
|
|
64
|
+
turns: (count) => {
|
|
65
|
+
for (let i = 0; i < count; i++) branch.push(assistantEntry());
|
|
66
|
+
},
|
|
67
|
+
async emit(event, ctx, payload = {}) {
|
|
68
|
+
const results = [];
|
|
69
|
+
for (const handler of handlers.get(event) ?? []) {
|
|
70
|
+
results.push(await handler({ type: event, ...payload }, ctx));
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
createTodoExtension().factory(pi);
|
|
77
|
+
const ctx = { sessionManager: { getBranch: () => branch } };
|
|
78
|
+
const tool = pi.tools.get("todo");
|
|
79
|
+
const call = (params) => tool.execute("call-1", params, undefined, undefined, ctx);
|
|
80
|
+
return { pi, ctx, tool, call, branch };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lastSnapshot = (pi) => pi.appended[pi.appended.length - 1]?.data;
|
|
84
|
+
|
|
85
|
+
// ── 扩展装配 ─────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
test("the inline extension is hidden and registers the todo tool", () => {
|
|
88
|
+
const extension = createTodoExtension();
|
|
89
|
+
assert.equal(extension.name, "pi-web-todo");
|
|
90
|
+
assert.equal(extension.hidden, true);
|
|
91
|
+
|
|
92
|
+
const { tool } = mount();
|
|
93
|
+
assert.ok(tool, "todo tool registered");
|
|
94
|
+
assert.equal(tool.name, "todo");
|
|
95
|
+
assert.equal(tool.executionMode, "sequential");
|
|
96
|
+
assert.equal(tool.parameters.type, "object");
|
|
97
|
+
// 顶层 union 会被严格 OpenAI 兼容网关 400 —— schema 顶层必须是 object
|
|
98
|
+
assert.equal(tool.parameters.additionalProperties, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("mutations return the full list in details and persist a pi-todo.state snapshot", async () => {
|
|
102
|
+
const { pi, call } = mount();
|
|
103
|
+
|
|
104
|
+
const added = await call({ action: "add", texts: ["first", "second"] });
|
|
105
|
+
assert.equal(added.details.action, "add");
|
|
106
|
+
assert.deepEqual(added.details.todos.map((t) => t.content), ["first", "second"]);
|
|
107
|
+
assert.equal(added.details.nextId, 3);
|
|
108
|
+
assert.match(added.content[0].text, /\[\s\] #1: first/);
|
|
109
|
+
assert.deepEqual(pi.appended[0], {
|
|
110
|
+
customType: TODO_STATE_CUSTOM_TYPE,
|
|
111
|
+
data: { todos: added.details.todos, nextId: 3 },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const updated = await call({ action: "update", id: 1, status: "in_progress" });
|
|
115
|
+
assert.equal(updated.details.todos[0].status, "in_progress");
|
|
116
|
+
assert.equal(lastSnapshot(pi).todos[0].status, "in_progress");
|
|
117
|
+
|
|
118
|
+
const deleted = await call({ action: "delete", ids: [2] });
|
|
119
|
+
assert.deepEqual(deleted.details.todos.map((t) => t.id), [1]);
|
|
120
|
+
|
|
121
|
+
// list 不变更 → 不落盘
|
|
122
|
+
const appendedBefore = pi.appended.length;
|
|
123
|
+
const listed = await call({ action: "list" });
|
|
124
|
+
assert.equal(pi.appended.length, appendedBefore);
|
|
125
|
+
assert.equal(listed.content[0].text, "[~] #1: first");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("handler failures throw and leave state untouched", async () => {
|
|
129
|
+
const { pi, call } = mount();
|
|
130
|
+
await call({ action: "add", texts: ["a"] });
|
|
131
|
+
|
|
132
|
+
await assert.rejects(() => call({ action: "add", text: "singular" }), /You passed singular "text"/);
|
|
133
|
+
await assert.rejects(() => call({ action: "update", id: 99, status: "completed" }), /Todo #99 not found/);
|
|
134
|
+
await assert.rejects(() => call({ action: "delete", ids: [1, 99] }), /Todo #99 not found/);
|
|
135
|
+
|
|
136
|
+
assert.equal(pi.appended.length, 1, "失败不落盘");
|
|
137
|
+
assert.equal((await call({ action: "list" })).content[0].text, "[ ] #1: a");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("session_start replays the last snapshot from the current branch", async () => {
|
|
141
|
+
const branch = [
|
|
142
|
+
stateEntry({ todos: [{ id: 1, content: "old", status: "completed" }], nextId: 2 }),
|
|
143
|
+
{ type: "message", message: { role: "user", content: "hi" } },
|
|
144
|
+
stateEntry({ todos: [{ id: 7, content: "latest", status: "in_progress" }], nextId: 8 }),
|
|
145
|
+
];
|
|
146
|
+
const { pi, ctx, call } = mount(branch);
|
|
147
|
+
await pi.emit("session_start", ctx);
|
|
148
|
+
|
|
149
|
+
const listed = await call({ action: "list" });
|
|
150
|
+
assert.equal(listed.content[0].text, "[~] #7: latest");
|
|
151
|
+
assert.equal(listed.details.nextId, 8);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("session_tree rebuilds from the (switched) branch and drops stale state", async () => {
|
|
155
|
+
const { pi, ctx, call } = mount();
|
|
156
|
+
await call({ action: "add", texts: ["a", "b"] });
|
|
157
|
+
|
|
158
|
+
// 切到不含快照的分支 → 状态清空
|
|
159
|
+
ctx.sessionManager.getBranch = () => [];
|
|
160
|
+
await pi.emit("session_tree", ctx);
|
|
161
|
+
assert.deepEqual((await call({ action: "list" })).details.todos, []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("auto-clear wipes the list after the configured number of rounds and persists the empty snapshot", async () => {
|
|
165
|
+
const { pi, ctx, call } = mount();
|
|
166
|
+
await call({ action: "add", texts: ["a", "b"] });
|
|
167
|
+
await call({ action: "update", updates: [{ id: 1, status: "completed" }, { id: 2, status: "completed" }] });
|
|
168
|
+
|
|
169
|
+
const appendedBefore = pi.appended.length;
|
|
170
|
+
await pi.emit("agent_end", ctx);
|
|
171
|
+
assert.equal((await call({ action: "list" })).details.todos.length, 2, "同轮不清空");
|
|
172
|
+
|
|
173
|
+
pi.rounds(AUTO_CLEAR_DELAY_ROUNDS - 1);
|
|
174
|
+
await pi.emit("agent_end", ctx);
|
|
175
|
+
assert.equal((await call({ action: "list" })).details.todos.length, 2, `${AUTO_CLEAR_DELAY_ROUNDS} 轮未到不清空`);
|
|
176
|
+
|
|
177
|
+
pi.rounds(1);
|
|
178
|
+
await pi.emit("agent_end", ctx);
|
|
179
|
+
assert.deepEqual((await call({ action: "list" })).details.todos, [], "自动清空");
|
|
180
|
+
assert.deepEqual(lastSnapshot(pi), { todos: [], nextId: 1 });
|
|
181
|
+
assert.ok(pi.appended.length > appendedBefore, "清空必须落盘,否则顶栏面板残留旧列表");
|
|
182
|
+
|
|
183
|
+
// 清空后重新 add,id 从 1 重新开始
|
|
184
|
+
assert.equal((await call({ action: "add", texts: ["fresh"] })).details.todos[0].id, 1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("auto-clear never fires while work is open", async () => {
|
|
188
|
+
const { pi, ctx, call } = mount();
|
|
189
|
+
await call({ action: "add", texts: ["a"] });
|
|
190
|
+
|
|
191
|
+
pi.rounds(AUTO_CLEAR_DELAY_ROUNDS + 3);
|
|
192
|
+
await pi.emit("agent_end", ctx);
|
|
193
|
+
assert.equal((await call({ action: "list" })).details.todos.length, 1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// 回归:会话 wrapper 空闲 10 分钟即销毁(rpc-manager 的 idle timer),
|
|
197
|
+
// 轮数必须能从持久的分支重算 —— 旧版记内存计数,间隔 >10 分钟的交互永远数不满。
|
|
198
|
+
test("auto-clear still works when the session was recreated between rounds", async () => {
|
|
199
|
+
const first = mount();
|
|
200
|
+
await first.call({ action: "add", texts: ["a"] });
|
|
201
|
+
await first.call({ action: "update", id: 1, status: "completed" });
|
|
202
|
+
const persisted = first.branch.slice(); // 模拟落盘后进程结束
|
|
203
|
+
|
|
204
|
+
// 新进程:从分支回放(此刻快照仍是「全部完成」,其后 1 轮)
|
|
205
|
+
const second = mount([...persisted, userEntry()]);
|
|
206
|
+
await second.pi.emit("session_start", second.ctx);
|
|
207
|
+
assert.equal((await second.call({ action: "list" })).details.todos.length, 1, "回放后可读");
|
|
208
|
+
|
|
209
|
+
second.pi.rounds(1); // 第 2 轮
|
|
210
|
+
await second.pi.emit("agent_end", second.ctx);
|
|
211
|
+
assert.deepEqual((await second.call({ action: "list" })).details.todos, [], "跨会话重建仍能清空");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── cadence 纯函数 ───────────────────────────────────
|
|
215
|
+
|
|
216
|
+
test("turnsSinceLastTodoUse counts model turns after the last snapshot", () => {
|
|
217
|
+
assert.equal(turnsSinceLastTodoUse([]), 0);
|
|
218
|
+
assert.equal(turnsSinceLastTodoUse([assistantEntry()]), 1);
|
|
219
|
+
// 快照之后才起算;快照之前的历史回合不算
|
|
220
|
+
assert.equal(turnsSinceLastTodoUse([assistantEntry(), assistantEntry(), stateEntry({ todos: [] })]), 0);
|
|
221
|
+
assert.equal(
|
|
222
|
+
turnsSinceLastTodoUse([stateEntry({ todos: [] }), assistantEntry(), assistantEntry(), assistantEntry()]),
|
|
223
|
+
3,
|
|
224
|
+
);
|
|
225
|
+
// 又改过 todo → 重新起算
|
|
226
|
+
assert.equal(
|
|
227
|
+
turnsSinceLastTodoUse([stateEntry({ todos: [] }), assistantEntry(), stateEntry({ todos: [] }), assistantEntry()]),
|
|
228
|
+
1,
|
|
229
|
+
);
|
|
230
|
+
// user 消息不是回合(一次提问里可能跑十几个回合)
|
|
231
|
+
assert.equal(turnsSinceLastTodoUse([stateEntry({ todos: [] }), userEntry(), userEntry()]), 0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// 回归:扩展闭包里的计数器活不过 AgentSession 的 10 分钟空闲回收,
|
|
235
|
+
// 「隔几分钟问一句」永远数不到 4 轮 → 提醒等于没有(auto-clear 踩过同一个坑)。
|
|
236
|
+
test("reconstruct seeds the cadence from the branch so the reminder survives a recreated session", async () => {
|
|
237
|
+
const persisted = [
|
|
238
|
+
stateEntry({ todos: [{ id: 1, content: "stale", status: "in_progress" }], nextId: 2 }),
|
|
239
|
+
assistantEntry(),
|
|
240
|
+
assistantEntry(),
|
|
241
|
+
assistantEntry(),
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
// 新进程:session_start 回放,并把「距上次动 todo 的回合数」续算上
|
|
245
|
+
const { pi, ctx } = mount([...persisted]);
|
|
246
|
+
await pi.emit("session_start", ctx);
|
|
247
|
+
|
|
248
|
+
// 下一个回合即到期(3 个已过去 + 本回合 = REMINDER_INTERVAL)
|
|
249
|
+
await pi.emit("turn_start", ctx, { turnIndex: 0 });
|
|
250
|
+
const [injected] = await pi.emit("context", ctx, { messages: [{ role: "user", content: "hi" }] });
|
|
251
|
+
assert.match(injected.messages[1].content[0].text, /<system-reminder>/);
|
|
252
|
+
|
|
253
|
+
// 刚改过 todo 的分支 → 不续算,静默
|
|
254
|
+
const fresh = mount([
|
|
255
|
+
stateEntry({ todos: [{ id: 1, content: "doing", status: "in_progress" }], nextId: 2 }),
|
|
256
|
+
assistantEntry(),
|
|
257
|
+
]);
|
|
258
|
+
await fresh.pi.emit("session_start", fresh.ctx);
|
|
259
|
+
await fresh.pi.emit("turn_start", fresh.ctx, { turnIndex: 0 });
|
|
260
|
+
const [silent] = await fresh.pi.emit("context", fresh.ctx, { messages: [] });
|
|
261
|
+
assert.equal(silent, undefined, "刚动过 todo → 不提醒");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("cadence fires only after N quiet turns while work is open", () => {
|
|
265
|
+
const state = createReminderState();
|
|
266
|
+
for (let i = 0; i < REMINDER_INTERVAL - 1; i++) {
|
|
267
|
+
onTurnStart(state);
|
|
268
|
+
assert.equal(takeReminderSlot(state, true), false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
onTurnStart(state);
|
|
272
|
+
assert.equal(takeReminderSlot(state, true), true, `第 ${REMINDER_INTERVAL} 轮触发`);
|
|
273
|
+
// 取走配额后计时器推到当前轮 → 下一轮不再触发
|
|
274
|
+
onTurnStart(state);
|
|
275
|
+
assert.equal(takeReminderSlot(state, true), false, "同周期只注入一次");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// 纯文本轮没有 tool_result:求值点必须在 context,否则提醒永不触发
|
|
279
|
+
// (探针实测:旧版只在 tool_result 里 arm → 模型连续 5 轮不调工具时零注入)
|
|
280
|
+
test("cadence fires even when the model calls no tools at all", () => {
|
|
281
|
+
const state = createReminderState();
|
|
282
|
+
for (let i = 0; i < REMINDER_INTERVAL; i++) onTurnStart(state);
|
|
283
|
+
assert.equal(takeReminderSlot(state, true), true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("using the todo tool resets cadence", () => {
|
|
287
|
+
const state = createReminderState();
|
|
288
|
+
for (let i = 0; i < REMINDER_INTERVAL + 2; i++) onTurnStart(state);
|
|
289
|
+
|
|
290
|
+
noteToolResult(state, "todo");
|
|
291
|
+
assert.equal(takeReminderSlot(state, true), false, "刚用过 todo 工具,不提醒");
|
|
292
|
+
|
|
293
|
+
onTurnStart(state);
|
|
294
|
+
assert.equal(takeReminderSlot(state, true), false);
|
|
295
|
+
// 重置后重新数满 REMINDER_INTERVAL 轮才提醒
|
|
296
|
+
for (let i = 0; i < REMINDER_INTERVAL; i++) onTurnStart(state);
|
|
297
|
+
assert.equal(takeReminderSlot(state, true), true);
|
|
298
|
+
|
|
299
|
+
// 非 todo 工具不重置节奏
|
|
300
|
+
const other = createReminderState();
|
|
301
|
+
for (let i = 0; i < REMINDER_INTERVAL; i++) onTurnStart(other);
|
|
302
|
+
noteToolResult(other, "bash");
|
|
303
|
+
assert.equal(takeReminderSlot(other, true), true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("cadence stays silent when nothing is open", () => {
|
|
307
|
+
const state = createReminderState();
|
|
308
|
+
for (let i = 0; i < REMINDER_INTERVAL + 3; i++) onTurnStart(state);
|
|
309
|
+
assert.equal(takeReminderSlot(state, false), false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("buildReminder lists open items and names the in_progress one", () => {
|
|
313
|
+
assert.equal(buildReminder([]), null);
|
|
314
|
+
assert.equal(buildReminder([{ id: 1, content: "done", status: "completed" }]), null);
|
|
315
|
+
|
|
316
|
+
const text = buildReminder([
|
|
317
|
+
{ id: 1, content: "done", status: "completed" },
|
|
318
|
+
{ id: 2, content: "doing", status: "in_progress" },
|
|
319
|
+
{ id: 3, content: "waiting", status: "pending" },
|
|
320
|
+
]);
|
|
321
|
+
assert.match(text, /^<system-reminder>/);
|
|
322
|
+
assert.doesNotMatch(text, /done/, "已完成的项不进提醒");
|
|
323
|
+
assert.match(text, /\[~\] #2: doing/);
|
|
324
|
+
assert.match(text, /\[ \] #3: waiting/);
|
|
325
|
+
assert.match(text, /#2("doing")仍标记为 in_progress/);
|
|
326
|
+
assert.match(text, /绝不要向用户提及这条提醒/);
|
|
327
|
+
|
|
328
|
+
const noInProgress = buildReminder([{ id: 4, content: "todo", status: "pending" }]);
|
|
329
|
+
assert.match(noInProgress, /没有任何 in_progress/);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("auto-clear counts rounds from the session branch, not from in-memory state", () => {
|
|
333
|
+
const todoState = (todos) => ({ type: "custom", customType: TODO_STATE_CUSTOM_TYPE, data: { todos, nextId: 9 } });
|
|
334
|
+
const user = () => ({ type: "message", message: { role: "user" } });
|
|
335
|
+
const allDone = [{ id: 1, content: "a", status: "completed" }];
|
|
336
|
+
|
|
337
|
+
// 快照之后 0 / 1 轮 → 不清
|
|
338
|
+
assert.equal(shouldAutoClear([todoState(allDone)]), false);
|
|
339
|
+
assert.equal(shouldAutoClear([todoState(allDone), user()]), false);
|
|
340
|
+
|
|
341
|
+
// 达到延迟轮数 → 清
|
|
342
|
+
assert.equal(shouldAutoClear([todoState(allDone), user(), user()]), true);
|
|
343
|
+
|
|
344
|
+
// 有未完成项 → 永不清
|
|
345
|
+
const open = [{ id: 1, content: "a", status: "in_progress" }];
|
|
346
|
+
assert.equal(shouldAutoClear([todoState(open), user(), user(), user(), user()]), false);
|
|
347
|
+
|
|
348
|
+
// 没有快照 / 空快照 → 不清(空列表无需再清)
|
|
349
|
+
assert.equal(shouldAutoClear([user(), user()]), false);
|
|
350
|
+
assert.equal(shouldAutoClear([todoState([]), user(), user()]), false);
|
|
351
|
+
|
|
352
|
+
// 只认最后一条快照:中途被改回未完成 → 不清
|
|
353
|
+
assert.equal(
|
|
354
|
+
shouldAutoClear([todoState(allDone), user(), todoState(open), user(), user()]),
|
|
355
|
+
false,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ── 事件接线 ─────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
test("the context event injects a transient reminder and never a persisted message", async () => {
|
|
362
|
+
const { pi, ctx, call } = mount();
|
|
363
|
+
await call({ action: "add", texts: ["step one"] });
|
|
364
|
+
|
|
365
|
+
// 无工具调用的纯文本轮也要能触发
|
|
366
|
+
for (let i = 0; i < REMINDER_INTERVAL; i++) {
|
|
367
|
+
await pi.emit("turn_start", ctx, { turnIndex: i });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const [injected] = await pi.emit("context", ctx, { messages: [{ role: "user", content: "hi" }] });
|
|
371
|
+
assert.equal(injected.messages.length, 2);
|
|
372
|
+
const reminder = injected.messages[1];
|
|
373
|
+
assert.equal(reminder.role, "user");
|
|
374
|
+
assert.match(reminder.content[0].text, /<system-reminder>/);
|
|
375
|
+
assert.match(reminder.content[0].text, /#1: step one/);
|
|
376
|
+
|
|
377
|
+
// 注入走的是 context 的瞬时 messages —— 不落盘、不产生 custom_message
|
|
378
|
+
const persistedTypes = new Set(pi.appended.map((entry) => entry.customType));
|
|
379
|
+
assert.deepEqual([...persistedTypes], [TODO_STATE_CUSTOM_TYPE]);
|
|
380
|
+
|
|
381
|
+
const [silent] = await pi.emit("context", ctx, { messages: [] });
|
|
382
|
+
assert.equal(silent, undefined, "同周期不重复注入");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ── 源码级约束(原始需求:不要「不停地发扩展消息」)──────────
|
|
386
|
+
|
|
387
|
+
test("the extension never injects persisted messages or steer", () => {
|
|
388
|
+
assert.doesNotMatch(EXTENSION_SOURCE, /pi\.on\("before_agent_start"/);
|
|
389
|
+
assert.doesNotMatch(EXTENSION_SOURCE, /pi\.sendMessage|pi\.sendUserMessage/);
|
|
390
|
+
assert.doesNotMatch(EXTENSION_SOURCE, /appendCustomMessageEntry/);
|
|
391
|
+
assert.match(EXTENSION_SOURCE, /pi\.on\("context"/, "提醒只走 context 瞬时注入");
|
|
392
|
+
});
|