zen-gitsync 2.17.15 → 2.17.17

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 (132) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +24 -2
  4. package/src/cli/ai/runtime.test.js +1 -1
  5. package/src/cli/ai/tools.js +7 -1
  6. package/src/cli/ai/tools.test.js +11 -0
  7. package/src/config.js +4 -4
  8. package/src/paths.js +3 -0
  9. package/src/ui/public/assets/AgentView-CXZUWLPL.css +1 -0
  10. package/src/ui/public/assets/AgentView-Dhs3eKFY.js +7 -0
  11. package/src/ui/public/assets/AppVersionBadge-D70AjIBo.js +2 -0
  12. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  13. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  14. package/src/ui/public/assets/BranchSelector-t3fnGS_5.js +1 -0
  15. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  16. package/src/ui/public/assets/CommitForm-DmlTnTwc.js +6 -0
  17. package/src/ui/public/assets/CommonDialog-CecgvEzd.js +1 -0
  18. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  19. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  20. package/src/ui/public/assets/EditorView-DVRemzZU.js +16 -0
  21. package/src/ui/public/assets/FileDiffViewer-DH8Y2lUO.js +15 -0
  22. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  23. package/src/ui/public/assets/{FlowExecutionViewer-BmfstdaT.js → FlowExecutionViewer-Cie2_TG4.js} +1 -1
  24. package/src/ui/public/assets/FlowOrchestrationWorkspace-Bbf8X1KP.js +21 -0
  25. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  26. package/src/ui/public/assets/IconButton-BEiZI4F6.js +1 -0
  27. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  28. package/src/ui/public/assets/LogList-DvuC03Tx.js +7 -0
  29. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  30. package/src/ui/public/assets/MindmapView-B6rITyNs.js +2 -0
  31. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  32. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  33. package/src/ui/public/assets/MonitorView-q92EZvay.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  35. package/src/ui/public/assets/ProjectStartupButton-RiIi8TwW.js +1 -0
  36. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  37. package/src/ui/public/assets/RemoteManagerDialog-qtBotYAQ.js +4 -0
  38. package/src/ui/public/assets/RemoteRepoCard-DvtzfwHV.js +7 -0
  39. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  40. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  41. package/src/ui/public/assets/SourceMapView-EEWAmy4U.js +3 -0
  42. package/src/ui/public/assets/SvgIcon-Bkc_oGUI.js +1 -0
  43. package/src/ui/public/assets/TemplateManager-9e4amMlI.js +1 -0
  44. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  45. package/src/ui/public/assets/UserInputNode-4FoJblJi.js +1 -0
  46. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  47. package/src/ui/public/assets/WorkbenchView-BFaTinH8.js +56 -0
  48. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  49. package/src/ui/public/assets/{_plugin-vue_export-helper-B1mHBne9.js → _plugin-vue_export-helper-BLGowG5t.js} +7 -7
  50. package/src/ui/public/assets/agentConversations-BQCIv4u1.js +5 -0
  51. package/src/ui/public/assets/configStore-BUXv5i5j.js +1 -0
  52. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  53. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  54. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  55. package/src/ui/public/assets/gitStore-BGbEYd0U.js +6 -0
  56. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  57. package/src/ui/public/assets/index-Di5cimoI.js +115 -0
  58. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  59. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  60. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  61. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  62. package/src/ui/public/assets/previewSandbox-DEQpyVNu.js +0 -0
  63. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  64. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  65. package/src/ui/public/assets/{vendor-C5qX_I8r.css → vendor-C-GCxMMu.css} +1 -1
  66. package/src/ui/public/assets/{vendor-IQQMq-1c.js → vendor-CLmPUj3K.js} +43 -43
  67. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  68. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  69. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  70. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  71. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  72. package/src/ui/public/index.html +28 -27
  73. package/src/ui/server/routes/workbench/agentChat.js +69 -12
  74. package/src/ui/server/routes/workbench/agentChatContext.test.js +106 -0
  75. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  76. package/src/ui/server/routes/workbench/agentMarketplace.js +23 -9
  77. package/src/ui/server/routes/workbench/agentMarketplace.test.js +32 -0
  78. package/src/ui/server/routes/workbench/agentRoutes.js +57 -15
  79. package/src/ui/server/routes/workbench/agentRoutes.test.js +142 -2
  80. package/src/ui/server/routes/workbench/taskRunner.codex.test.js +173 -0
  81. package/src/ui/server/routes/workbench/taskRunner.js +259 -10
  82. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +4 -2
  83. package/src/ui/server/utils/agentAttachments.js +169 -0
  84. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  85. package/src/ui/public/assets/AgentView-Bz9LFamz.js +0 -5
  86. package/src/ui/public/assets/AgentView-vvVhbIQ5.css +0 -1
  87. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  88. package/src/ui/public/assets/AppVersionBadge-D5iXcAEV.js +0 -2
  89. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  90. package/src/ui/public/assets/BranchSelector-DP4u1f_B.js +0 -1
  91. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  92. package/src/ui/public/assets/CommitForm-boOw8uGS.js +0 -6
  93. package/src/ui/public/assets/CommonDialog-B_qUsIGr.js +0 -1
  94. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  95. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  96. package/src/ui/public/assets/EditorView-ZULtfIzl.js +0 -1
  97. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  98. package/src/ui/public/assets/FileDiffViewer-C4xxq5S0.js +0 -15
  99. package/src/ui/public/assets/FlowOrchestrationWorkspace-28kErqrn.js +0 -21
  100. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  101. package/src/ui/public/assets/IconButton-DcOAqpq0.js +0 -1
  102. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  103. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  104. package/src/ui/public/assets/LogList-FEP7EeJt.js +0 -7
  105. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  106. package/src/ui/public/assets/MindmapView-NDJvfSCn.js +0 -2
  107. package/src/ui/public/assets/MonitorView-DU4aBCK0.js +0 -1
  108. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  109. package/src/ui/public/assets/ProjectStartupButton-JQk4Etsu.js +0 -1
  110. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  111. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  112. package/src/ui/public/assets/RemoteManagerDialog-Di2WizMS.js +0 -4
  113. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  114. package/src/ui/public/assets/RemoteRepoCard-DxB8Q8yE.js +0 -7
  115. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  116. package/src/ui/public/assets/SourceMapView-CfmrPMBm.js +0 -3
  117. package/src/ui/public/assets/SvgIcon-yefEMV0-.js +0 -1
  118. package/src/ui/public/assets/TemplateManager-CCy_WcSK.js +0 -1
  119. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  120. package/src/ui/public/assets/UserInputNode-3Gv_9YZx.js +0 -1
  121. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  122. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  123. package/src/ui/public/assets/WorkbenchView-Dgp6A83v.js +0 -56
  124. package/src/ui/public/assets/configStore-BDxel6Co.js +0 -1
  125. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  126. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  127. package/src/ui/public/assets/gitStore-D5CWxPLv.js +0 -6
  128. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  129. package/src/ui/public/assets/index-gpuX9fhG.js +0 -111
  130. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  131. package/src/ui/public/assets/previewSandbox-qLKxghfr.js +0 -0
  132. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
26
26
  import { agentSessionStore } from './agentSessionStore.js';
27
27
  import { runAgentTurn } from './agentChat.js';
28
+ import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
28
29
  import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
29
30
  import { createProjectListProvider } from './projectTool.js';
30
31
  import { nowIso } from './shared.js';
@@ -51,11 +52,14 @@ function sessionBelongsTo(sessionCwd, projectCwd) {
51
52
  // matching response. The entry is removed on every completion or disconnect.
52
53
  const pendingAgentQuestions = new Map();
53
54
 
55
+ /** 正在进行中的会话 id 集合:同一会话同一时刻只允许一个生成中的轮次 */
56
+ const activeSessionTurns = new Set();
57
+
54
58
  function interactionKey(sessionId, interactionId) {
55
59
  return `${String(sessionId)}:${String(interactionId)}`;
56
60
  }
57
61
 
58
- export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, send, signal }) {
62
+ export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, multiple, send, signal }) {
59
63
  const key = interactionKey(sessionId, interactionId);
60
64
  return new Promise((resolve) => {
61
65
  let settled = false;
@@ -75,10 +79,11 @@ export function waitForAgentAnswer({ sessionId, interactionId, question, options
75
79
  question,
76
80
  options,
77
81
  allowFreeText,
82
+ multiple: multiple === true,
78
83
  settle,
79
84
  });
80
85
  signal?.addEventListener?.('abort', onAbort, { once: true });
81
- send({ type: 'ask_user', interactionId, question, options, allowFreeText });
86
+ send({ type: 'ask_user', interactionId, question, options, allowFreeText, multiple: multiple === true });
82
87
  if (signal?.aborted) onAbort();
83
88
  });
84
89
  }
@@ -88,12 +93,17 @@ export function submitAgentAnswer({ sessionId, interactionId, answer }) {
88
93
  const pending = pendingAgentQuestions.get(key);
89
94
  if (!pending) return { ok: false, status: 404, error: 'No pending question for this session.' };
90
95
 
91
- const value = String(answer || '').trim();
92
- if (!value) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
93
- if (!pending.allowFreeText && pending.options.length > 0 && !pending.options.includes(value)) {
94
- return { ok: false, status: 400, error: 'Choose one of the listed options.' };
96
+ // 单选发字符串、多选发数组;这里统一归一成"非空的字符串列表"
97
+ const values = (Array.isArray(answer) ? answer : [answer])
98
+ .map((v) => String(v ?? '').trim())
99
+ .filter(Boolean);
100
+ if (!values.length) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
101
+ if (!pending.allowFreeText && pending.options.length > 0) {
102
+ const unknown = values.filter((v) => !pending.options.includes(v));
103
+ if (unknown.length) return { ok: false, status: 400, error: 'Choose one of the listed options.' };
95
104
  }
96
- pending.settle(value);
105
+ // 多选回给模型的是 JSON 数组字符串(单选/自由输入保持原样字符串)
106
+ pending.settle(pending.multiple ? JSON.stringify(values) : values[0]);
97
107
  return { ok: true };
98
108
  }
99
109
 
@@ -181,10 +191,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
181
191
  .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
182
192
  .slice(0, 10);
183
193
 
184
- if (!userMessage && images.length === 0) {
194
+ // 非图片附件:前端只传文件名 + 字节,服务端落盘到数据目录,只把**绝对路径**交给模型。
195
+ // 内容不进消息体(省 token,模型自己用 read/grep 按需取),校验见 utils/agentAttachments.js。
196
+ const rawAttachments = (Array.isArray(req.body?.attachments) ? req.body.attachments : [])
197
+ .slice(0, MAX_ATTACHMENTS)
198
+ .map(a => ({ name: a?.name, dataUrl: a?.dataUrl }));
199
+
200
+ if (!userMessage && images.length === 0 && rawAttachments.length === 0) {
185
201
  return res.status(400).json({ success: false, error: '消息内容不能为空' });
186
202
  }
187
203
 
204
+ // 文件空间对话:客户端把"当前打开的文档"带上来,服务端只在请求副本里注入上下文(不落库)
205
+ const openFilePath = String(req.body?.openFilePath || '').trim().slice(0, 512);
206
+
188
207
  // SSE 头
189
208
  res.set({
190
209
  'Content-Type': 'text/event-stream',
@@ -201,6 +220,15 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
201
220
  let finished = false;
202
221
  let activeChild = null;
203
222
 
223
+ // 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
224
+ // 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
225
+ if (sessionIdInput && activeSessionTurns.has(sessionIdInput)) {
226
+ send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
227
+ finished = true;
228
+ return res.end();
229
+ }
230
+ let activeSessionKey = null;
231
+
204
232
  // 客户端断开
205
233
  if (req.socket) {
206
234
  req.socket.once('close', () => {
@@ -258,6 +286,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
258
286
  isNew = true;
259
287
  }
260
288
 
289
+ // 本轮占用该会话(finally 里释放)
290
+ activeSessionKey = session.sessionId;
291
+ activeSessionTurns.add(activeSessionKey);
292
+
293
+ // 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
294
+ // 拿不到路径最多是模型看不到附件,用户仍能正常对话。
295
+ const attachments = await saveAgentAttachments(rawAttachments, {
296
+ onSkip: (info) => console.warn('[agent] 附件被忽略:', info.name, info.reason)
297
+ }).catch((err) => {
298
+ console.warn('[agent] 附件落盘失败:', err?.message || err);
299
+ return [];
300
+ });
301
+
261
302
  // 获取模型配置
262
303
  let model;
263
304
  try {
@@ -291,13 +332,15 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
291
332
  });
292
333
 
293
334
  // 运行 agent 循环
294
- const { aborted } = await runAgentTurn({
335
+ await runAgentTurn({
295
336
  session,
296
337
  model,
297
338
  userMessage,
298
339
  images,
299
340
  cwd,
300
341
  locale,
342
+ openFilePath,
343
+ attachments,
301
344
  signal: abortController.signal,
302
345
  send,
303
346
  onChild: (child) => { activeChild = child; },
@@ -307,23 +350,20 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
307
350
  question: args.question,
308
351
  options: Array.isArray(args.options) ? args.options : [],
309
352
  allowFreeText: args.allowFreeText !== false,
353
+ multiple: args.multiple === true,
310
354
  send,
311
355
  signal: abortController.signal,
312
356
  }),
313
357
  listProjects
314
358
  });
315
359
 
316
- if (aborted) {
317
- finished = true;
318
- return res.end();
319
- }
320
-
321
360
  // 更新标题(新会话从第一条 user 消息自动生成)
322
361
  if (isNew) {
323
362
  session.title = autoTitle(session.messages);
324
363
  }
325
364
 
326
- // 持久化
365
+ // 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
366
+ // 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
327
367
  session.updatedAt = nowIso();
328
368
  await writeSession(session.sessionId, session);
329
369
  enforceRetention().catch(() => {});
@@ -334,6 +374,8 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
334
374
  send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
335
375
  finished = true;
336
376
  res.end();
377
+ } finally {
378
+ if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
337
379
  }
338
380
  });
339
381
  }
@@ -1,7 +1,29 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
- import { registerAgentRoutes, waitForAgentAnswer } from './agentRoutes.js';
6
+
7
+ // agentSessionStore 的落盘目录在模块加载时由 os.homedir() 求值,必须在 import 之前
8
+ // 把 HOME 指到沙箱,否则这个文件会往真实 ~/.zen-gitsync/agent-sessions 里写会话。
9
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-routes-'));
10
+ process.env.USERPROFILE = SANDBOX;
11
+ process.env.HOME = SANDBOX;
12
+ delete process.env.HOMEDRIVE;
13
+ delete process.env.HOMEPATH;
14
+
15
+ const { registerAgentRoutes, waitForAgentAnswer } = await import('./agentRoutes.js');
16
+
17
+ const event = data => `data: ${JSON.stringify(data)}\n\n`;
18
+
19
+ // 轮询等待条件成立(SSE 是边跑边写出来的,没有可 await 的句柄)
20
+ async function waitUntil(check, timeoutMs = 2000) {
21
+ const start = Date.now();
22
+ while (!check()) {
23
+ if (Date.now() - start > timeoutMs) throw new Error('等待超时');
24
+ await new Promise(resolve => setTimeout(resolve, 5));
25
+ }
26
+ }
5
27
 
6
28
  test('agent respond resolves a pending ask_user question and validates answers', async () => {
7
29
  let respondHandler;
@@ -29,7 +51,7 @@ test('agent respond resolves a pending ask_user question and validates answers',
29
51
  send: event => sent.push(event),
30
52
  });
31
53
  await new Promise(resolve => setImmediate(resolve));
32
- assert.deepEqual(sent, [{ type: 'ask_user', interactionId: 'call-test', question: 'Pick one', options: ['A', 'B'], allowFreeText: false }]);
54
+ assert.deepEqual(sent, [{ type: 'ask_user', interactionId: 'call-test', question: 'Pick one', options: ['A', 'B'], allowFreeText: false, multiple: false }]);
33
55
 
34
56
  let statusCode = 200;
35
57
  let responseBody;
@@ -52,6 +74,52 @@ test('agent respond resolves a pending ask_user question and validates answers',
52
74
  assert.equal(responseBody.success, false);
53
75
  });
54
76
 
77
+ test('agent respond: 多选提交逐项校验,回给模型的是 JSON 数组字符串', async () => {
78
+ let respondHandler;
79
+ const app = {
80
+ get() {},
81
+ delete() {},
82
+ put() {},
83
+ post(route, handler) {
84
+ if (route === '/api/agent/respond') respondHandler = handler;
85
+ }
86
+ };
87
+ registerAgentRoutes({
88
+ app,
89
+ getCurrentProjectPath: () => path.resolve('active-project'),
90
+ configManager: null
91
+ });
92
+
93
+ const sent = [];
94
+ const pending = waitForAgentAnswer({
95
+ sessionId: 'session-multi',
96
+ interactionId: 'call-multi',
97
+ question: 'Pick some',
98
+ options: ['A', 'B', 'C'],
99
+ allowFreeText: false,
100
+ multiple: true,
101
+ send: event => sent.push(event),
102
+ });
103
+ await new Promise(resolve => setImmediate(resolve));
104
+ assert.equal(sent[0].multiple, true);
105
+
106
+ let statusCode = 200;
107
+ let responseBody;
108
+ const response = {
109
+ status(code) { statusCode = code; return this; },
110
+ json(body) { responseBody = body; return body; },
111
+ };
112
+
113
+ // 只要有一项不在选项里就整体拒绝
114
+ await respondHandler({ body: { sessionId: 'session-multi', interactionId: 'call-multi', answer: ['A', 'X'] } }, response);
115
+ assert.equal(statusCode, 400);
116
+ assert.equal(responseBody.success, false);
117
+
118
+ await respondHandler({ body: { sessionId: 'session-multi', interactionId: 'call-multi', answer: ['A', 'C'] } }, response);
119
+ assert.deepEqual(responseBody, { success: true });
120
+ assert.equal(await pending, '["A","C"]');
121
+ });
122
+
55
123
  test('agent chat rejects a cwd that differs from the active server project', async () => {
56
124
  let chatHandler;
57
125
  const app = {
@@ -87,3 +155,75 @@ test('agent chat rejects a cwd that differs from the active server project', asy
87
155
  assert.match(output, /PROJECT_MISMATCH/);
88
156
  assert.match(output, /项目不一致/);
89
157
  });
158
+
159
+ // 回归:用户点"停止"(客户端断开连接 → 服务端 abort)后,这条会话仍要落盘。
160
+ // 之前中止分支直接 res.end(),磁盘上没有会话文件,前端停止后立刻刷新列表
161
+ // 就把左栏这一条整个吞掉了 —— 用户看到的是"一停止任务就没了"。
162
+ test('停止(客户端断开)后会话仍要落盘,并带上自动标题与已流出的正文', async () => {
163
+ const activeCwd = path.resolve('active-project');
164
+ let chatHandler;
165
+ const app = {
166
+ get() {},
167
+ delete() {},
168
+ put() {},
169
+ post(route, handler) {
170
+ if (route === '/api/agent/chat') chatHandler = handler;
171
+ }
172
+ };
173
+ registerAgentRoutes({
174
+ app,
175
+ getCurrentProjectPath: () => activeCwd,
176
+ configManager: {
177
+ readRawConfigFile: async () => ({
178
+ models: [{ model: 'test', name: 'T', baseURL: 'https://example.invalid/v1', apiKey: 'k', isDefault: true }]
179
+ })
180
+ }
181
+ });
182
+
183
+ // 模型流:吐一段正文后挂住(模拟"停在生成中")。真 fetch 在 signal abort 时会打断
184
+ // body 读取,桩里照做才能真的走到中止分支
185
+ const hanging = init => new Response(new ReadableStream({
186
+ start(stream) {
187
+ const bytes = new TextEncoder();
188
+ stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })));
189
+ init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')));
190
+ }
191
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } });
192
+
193
+ const originalFetch = globalThis.fetch;
194
+ globalThis.fetch = async (_url, init) => hanging(init);
195
+
196
+ let onSocketClose;
197
+ let output = '';
198
+ let ended = false;
199
+ const req = {
200
+ body: { userMessage: '请列出当前项目的目录结构', cwd: activeCwd },
201
+ headers: {},
202
+ socket: { once(_evt, cb) { onSocketClose = cb; } }
203
+ };
204
+ const res = {
205
+ set() {},
206
+ flushHeaders() {},
207
+ write(chunk) { output += chunk; },
208
+ end() { ended = true; }
209
+ };
210
+
211
+ try {
212
+ const route = chatHandler(req, res);
213
+ await waitUntil(() => output.includes('"type":"content"'));
214
+ onSocketClose();
215
+ await route;
216
+ } finally {
217
+ globalThis.fetch = originalFetch;
218
+ }
219
+
220
+ assert.equal(ended, true, '中止后也要正常收尾');
221
+ const dir = path.join(SANDBOX, '.zen-gitsync', 'agent-sessions');
222
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
223
+ assert.equal(files.length, 1, '被停止的会话必须落盘,否则左栏这一条会消失');
224
+ const saved = JSON.parse(readFileSync(path.join(dir, files[0]), 'utf-8'));
225
+ assert.equal(saved.title, '请列出当前项目的目录结构');
226
+ const last = saved.messages[saved.messages.length - 1];
227
+ assert.equal(last.role, 'assistant');
228
+ assert.equal(last.content, '已经写了一半');
229
+ });
@@ -0,0 +1,173 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+
16
+ // codex 执行器事件处理器的回归测试(2026-09-27)。
17
+ //
18
+ // 钉住 codex `exec --json` 适配层的五条口径(事件样例取自 codex-cli 0.157.x 实测输出):
19
+ // 1. thread.started 的 thread_id 落 job.claudeSessionId(续接 exec resume 的取值来源)
20
+ // 2. agent_message / reasoning 分别落 output / thinking,且按 item.id 去重累积推送
21
+ // 3. command_execution / file_change / mcp_tool_call / web_search 归一成工具调用,
22
+ // item.started(status=in_progress) → item.completed 更新同一条
23
+ // 4. error 类事件(顶层 error / item.type=error)是非致命信息,只贴 output,
24
+ // 绝不写 job.agentError —— 否则配置告警会被误判成任务失败
25
+ // 5. turn.failed 才是终态失败:error.message 写进 job.agentError
26
+ //
27
+ // 隔离策略同 taskRunner.opencode.test.js:import taskRunner.js 之前把
28
+ // USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
29
+
30
+ import { test } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import path from 'node:path';
33
+ import os from 'node:os';
34
+ import { mkdtempSync } from 'node:fs';
35
+ import { fileURLToPath, pathToFileURL } from 'node:url';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const projectRoot = path.resolve(__dirname, '../../../../..');
39
+
40
+ // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
41
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-codex-test-'));
42
+ process.env.USERPROFILE = sandbox;
43
+ process.env.HOME = sandbox;
44
+ delete process.env.HOMEDRIVE;
45
+ delete process.env.HOMEPATH;
46
+
47
+ const { createCodexEventHandler } = await import(
48
+ pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
49
+ );
50
+
51
+ /** 可断言的 appendOutput/appendThinking 收集器 */
52
+ function makeSink() {
53
+ const sink = { output: [], thinking: [] };
54
+ sink.appendOutput = (t) => { if (t) sink.output.push(t); };
55
+ sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
56
+ return sink;
57
+ }
58
+
59
+ test('codex 事件:thread_id 落 claudeSessionId,正文/思考分别归位', () => {
60
+ const job = {};
61
+ const sink = makeSink();
62
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
63
+
64
+ handle({ type: 'thread.started', thread_id: '01a0e088-32fc-7511-aa6e-4e836bf138f7' });
65
+ handle({ type: 'turn.started' });
66
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'reasoning', text: '先看一眼目录' } });
67
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: '改好了' } });
68
+ handle({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } });
69
+
70
+ assert.equal(job.claudeSessionId, '01a0e088-32fc-7511-aa6e-4e836bf138f7');
71
+ assert.deepEqual(sink.output, ['改好了']);
72
+ assert.deepEqual(sink.thinking, ['先看一眼目录']);
73
+ assert.equal(job.agentError, undefined);
74
+ });
75
+
76
+ test('codex 事件:同一 item.id 累积推送只追加增量', () => {
77
+ const job = {};
78
+ const sink = makeSink();
79
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
80
+
81
+ // 累积语义:第二次的 text 是第一次的前缀延长
82
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello' } });
83
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello, world' } });
84
+ // 重复事件(完全相同):不产生任何输出
85
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello, world' } });
86
+ // 不同 item.id:各自独立,整段落下
87
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'Hello' } });
88
+
89
+ assert.deepEqual(sink.output, ['Hello', ', world', 'Hello']);
90
+ });
91
+
92
+ test('codex 事件:command_execution 的 started → completed 更新同一条工具调用', () => {
93
+ const job = {};
94
+ const sink = makeSink();
95
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
96
+
97
+ handle({ type: 'item.started', item: { id: 'item_0', type: 'command_execution', command: 'npm test', aggregated_output: '', exit_code: null, status: 'in_progress' } });
98
+ assert.equal(job.toolCalls.length, 1);
99
+ assert.equal(job.toolCalls[0].status, 'running');
100
+ assert.equal(job.toolCalls[0].argsPreview, 'npm test');
101
+
102
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'npm test', aggregated_output: 'ok 12 tests', exit_code: 0, status: 'completed' } });
103
+ assert.equal(job.toolCalls.length, 1, '同一 id 不新增记录');
104
+ assert.equal(job.toolCalls[0].status, 'done');
105
+ assert.equal(job.toolCalls[0].result, 'ok 12 tests');
106
+ assert.ok(!job.toolCalls[0].error);
107
+ });
108
+
109
+ test('codex 事件:命令失败/文件改动的工具调用落 error、文件清单作结果', () => {
110
+ const job = {};
111
+ const sink = makeSink();
112
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
113
+
114
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'npm run build', aggregated_output: 'boom', exit_code: 1, status: 'failed' } });
115
+ assert.equal(job.toolCalls[0].status, 'error');
116
+ assert.match(job.toolCalls[0].error, /exit_code=1/);
117
+
118
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'file_change', changes: [{ path: 'src/a.js', kind: 'update' }, { path: 'src/b.js', kind: 'add' }], status: 'completed' } });
119
+ assert.equal(job.toolCalls[1].status, 'done');
120
+ assert.equal(job.toolCalls[1].result, 'update src/a.js\nadd src/b.js');
121
+ });
122
+
123
+ test('codex 事件:mcp_tool_call / web_search 归一成工具调用', () => {
124
+ const job = {};
125
+ const sink = makeSink();
126
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
127
+
128
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'mcp_tool_call', server: 'node_repl', tool: 'run', arguments: { code: '1+1' }, result: { content: [{ type: 'text', text: '2' }] }, status: 'completed' } });
129
+ assert.equal(job.toolCalls[0].name, 'node_repl.run');
130
+ assert.equal(job.toolCalls[0].status, 'done');
131
+ assert.equal(job.toolCalls[0].result, '2');
132
+
133
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'web_search', query: 'codex exec json', action: 'search', status: 'completed' } });
134
+ assert.equal(job.toolCalls[1].name, 'web_search');
135
+ assert.equal(job.toolCalls[1].argsPreview, 'codex exec json');
136
+ });
137
+
138
+ test('codex 事件:告警只贴 output,不置 agentError;turn.failed 才判失败', () => {
139
+ const job = {};
140
+ const sink = makeSink();
141
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
142
+
143
+ // 配置告警(item.type=error)与重试提示(顶层 error)都是非终止信息
144
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'error', message: 'Codex is ignoring 2 unrecognized configuration settings.' } });
145
+ handle({ type: 'error', message: 'Reconnecting... 1/5 (unexpected status 401 Unauthorized)' });
146
+ assert.equal(job.agentError, undefined, '告警不能把任务判成失败');
147
+ assert.equal(sink.output.length, 2);
148
+
149
+ // 终态失败
150
+ handle({ type: 'turn.failed', error: { message: 'unexpected status 401 Unauthorized' } });
151
+ assert.equal(job.agentError, 'unexpected status 401 Unauthorized');
152
+ // 只记第一条
153
+ handle({ type: 'turn.failed', error: { message: 'second one' } });
154
+ assert.equal(job.agentError, 'unexpected status 401 Unauthorized');
155
+ });
156
+
157
+ test('codex 事件:未知 item 类型 / 缺字段事件不抛错也不产生输出', () => {
158
+ const job = {};
159
+ const sink = makeSink();
160
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
161
+
162
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'todo_list', items: [{ text: 'step', completed: false }] } });
163
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'collab_tool_call', tool: 'wait', status: 'completed' } });
164
+ handle({ type: 'item.completed', item: { id: 'item_2', type: 'some_future_item_type' } });
165
+ handle({ type: 'item.completed' });
166
+ handle({ type: 'turn.started' });
167
+ handle(null);
168
+
169
+ assert.deepEqual(sink.output, []);
170
+ assert.deepEqual(sink.thinking, []);
171
+ assert.equal(job.claudeSessionId, undefined);
172
+ assert.equal(job.agentError, undefined);
173
+ });