@baique/pi-web-sky 0.1.56 → 0.1.57

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 (138) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +2 -2
  5. package/.next/required-server-files.js +3 -2
  6. package/.next/required-server-files.json +3 -2
  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/[id]/bash-output/route.js +2 -2
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/agent/new/route.js +1 -1
  23. package/.next/server/app/api/agent/running/route.js +1 -1
  24. package/.next/server/app/api/app-update/route.js +1 -1
  25. package/.next/server/app/api/board-assets/[...path]/route.js +1 -1
  26. package/.next/server/app/api/board-assets/route.js +1 -1
  27. package/.next/server/app/api/boards/[id]/add-session/route.js +1 -1
  28. package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -1
  29. package/.next/server/app/api/boards/[id]/route.js +1 -1
  30. package/.next/server/app/api/boards/reorder/route.js +1 -1
  31. package/.next/server/app/api/boards/route.js +1 -1
  32. package/.next/server/app/api/cwd/validate/route.js +1 -1
  33. package/.next/server/app/api/default-cwd/route.js +1 -1
  34. package/.next/server/app/api/file-index/route.js +1 -1
  35. package/.next/server/app/api/files/[...path]/route.js +2 -2
  36. package/.next/server/app/api/mcp/route.js +1 -1
  37. package/.next/server/app/api/models/route.js +1 -1
  38. package/.next/server/app/api/plugins/route.js +1 -1
  39. package/.next/server/app/api/project-trust/route.js +1 -1
  40. package/.next/server/app/api/search/route.js +2 -2
  41. package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
  42. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  43. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
  44. package/.next/server/app/api/sessions/[id]/export/route.js +2 -2
  45. package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  47. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  48. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  49. package/.next/server/app/api/sessions/route.js +1 -1
  50. package/.next/server/app/api/sessions/summary/route.js +1 -1
  51. package/.next/server/app/api/skills/install/route.js +1 -1
  52. package/.next/server/app/api/subagents/[id]/route.js +1 -1
  53. package/.next/server/app/api/subagents/profiles/route.js +1 -1
  54. package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -1
  55. package/.next/server/app/api/task-card-questions/route.js +1 -1
  56. package/.next/server/app/api/task-cards/[id]/route.js +1 -1
  57. package/.next/server/app/api/task-cards/route.js +1 -1
  58. package/.next/server/app/api/task-cards/unbind/route.js +1 -1
  59. package/.next/server/app/api/task-scheduler/status/route.js +1 -1
  60. package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -12
  61. package/.next/server/app/api/tasks/[id]/board/route.js +1 -1
  62. package/.next/server/app/api/tasks/[id]/route.js +1 -1
  63. package/.next/server/app/api/tasks/reorder/route.js +2 -12
  64. package/.next/server/app/api/tasks/route.js +2 -12
  65. package/.next/server/app/api/worktrees/route.js +1 -1
  66. package/.next/server/app/index.html +1 -1
  67. package/.next/server/app/index.rsc +5 -5
  68. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  69. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  70. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/page.js +10 -10
  72. package/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app-paths-manifest.json +5 -5
  74. package/.next/server/chunks/1937.js +14 -13
  75. package/.next/server/chunks/2524.js +2 -2
  76. package/.next/server/chunks/3500.js +1 -1
  77. package/.next/server/chunks/402.js +21 -10
  78. package/.next/server/chunks/4457.js +3 -13
  79. package/.next/server/chunks/542.js +8 -8
  80. package/.next/server/chunks/5794.js +1 -1
  81. package/.next/server/chunks/5940.js +1 -1
  82. package/.next/server/chunks/6429.js +4 -4
  83. package/.next/server/chunks/6525.js +18 -7
  84. package/.next/server/chunks/6583.js +12 -11
  85. package/.next/server/middleware-build-manifest.js +1 -1
  86. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  87. package/.next/server/pages/404.html +1 -1
  88. package/.next/server/pages/500.html +1 -1
  89. package/.next/static/chunks/752.bafff304e6b00987.js +1 -0
  90. package/.next/static/chunks/8088.39866dc5f1d15868.js +1 -0
  91. package/.next/static/chunks/app/{layout-87087b955e0c9507.js → layout-fae2b226b3c78448.js} +1 -1
  92. package/.next/static/chunks/app/page-d5af3bd535589daf.js +256 -0
  93. package/.next/static/chunks/{webpack-4de8e8dd9f2f3173.js → webpack-1ce35e1249cfd931.js} +1 -1
  94. package/.next/static/css/{c6d32b6031181be7.css → c7d127e07b1a786c.css} +1 -1
  95. package/.next/trace +8 -7
  96. package/.next/trace-build +1 -1
  97. package/lib/board-reconcile.test.mjs +26 -8
  98. package/lib/board-reconcile.ts +3 -1
  99. package/lib/board-store.test.mjs +2 -2
  100. package/lib/ime-guard.test.mjs +52 -0
  101. package/lib/ime-guard.ts +54 -0
  102. package/lib/rpc-manager.activity.test.mjs +223 -0
  103. package/lib/rpc-manager.test.mjs +22 -0
  104. package/lib/rpc-manager.title.test.mjs +152 -0
  105. package/lib/rpc-manager.ts +95 -8
  106. package/lib/session-activity.test.mjs +123 -0
  107. package/lib/session-activity.ts +77 -0
  108. package/lib/session-delete.test.mjs +45 -41
  109. package/lib/session-delete.ts +8 -34
  110. package/lib/session-index-scanner.test.mjs +364 -2
  111. package/lib/session-index-scanner.ts +162 -57
  112. package/lib/session-reader.noscan.test.mjs +371 -0
  113. package/lib/session-reader.project.test.mjs +30 -52
  114. package/lib/session-reader.test.mjs +2 -65
  115. package/lib/session-reader.ts +177 -213
  116. package/lib/session-scanner.test.mjs +151 -38
  117. package/lib/session-scanner.ts +140 -61
  118. package/lib/session-search.ts +6 -5
  119. package/lib/sqlite-db.test.mjs +26 -0
  120. package/lib/sqlite-db.ts +10 -1
  121. package/lib/subagent-runtime.ts +42 -0
  122. package/lib/subagent-session-title.test.mjs +120 -0
  123. package/lib/task-scheduler.test.mjs +27 -0
  124. package/lib/task-scheduler.ts +8 -1
  125. package/lib/task-store.activity.test.mjs +141 -0
  126. package/lib/task-store.meta-row.test.mjs +25 -5
  127. package/lib/task-store.partial-row.test.mjs +198 -0
  128. package/lib/task-store.subtree.test.mjs +172 -0
  129. package/lib/task-store.test.mjs +46 -20
  130. package/lib/task-store.ts +344 -68
  131. package/next.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/.next/static/chunks/752.adce8bc3ebbaf4b6.js +0 -1
  134. package/.next/static/chunks/8088.0cad1ff2e09fd8ff.js +0 -1
  135. package/.next/static/chunks/app/page-efca6bf51a5f8ccb.js +0 -256
  136. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_buildManifest.js +0 -0
  137. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_ssgManifest.js +0 -0
  138. /package/lib/{subagent-isolation.test.mjs → worktree-isolation.test.mjs} +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-webpack","duration":21788610,"timestamp":112598116018,"id":14,"parentId":1,"tags":{},"startTime":1789635999467,"traceId":"8ab6295b2022b209"},{"name":"run-typescript","duration":17416676,"timestamp":112619906570,"id":1026,"parentId":1,"tags":{},"startTime":1789636021257,"traceId":"8ab6295b2022b209"},{"name":"static-check","duration":4973335,"timestamp":112637338702,"id":1029,"parentId":1,"tags":{},"startTime":1789636038689,"traceId":"8ab6295b2022b209"},{"name":"static-generation","duration":5601140,"timestamp":112642593780,"id":1185,"parentId":1,"tags":{},"startTime":1789636043944,"traceId":"8ab6295b2022b209"},{"name":"collect-build-traces","duration":33050130,"timestamp":112642314747,"id":1182,"parentId":1,"tags":{},"startTime":1789636043665,"traceId":"8ab6295b2022b209"},{"name":"telemetry-flush","duration":48,"timestamp":112675367892,"id":1194,"parentId":1,"tags":{},"startTime":1789636076718,"traceId":"8ab6295b2022b209"},{"name":"next-build","duration":77526811,"timestamp":112597841136,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789635999192,"traceId":"8ab6295b2022b209"}]
1
+ [{"name":"run-webpack","duration":13746815,"timestamp":79464376514,"id":14,"parentId":1,"tags":{},"startTime":1789788276790,"traceId":"4f060d2e7ba61083"},{"name":"run-typescript","duration":11766992,"timestamp":79478124864,"id":877,"parentId":1,"tags":{},"startTime":1789788290539,"traceId":"4f060d2e7ba61083"},{"name":"static-check","duration":2683425,"timestamp":79489906025,"id":880,"parentId":1,"tags":{},"startTime":1789788302320,"traceId":"4f060d2e7ba61083"},{"name":"static-generation","duration":4026810,"timestamp":79492772500,"id":1036,"parentId":1,"tags":{},"startTime":1789788305186,"traceId":"4f060d2e7ba61083"},{"name":"collect-build-traces","duration":24886444,"timestamp":79492591686,"id":1033,"parentId":1,"tags":{},"startTime":1789788305005,"traceId":"4f060d2e7ba61083"},{"name":"telemetry-flush","duration":36,"timestamp":79517480961,"id":1045,"parentId":1,"tags":{},"startTime":1789788329894,"traceId":"4f060d2e7ba61083"},{"name":"next-build","duration":53289106,"timestamp":79464191897,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1789788276606,"traceId":"4f060d2e7ba61083"}]
@@ -1,17 +1,35 @@
1
- import { test, beforeEach, afterEach } from "node:test";
1
+ import { test, beforeEach, afterEach, after } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
  import { DatabaseSync } from "node:sqlite";
4
7
  import { createJiti } from "jiti";
5
8
 
9
+ // 隔离:归属写入现在会对「库里无行」的会话解析会话文件,临时 agentDir 保证
10
+ // 测试绝不扫开发机真实的 ~/.pi/agent/sessions。
11
+ const agentDir = mkdtempSync(join(tmpdir(), "pi-web-board-reconcile-"));
12
+ process.env.PI_CODING_AGENT_DIR = agentDir;
13
+ after(() => rmSync(agentDir, { recursive: true, force: true }));
14
+
6
15
  const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
7
16
  const { setDbForTesting, getDb } = await jiti.import("./sqlite-db.ts");
8
17
  const { createTask } = await jiti.import("./task-store.ts");
9
18
  const { getOrCreateTaskBoard } = await jiti.import("./board-store.ts");
10
19
  const { createCard, updateCard } = await jiti.import("./task-card-store.ts");
11
- const { assignSessionToTask } = await jiti.import("./task-store.ts");
12
20
 
13
21
  const PROJECT = "proj-reconcile";
14
22
 
23
+ /** 库里已有该会话的行且已归属本任务(生产链路:行由创建链路/扫描器建、归属由
24
+ * assignSessionToTask 写;reconcile 只消费「行 + 归属」,这里一次插齐)。 */
25
+ function seedAssignedSession(sid, taskId) {
26
+ const ts = Date.now();
27
+ getDb().prepare(
28
+ "INSERT INTO session_meta (session_id, task_id, updated, pinned, path, cwd, project_key, created, modified) " +
29
+ "VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?)",
30
+ ).run(sid, taskId, ts, `/sessions/proj/${sid}.jsonl`, "/proj", PROJECT, ts, ts);
31
+ }
32
+
15
33
  // 注入 mutateBoard mock:捕获每次事务调用(读入当前 Y 状态,执行 transaction 后
16
34
  // 记录对 nodes/edges 的增删,模拟服务端权威写)。
17
35
  function installYjsMock() {
@@ -79,9 +97,9 @@ test("窗口期 reconcile 不补同 sid 占位卡(新建会话指定 ID 场景
79
97
  const board = getOrCreateTaskBoard(task.id, PROJECT, "任务A"); // board.id = task.id
80
98
  assert.ok(board.taskId);
81
99
 
82
- // 服务端已落库(assignSessionToTask 在 /api/agent/new 中先于前端转正执行)
100
+ // 服务端已落库(行由创建链路建,归属在 /api/agent/new 中先于前端转正写入)
83
101
  const sid = "abc-123";
84
- assignSessionToTask(sid, task.id);
102
+ seedAssignedSession(sid, task.id);
85
103
 
86
104
  // 画布已存在「新建中占位卡」:cwd 非空 + sessionId = 即将创建/刚创建的会话 UUID
87
105
  mockState.nodeSets.set("user-card-1", cardNode("user-card-1", sid, { cwd: "/proj", expanded: true }));
@@ -99,7 +117,7 @@ test("无占位卡时业务表会话正常补卡(回归,不误伤)", async
99
117
  const task = createTask(PROJECT, "任务B");
100
118
  const board = getOrCreateTaskBoard(task.id, PROJECT, "任务B");
101
119
  const sid = "reg-session-1";
102
- assignSessionToTask(sid, task.id);
120
+ seedAssignedSession(sid, task.id);
103
121
 
104
122
  const { reconcileBoard } = await jiti.import("./board-reconcile.ts");
105
123
  await reconcileBoard(board.id);
@@ -119,11 +137,11 @@ test("占位卡不影响其他会话补卡(6461a61 语义保留)", async ()
119
137
 
120
138
  // 一个业务表真实会话 + 任务卡执行会话
121
139
  const realSid = "real-session";
122
- assignSessionToTask(realSid, task.id);
140
+ seedAssignedSession(realSid, task.id);
123
141
  const execSid = "exec-session";
124
142
  const card = createCard({ boardId: board.id, projectKey: PROJECT, name: "子任务" });
125
143
  updateCard(card.id, { sessionId: execSid });
126
- assignSessionToTask(execSid, task.id);
144
+ seedAssignedSession(execSid, task.id);
127
145
 
128
146
  const { reconcileBoard } = await jiti.import("./board-reconcile.ts");
129
147
  await reconcileBoard(board.id);
@@ -315,7 +333,7 @@ test("拖入任务看板的会话:无 session_meta 归属 → 判孤儿删;
315
333
 
316
334
  // 拖入的会话已写 session_meta(先归属后落卡)→ 存活;画布已有该卡 → 不补第二张
317
335
  const assignedSid = "drag-with-meta";
318
- assignSessionToTask(assignedSid, task.id);
336
+ seedAssignedSession(assignedSid, task.id);
319
337
  mockState.nodeSets.set("drag-card2", cardNode("drag-card2", assignedSid, { taskId: task.id }));
320
338
  await reconcileBoard(board.id);
321
339
  assert.ok(mockState.nodeSets.has("drag-card2"), "有 session_meta 归属的拖入卡存活");
@@ -175,7 +175,9 @@ export async function reconcileBoard(boardId: string, force = false): Promise<vo
175
175
  if (isTaskBoard) for (const sid of listTaskSessionIds(board.taskId!)) allSessionIds.add(sid);
176
176
  for (const c of cards) if (c.sessionId) allSessionIds.add(c.sessionId);
177
177
  // fork 会话派生:fork(parent_id 非空)会话的源会话归属本任务 → 本任务看板补 fork 卡。
178
- // fork 会话自身不继承 task_id,需经源会话归属定位;普通看板 board.taskId 为空天然不参与。
178
+ // fork 会话建行时已继承源会话的 task_id(T1),这里仍按「源会话归属」定位,
179
+ // 是为了兼容存量行(扫描器收敛前的父在任务/子为空的脏行)与跨任务拖入的过渡态;
180
+ // 普通看板 board.taskId 为空天然不参与。
179
181
  const forkBySid = new Map<string, string>(); // forkSid -> parentSid
180
182
  if (isTaskBoard) {
181
183
  for (const { sessionId, parentId } of listTaskForkedSessions(board.taskId!)) {
@@ -47,9 +47,9 @@ test("schema version reaches latest with board tables", () => {
47
47
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'boards'")
48
48
  .all();
49
49
  assert.equal(boards.length, 1);
50
- assert.equal(SCHEMA_VERSION, 11);
50
+ assert.equal(SCHEMA_VERSION, 12); // v12: session_meta.last_reply
51
51
  const row = db.prepare("PRAGMA user_version").get();
52
- assert.equal(row.user_version, 11);
52
+ assert.equal(row.user_version, SCHEMA_VERSION);
53
53
  // v5 迁移:boards.task_id 列存在
54
54
  const cols = db.prepare("PRAGMA table_info(boards)").all().map((c) => c.name);
55
55
  assert.ok(cols.includes("task_id"));
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ async function loadSubject() {
5
+ return import("./ime-guard.ts");
6
+ }
7
+
8
+ test("plain key events are not IME-busy", async () => {
9
+ const { createIMECompositionState, isIMEBusy } = await loadSubject();
10
+ const state = createIMECompositionState();
11
+
12
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }), false);
13
+ assert.equal(isIMEBusy(state, {}), false);
14
+ assert.equal(isIMEBusy(state, null), false);
15
+ });
16
+
17
+ test("composition session marks every keydown as IME-busy", async () => {
18
+ const { createIMECompositionState, markCompositionStart, markCompositionEnd, isIMEBusy } = await loadSubject();
19
+ const state = createIMECompositionState();
20
+
21
+ markCompositionStart(state);
22
+ assert.equal(state.composing, true);
23
+ // 组合期间即使事件没带信号(老输入法)也要让开
24
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }), true);
25
+
26
+ markCompositionEnd(state, 1_000);
27
+ assert.equal(state.composing, false);
28
+ });
29
+
30
+ test("isComposing and keyCode 229 are both honoured outside a tracked session", async () => {
31
+ const { createIMECompositionState, isIMEBusy } = await loadSubject();
32
+ const state = createIMECompositionState();
33
+
34
+ assert.equal(isIMEBusy(state, { isComposing: true, keyCode: 13 }), true);
35
+ assert.equal(isIMEBusy(state, { keyCode: 229 }), true);
36
+ });
37
+
38
+ test("keys right after compositionend are IME-busy (Safari / Sogou ordering)", async () => {
39
+ const { createIMECompositionState, markCompositionStart, markCompositionEnd, isIMEBusy, IME_GRACE_MS } = await loadSubject();
40
+ const state = createIMECompositionState();
41
+
42
+ markCompositionStart(state);
43
+ // Safari 反序:compositionend 先到,随后那次 keydown 的 isComposing 已是 false
44
+ markCompositionEnd(state, 5_000);
45
+
46
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }, 5_000), true);
47
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }, 5_000 + IME_GRACE_MS - 1), true);
48
+
49
+ // 宽限窗外恢复正常
50
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }, 5_000 + IME_GRACE_MS), false);
51
+ assert.equal(isIMEBusy(state, { isComposing: false, keyCode: 13 }, 5_000 + IME_GRACE_MS + 1), false);
52
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 输入法(IME)组合态判定。
3
+ *
4
+ * Enter / Escape / 方向键等业务快捷键在处理前必须先过 `isIMEBusy()`,
5
+ * 否则中文/日文/韩文输入法「回车确认候选」「Esc 取消候选」会被当成业务提交或业务取消。
6
+ *
7
+ * 四个信号缺一不可(靠前的先短路):
8
+ * 1. `composing` —— 由 compositionstart / compositionend 自己维护,最可靠;
9
+ * 2. `event.isComposing` —— 标准属性;
10
+ * 3. `event.keyCode === 229` —— 老 WebKit 与部分输入法不上报 isComposing;
11
+ * 4. `endedAt` 宽限窗 —— Safari(以及部分搜狗场景)先发 compositionend 再发 keydown,
12
+ * 那次 keydown 的 isComposing 已经是 false,只看属性必然误判成「用户主动按了 Enter」。
13
+ */
14
+
15
+ /** compositionend 之后仍把按键让给输入法的宽限窗口(ms)。 */
16
+ export const IME_GRACE_MS = 100;
17
+
18
+ export type IMECompositionState = {
19
+ /** 是否处于组合中(compositionstart 之后、compositionend 之前) */
20
+ composing: boolean;
21
+ /** 最近一次 compositionend 的时间戳;0 = 从未组合过 */
22
+ endedAt: number;
23
+ };
24
+
25
+ export function createIMECompositionState(): IMECompositionState {
26
+ return { composing: false, endedAt: 0 };
27
+ }
28
+
29
+ export function markCompositionStart(state: IMECompositionState): void {
30
+ state.composing = true;
31
+ }
32
+
33
+ export function markCompositionEnd(state: IMECompositionState, now: number = Date.now()): void {
34
+ state.composing = false;
35
+ state.endedAt = now;
36
+ }
37
+
38
+ /** keydown 上取得到的 IME 信号(KeyboardEvent 的结构子集,方便测试)。 */
39
+ export type IMEKeyEvent = {
40
+ isComposing?: boolean;
41
+ keyCode?: number;
42
+ };
43
+
44
+ /** 这次按键是否属于输入法:true → 业务逻辑让开,交给输入法上屏/取消候选。 */
45
+ export function isIMEBusy(
46
+ state: IMECompositionState,
47
+ event: IMEKeyEvent | null | undefined,
48
+ now: number = Date.now(),
49
+ ): boolean {
50
+ if (state.composing) return true;
51
+ if (event?.isComposing === true) return true;
52
+ if (event?.keyCode === 229) return true;
53
+ return now - state.endedAt < IME_GRACE_MS;
54
+ }
@@ -0,0 +1,223 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { createJiti } from "jiti";
5
+
6
+ const jiti = createJiti(import.meta.url, {
7
+ interopDefault: true,
8
+ moduleCache: false,
9
+ alias: { "@": process.cwd() },
10
+ });
11
+ const { setDbForTesting } = await jiti.import("./sqlite-db.ts");
12
+ const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts");
13
+
14
+ /** 事件链路的行为测试:真 wrapper + 真库(:memory:)+ 假 SDK 会话。 */
15
+ function freshDb() {
16
+ const db = new DatabaseSync(":memory:");
17
+ setDbForTesting(db);
18
+ return db;
19
+ }
20
+
21
+ function seedSession(db, sessionId) {
22
+ db.prepare(
23
+ `INSERT INTO session_meta
24
+ (session_id, task_id, updated, pinned, path, cwd, project_key, title, first_message, parent_id, created, modified, last_reply)
25
+ VALUES (?, NULL, 111, 0, ?, '/p', 'proj', NULL, NULL, NULL, 100, 200, NULL)`,
26
+ ).run(sessionId, `/s/${sessionId}.jsonl`);
27
+ }
28
+
29
+ const rowOf = (db, sessionId) =>
30
+ db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get(sessionId);
31
+
32
+ const assistant = (text, ts, stopReason = "stop") => ({
33
+ type: "message_end",
34
+ message: { role: "assistant", timestamp: ts, stopReason, content: [{ type: "text", text }] },
35
+ });
36
+
37
+ /** 假 AgentSessionLike:只实现事件订阅 + wrapper 读到的几处。 */
38
+ function makeWrapper(sessionId, entries = []) {
39
+ let emit = null;
40
+ const inner = {
41
+ sessionId,
42
+ isBashRunning: false,
43
+ isStreaming: false,
44
+ isCompacting: false,
45
+ extensionRunner: {},
46
+ sessionManager: { getCwd: () => "/tmp", getEntries: () => entries },
47
+ agent: { state: {} },
48
+ subscribe(listener) {
49
+ emit = listener;
50
+ return () => {
51
+ emit = null;
52
+ };
53
+ },
54
+ dispose() {},
55
+ };
56
+ const wrapper = new AgentSessionWrapper(inner);
57
+ wrapper.start();
58
+ return { wrapper, fire: (event) => emit(event) };
59
+ }
60
+
61
+ const userEntry = (text) => ({ type: "message", message: { role: "user", content: [{ type: "text", text }] } });
62
+
63
+ test("agent_start → 刷 modified(活跃时间立刻前移),不碰 last_reply", (t) => {
64
+ const db = freshDb();
65
+ seedSession(db, "sess-live");
66
+ const { wrapper, fire } = makeWrapper("sess-live");
67
+ t.after(() => wrapper.destroy());
68
+
69
+ const before = Date.now();
70
+ fire({ type: "agent_start" });
71
+
72
+ const row = rowOf(db, "sess-live");
73
+ assert.ok(row.modified >= before, "agent_start 立刻把活跃时间前移到当前");
74
+ assert.equal(row.last_reply, null);
75
+ assert.equal(row.updated, 111);
76
+ assert.ok(wrapper.lastAgentStartAt() >= before, "既有 agent_start 逻辑(缓存起始时间)保持原样");
77
+ });
78
+
79
+ test("一轮循环结束:message_end 缓存、agent_settled 落 last_reply + modified", (t) => {
80
+ const db = freshDb();
81
+ seedSession(db, "sess-live");
82
+ const { wrapper, fire } = makeWrapper("sess-live");
83
+ t.after(() => wrapper.destroy());
84
+
85
+ fire({ type: "agent_start" });
86
+ fire(assistant("中间回复", 1000));
87
+ assert.equal(rowOf(db, "sess-live").last_reply, null, "单条 message_end 不落库(不是循环最后一条)");
88
+ fire(assistant("最后一条回复", 2000));
89
+ fire({ type: "agent_settled" });
90
+
91
+ const row = rowOf(db, "sess-live");
92
+ assert.equal(row.last_reply, "最后一条回复");
93
+ assert.equal(row.modified, 2000);
94
+ });
95
+
96
+ test("用户取消(stopReason=aborted)走同一条路径落库", (t) => {
97
+ const db = freshDb();
98
+ seedSession(db, "sess-live");
99
+ const { wrapper, fire } = makeWrapper("sess-live");
100
+ t.after(() => wrapper.destroy());
101
+
102
+ fire(assistant("被打断的半截回复", 3000, "aborted"));
103
+ fire({ type: "agent_settled" });
104
+
105
+ assert.equal(rowOf(db, "sess-live").last_reply, "被打断的半截回复");
106
+ });
107
+
108
+ test("本轮没采到文本 → 保留库里的 last_reply,只刷 modified", (t) => {
109
+ const db = freshDb();
110
+ seedSession(db, "sess-live");
111
+ db.prepare("UPDATE session_meta SET last_reply = '上一条回复' WHERE session_id = 'sess-live'").run();
112
+ const { wrapper, fire } = makeWrapper("sess-live");
113
+ t.after(() => wrapper.destroy());
114
+
115
+ fire({ type: "agent_start" });
116
+ fire({ type: "agent_settled" });
117
+
118
+ const row = rowOf(db, "sess-live");
119
+ assert.equal(row.last_reply, "上一条回复", "空文本不清库");
120
+ assert.ok(row.modified > 200);
121
+ });
122
+
123
+ test("首条用户消息在落库时回填(内存 entries,不读文件),只补一次", (t) => {
124
+ const db = freshDb();
125
+ seedSession(db, "sess-live");
126
+ const entries = [userEntry("帮我写一个 e2e 测试"), { type: "message", message: { role: "assistant", content: [] } }];
127
+ const { wrapper, fire } = makeWrapper("sess-live", entries);
128
+ t.after(() => wrapper.destroy());
129
+
130
+ fire(assistant("好的", 1000));
131
+ fire({ type: "agent_settled" });
132
+ assert.equal(rowOf(db, "sess-live").first_message, "帮我写一个 e2e 测试");
133
+
134
+ entries[0] = userEntry("换了个说法");
135
+ fire(assistant("第二次回复", 2000));
136
+ fire({ type: "agent_settled" });
137
+ assert.equal(rowOf(db, "sess-live").first_message, "帮我写一个 e2e 测试", "首条消息不被后续覆盖");
138
+ });
139
+
140
+ test("首条用户消息截断到 300 字符(与扫描器同一上限)", (t) => {
141
+ const db = freshDb();
142
+ seedSession(db, "sess-live");
143
+ const long = "长".repeat(500);
144
+ const { wrapper, fire } = makeWrapper("sess-live", [userEntry(long)]);
145
+ t.after(() => wrapper.destroy());
146
+
147
+ fire(assistant("回复", 1000));
148
+ fire({ type: "agent_settled" });
149
+
150
+ assert.equal(rowOf(db, "sess-live").first_message.length, 300);
151
+ });
152
+
153
+ test("并发会话各自记账,事件不串台", (t) => {
154
+ const db = freshDb();
155
+ seedSession(db, "sess-a");
156
+ seedSession(db, "sess-b");
157
+ const a = makeWrapper("sess-a");
158
+ const b = makeWrapper("sess-b");
159
+ t.after(() => {
160
+ a.wrapper.destroy();
161
+ b.wrapper.destroy();
162
+ });
163
+
164
+ a.fire(assistant("A 的回复", 1000));
165
+ b.fire(assistant("B 的回复", 2000));
166
+ b.fire({ type: "agent_settled" });
167
+ a.fire({ type: "agent_settled" });
168
+
169
+ assert.deepEqual(
170
+ [rowOf(db, "sess-a").last_reply, rowOf(db, "sess-b").last_reply],
171
+ ["A 的回复", "B 的回复"],
172
+ );
173
+ });
174
+
175
+ test("库里没有行时事件写库静默 no-op,且不影响既有事件流", (t) => {
176
+ const db = freshDb();
177
+ const { wrapper, fire } = makeWrapper("sess-unknown");
178
+ t.after(() => wrapper.destroy());
179
+ const events = [];
180
+ wrapper.onEvent((event) => events.push(event.type));
181
+
182
+ fire({ type: "agent_start" });
183
+ fire(assistant("回复", 1000));
184
+ fire({ type: "agent_settled" });
185
+
186
+ assert.equal(db.prepare("SELECT COUNT(*) AS c FROM session_meta").get().c, 0, "事件不建行");
187
+ assert.deepEqual(events, ["agent_start", "message_end", "agent_settled"]);
188
+ });
189
+
190
+ test("写库失败必须可见且不打断既有事件流", (t) => {
191
+ const db = freshDb();
192
+ seedSession(db, "sess-live");
193
+ const { wrapper, fire } = makeWrapper("sess-live");
194
+ t.after(() => {
195
+ wrapper.destroy();
196
+ setDbForTesting(db);
197
+ });
198
+ const errors = [];
199
+ const original = console.error;
200
+ console.error = (...args) => errors.push(args.map(String).join(" "));
201
+ t.after(() => {
202
+ console.error = original;
203
+ });
204
+ const events = [];
205
+ wrapper.onEvent((event) => events.push(event.type));
206
+
207
+ // 让库写入抛错:stub 掉全局库句柄(真库仍在 db 变量里,未被触碰)
208
+ globalThis.__piWebDb = {
209
+ prepare() {
210
+ throw new Error("database is locked");
211
+ },
212
+ };
213
+
214
+ fire({ type: "agent_start" });
215
+ fire(assistant("回复", 1000));
216
+ fire({ type: "agent_settled" });
217
+
218
+ assert.equal(errors.length, 2, "agent_start / agent_settled 各报一次写库失败");
219
+ assert.match(errors.join("\n"), /会话活跃写库失败/);
220
+ assert.match(errors.join("\n"), /database is locked/);
221
+ assert.deepEqual(events, ["agent_start", "message_end", "agent_settled"], "写库失败不打断事件流");
222
+ assert.ok(wrapper.lastAgentStartAt() > 0, "既有回调逻辑(agent_start 缓存)不受影响");
223
+ });
@@ -137,3 +137,25 @@ test("reloading a session invalidates the models cache", async () => {
137
137
  assert.match(reloadSource, /await this\.inner\.reload\(\)/);
138
138
  assert.match(reloadSource, /this\.applyForcedEmptySystemPrompt\(\);\s*invalidateModelsCache\(\)/);
139
139
  });
140
+
141
+ test("fork 建行失败不再静默(可见报错,不依赖扫描器兜底)", async () => {
142
+ const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
143
+ const forkSource = source.slice(source.indexOf('case "fork"'), source.indexOf('case "navigate_tree"'));
144
+
145
+ assert.match(forkSource, /console\.error\(\s*`\[pi-web\] fork 建行失败 session=\$\{newSessionId\}/);
146
+ // 入板失败单独一条日志:不能被误报成「建行失败」。
147
+ assert.match(forkSource, /console\.error\(\s*`\[pi-web\] fork 入板失败 source=\$\{sourceSessionId\}/);
148
+ // 静默 catch 会让子会话以「无归属临时会话」出现在聊天区(V1 病灶):fork 分支里不得再有裸 catch。
149
+ assert.doesNotMatch(forkSource, /catch \{/);
150
+ });
151
+
152
+ test("新会话建行失败也不再静默(失败可见,不依赖扫描器兜底)", async () => {
153
+ const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
154
+ const persistSource = source.slice(
155
+ source.indexOf("async function persistNewSessionFile"),
156
+ source.indexOf("export function getRpcSession"),
157
+ );
158
+
159
+ assert.match(persistSource, /console\.error\(\s*`\[pi-web\] 新会话建行失败 session=\$\{sessionId\}/);
160
+ assert.doesNotMatch(persistSource, /catch \{/);
161
+ });
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { createJiti } from "jiti";
6
+
7
+ const jiti = createJiti(import.meta.url, {
8
+ interopDefault: true,
9
+ moduleCache: false,
10
+ alias: { "@": process.cwd() },
11
+ });
12
+ const { setDbForTesting } = await jiti.import("./sqlite-db.ts");
13
+ const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts");
14
+
15
+ /** set_session_name 命令(前端改名/看板卡改名)必须与路由层同语义落库:
16
+ * pi 侧成功 → 同一次 send 内写 session_meta.title → 缓存失效;
17
+ * 写库失败 console.error 可见,但不抛断命令响应(RPC 已改文件名,抛错无可恢复动作)。
18
+ * 真 wrapper + 真库(:memory:)+ 假 SDK 会话(参照 rpc-manager.activity.test.mjs)。 */
19
+
20
+ function freshDb() {
21
+ const db = new DatabaseSync(":memory:");
22
+ setDbForTesting(db);
23
+ return db;
24
+ }
25
+
26
+ /** 全列行(title 为 NULL:模拟还没改过名的老会话)。 */
27
+ function seedSession(db, sessionId) {
28
+ db.prepare(
29
+ `INSERT INTO session_meta
30
+ (session_id, task_id, updated, pinned, path, cwd, project_key, title, first_message, parent_id, created, modified, last_reply)
31
+ VALUES (?, NULL, 111, 0, ?, '/p', 'proj', NULL, NULL, NULL, 100, 200, NULL)`,
32
+ ).run(sessionId, `/s/${sessionId}.jsonl`);
33
+ }
34
+
35
+ const rowOf = (db, sessionId) =>
36
+ db.prepare("SELECT * FROM session_meta WHERE session_id = ?").get(sessionId);
37
+
38
+ /** 取 `case "<name>"` 到下一个 case 之间的源码片段。
39
+ * 右边界用「下一个 case」的形状(不依赖某个具体 case 名存在)——别人把
40
+ * get_session_stats 改名/删除也不会让切片静默退化成整文件;找不到边界直接报错。 */
41
+ function caseSourceOf(source, name) {
42
+ const marker = `case "${name}"`;
43
+ const start = source.indexOf(marker);
44
+ assert.ok(start >= 0, `找不到 ${marker}`);
45
+ const rest = source.slice(start + marker.length);
46
+ const next = rest.slice(1).search(/\n case "/);
47
+ assert.ok(next >= 0, `${marker} 之后找不到下一个 case(切片无法闭合)`);
48
+ return rest.slice(0, next + 1);
49
+ }
50
+
51
+ /** 假 AgentSessionLike:只实现 wrapper 用到的几处 + 记录 pi 侧改名调用。 */
52
+ function makeWrapper(sessionId) {
53
+ const nameCalls = [];
54
+ const inner = {
55
+ sessionId,
56
+ isBashRunning: false,
57
+ isStreaming: false,
58
+ isCompacting: false,
59
+ extensionRunner: {},
60
+ sessionManager: { getCwd: () => "/tmp", getEntries: () => [] },
61
+ agent: { state: {} },
62
+ setSessionName(name) {
63
+ nameCalls.push(name);
64
+ },
65
+ subscribe() {
66
+ return () => {};
67
+ },
68
+ dispose() {},
69
+ };
70
+ const wrapper = new AgentSessionWrapper(inner);
71
+ wrapper.start();
72
+ return { wrapper, nameCalls };
73
+ }
74
+
75
+ /** 源码断言:命令分支确实接了库写,且失败处理形状正确。 */
76
+ test("set_session_name 分支接线:pi 成功 → await setSessionTitle → 失败只记日志不抛", async () => {
77
+ const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
78
+ const caseSource = caseSourceOf(source, "set_session_name");
79
+
80
+ assert.match(source, /import \{[^}]*\bsetSessionTitle\b[^}]*\} from "\.\/task-store";/s);
81
+ assert.ok(caseSource.includes("await setSessionTitle("), "库写必须 await(命令响应返回时库已落地)");
82
+ assert.match(caseSource, /setSessionTitle\(this\.inner\.sessionId, name\)/);
83
+ assert.ok(
84
+ caseSource.indexOf("this.inner.setSessionName(name)") < caseSource.indexOf("await setSessionTitle("),
85
+ "先 pi 侧改名成功、再写库(pi 失败时不该写库)",
86
+ );
87
+ assert.match(caseSource, /catch \(error\) \{[\s\S]*?console\.error\([\s\S]*?会话标题写库失败/);
88
+ assert.doesNotMatch(caseSource, /catch[\s\S]{0,400}?\bthrow\b/, "写库失败不得抛断命令响应/事件流");
89
+ assert.match(caseSource, /invalidateSessionListCache\(\);/);
90
+ assert.match(caseSource, /return null;/);
91
+ });
92
+
93
+ test("改名成功:同一次 send 内把 title 写库(不依赖扫描器/懒更新)", async (t) => {
94
+ const db = freshDb();
95
+ seedSession(db, "sess-name");
96
+ const { wrapper, nameCalls } = makeWrapper("sess-name");
97
+ t.after(() => wrapper.destroy());
98
+
99
+ const before = Date.now();
100
+ const result = await wrapper.send({ type: "set_session_name", name: " 新标题 " });
101
+
102
+ assert.deepEqual(nameCalls, ["新标题"], "pi 侧收到 trim 后的名字(校验行为不变)");
103
+ assert.equal(result, null, "返回值保持原样");
104
+ const row = rowOf(db, "sess-name");
105
+ assert.equal(row.title, "新标题", "刷新侧栏/看板卡不再退回旧标题");
106
+ assert.ok(row.updated >= before, "写库顺带前移 updated");
107
+ assert.equal(row.task_id, null, "改名不碰归属");
108
+ assert.equal(row.path, "/s/sess-name.jsonl", "改名不碰索引列(只 UPDATE title/updated)");
109
+ });
110
+
111
+ test("写库失败:console.error 可见、不抛(命令响应与事件流都不被打断)", async (t) => {
112
+ const db = freshDb();
113
+ seedSession(db, "sess-name");
114
+ const { wrapper, nameCalls } = makeWrapper("sess-name");
115
+ const errors = [];
116
+ const original = console.error;
117
+ t.after(() => {
118
+ wrapper.destroy();
119
+ console.error = original;
120
+ globalThis.__piWebDb = db;
121
+ });
122
+ console.error = (...args) => errors.push(args.map(String).join(" "));
123
+
124
+ // 让库写入抛错:stub 掉全局库句柄(真库仍在 db 变量里,未被触碰)
125
+ globalThis.__piWebDb = {
126
+ prepare() {
127
+ throw new Error("database is locked");
128
+ },
129
+ };
130
+
131
+ const result = await wrapper.send({ type: "set_session_name", name: "新标题" });
132
+
133
+ assert.equal(result, null, "RPC 命令照常返回成功响应(与路由层 500 语义不同:这里无可恢复动作)");
134
+ assert.deepEqual(nameCalls, ["新标题"], "pi 侧改名已生效");
135
+ assert.equal(errors.length, 1, "写库失败必须可见");
136
+ assert.match(errors[0], /\[pi-web\] 会话标题写库失败/);
137
+ assert.match(errors[0], /database is locked/);
138
+ });
139
+
140
+ test("空名字仍然报错(既有校验不变),不碰 pi 也不碰库", async (t) => {
141
+ const db = freshDb();
142
+ seedSession(db, "sess-name");
143
+ const { wrapper, nameCalls } = makeWrapper("sess-name");
144
+ t.after(() => wrapper.destroy());
145
+
146
+ await assert.rejects(
147
+ () => wrapper.send({ type: "set_session_name", name: " " }),
148
+ /Session name cannot be empty/,
149
+ );
150
+ assert.deepEqual(nameCalls, []);
151
+ assert.equal(rowOf(db, "sess-name").title, null);
152
+ });