@baique/pi-web-sky 0.1.59 → 0.1.61

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.
Files changed (63) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +7 -7
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/running/route.js +1 -1
  20. package/.next/server/app/api/app-update/route.js +1 -1
  21. package/.next/server/app/api/sessions/route.js +1 -1
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +5 -5
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  25. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  26. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  27. package/.next/server/app/page.js +8 -8
  28. package/.next/server/app/page_client-reference-manifest.js +1 -1
  29. package/.next/server/app-paths-manifest.json +7 -7
  30. package/.next/server/chunks/1937.js +1 -1
  31. package/.next/server/chunks/402.js +8 -8
  32. package/.next/server/chunks/542.js +1 -1
  33. package/.next/server/chunks/6429.js +1 -1
  34. package/.next/server/chunks/6525.js +4 -4
  35. package/.next/server/chunks/6583.js +1 -1
  36. package/.next/server/middleware-build-manifest.js +1 -1
  37. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  38. package/.next/server/pages/404.html +1 -1
  39. package/.next/server/pages/500.html +1 -1
  40. package/.next/static/chunks/{8088.39866dc5f1d15868.js → 8088.a8a689f302a4f56d.js} +1 -1
  41. package/.next/static/chunks/app/{layout-15b8d7ddfb49a19d.js → layout-58dd6111497e161d.js} +1 -1
  42. package/.next/static/chunks/app/page-9bbf25bd34fa91f1.js +256 -0
  43. package/.next/static/chunks/{webpack-1ce35e1249cfd931.js → webpack-4b8e9412e72f86c0.js} +1 -1
  44. package/.next/static/css/21bd3ff21f2866ac.css +1 -0
  45. package/.next/trace +7 -8
  46. package/.next/trace-build +1 -1
  47. package/lib/board-types.ts +6 -0
  48. package/lib/rpc-manager.title.test.mjs +128 -0
  49. package/lib/rpc-manager.ts +25 -0
  50. package/lib/session-index-scanner.test.mjs +25 -0
  51. package/lib/session-index-scanner.ts +9 -0
  52. package/lib/session-list-signal.test.mjs +50 -0
  53. package/lib/session-list-signal.ts +32 -0
  54. package/lib/session-reader.ts +5 -6
  55. package/lib/task-store.ts +5 -3
  56. package/lib/transcript.test.mjs +186 -0
  57. package/lib/transcript.ts +304 -0
  58. package/lib/turn-merge.ts +1 -1
  59. package/package.json +1 -1
  60. package/.next/static/chunks/app/page-06102d7540ccaf75.js +0 -256
  61. package/.next/static/css/c7d127e07b1a786c.css +0 -1
  62. /package/.next/static/{qD0gvlcxBrjwm2vdTuA1h → ImiqQqbpF4KxeBm1KeB5N}/_buildManifest.js +0 -0
  63. /package/.next/static/{qD0gvlcxBrjwm2vdTuA1h → ImiqQqbpF4KxeBm1KeB5N}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-webpack","duration":11699804,"timestamp":191292283381,"id":14,"parentId":1,"tags":{},"startTime":1789900102827,"traceId":"cdfc6ca0ef3552f1"},{"name":"run-typescript","duration":5589684,"timestamp":191303984913,"id":809,"parentId":1,"tags":{},"startTime":1789900114529,"traceId":"cdfc6ca0ef3552f1"},{"name":"static-check","duration":2676964,"timestamp":191309588298,"id":812,"parentId":1,"tags":{},"startTime":1789900120132,"traceId":"cdfc6ca0ef3552f1"},{"name":"static-generation","duration":4034028,"timestamp":191312459363,"id":968,"parentId":1,"tags":{},"startTime":1789900123003,"traceId":"cdfc6ca0ef3552f1"},{"name":"collect-build-traces","duration":28313982,"timestamp":191312267468,"id":965,"parentId":1,"tags":{},"startTime":1789900122811,"traceId":"cdfc6ca0ef3552f1"},{"name":"telemetry-flush","duration":34,"timestamp":191340584252,"id":977,"parentId":1,"tags":{},"startTime":1789900151127,"traceId":"cdfc6ca0ef3552f1"},{"name":"next-build","duration":48482632,"timestamp":191292101660,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789900102646,"traceId":"cdfc6ca0ef3552f1"}]
1
+ [{"name":"run-webpack","duration":14533465,"timestamp":272072983657,"id":14,"parentId":1,"tags":{},"startTime":1790237349471,"traceId":"80188c259035d77e"},{"name":"run-typescript","duration":13931446,"timestamp":272087518906,"id":824,"parentId":1,"tags":{},"startTime":1790237364006,"traceId":"80188c259035d77e"},{"name":"static-check","duration":3363686,"timestamp":272101465709,"id":827,"parentId":1,"tags":{},"startTime":1790237377953,"traceId":"80188c259035d77e"},{"name":"static-generation","duration":4133250,"timestamp":272105054082,"id":983,"parentId":1,"tags":{},"startTime":1790237381541,"traceId":"80188c259035d77e"},{"name":"collect-build-traces","duration":30667433,"timestamp":272104831548,"id":980,"parentId":1,"tags":{},"startTime":1790237381319,"traceId":"80188c259035d77e"},{"name":"telemetry-flush","duration":38,"timestamp":272135501894,"id":992,"parentId":1,"tags":{},"startTime":1790237411989,"traceId":"80188c259035d77e"},{"name":"next-build","duration":62752361,"timestamp":272072749578,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1790237349237,"traceId":"80188c259035d77e"}]
@@ -40,6 +40,12 @@ export interface RunningSnapshot {
40
40
  * 带 ?boardId=&cardIds=:返回这批可见卡的**全量**状态(含 failed/done/not_started),供画布徽章用。
41
41
  */
42
42
  taskCards: TaskCardRunningState[];
43
+ /**
44
+ * 会话列表代次(服务端任何影响列表的写入都自增)。
45
+ * 前端比对代次:变了就重拉列表——标题/首条消息/最后回复/外部新建会话不必等
46
+ * 下次本地动作或刷新页面。见 lib/session-list-signal.ts。
47
+ */
48
+ listGeneration: number;
43
49
  }
44
50
 
45
51
  /** 任务卡状态(running 快照透传的画布可见卡状态;DB 是唯一真相源,此为展示镜像) */
@@ -150,3 +150,131 @@ test("空名字仍然报错(既有校验不变),不碰 pi 也不碰库", a
150
150
  assert.deepEqual(nameCalls, []);
151
151
  assert.equal(rowOf(db, "sess-name").title, null);
152
152
  });
153
+
154
+ /** 假 inner:prompt 提交的接受/拒绝由调用方控制(SDK 通过 preflightResult 同步 ack)。
155
+ * `entries` 模拟 transcript 已有内容(判定「是不是真首条」靠它)。 */
156
+ function makePromptWrapper(sessionId, { preflight = "accept", entries = [] } = {}) {
157
+ const inner = {
158
+ sessionId,
159
+ isBashRunning: false,
160
+ isStreaming: false,
161
+ isCompacting: false,
162
+ extensionRunner: {},
163
+ sessionManager: { getCwd: () => "/tmp", getEntries: () => entries },
164
+ agent: { state: {} },
165
+ subscribe() {
166
+ return () => {};
167
+ },
168
+ dispose() {},
169
+ prompt(_message, options) {
170
+ if (preflight === "reject") return Promise.reject(new Error("prompt rejected"));
171
+ options.preflightResult(true);
172
+ return Promise.resolve();
173
+ },
174
+ };
175
+ const wrapper = new AgentSessionWrapper(inner);
176
+ wrapper.start();
177
+ return wrapper;
178
+ }
179
+
180
+ test("首条用户消息在 prompt 被接受时即落库:标题不必等整轮跑完", async (t) => {
181
+ const db = freshDb();
182
+ seedSession(db, "sess-title");
183
+ const wrapper = makePromptWrapper("sess-title");
184
+ t.after(() => wrapper.destroy());
185
+ const generationBefore = globalThis.__piSessionListGeneration ?? 0;
186
+
187
+ await wrapper.send({ type: "prompt", message: " 会话标题为什么不及时 " });
188
+
189
+ assert.equal(rowOf(db, "sess-title").first_message, "会话标题为什么不及时", "trim 后入库");
190
+ assert.ok(
191
+ (globalThis.__piSessionListGeneration ?? 0) > generationBefore,
192
+ "同一次写库推列表代次:正在看侧栏的客户端 ≤2.5s 内重拉,标题立刻可见",
193
+ );
194
+ });
195
+
196
+ test("首条用户消息截断到 300 字符(与扫描器同一上限)", async (t) => {
197
+ const db = freshDb();
198
+ seedSession(db, "sess-title");
199
+ const wrapper = makePromptWrapper("sess-title");
200
+ t.after(() => wrapper.destroy());
201
+
202
+ await wrapper.send({ type: "prompt", message: "长".repeat(500) });
203
+
204
+ assert.equal(rowOf(db, "sess-title").first_message.length, 300);
205
+ });
206
+
207
+ test("prompt 没被接受(preflight 拒绝):不写 first_message", async (t) => {
208
+ const db = freshDb();
209
+ seedSession(db, "sess-title");
210
+ const wrapper = makePromptWrapper("sess-title", { preflight: "reject" });
211
+ t.after(() => wrapper.destroy());
212
+
213
+ await assert.rejects(() => wrapper.send({ type: "prompt", message: "会被拒绝的提交" }), /prompt rejected/);
214
+ assert.equal(rowOf(db, "sess-title").first_message, null);
215
+ });
216
+
217
+ test("prompt 分支接线:await preflight 之后才写 first_message(拒绝的提交不留标题)", async () => {
218
+ const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
219
+ const caseSource = caseSourceOf(source, "prompt");
220
+
221
+ assert.match(caseSource, /await preflight;[\s\S]*?fillFirstMessageIfEmpty\(/);
222
+ assert.match(caseSource, /command\.message[\s\S]*?slice\(0, FIRST_MESSAGE_PREVIEW_LENGTH\)/);
223
+ assert.match(caseSource, /try \{[\s\S]*?fillFirstMessageIfEmpty\([\s\S]*?catch \(error\)/, "库写失败不得冒泡(否则路由把已接受的 prompt 报成 prompt_rejected)");
224
+ });
225
+
226
+ test("transcript 里已有用户消息(老会话):prompt 不写 first_message(不把最新消息写成首条)", async (t) => {
227
+ const db = freshDb();
228
+ seedSession(db, "sess-title");
229
+ // 模拟 CLI 建的老会话:transcript 有历史,但 first_message 还空着(扫描器拿不到)
230
+ const wrapper = makePromptWrapper("sess-title", {
231
+ entries: [{ type: "message", message: { role: "user", content: [{ type: "text", text: "历史里的真首条消息" }] } }],
232
+ });
233
+ t.after(() => wrapper.destroy());
234
+ const generationBefore = globalThis.__piSessionListGeneration ?? 0;
235
+
236
+ await wrapper.send({ type: "prompt", message: "这是这条会话里最新的一句" });
237
+
238
+ assert.equal(rowOf(db, "sess-title").first_message, null, "留给 agent_settled/扫描器按真首条回填");
239
+ assert.equal(globalThis.__piSessionListGeneration ?? 0, generationBefore, "没写库就不推代次");
240
+ });
241
+
242
+ test("空的 message(图片-only 等):不写库、不推代次", async (t) => {
243
+ const db = freshDb();
244
+ seedSession(db, "sess-title");
245
+ const wrapper = makePromptWrapper("sess-title");
246
+ t.after(() => wrapper.destroy());
247
+ const generationBefore = globalThis.__piSessionListGeneration ?? 0;
248
+
249
+ await wrapper.send({ type: "prompt", message: " ", images: [] });
250
+
251
+ assert.equal(rowOf(db, "sess-title").first_message, null);
252
+ assert.equal(globalThis.__piSessionListGeneration ?? 0, generationBefore);
253
+ });
254
+
255
+ test("写库失败不拖累 send:命令照常成功响应(库是旁路索引)", async (t) => {
256
+ const db = freshDb();
257
+ seedSession(db, "sess-title");
258
+ const wrapper = makePromptWrapper("sess-title");
259
+ const errors = [];
260
+ const original = console.error;
261
+ t.after(() => {
262
+ wrapper.destroy();
263
+ console.error = original;
264
+ globalThis.__piWebDb = db;
265
+ });
266
+ console.error = (...args) => errors.push(args.map(String).join(" "));
267
+ // 让库写入抛错:stub 掉全局库句柄(真库仍在 db 变量里,未被触碰)
268
+ globalThis.__piWebDb = {
269
+ prepare() {
270
+ throw new Error("database is locked");
271
+ },
272
+ };
273
+
274
+ const result = await wrapper.send({ type: "prompt", message: "阻塞的库也不能让 prompt 变失败" });
275
+
276
+ assert.equal(result, null, "send 正常返回:路由才会写 promptAccepted=true(否则客户端回填草稿、诱导重发)");
277
+ assert.equal(errors.length, 1, "写库失败必须可见");
278
+ assert.match(errors[0], /\[pi-web\] 首条消息写库失败/);
279
+ assert.match(errors[0], /database is locked/);
280
+ });
@@ -333,6 +333,8 @@ export class AgentSessionWrapper {
333
333
  }
334
334
  recordSessionOutcome(sessionId, effect);
335
335
  // 首条用户消息此刻已在内存 entries 里(不读文件,读取路径零扫盘)。
336
+ // 正常链路上它更早就写好了(prompt 一被接受就写,见 send 的 prompt 分支);
337
+ // 这里兑底扩展/外部注入的首条用户消息(那一轮的起点不是 RPC prompt)。
336
338
  fillFirstMessageIfEmpty(sessionId, firstUserMessageOf(this.inner.sessionManager.getEntries()));
337
339
  }
338
340
 
@@ -613,6 +615,29 @@ export class AgentSessionWrapper {
613
615
  });
614
616
 
615
617
  await preflight;
618
+ // 首条用户消息:prompt 一被接受就落库,**不等整轮跑完**。标题/列表的默认
619
+ // 展示就是「用户发送的第一句话」(title || first_message),只在 agent_settled
620
+ // 写的话,长首轮(几分钟~几十分钟)期间刷新页面/换客户端/看板卡上标题一直空着
621
+ // (-> "(no messages)")。也不能指望 agent_start:实测那一刻用户消息还没进
622
+ // 内存 entries(拿不到文本)。
623
+ // 只在「transcript 里还没有用户消息」时写(= 这才是真首条):已有历史的会话
624
+ // (CLI 建的、first_message 还空着)走 agent_settled/扫描器按真实首条回填,
625
+ // prompt 路径写「这一条最新消息」会被 fill-empty 钉死成永久错值。
626
+ const promptText = typeof command.message === "string" ? command.message.trim() : "";
627
+ const sessionId = this.inner.sessionId;
628
+ if (promptText && sessionId && !firstUserMessageOf(this.inner.sessionManager.getEntries())) {
629
+ try {
630
+ fillFirstMessageIfEmpty(sessionId, promptText.slice(0, FIRST_MESSAGE_PREVIEW_LENGTH));
631
+ } catch (error) {
632
+ // 库写失败绝不能冒泡:send() 抛出会让路由把「已经接受的 prompt」报成
633
+ // prompt_rejected(客户端回填草稿、诱导重发,而 agent 其实在跑)。
634
+ // 这条写只是旁路索引,失败记账即可(同 persistSessionActivity 口径)。
635
+ console.error(
636
+ `[pi-web] 首条消息写库失败 session=${sessionId}:`,
637
+ error instanceof Error ? error.message : String(error),
638
+ );
639
+ }
640
+ }
616
641
  return null;
617
642
  } finally {
618
643
  releaseAdmission();
@@ -69,6 +69,31 @@ test("runSessionIndexScan:幂等(重复扫描不重复建行)", async () =
69
69
  }
70
70
  });
71
71
 
72
+ test("runSessionIndexScan:列表代次只在本轮真变更时自增(有变更推一次,空轮不推)", async () => {
73
+ freshDb();
74
+ const { root, projDir } = makeRoot();
75
+ try {
76
+ const generation = () => globalThis.__piSessionListGeneration ?? 0;
77
+ const before = generation();
78
+
79
+ await runSessionIndexScan(root); // 磁盘空:无变更
80
+ assert.equal(generation(), before, "空轮不推代次(否则每 30s 白刷一次前端列表)");
81
+
82
+ writeFileSync(join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl"), sessionFileLines("aaa", "/home/u/project/alpha"));
83
+ await runSessionIndexScan(root);
84
+ assert.equal(generation(), before + 1, "外部/CLI 新建的会话入索引 → 推一次(侧栏 ≤2.5s 就能看到它)");
85
+
86
+ const afterInsert = generation();
87
+ await runSessionIndexScan(root); // 再扫:幂等无变更
88
+ assert.equal(generation(), afterInsert, "幂等轮不推");
89
+
90
+ await indexSessionFileNow(join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl"), null, null);
91
+ assert.equal(generation(), afterInsert + 1, "单文件立即索引 → 也推一次(fork/引用分支当场进列表)");
92
+ } finally {
93
+ rmSync(root, { recursive: true, force: true });
94
+ }
95
+ });
96
+
72
97
  test("runSessionIndexScan:mtime 变化刷 modified + 回填 first_message(文件是内容事实源)", async () => {
73
98
  freshDb();
74
99
  const { root, projDir } = makeRoot();
@@ -34,6 +34,7 @@ import { getDb } from "./sqlite-db";
34
34
  import { projectIdentityKey } from "./project-identity";
35
35
  import { sessionPathKey } from "./session-path";
36
36
  import { scanOneSessionHead, scanSessionFileMeta, readSessionTail, type SessionFileMeta } from "./session-scanner";
37
+ import { markSessionListChanged } from "./session-list-signal";
37
38
  import { resolveProject } from "./worktree";
38
39
 
39
40
  /** 一轮扫描的变更摘要(日志/测试断言用)。 */
@@ -97,6 +98,8 @@ export async function indexSessionFileNow(
97
98
  head.created.getTime(),
98
99
  Date.now(),
99
100
  );
101
+ // 行已落库 → 列表可能变了(会话当场出现在列表/派生列被补上)。
102
+ markSessionListChanged();
100
103
  }
101
104
 
102
105
  /** 一轮全量扫描。`sessionsDir` 仅在测试注入(默认走 agentDir)。 */
@@ -319,6 +322,12 @@ export async function runSessionIndexScan(
319
322
  }
320
323
  }
321
324
 
325
+ // 本轮真改了什么 → 通知列表读者(外部/CLI 新建的会话、首条消息/最后回复回填、
326
+ // 删除都走这里;无变更不 mark,避免每 30s 空推一次前端重拉)。
327
+ if (summary.inserted > 0 || summary.updated > 0 || summary.deleted > 0) {
328
+ markSessionListChanged();
329
+ }
330
+
322
331
  return summary;
323
332
  }
324
333
 
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+ import { createJiti } from "jiti";
5
+
6
+ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
7
+ const { markSessionListChanged, getSessionListGeneration } = await jiti.import("./session-list-signal.ts");
8
+
9
+ const routeSource = await readFile(new URL("../app/api/agent/running/route.ts", import.meta.url), "utf8");
10
+ const sidebarSource = await readFile(new URL("../components/SessionSidebar.tsx", import.meta.url), "utf8");
11
+ const readerSource = await readFile(new URL("./session-reader.ts", import.meta.url), "utf8");
12
+ const sessionsRouteSource = await readFile(new URL("../app/api/sessions/route.ts", import.meta.url), "utf8");
13
+
14
+ test("mark 自增代次,get 读同一处(globalThis:热重载后仍是同一个计数器)", () => {
15
+ const before = getSessionListGeneration();
16
+ markSessionListChanged();
17
+ markSessionListChanged();
18
+ assert.equal(getSessionListGeneration(), before + 2);
19
+ assert.equal(globalThis.__piSessionListGeneration, before + 2);
20
+ });
21
+
22
+ test("接线:写路径 invalidateSessionListCache 委托 mark(改名/pin/新建/事件写库都算列表变更)", () => {
23
+ assert.match(
24
+ readerSource,
25
+ /export function invalidateSessionListCache\(\): void \{\n markSessionListChanged\(\);/,
26
+ );
27
+ assert.match(readerSource, /from "\.\/session-list-signal"/);
28
+ });
29
+
30
+ test("接线:running 快照把代次带给前端(侧栏本来就在轮询它,不新增请求)", () => {
31
+ assert.match(routeSource, /listGeneration: getSessionListGeneration\(\)/);
32
+ assert.match(routeSource, /from "@\/lib\/session-list-signal"/);
33
+ });
34
+
35
+ test("接线:侧栏轮询见代次变化就全量刷新;首个快照只做种子(挂载时已拉过列表,不空刷)", () => {
36
+ assert.match(
37
+ sidebarSource,
38
+ /if \(shouldRefreshForListGeneration\(listGenerationRef\.current, generation\)\) \{\n\s+onRefreshRef\.current\?\.\(\);/,
39
+ );
40
+ // 列表响应把自己的代次记下来(不靠轮询的「首个快照」去猜)。
41
+ assert.match(sidebarSource, /if \(typeof data\.listGeneration === "number"\) listGenerationRef\.current = data\.listGeneration;/);
42
+ });
43
+
44
+ test("接线:/api/sessions 两份列表响应都带上自己的代次(作为「我的列表对应哪个代次」的种子)", () => {
45
+ assert.match(sessionsRouteSource, /sessions: \[\.\.\.persisted, \.\.\.extraRuntime\],[\s\S]*?listGeneration: getSessionListGeneration\(\),/);
46
+ assert.match(
47
+ sessionsRouteSource,
48
+ /\{ sessions, runningSessionIds: getRunningRpcSessionIds\(\), listGeneration: getSessionListGeneration\(\) \}/,
49
+ );
50
+ });
@@ -0,0 +1,32 @@
1
+ // ============================================================================
2
+ // 会话列表「已变更」信号
3
+ //
4
+ // 谁需要知道:列表不再有服务端缓存(每次读取 = 一次单表查询),但**前端**的列表
5
+ // 只在挂载/项目切换/本地动作(新建、删除、改名、拖拽)时重拉——服务端写进去的
6
+ // 新标题、首条消息、最后回复、外部(CLI)新建的会话,都要等下一次重拉才现身。
7
+ //
8
+ // 机制:任何影响列表读取的写路径都 mark 一次(自增计数器),`/api/agent/running`
9
+ // 的轮询把它带给前端(侧栏本来就每 2.5s 拉一次),前端见变化就重拉列表。
10
+ //
11
+ // **进程内计数器**(与库同进程的写入才可见):同时跑多个 pi-web 实例、共用一个
12
+ // `pi-web.db` 时,A 进程的写不会推给 B 进程的侧栏——B 只能靠自己的 30s 扫描器按
13
+ // 文件 mtime 收敛。跨进程唯一可靠的事实源是库本身(真要做就是库里的代次行)。
14
+ //
15
+ // 单独成文件只为打断循环依赖:`session-reader` 已经 import 扫描器(ensureSessionIndexReady),
16
+ // 扫描器不能再反向 import `session-reader` 的 invalidateSessionListCache。globalThis
17
+ // 存活热重载(模块级变量不会)。
18
+ // ============================================================================
19
+
20
+ declare global {
21
+ var __piSessionListGeneration: number | undefined;
22
+ }
23
+
24
+ /** 列表内容已变(标题/首条消息/最后回复/排序/新建/删除)。写路径调用,幂等无副作用。 */
25
+ export function markSessionListChanged(): void {
26
+ globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
27
+ }
28
+
29
+ /** 当前代次:前端比对「我上次看到的」与「这次拿到的」,不等即重拉列表。 */
30
+ export function getSessionListGeneration(): number {
31
+ return globalThis.__piSessionListGeneration ?? 0;
32
+ }
@@ -16,6 +16,7 @@ import { projectIdentityKey } from "./project-identity";
16
16
  import { sessionPathKey } from "./session-path";
17
17
  import { resolveProject, type ProjectInfo } from "./worktree";
18
18
  import { scanOneSessionFile } from "./session-scanner";
19
+ import { markSessionListChanged } from "./session-list-signal";
19
20
  import { ensureSessionIndexReady } from "./session-index-scanner";
20
21
  import type { TurnIndexItem } from "./api-types";
21
22
 
@@ -264,17 +265,15 @@ export async function loadTaskSessionsPage(
264
265
  declare global {
265
266
  var __piSessionPathCache: Map<string, string> | undefined;
266
267
  var __piPathToSessionIdCache: Map<string, string> | undefined;
267
- var __piSessionListGeneration: number | undefined;
268
268
  }
269
269
 
270
270
  /** 写路径的「数据变了」信号:归属/pin/改名/新建/删除/事件都会调它。
271
271
  *
272
- * 列表已无缓存(读取 = 每次一次单表查询),所以目前**没有生产读者**:这里只保留
273
- * generation 自增的既有行为(T1–T4b 的写路径与 auto-name / patch-write-failure 的
274
- * 断言都依赖调用本身)。若要连函数一起删,需要同时改掉全部调用方与那两处断言——
275
- * 属待清理项,本次不动。 */
272
+ * 计数器本体在 `session-list-signal`(扫描器也要 mark,不能反向 import 本模块);
273
+ * 读者是 `/api/agent/running` 的 `listGeneration` —— 侧栏 2.5s 轮询见代次变化就
274
+ * 重拉列表,标题/首条消息/最后回复/外部新建会话因此能在几秒内现身。 */
276
275
  export function invalidateSessionListCache(): void {
277
- globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
276
+ markSessionListChanged();
278
277
  }
279
278
 
280
279
  function getPathCache(): Map<string, string> {
package/lib/task-store.ts CHANGED
@@ -613,14 +613,16 @@ export function recordSessionOutcome(sessionId: string, outcome: { lastReply: st
613
613
  invalidateSessionListCache();
614
614
  }
615
615
 
616
- /** 首条用户消息回填(读取路径不再读文件,改由事件补齐;只补空行)。 */
616
+ /** 首条用户消息回填(读取路径不再读文件,改由事件补齐;只补空行)。
617
+ * 只在真的改了行时推列表代次:这个函数在一条会话的一生里会被调多次(每次 prompt 一次),
618
+ * 无变化的空推会让前端白刷一轮会话/任务/看板。「空轮不推」与扫描器同一口径。 */
617
619
  export function fillFirstMessageIfEmpty(sessionId: string, firstMessage: string): void {
618
620
  const text = firstMessage.trim();
619
621
  if (!text) return;
620
- getDb()
622
+ const result = getDb()
621
623
  .prepare(
622
624
  "UPDATE session_meta SET first_message = ? WHERE session_id = ? AND (first_message IS NULL OR first_message = '')",
623
625
  )
624
626
  .run(text, sessionId);
625
- invalidateSessionListCache();
627
+ if (Number(result.changes) > 0) invalidateSessionListCache();
626
628
  }
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createJiti } from "jiti";
4
+
5
+ const jiti = createJiti(import.meta.url);
6
+ const {
7
+ createTranscriptState,
8
+ transcriptReducer,
9
+ transcriptMessages,
10
+ transcriptEntryIds,
11
+ } = await jiti.import("./transcript.ts");
12
+
13
+ /** 造一条用户消息(提交时客户端自己的形态)。 */
14
+ const user = (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp });
15
+ const assistant = (text, timestamp) => ({ role: "assistant", content: [{ type: "text", text }], timestamp });
16
+ const system = (text, timestamp) => ({ role: "system", content: text, timestamp });
17
+
18
+ /** 服务端窗口里的一条 entry。 */
19
+ const entry = (entryId, message, parentId = null) => ({ entryId, parentId, message });
20
+
21
+ const reduce = (state, ...actions) => actions.reduce(transcriptReducer, state);
22
+
23
+ test("提交把消息作为 pending 条目放进列表,并给出稳定的客户端 id", () => {
24
+ const state = reduce(
25
+ createTranscriptState(),
26
+ { type: "submit", id: "c1", message: user("你好", 100), runId: 1, submittedAt: 100 },
27
+ );
28
+ assert.equal(state.items.length, 1);
29
+ assert.deepEqual(
30
+ { id: state.items[0].id, state: state.items[0].state, serverEntryId: state.items[0].serverEntryId },
31
+ { id: "c1", state: "pending", serverEntryId: null },
32
+ );
33
+ assert.deepEqual(transcriptMessages(state).map((m) => m.role), ["user"]);
34
+ });
35
+
36
+ // 回归:当前实现里 message_end(user) 只有在"乐观气泡刚好还在末尾"时才认它;
37
+ // 中间夹一条别的 message_end 就会改走追加分支 → 界面上两个一样的用户气泡。
38
+ test("回声到达时原地升级,绝不新增条目(即使前面先夹了一条 system)", () => {
39
+ let state = reduce(
40
+ createTranscriptState(),
41
+ { type: "submit", id: "c1", message: user("重复测试", 100), runId: 1, submittedAt: 100 },
42
+ );
43
+ // pi 先推一条 system(历史上就是把乐观气泡挤出末尾的那条)
44
+ state = reduce(state, { type: "server", message: system("system prompt 更新", 101) });
45
+ // 紧接着才是这条用户消息的回声
46
+ state = reduce(state, { type: "echo", runId: 1, message: user("重复测试", 102) });
47
+
48
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 1, "用户消息必须只有一条");
49
+ assert.equal(state.items[0].id, "c1", "必须是同一条目(id 不变 → 渲染不重挂)");
50
+ assert.equal(state.items[0].state, "confirmed");
51
+ assert.equal(state.items[0].serverEntryId, null, "回声不带 entryId,等合并时补");
52
+ });
53
+
54
+ test("合并服务端整表:同一位置沿用原条目 id,不新增、不重挂", () => {
55
+ let state = reduce(
56
+ createTranscriptState(),
57
+ { type: "submit", id: "c1", message: user("合并测试", 100), runId: 1, submittedAt: 100 },
58
+ { type: "echo", runId: 1, message: user("合并测试", 102) },
59
+ { type: "server", message: assistant("收到", 110) },
60
+ );
61
+ const idsBefore = state.items.map((i) => i.id);
62
+
63
+ state = reduce(state, {
64
+ type: "mergeTail",
65
+ entries: [
66
+ entry("e1", user("更早的问题", 10)),
67
+ entry("e2", assistant("更早的回答", 20)),
68
+ entry("e3", user("合并测试", 102), "e2"),
69
+ entry("e4", assistant("收到", 110), "e3"),
70
+ ],
71
+ });
72
+
73
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2, "用户消息两条:历史一条 + 本次一条");
74
+ // 已存在的条目沿用原 id(命中位置不重挂),更早的历史作为新条目补在前面
75
+ assert.deepEqual(state.items.map((i) => i.id).slice(-2), idsBefore, "已存在的条目 id 一个都不能变");
76
+ assert.deepEqual(transcriptEntryIds(state), ["e1", "e2", "e3", "e4"]);
77
+ assert.ok(state.items.every((i) => i.state === "confirmed"));
78
+ });
79
+
80
+ // 回归:当前实现里 loadSession 整表替换会把还没落盘的乐观气泡抹掉("消息消失、AI 直接开始回复")。
81
+ test("确认前合并:服务端还没有这一条,pending 条目原位保留", () => {
82
+ let state = reduce(
83
+ createTranscriptState(),
84
+ { type: "submit", id: "c1", message: user("还没落盘", 100), runId: 1, submittedAt: 100 },
85
+ );
86
+ state = reduce(state, {
87
+ type: "mergeTail",
88
+ entries: [entry("e1", user("历史问题", 10)), entry("e2", assistant("历史回答", 20))],
89
+ });
90
+
91
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2);
92
+ const pending = state.items.find((i) => i.id === "c1");
93
+ assert.equal(pending?.state, "pending", "未确认的条目必须还在");
94
+ assert.equal(state.items[state.items.length - 1].id, "c1", "pending 排在末尾");
95
+ });
96
+
97
+ test("合并时空窗口不清空列表(避免瞬态空响应把界面刷白)", () => {
98
+ let state = reduce(
99
+ createTranscriptState(),
100
+ { type: "mergeTail", entries: [entry("e1", user("已有", 10))] },
101
+ );
102
+ state = reduce(state, { type: "mergeTail", entries: [] });
103
+ assert.equal(state.items.length, 1);
104
+ });
105
+
106
+ test("同文本两条 steer:各自认领,不互相替换、不丢", () => {
107
+ let state = reduce(
108
+ createTranscriptState(),
109
+ { type: "submit", id: "c1", message: user("同样的话", 100), runId: 1, submittedAt: 100 },
110
+ );
111
+ state = reduce(
112
+ state,
113
+ { type: "echo", runId: 1, message: user("同样的话", 102) },
114
+ { type: "submit", id: "c2", message: user("同样的话", 200), runId: 1, submittedAt: 200 },
115
+ { type: "echo", runId: 1, message: user("同样的话", 202) },
116
+ );
117
+ assert.deepEqual(state.items.map((i) => i.id), ["c1", "c2"]);
118
+ assert.equal(state.items.filter((i) => i.message.role === "user").length, 2);
119
+ });
120
+
121
+ test("没有本地回显的服务端用户消息(别的客户端/扩展发的)作为新条目追加", () => {
122
+ const state = reduce(
123
+ createTranscriptState(),
124
+ { type: "echo", runId: 3, message: user("来自别处", 500) },
125
+ );
126
+ assert.equal(state.items.length, 1);
127
+ assert.equal(state.items[0].state, "confirmed");
128
+ });
129
+
130
+ test("提交被拒:按 id 移除 pending,别的条目不受影响", () => {
131
+ let state = reduce(
132
+ createTranscriptState(),
133
+ { type: "mergeTail", entries: [entry("e1", assistant("历史", 10))] },
134
+ { type: "submit", id: "c1", message: user("被拒的消息", 100), runId: 1, submittedAt: 100 },
135
+ );
136
+ state = reduce(state, { type: "fail", id: "c1" });
137
+ assert.deepEqual(state.items.map((i) => i.id), [state.items[0].id]);
138
+ assert.equal(state.items.length, 1);
139
+ assert.equal(transcriptMessages(state)[0].role, "assistant");
140
+ });
141
+
142
+ test("更早一页 prepend:重复 prepend 同一页只算一次", () => {
143
+ let state = reduce(
144
+ createTranscriptState(),
145
+ { type: "mergeTail", entries: [entry("e3", user("最新", 30))] },
146
+ );
147
+ const older = [entry("e1", user("最早", 10)), entry("e2", assistant("中间", 20))];
148
+ state = reduce(state, { type: "prependOlder", entries: older });
149
+ const once = state.items.map((i) => i.serverEntryId);
150
+ state = reduce(state, { type: "prependOlder", entries: older });
151
+ assert.deepEqual(state.items.map((i) => i.serverEntryId), once, "同一页重复加载不得重复插入");
152
+ assert.equal(state.items.length, 3);
153
+ });
154
+
155
+ // 回归:回退(navigate_tree)/「编辑从此处」会把 leaf 移到更早的 entry,服务端窗口随之变短。
156
+ // 旧的 loadSession 是整表替换,落在窗口之外的条目会消失;合并实现里必须同样把它们判为"离开当前分支"丢弃,
157
+ // 否则回退后旧消息仍然留在消息流里。
158
+ test("回退到更早的 leaf:窗口之外的已落盘条目必须消失,pending 保留", () => {
159
+ const e1 = entry("e1", user("第一问", 10));
160
+ const e2 = entry("e2", assistant("第一答", 20), "e1");
161
+ const e3 = entry("e3", user("第二问", 30), "e2");
162
+ const e4 = entry("e4", assistant("第二答", 40), "e3");
163
+
164
+ let state = reduce(
165
+ createTranscriptState(),
166
+ { type: "mergeTail", entries: [e1, e2, e3, e4] },
167
+ { type: "submit", id: "c1", message: user("新提交还没落盘", 500), runId: 2, submittedAt: 500 },
168
+ );
169
+ assert.equal(state.items.length, 5);
170
+
171
+ // 回退到 e2:服务端窗口只到 e2
172
+ state = reduce(state, { type: "mergeTail", entries: [e1, e2] });
173
+
174
+ assert.deepEqual(transcriptEntryIds(state).filter(Boolean), ["e1", "e2"], "离分支的 e3/e4 必须移除");
175
+ assert.deepEqual(state.items.map((i) => i.id).filter((id) => id.startsWith("srv")), ["srv:e1", "srv:e2"]);
176
+ assert.deepEqual(state.items.filter((i) => i.state === "pending").map((i) => i.id), ["c1"], "未落盘的提交不能被顺手丢掉");
177
+ });
178
+
179
+ test("切分支 reset:显式清空(唯一允许清空列表的动作)", () => {
180
+ let state = reduce(
181
+ createTranscriptState(),
182
+ { type: "submit", id: "c1", message: user("旧分支", 100), runId: 1, submittedAt: 100 },
183
+ );
184
+ state = reduce(state, { type: "reset" });
185
+ assert.equal(state.items.length, 0);
186
+ });