zen-gitsync 2.17.16 → 2.17.18

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 (144) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +13 -0
  4. package/src/ui/public/assets/AgentView-AMFKSDeH.css +1 -0
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/AppVersionBadge-B5VG1_Lu.js +2 -0
  7. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  8. package/src/ui/public/assets/BranchSelector-Cj6athRO.js +1 -0
  9. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  10. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  11. package/src/ui/public/assets/CommitForm-a2tMsfF9.js +6 -0
  12. package/src/ui/public/assets/CommonDialog-I-GCqRMC.js +1 -0
  13. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  14. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  15. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  16. package/src/ui/public/assets/FileDiffViewer-B7i4BmbH.js +15 -0
  17. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  20. package/src/ui/public/assets/FlowOrchestrationWorkspace-R0QKmUpG.js +21 -0
  21. package/src/ui/public/assets/IconButton-B6eqvssJ.js +1 -0
  22. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  23. package/src/ui/public/assets/LogList-BPqurE9g.js +7 -0
  24. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  25. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  26. package/src/ui/public/assets/MindmapView-NdFNlkTF.js +2 -0
  27. package/src/ui/public/assets/MonitorView-DuSZa8a3.js +1 -0
  28. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-BrfKVP-w.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  31. package/src/ui/public/assets/RemoteManagerDialog-BI7fRsOQ.js +4 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  33. package/src/ui/public/assets/RemoteRepoCard-B4E8Ud3F.js +7 -0
  34. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  35. package/src/ui/public/assets/SourceMapView-B0OzHjxP.js +3 -0
  36. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  37. package/src/ui/public/assets/SvgIcon-B2h7B2yF.js +1 -0
  38. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  39. package/src/ui/public/assets/TemplateManager-mIWE3Qf8.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-BafC-B2U.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-BseGz8hJ.js +56 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +7 -7
  45. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  46. package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-CX0FkQ94.js} +1 -1
  47. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  48. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  49. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  50. package/src/ui/public/assets/gitStore-CMuWWr2d.js +6 -0
  51. package/src/ui/public/assets/index-CB2cIiHl.js +115 -0
  52. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  53. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  54. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  55. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  56. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  57. package/src/ui/public/assets/previewSandbox-DxR4iN9P.js +0 -0
  58. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  59. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  60. package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
  61. package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
  62. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  63. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  64. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  65. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  66. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  67. package/src/ui/public/index.html +28 -27
  68. package/src/ui/server/index.js +28 -2
  69. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  70. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  71. package/src/ui/server/routes/aiContext/index.js +444 -0
  72. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  73. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  74. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  75. package/src/ui/server/routes/aiContext/render.js +366 -0
  76. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  77. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  78. package/src/ui/server/routes/fs.js +43 -0
  79. package/src/ui/server/routes/mindmap.js +36 -24
  80. package/src/ui/server/routes/monitor.js +44 -35
  81. package/src/ui/server/routes/remoteRepos.js +57 -48
  82. package/src/ui/server/routes/workbench/agentChat.js +94 -10
  83. package/src/ui/server/routes/workbench/agentChatContext.test.js +171 -0
  84. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  85. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  86. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  87. package/src/ui/server/routes/workbench/agentRoutes.js +208 -48
  88. package/src/ui/server/routes/workbench/agentRoutes.test.js +160 -4
  89. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  90. package/src/ui/server/routes/workbench/envContext.js +61 -64
  91. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  92. package/src/ui/server/routes/workbench/index.js +78 -32
  93. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  94. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  95. package/src/ui/server/routes/workbench/shared.js +17 -0
  96. package/src/ui/server/utils/agentAttachments.js +169 -0
  97. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  98. package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
  99. package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
  100. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  101. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
  102. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  103. package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
  104. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  105. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
  106. package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
  107. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  108. package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
  109. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  110. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  111. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
  112. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
  113. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  114. package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
  115. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  116. package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
  117. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  118. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  119. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
  120. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
  121. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  122. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
  123. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  124. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  125. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
  126. package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
  127. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  128. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  129. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
  130. package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
  131. package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
  132. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  133. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
  134. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  135. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  136. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
  137. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  138. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  139. package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
  140. package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
  141. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  142. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  143. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  144. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -31,7 +31,6 @@
31
31
  import path from 'path';
32
32
  import os from 'os';
33
33
  import { logger } from './shared.js';
34
- import { agentSessionStore } from './agentSessionStore.js';
35
34
 
36
35
  // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
37
36
  import { TOOL_DEFINITIONS, executeTool } from '../../../../cli/ai/tools.js';
@@ -242,6 +241,9 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
242
241
  // - model: { baseURL, model, apiKey }
243
242
  // - userMessage: 用户输入文本
244
243
  // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
244
+ // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
245
+ // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
246
+ // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
245
247
  // - cwd: 工作目录
246
248
  // - locale: 'zh' | 'en'
247
249
  // - signal: AbortSignal (客户端断开时触发)
@@ -250,9 +252,11 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
250
252
  // - askUser: (args, meta) => Promise<string> 等待用户回答
251
253
  // - listProjects: (args) => Promise<string> list_projects 工具的数据源
252
254
  // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
255
+ // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
256
+ // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
253
257
  //
254
258
  // 返回: { aborted: boolean }
255
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, signal, send, onChild, askUser, listProjects }) {
259
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], signal, send, onChild, askUser, listProjects, getContextBlock }) {
256
260
  const ctx = { cwd, locale, onChild, askUser, listProjects };
257
261
 
258
262
  // 确保 session.messages 存在
@@ -277,6 +281,19 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
277
281
  : userMessage
278
282
  });
279
283
 
284
+ // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
285
+ // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
286
+ // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
287
+ let workspaceBlock = '';
288
+ if (typeof getContextBlock === 'function') {
289
+ try {
290
+ workspaceBlock = (await getContextBlock({ locale })) || '';
291
+ } catch (err) {
292
+ logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
293
+ workspaceBlock = '';
294
+ }
295
+ }
296
+
280
297
  const maxIterations = await resolveMaxToolIterations();
281
298
 
282
299
  for (let iter = 0; iter < maxIterations; iter++) {
@@ -284,6 +301,9 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
284
301
  // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
285
302
  // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
286
303
  const messages = prepareRequestMessages(session.messages, { locale });
304
+ // 请求级上下文:工作区状态快照 + 当前打开的文档 + 本轮附件路径
305
+ // (只改副本,不落 session.messages,下一轮不重复累积)
306
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock });
287
307
 
288
308
  let result;
289
309
  try {
@@ -306,14 +326,6 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
306
326
  return { aborted: false };
307
327
  }
308
328
 
309
- if (result.aborted) {
310
- // 保存当前会话状态
311
- session.updatedAt = new Date().toISOString();
312
- await agentSessionStore.write(session.sessionId, session).catch(() => {});
313
- send({ type: 'error', error: '已取消' });
314
- return { aborted: true };
315
- }
316
-
317
329
  const { content, toolCalls } = result;
318
330
 
319
331
  // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
@@ -322,6 +334,18 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
322
334
  // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
323
335
  const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
324
336
 
337
+ if (result.aborted) {
338
+ // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
339
+ // user 消息,重新打开会话时刚才生成的内容会整段丢失。
340
+ // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
341
+ // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
342
+ if (content) {
343
+ session.messages.push(withReasoning({ role: 'assistant', content }));
344
+ }
345
+ send({ type: 'error', error: '已取消' });
346
+ return { aborted: true };
347
+ }
348
+
325
349
  // 无工具调用:本轮结束
326
350
  if (toolCalls.length === 0) {
327
351
  session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
@@ -409,4 +433,64 @@ function summarizeArgs(name, args) {
409
433
  }
410
434
  }
411
435
 
436
+ // ── 请求级上下文注入(工作区状态 / 文件空间对话 / 本轮附件) ──────────────
437
+ // 把"工作区各板块的状态摘要""用户当前打开的文件"与"本轮附件的落盘路径"追加到
438
+ // **请求副本**的 system 消息末尾:只影响这一次请求,session.messages 与磁盘历史保持原样,
439
+ // 下一轮也不会重复累积。
440
+ //
441
+ // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
442
+ // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
443
+ // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
444
+ // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
445
+ //
446
+ // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
447
+ // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
448
+ // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
449
+ export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '' }) {
450
+ if (!Array.isArray(messages)) return;
451
+ const en = String(locale || '').startsWith('en');
452
+
453
+ const parts = [];
454
+
455
+ // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
456
+ if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
457
+ parts.push(workspaceBlock.trim());
458
+ }
459
+
460
+ // ① 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
461
+ if (openFilePath) {
462
+ const root = cwd || process.cwd();
463
+ let rel = '';
464
+ try {
465
+ rel = path.relative(root, path.resolve(root, openFilePath));
466
+ } catch {
467
+ rel = '';
468
+ }
469
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
470
+ const file = rel.split(path.sep).join('/');
471
+ parts.push(en
472
+ ? `The file the user currently has open in the editor is \`${file}\` (relative to the project root). When the user says "this file" / "the current file" / "here", that is what they mean; read it with your tools before assuming its content.`
473
+ : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
474
+ }
475
+ }
476
+
477
+ // ② 本轮附件:只给绝对路径,内容让模型自己去读
478
+ const files = (Array.isArray(attachments) ? attachments : [])
479
+ .filter(a => a && typeof a.path === 'string' && a.path)
480
+ .slice(0, 20);
481
+ if (files.length > 0) {
482
+ const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
483
+ parts.push(en
484
+ ? `The user attached ${files.length} file(s) this turn; they were saved to these absolute paths:\n${lines.join('\n')}\nRead them with your tools when relevant (they are NOT inlined here). Do not guess their contents.`
485
+ : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
486
+ }
487
+
488
+ if (parts.length === 0) return;
489
+
490
+ const note = `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
491
+ const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
492
+ if (sys) sys.content += note;
493
+ else messages.unshift({ role: 'system', content: note.trim() });
494
+ }
495
+
412
496
  export { buildWebSystemPrompt };
@@ -0,0 +1,171 @@
1
+ // injectRequestContext:把"当前打开的文档"与"本轮附件路径"注进**请求副本**。
2
+ // 关键口径:只改请求副本、不碰 session.messages;越界路径忽略;下一轮不会重复累积;
3
+ // 附件只给绝对路径、不给内容(内容由模型自己用 read / grep 取)。
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import path from 'node:path'
7
+ import { injectRequestContext, buildWebSystemPrompt } from './agentChat.js'
8
+
9
+ const ROOT = path.resolve('C:/proj')
10
+
11
+ test('injectRequestContext: 相对项目根的斜杠路径追加进 system 消息', () => {
12
+ const messages = [
13
+ { role: 'system', content: 'rules' },
14
+ { role: 'user', content: 'hi' }
15
+ ]
16
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: path.join(ROOT, 'src', 'a.md'), locale: 'zh' })
17
+ assert.match(messages[0].content, /^rules\n\n# 当前上下文/)
18
+ assert.match(messages[0].content, /src\/a\.md/)
19
+ assert.equal(messages[1].content, 'hi', '其它消息不受影响')
20
+ })
21
+
22
+ test('injectRequestContext: 项目外路径 / 空值一律忽略', () => {
23
+ const outside = [{ role: 'system', content: 'rules' }]
24
+ injectRequestContext(outside, { cwd: ROOT, openFilePath: 'D:/other/x.md', locale: 'zh' })
25
+ assert.equal(outside[0].content, 'rules', '项目外路径不注入')
26
+
27
+ const none = [{ role: 'system', content: 'rules' }]
28
+ injectRequestContext(none, { cwd: ROOT, openFilePath: '', locale: 'zh' })
29
+ assert.equal(none[0].content, 'rules', '没传 openFilePath 时不动消息')
30
+ })
31
+
32
+ test('injectRequestContext: 没有 system 消息时补一条,英文 locale 用英文说明', () => {
33
+ const messages = [{ role: 'user', content: 'hi' }]
34
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: 'README.md', locale: 'en' })
35
+ assert.equal(messages.length, 2)
36
+ assert.equal(messages[0].role, 'system')
37
+ assert.match(messages[0].content, /README\.md/)
38
+ assert.match(messages[0].content, /Current context/)
39
+ })
40
+
41
+ test('injectRequestContext: 每轮都基于副本重新注入,不会重复累积', () => {
42
+ const session = [{ role: 'system', content: 'rules' }]
43
+ const first = session.map(m => ({ ...m }))
44
+ const second = session.map(m => ({ ...m }))
45
+ injectRequestContext(first, { cwd: ROOT, openFilePath: 'a.md', locale: 'zh' })
46
+ injectRequestContext(second, { cwd: ROOT, openFilePath: 'a.md', locale: 'zh' })
47
+ assert.equal(session[0].content, 'rules', '原始会话记录保持干净')
48
+ assert.equal((first[0].content.match(/# 当前上下文/g) || []).length, 1)
49
+ assert.equal((second[0].content.match(/# 当前上下文/g) || []).length, 1)
50
+ })
51
+
52
+ test('injectRequestContext: 附件只注入绝对路径,内容不进消息体', () => {
53
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: '看下这个日志' }]
54
+ injectRequestContext(messages, {
55
+ cwd: ROOT,
56
+ locale: 'zh',
57
+ attachments: [
58
+ { name: '错误日志.log', path: 'C:\\Users\\x\\.zen-gitsync\\agent-attachments\\20260927-193000-ab12\\错误日志.log' }
59
+ ]
60
+ })
61
+ assert.match(messages[0].content, /^rules\n\n# 当前上下文/)
62
+ assert.match(messages[0].content, /用户本轮附带了 1 个文件/)
63
+ assert.match(messages[0].content, /agent-attachments.+错误日志\.log/)
64
+ assert.match(messages[0].content, /不要臆测/)
65
+ // 消息体本身没有任何文件内容
66
+ assert.equal(messages[1].content, '看下这个日志')
67
+ })
68
+
69
+ test('injectRequestContext: 文档 + 附件可以同时注入,各成一段', () => {
70
+ const messages = [{ role: 'system', content: 'rules' }]
71
+ injectRequestContext(messages, {
72
+ cwd: ROOT,
73
+ openFilePath: 'src/a.md',
74
+ locale: 'en',
75
+ attachments: [
76
+ { name: 'a.log', path: '/tmp/att/a.log' },
77
+ { name: 'b.csv', path: '/tmp/att/b.csv' }
78
+ ]
79
+ })
80
+ const sys = messages[0].content
81
+ assert.match(sys, /# Current context/)
82
+ assert.match(sys, /src\/a\.md/)
83
+ assert.match(sys, /attached 2 file/)
84
+ assert.match(sys, /\/tmp\/att\/a\.log/)
85
+ assert.match(sys, /\/tmp\/att\/b\.csv/)
86
+ // 只加一个标题段
87
+ assert.equal((sys.match(/# Current context/g) || []).length, 1)
88
+ })
89
+
90
+ test('injectRequestContext: 脏附件数据(缺 path / 空数组)不会破坏消息,也不留空段', () => {
91
+ const dirty = [{ role: 'system', content: 'rules' }]
92
+ injectRequestContext(dirty, { cwd: ROOT, locale: 'zh', attachments: [{ name: 'x.txt' }, null, 'oops'] })
93
+ assert.equal(dirty[0].content, 'rules', '没有可用 path 时不注入')
94
+
95
+ const empty = [{ role: 'system', content: 'rules' }]
96
+ injectRequestContext(empty, { cwd: ROOT, locale: 'zh', attachments: [] })
97
+ assert.equal(empty[0].content, 'rules')
98
+ })
99
+
100
+ test('injectRequestContext: 附件条数超过 20 时截断(防御性上限)', () => {
101
+ const messages = [{ role: 'system', content: 'rules' }]
102
+ const many = Array.from({ length: 25 }, (_, i) => ({ name: `f${i}.txt`, path: `/tmp/att/f${i}.txt` }))
103
+ injectRequestContext(messages, { cwd: ROOT, locale: 'zh', attachments: many })
104
+ assert.match(messages[0].content, /用户本轮附带了 20 个文件/)
105
+ assert.ok(!messages[0].content.includes('f24.txt'), '第 21 个之后不该出现')
106
+ })
107
+
108
+ // ── 工作区状态快照(七个板块) ────────────────────────────────
109
+ // 这一段守的是两条容易静默出错的规则:
110
+ // 1) 快照只进**请求副本**,绝不进那条会落盘的 system 消息 —— 否则它会永久停在
111
+ // 会话创建那天(首轮之后不再重建),而且不报错、只是答案悄悄过期。
112
+ // 2) 快照空 / 取不到时不留下空段、不影响其它上下文。
113
+
114
+ const SNAPSHOT = '[工作区状态 · 由 zen-gitsync 服务端自动生成,不是用户输入的内容]\n\n- 当前项目 Git 状态 | git-current.md | 分支 main,工作区干净'
115
+
116
+ test('injectRequestContext: 工作区快照注入请求副本,且排在其它上下文之前', () => {
117
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
118
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: 'src/a.md', locale: 'zh', workspaceBlock: SNAPSHOT })
119
+ const sys = messages[0].content
120
+ assert.match(sys, /^rules\n\n# 当前上下文/)
121
+ assert.ok(sys.includes('工作区状态'), '快照必须在')
122
+ assert.ok(sys.indexOf('工作区状态') < sys.indexOf('src/a.md'), '快照应排在"当前打开的文件"之前')
123
+ assert.equal(messages[1].content, 'hi', '其它消息不受影响')
124
+ })
125
+
126
+ test('注入路径不会污染会话记录:快照不进那条会落盘的 system 消息', () => {
127
+ // 首轮 push 进 session.messages 的 system 提示词(会落盘、之后不再重建)
128
+ const persistent = buildWebSystemPrompt({ cwd: ROOT, locale: 'zh' })
129
+ assert.ok(!persistent.includes('工作区状态'), '落库的 system 里不许有快照');
130
+ assert.ok(!persistent.includes('快照目录'), '落库的 system 里不许有快照路径');
131
+
132
+ // 每轮从完整会话记录重建副本 → 快照出现且只出现一次,不会跨轮累积
133
+ const session = [{ role: 'system', content: 'rules' }]
134
+ for (let i = 0; i < 3; i++) {
135
+ const copy = session.map(m => ({ ...m }))
136
+ injectRequestContext(copy, { cwd: ROOT, locale: 'zh', workspaceBlock: SNAPSHOT })
137
+ assert.equal((copy[0].content.match(/工作区状态/g) || []).length, 1)
138
+ }
139
+ assert.equal(session[0].content, 'rules', '原始会话记录保持干净')
140
+ })
141
+
142
+ test('injectRequestContext: 快照为空 / 只有空白时不注入、不留空段', () => {
143
+ const empty = [{ role: 'system', content: 'rules' }]
144
+ injectRequestContext(empty, { cwd: ROOT, locale: 'zh', workspaceBlock: '' })
145
+ assert.equal(empty[0].content, 'rules')
146
+
147
+ const blank = [{ role: 'system', content: 'rules' }]
148
+ injectRequestContext(blank, { cwd: ROOT, locale: 'zh', workspaceBlock: ' \n ' })
149
+ assert.equal(blank[0].content, 'rules')
150
+
151
+ // 没传这个参数时(老调用点)行为与从前完全一致
152
+ const legacy = [{ role: 'system', content: 'rules' }]
153
+ injectRequestContext(legacy, { cwd: ROOT, locale: 'zh' })
154
+ assert.equal(legacy[0].content, 'rules')
155
+ })
156
+
157
+ test('injectRequestContext: 快照 + 文档 + 附件三者可以共存,共用一个标题段', () => {
158
+ const messages = [{ role: 'system', content: 'rules' }]
159
+ injectRequestContext(messages, {
160
+ cwd: ROOT,
161
+ locale: 'zh',
162
+ workspaceBlock: SNAPSHOT,
163
+ openFilePath: 'src/a.md',
164
+ attachments: [{ name: 'a.log', path: '/tmp/att/a.log' }]
165
+ })
166
+ const sys = messages[0].content
167
+ assert.equal((sys.match(/# 当前上下文/g) || []).length, 1, '只加一个标题段')
168
+ assert.ok(sys.includes('工作区状态'))
169
+ assert.ok(sys.includes('src/a.md'))
170
+ assert.ok(sys.includes('用户本轮附带了 1 个文件'))
171
+ })
@@ -45,16 +45,18 @@ const longSession = () => {
45
45
  }
46
46
 
47
47
  // 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与产生的事件
48
- async function runWeb({ session, userMessage, respond, locale = 'zh-CN' }) {
48
+ // (respond 收到 fetch 的 init,方便测试按 signal 模拟"读到一半被中止")
49
+ async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent }) {
49
50
  const { runAgentTurn } = await import('./agentChat.js')
50
51
  const events = []
51
52
  let sent
52
53
  const original = globalThis.fetch
53
- globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond() }
54
+ globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond(init) }
54
55
  try {
55
56
  const result = await runAgentTurn({
56
57
  session, model, userMessage, locale, cwd: process.cwd(),
57
- signal: new AbortController().signal, send: e => events.push(e),
58
+ signal: signal ?? new AbortController().signal,
59
+ send: e => { events.push(e); onEvent?.(e) },
58
60
  })
59
61
  return { sent, events, result }
60
62
  } finally {
@@ -134,6 +136,34 @@ test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截
134
136
  assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
135
137
  })
136
138
 
139
+ // 回归:用户点"停止"(中止正在跑的这一轮)时,已经流出来的正文必须留在会话记录里。
140
+ // 之前中止分支只把当时的状态写盘、不回填正文,重新打开这条会话时刚才生成的内容整段消失。
141
+ test('中止时已流出的正文要留在会话记录里', async () => {
142
+ const session = newSession('ag-sandbox-aborted', withSystem())
143
+ const controller = new AbortController()
144
+
145
+ // 吐一段正文后挂住(模拟"停在生成中");真 fetch 在 signal abort 时会打断 body 读取,
146
+ // 桩里照做才能真的走到中止分支
147
+ const hanging = init => new Response(new ReadableStream({
148
+ start(stream) {
149
+ const bytes = new TextEncoder()
150
+ stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })))
151
+ init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')))
152
+ },
153
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
154
+
155
+ const { result } = await runWeb({
156
+ session, userMessage: '写一半就停', respond: hanging, signal: controller.signal,
157
+ // 正文事件到达 = 这段已经被读进来,此刻中止最贴近用户点"停止"的时机
158
+ onEvent: e => { if (e.type === 'content') controller.abort() },
159
+ })
160
+
161
+ assert.equal(result.aborted, true)
162
+ const last = session.messages[session.messages.length - 1]
163
+ assert.equal(last.role, 'assistant')
164
+ assert.equal(last.content, '已经写了一半')
165
+ })
166
+
137
167
  test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
138
168
  const session = newSession('ag-sandbox-nofc', withSystem())
139
169
  const noFunctionCalling = () => new Response(
@@ -0,0 +1,77 @@
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
+ //
17
+ // 引擎 = 谁来跑这一轮对话的智能体循环。两种性质完全不同:
18
+ //
19
+ // gai ── 内置。**服务端**跑循环:LLM 流式 + 服务端执行工具(src/cli/ai/tools.js),
20
+ // 前端只渲染事件。默认引擎,历史行为。
21
+ //
22
+ // claude / opencode / codex ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
23
+ // 自己的系统提示词、自己的权限模型。服务端退化成一根管子 —— spawn 出来、
24
+ // 把它的 NDJSON 翻译成同一组 SSE 事件。见 runExternalTurn.js。
25
+ //
26
+ // ⚠️ 这儿**不新写一份 id 清单**:外部三家直接复用 config.js 的 TASK_EXECUTORS。
27
+ // 本仓库为"同一份口径在多个地方各写一遍"吃过亏(见 agentParity.test.js 的注释:
28
+ // 提示词四处、路径归一三处,分叉的失效方式是「不报错,两个入口表现不一样」)。
29
+ // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第四家时
30
+ // 应当只改 config.js 一处。有单测钉住这条。
31
+
32
+ import { TASK_EXECUTORS } from '../../../../config.js';
33
+
34
+ /** 内置引擎 id。用 'gai' 而不是 'g-ai'/'zen':前端路由与 i18n key 都用它。 */
35
+ export const BUILTIN_ENGINE = 'gai';
36
+
37
+ /** 外部 CLI 引擎(顺序继承 config.js,与工作台执行器列表一致) */
38
+ export const EXTERNAL_ENGINES = TASK_EXECUTORS;
39
+
40
+ /** 全部可选引擎,内置排第一(前端下拉默认选中第一项) */
41
+ export const AGENT_ENGINES = [BUILTIN_ENGINE, ...EXTERNAL_ENGINES];
42
+
43
+ /**
44
+ * 展示名。产品名中英文一致、不走 i18n —— 与前端 utils/taskExecutor.ts 的
45
+ * TASK_EXECUTOR_OPTIONS 同一条判断("Claude Code" 在中文界面里也不译)。
46
+ */
47
+ export const ENGINE_LABELS = {
48
+ [BUILTIN_ENGINE]: 'g ai',
49
+ claude: 'Claude Code',
50
+ opencode: 'OpenCode',
51
+ codex: 'Codex',
52
+ };
53
+
54
+ export function isAgentEngine(value) {
55
+ return typeof value === 'string' && AGENT_ENGINES.includes(value);
56
+ }
57
+
58
+ export function isExternalEngine(value) {
59
+ return typeof value === 'string' && EXTERNAL_ENGINES.includes(value);
60
+ }
61
+
62
+ /**
63
+ * 归一化:非法/缺省一律回落内置引擎。
64
+ *
65
+ * 为什么回落而不是报错:body.engine 来自网络,老前端不会带这个字段。
66
+ * 报错会让所有老客户端在升级服务端后立刻不能聊天 —— 回落才是安全的兼容方向
67
+ * (与 taskRunner.normalizeTaskExecutor 的取舍一致)。
68
+ */
69
+ export function normalizeAgentEngine(value) {
70
+ if (typeof value !== 'string') return BUILTIN_ENGINE;
71
+ const v = value.trim().toLowerCase();
72
+ return isAgentEngine(v) ? v : BUILTIN_ENGINE;
73
+ }
74
+
75
+ export function engineLabel(id) {
76
+ return ENGINE_LABELS[normalizeAgentEngine(id)];
77
+ }
@@ -0,0 +1,65 @@
1
+ // 引擎注册表单测。
2
+ //
3
+ // 最要紧的一条是**不新写清单**:外部三家必须与 config.js 的 TASK_EXECUTORS 同源。
4
+ // 本仓库为"同一份口径各写一遍"吃过亏(提示词四处、路径归一三处),分叉的失效方式是
5
+ // 「不报错,两个入口表现不一样」—— 加第四家 CLI 时若只改了工作台,这里就会静默漏掉。
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+
9
+ import {
10
+ AGENT_ENGINES,
11
+ BUILTIN_ENGINE,
12
+ ENGINE_LABELS,
13
+ EXTERNAL_ENGINES,
14
+ engineLabel,
15
+ isAgentEngine,
16
+ isExternalEngine,
17
+ normalizeAgentEngine,
18
+ } from './agentEngines.js';
19
+ import { TASK_EXECUTORS } from '../../../../config.js';
20
+
21
+ test('外部引擎清单与 config.js 同源(加第四家只该改一处)', () => {
22
+ assert.deepEqual(EXTERNAL_ENGINES, TASK_EXECUTORS, '外部引擎必须直接复用 TASK_EXECUTORS');
23
+ assert.equal(EXTERNAL_ENGINES, TASK_EXECUTORS, '应当是同一个数组引用,不是复制一份');
24
+ });
25
+
26
+ test('内置引擎排第一(前端下拉默认选首项)', () => {
27
+ assert.equal(AGENT_ENGINES[0], BUILTIN_ENGINE);
28
+ assert.equal(BUILTIN_ENGINE, 'gai');
29
+ assert.equal(new Set(AGENT_ENGINES).size, AGENT_ENGINES.length, 'id 不能重复');
30
+ });
31
+
32
+ test('每个引擎都有展示名', () => {
33
+ for (const id of AGENT_ENGINES) {
34
+ assert.ok(ENGINE_LABELS[id], `${id} 缺展示名`);
35
+ }
36
+ });
37
+
38
+ test('normalizeAgentEngine:非法值一律回落内置引擎(老前端不带这个字段)', () => {
39
+ assert.equal(normalizeAgentEngine(undefined), 'gai');
40
+ assert.equal(normalizeAgentEngine(null), 'gai');
41
+ assert.equal(normalizeAgentEngine(''), 'gai');
42
+ assert.equal(normalizeAgentEngine(' '), 'gai');
43
+ assert.equal(normalizeAgentEngine('CLAUDE'), 'claude', '要容错大小写与首尾空格');
44
+ assert.equal(normalizeAgentEngine(' claude '), 'claude');
45
+ assert.equal(normalizeAgentEngine('gemini'), 'gai', '未知引擎回落,而不是报错');
46
+ assert.equal(normalizeAgentEngine(42), 'gai');
47
+ });
48
+
49
+ test('isAgentEngine / isExternalEngine 的边界', () => {
50
+ assert.equal(isAgentEngine('gai'), true);
51
+ assert.equal(isAgentEngine('claude'), true);
52
+ assert.equal(isAgentEngine('nope'), false);
53
+ assert.equal(isAgentEngine(undefined), false);
54
+ // gai 是内置的,不是外部引擎 —— 两条链路的分派全靠这个判据
55
+ assert.equal(isExternalEngine('gai'), false);
56
+ assert.equal(isExternalEngine('claude'), true);
57
+ assert.equal(isExternalEngine('opencode'), true);
58
+ assert.equal(isExternalEngine('codex'), true);
59
+ });
60
+
61
+ test('engineLabel:未知值回落内置引擎名(不显示 undefined)', () => {
62
+ assert.equal(engineLabel('claude'), 'Claude Code');
63
+ assert.equal(engineLabel('gai'), 'g ai');
64
+ assert.equal(engineLabel('whatever'), 'g ai');
65
+ });