zen-gitsync 2.17.8 → 2.17.10

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 (112) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +210 -58
  4. package/src/cli/ai/context.js +58 -0
  5. package/src/cli/ai/context.test.js +117 -0
  6. package/src/cli/ai/extensions.js +131 -0
  7. package/src/cli/ai/extensions.test.js +158 -0
  8. package/src/cli/ai/mcp.js +498 -0
  9. package/src/cli/ai/mcp.test.js +275 -0
  10. package/src/cli/ai/runtime.test.js +32 -0
  11. package/src/cli/ai/skills.js +207 -0
  12. package/src/cli/ai/skills.test.js +126 -0
  13. package/src/cli/ai/termui.js +1 -0
  14. package/src/cli/ai/tools.js +94 -3
  15. package/src/cli/ai/tools.test.js +82 -0
  16. package/src/cli/ai/transport.js +18 -4
  17. package/src/cli/ai/turn.js +21 -5
  18. package/src/config.js +27 -3
  19. package/src/paths.js +11 -0
  20. package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
  21. package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
  22. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
  23. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
  24. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
  25. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
  26. package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
  27. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
  28. package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
  30. package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
  31. package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
  32. package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
  33. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
  35. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
  36. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
  37. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
  38. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
  39. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
  41. package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
  44. package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
  45. package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
  46. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
  47. package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
  48. package/src/ui/public/assets/index-CW3xs-af.css +1 -0
  49. package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
  50. package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
  51. package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
  52. package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
  53. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  54. package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
  55. package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
  56. package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
  57. package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
  58. package/src/ui/public/index.html +17 -17
  59. package/src/ui/server/index.js +4 -0
  60. package/src/ui/server/routes/config.js +16 -3
  61. package/src/ui/server/routes/exec.js +19 -11
  62. package/src/ui/server/routes/fileOpen.js +142 -2
  63. package/src/ui/server/routes/remoteRepos.js +604 -0
  64. package/src/ui/server/routes/remoteRepos.test.js +386 -0
  65. package/src/ui/server/routes/workbench/agentChat.js +86 -180
  66. package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
  67. package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
  68. package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
  69. package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
  70. package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
  71. package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
  72. package/src/ui/server/routes/workbench/jobStore.js +17 -3
  73. package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
  74. package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
  75. package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
  76. package/src/ui/server/routes/workbench/projectTool.js +198 -0
  77. package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
  78. package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
  79. package/src/ui/server/routes/workbench/taskRunner.js +269 -12
  80. package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
  81. package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
  82. package/src/utils/shellPath.js +253 -0
  83. package/src/utils/shellPath.test.js +221 -0
  84. package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
  85. package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
  86. package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
  87. package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
  88. package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
  89. package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
  90. package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
  91. package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
  93. package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
  94. package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
  95. package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
  96. package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
  97. package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
  98. package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
  99. package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
  100. package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
  101. package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
  102. package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
  103. package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
  104. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
  105. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
  106. package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
  107. package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
  108. package/src/ui/public/assets/index-cApp6w0b.css +0 -1
  109. package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
  110. package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
  111. package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
  112. package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
@@ -23,6 +23,7 @@
23
23
  // - { type: 'content', delta } — 正文增量
24
24
  // - { type: 'tool_call_start', toolCallId, name, argsPreview }
25
25
  // - { type: 'tool_result', toolCallId, name, result }
26
+ // - { type: 'ask_user', interactionId, question, options, allowFreeText }
26
27
  // - { type: 'done', content } — 本轮最终完成
27
28
  // - { type: 'error', error }
28
29
 
@@ -31,17 +32,17 @@ import os from 'os';
31
32
  import { logger } from './shared.js';
32
33
  import { agentSessionStore } from './agentSessionStore.js';
33
34
 
34
- // 从 CLI 侧导入工具定义与执行器(同一 monorepo,路径可达)
35
+ // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
35
36
  import { TOOL_DEFINITIONS, executeTool } from '../../../../cli/ai/tools.js';
37
+ import { prepareRequestMessages } from '../../../../cli/ai/context.js';
38
+ import { streamChatOnce } from '../../../../cli/ai/transport.js';
36
39
  import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
37
40
  import { guardCommand } from '../../../../cli/ai/platformGuard.js';
38
41
  import configManager from '../../../../config.js';
39
- import { buildAiChatRequest, describeAiHttpError } from '../../../../utils/aiEndpoint.js';
40
42
 
41
43
  // 单轮工具调用循环数的兜底值(防失控);实际值取全局配置 aiMaxToolIterations,
42
44
  // 与 CLI 侧 src/cli/ai/agent.js 共用同一个配置项。
43
45
  const DEFAULT_MAX_TOOL_ITERATIONS = 200;
44
- const LLM_TIMEOUT_MS = 300000; // 5 分钟
45
46
 
46
47
  // 读取全局配置里的单轮工具调用上限。
47
48
  // 读配置失败不该把整轮对话打挂 —— 退回默认值继续跑,比用户消息直接发不出去好。
@@ -62,6 +63,7 @@ function buildWebSystemPrompt({ cwd, locale }) {
62
63
  const zh = !String(locale || '').startsWith('en');
63
64
  const now = new Date().toLocaleString();
64
65
  const isWin = process.platform === 'win32';
66
+ const builtinToolCount = TOOL_DEFINITIONS.length;
65
67
  const shellDesc = isWin ? 'cmd.exe / PowerShell' : '/bin/sh';
66
68
 
67
69
  if (zh) {
@@ -87,6 +89,26 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
87
89
  · 查命令路径 → cmd 的 where(不是 which)
88
90
  - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
89
91
 
92
+ # 远程仓库(GitHub / Gitee)
93
+ - 问"我有哪些项目""哪些项目需要 pull / 推送" → 用 list_projects 工具。它返回的就是 g ui
94
+ 「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支/领先/落后/未提交数与任务进度),
95
+ 口径与界面完全一致。**不要**用 list_files 自己扫盘数仓库 —— 那会把 node_modules 里的嵌套
96
+ 仓库也算进来,数出来的个数跟界面对不上。领先/落后是本地快照,要真实值就带 refresh=true
97
+ 先联网 fetch 一轮,再回答"要不要 pull"
98
+ - 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
99
+ - 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
100
+ · GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
101
+ 看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
102
+ · Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
103
+ (未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
104
+ · 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
105
+ 不要用其他途径绕过
106
+ · 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
107
+ - CLI 可能没装、或没在服务端进程的 PATH 里(装完没重启服务就是这个表现,Web 端尤其常见):
108
+ 报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
109
+ 直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
110
+ 若只是想回答本项目的问题,退回 \`git remote -v\`
111
+
90
112
  # 权限(用户已明确授权,无需反复征求同意)
91
113
  - 工作目录内:读写文件、执行命令等所有操作直接执行
92
114
  - 其他目录:同样可以读取和修改
@@ -104,7 +126,8 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
104
126
 
105
127
  # 与用户交互
106
128
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
107
- - 不要调用不存在的工具,可用工具只有上面列出的 6 个
129
+ - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
130
+ - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
108
131
  - 用户可能随消息附带图片:图片以 image_url 部件出现在 user 消息里;如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
109
132
  - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
110
133
 
@@ -134,6 +157,30 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
134
157
  · Text processing → node -e "..." or PowerShell
135
158
  · Find executable → cmd's where (not which)` : `- POSIX environment: Unix commands are available`}
136
159
 
160
+ # Remote repositories (GitHub / Gitee)
161
+ - "Which projects do I have?" / "Which ones need a pull or push?" → use the list_projects tool.
162
+ It returns exactly the list behind the GUI's "Recent projects" panel (recent directories plus any
163
+ directory a task was created in, with branch / ahead / behind / uncommitted counts and task
164
+ progress) — the same numbers the UI shows. Do NOT scan the disk with list_files to count repos:
165
+ that also picks up nested repos inside node_modules and the totals will not match the UI.
166
+ Ahead/behind comes from local refs, so pass refresh=true for a real answer about pulling
167
+ - "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
168
+ - "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
169
+ · GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
170
+ details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
171
+ · Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
172
+ (the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
173
+ wrongly report "logged in")
174
+ · Both default to the **currently authenticated account only**; org and private repos may need
175
+ broader token scopes. If you cannot see them, say so plainly — do not route around it
176
+ · Never ask the user for a token, and never have them paste one into the conversation
177
+ - The CLI may not be installed, or missing from the server process's PATH (that is what "installed but
178
+ the server cannot find it" looks like — common on the Web side):
179
+ on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
180
+ repo or directory names. Tell the user the CLI was not found and point them to the "Remote
181
+ repositories" page (one-click install / login), or restart the server so a freshly installed CLI
182
+ becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
183
+
137
184
  # Permissions (explicitly granted by the user)
138
185
  - Inside the working directory: read/write files and run commands directly
139
186
  - Other directories: may also be read and modified
@@ -141,190 +188,38 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
141
188
 
142
189
  # How you work
143
190
  - Act first, ask later: use tools to investigate before asking the user
191
+ - When a decision or missing detail must come from the user, call ask_user. Do not guess or merely describe a question in plain text.
144
192
  - After modifying code, verify: run tests, build, or at least syntax check
145
193
  - Prefer edit_file for precise replacements; read_file first to confirm original text
146
194
  - Use offset/limit for large files
147
195
  - git operations via run_command
148
196
  - run_command defaults to the working directory; default timeout 120s, max 600s
149
- - The user may attach images to a message; they arrive as image_url parts in the user message. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
197
+ - When the task must pause for a decision or missing detail, call ask_user and wait for the user's answer. The user may attach images to a message; they arrive as image_url parts in the user message. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
150
198
 
151
199
  # Output
152
200
  - Your text output is displayed in the user's Web UI
153
201
  - After completing a task, briefly summarize the result`;
154
202
  }
155
203
 
156
- // ── LLM 流式调用(OpenAI 兼容 + function calling) ──────────
157
- // 返回 { content, toolCalls, aborted }
158
- async function streamChatOnce({ model, messages, signal, onToken, sessionId }) {
159
- // sessionId = 对话 ID:OpenCode 网关靠它做路由与提示缓存,同一轮对话的所有请求要复用
160
- const { url, headers } = buildAiChatRequest({
161
- baseURL: model.baseURL,
162
- model: model.model,
163
- apiKey: model.apiKey,
164
- sessionId,
165
- });
166
-
167
- const body = JSON.stringify({
168
- model: model.model,
169
- messages,
170
- tools: TOOL_DEFINITIONS,
171
- temperature: 0.3,
172
- stream: true,
173
- });
174
-
175
- const controller = new AbortController();
176
- const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
177
- const onExternalAbort = () => controller.abort();
178
- if (signal) {
179
- if (signal.aborted) controller.abort();
180
- else signal.addEventListener('abort', onExternalAbort);
181
- }
182
-
183
- let content = '';
184
- const toolCalls = [];
185
- let aborted = false;
186
-
187
- try {
188
- const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
189
- if (!resp.ok || !resp.body) {
190
- const errText = await resp.text().catch(() => '');
191
- // 抽 error.message:网关/provider 的说明都在里面,整坨 JSON 甩到聊天窗没法看
192
- const snippet = describeAiHttpError(errText, resp.status);
193
- if (resp.status === 400 && /tool|function/i.test(snippet)) {
194
- throw new Error(`HTTP 400: 当前模型可能不支持 function calling(${snippet})。请在设置中换用支持工具调用的模型。`);
195
- }
196
- throw new Error(`HTTP ${resp.status}: ${snippet || resp.statusText}`);
197
- }
198
-
199
- const decoder = new TextDecoder('utf-8');
200
- let buf = '';
201
- for await (const chunk of resp.body) {
202
- buf += decoder.decode(chunk, { stream: true });
203
- const lines = buf.split('\n');
204
- buf = lines.pop() ?? '';
205
- for (const line of lines) {
206
- const trimmed = line.trim();
207
- if (!trimmed || !trimmed.startsWith('data:')) continue;
208
- const payload = trimmed.slice(5).trim();
209
- if (payload === '[DONE]') continue;
210
- let evt;
211
- try { evt = JSON.parse(payload); } catch { continue; }
212
- const delta = evt.choices?.[0]?.delta || {};
213
-
214
- const thinkingChunk = delta.reasoning_content || delta.reasoning || delta.reasoning_text || '';
215
- if (thinkingChunk) onToken({ thinking: thinkingChunk });
216
-
217
- if (delta.content) {
218
- content += delta.content;
219
- onToken({ content: delta.content });
220
- }
221
-
222
- for (const tc of delta.tool_calls || []) {
223
- const i = tc.index ?? 0;
224
- if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
225
- if (tc.id) toolCalls[i].id += tc.id;
226
- if (tc.function?.name) toolCalls[i].function.name += tc.function.name;
227
- if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments;
228
- }
229
- }
230
- }
231
- } catch (err) {
232
- if (err?.name === 'AbortError' || controller.signal.aborted) {
233
- aborted = true;
234
- } else {
235
- throw err;
236
- }
237
- } finally {
238
- clearTimeout(timer);
239
- if (signal) signal.removeEventListener('abort', onExternalAbort);
240
- }
241
- return { content, toolCalls: toolCalls.filter(Boolean), aborted };
242
- }
204
+ // ── LLM 流式调用 ─────────────────────────────────────────
205
+ // 统一实现在 src/cli/ai/transport.js 的 streamChatOnce —— CLI 的 `g ai` 与这条
206
+ // Web 链路共用同一份。比这里原先那份多出来的能力:请求 usage(带 stream_options
207
+ // 降级重试)、校验 tool call index 边界、吃 evt.error,以及最要紧的一条 ——
208
+ // 流被截断但 tool_calls 已经部分到达时抛"中断"、拒绝执行半截的工具调用。
209
+ // 这里曾经是第二份实现,弱就弱在最后那条:半截参数有可能被拿去执行。
243
210
 
244
- // ── 消息消毒 ──────────────────────────────────────────────
245
- // 目的:确保发往 LLM provider 的 messages 数组里没有"空内容"或
246
- // "看似有内容但全是空白"的字段。
247
- //
248
- // 背景:部分 LLM provider(Moonshot/Kimi、智谱、火山引擎、MiniMax 等)
249
- // 对 assistant 历史消息 content 校验严格 —— 当某轮 assistant 只返回
250
- // tool_calls 而没有文本、或正文被 trim 后只剩空白时,provider 会拒绝
251
- // 并报 "chat content is empty (2013)"。OpenAI 官方规范允许 assistant
252
- // 消息在带 tool_calls 时 content 为 null,但 provider 实现不一致:
211
+ // ── 消息准备(消毒 / 历史有界化 / 旧图片降级) ──────────────────
212
+ // 统一实现在 src/cli/ai/context.js 的 prepareRequestMessages —— CLI 的 `g ai`
213
+ // 与这条 Web 链路共用同一份,两侧不再各写一遍。
253
214
  //
254
- // - assistant 带 tool_calls → content 强制 null(即使有字符串)
255
- // - assistant 不带 tool_calls 且 content 全空白 → null(避免触发 2013)
256
- // - user content 全空白 → 用单个空格 ' ' 占位(provider 通常可接受)
257
- // - tool content 全空白 → '(no output)' 占位(防止序列化时被丢)
258
- function sanitizeMessages(messages) {
259
- for (const m of messages) {
260
- if (m == null || typeof m !== 'object') continue;
261
- // 非字符串 content(数组形态的多模态 user 消息、已是 null 等) 不动
262
- if (m.content === null || m.content === undefined) {
263
- // assistant 必须显式 null,不要让 provider 看到 undefined
264
- if (m.role === 'assistant') m.content = null;
265
- continue;
266
- }
267
- if (typeof m.content !== 'string') continue;
268
- const trimmed = m.content.trim();
269
- if (trimmed === '') {
270
- if (m.role === 'assistant') {
271
- // 带 tool_calls 时强制 null;否则也置 null(provider 更安全)
272
- m.content = null;
273
- } else if (m.role === 'tool') {
274
- m.content = '(no output)';
275
- } else if (m.role === 'user') {
276
- // user 不能 null(部分 provider 拒),用单个空格占位
277
- m.content = ' ';
278
- }
279
- continue;
280
- }
281
- // assistant 带 tool_calls:即使是有效正文也强制 null
282
- // (OpenAI 规范要求 tool_calls 出现时 content=null,避免 provider 误判)
283
- if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
284
- m.content = null;
285
- }
286
- }
287
- return messages;
288
- }
289
-
290
- // ── 历史裁剪 ──────────────────────────────────────────────
291
- // 保留 system + 最近 MAX_HISTORY_MESSAGES 条
292
- // 切口必须落在 user 消息上,避免把 assistant(tool_calls) 与其 tool 结果从中间撕开
293
- const MAX_HISTORY_MESSAGES = 40;
294
- function trimHistory(messages) {
295
- if (messages.length <= MAX_HISTORY_MESSAGES + 1) return;
296
- let cut = messages.length - MAX_HISTORY_MESSAGES;
297
- // 至少向后扫 5 条,跳过孤立的 tool / assistant(tool_calls),
298
- // 找到真正的 user 节点再切,防止撕裂 tool 调用链
299
- let safety = 0;
300
- while (cut < messages.length && messages[cut].role !== 'user' && safety < 8) {
301
- cut++;
302
- safety++;
303
- }
304
- if (cut <= 1 || messages[cut]?.role !== 'user') return;
305
- messages.splice(1, cut - 1);
306
- }
307
-
308
- // ── 多模态历史:旧图片降级为文字 ──────────────────────────
309
- function stripStaleImages(messages) {
310
- const placeholder = '[图片已从历史中省略]';
311
- let seenLatest = false;
312
- for (let i = messages.length - 1; i >= 0; i--) {
313
- const m = messages[i];
314
- if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
315
- const hasImage = m.content.some(p => p?.type === 'image_url');
316
- if (!hasImage) continue;
317
- if (!seenLatest) { seenLatest = true; continue; }
318
- m.content = m.content.map(p => p?.type === 'image_url'
319
- ? { type: 'text', text: placeholder }
320
- : p);
321
- }
322
- }
215
+ // 这里曾经是第二份实现:历史只按条数硬切(MAX_HISTORY_MESSAGES=40)+ 纯 splice 丢弃,
216
+ // 而 CLI 侧早已改成「条数/字符双预算 + 丢弃项摘录成一条梗概」,两侧行为因此分叉 ——
217
+ // Web 面板聊久了模型会直接失忆,CLI 还记得要点。现在收敛到一处,口径见 context.js。
323
218
 
324
219
  // ── 核心入口:运行一轮 agent 对话 ────────────────────────
325
220
  //
326
221
  // 参数:
327
- // { session, model, userMessage, cwd, locale, signal, send, onChild }
222
+ // { session, model, userMessage, cwd, locale, signal, send, onChild, askUser, listProjects }
328
223
  // - session: 从 agentSessionStore 读取的会话对象
329
224
  // - model: { baseURL, model, apiKey }
330
225
  // - userMessage: 用户输入文本
@@ -334,10 +229,13 @@ function stripStaleImages(messages) {
334
229
  // - signal: AbortSignal (客户端断开时触发)
335
230
  // - send: (obj) => void SSE 发送函数
336
231
  // - onChild: (child) => void 子进程回调(用于取消)
232
+ // - askUser: (args, meta) => Promise<string> 等待用户回答
233
+ // - listProjects: (args) => Promise<string> list_projects 工具的数据源
234
+ // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
337
235
  //
338
236
  // 返回: { aborted: boolean }
339
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, signal, send, onChild }) {
340
- const ctx = { cwd, locale, onChild };
237
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, signal, send, onChild, askUser, listProjects }) {
238
+ const ctx = { cwd, locale, onChild, askUser, listProjects };
341
239
 
342
240
  // 确保 session.messages 存在
343
241
  if (!Array.isArray(session.messages)) session.messages = [];
@@ -361,20 +259,19 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
361
259
  : userMessage
362
260
  });
363
261
 
364
- // 旧图片降级
365
- stripStaleImages(session.messages);
366
-
367
262
  const maxIterations = await resolveMaxToolIterations();
368
263
 
369
264
  for (let iter = 0; iter < maxIterations; iter++) {
370
- trimHistory(session.messages);
371
- sanitizeMessages(session.messages);
265
+ // 每轮都从完整会话记录重新构建一次请求副本:条数/字符双预算 → 被丢掉的旧消息
266
+ // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
267
+ // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
268
+ const messages = prepareRequestMessages(session.messages, { locale });
372
269
 
373
270
  let result;
374
271
  try {
375
272
  result = await streamChatOnce({
376
273
  model,
377
- messages: session.messages,
274
+ messages,
378
275
  signal,
379
276
  // 整轮对话(含后续工具调用产生的每一轮请求)复用同一个会话 ID
380
277
  sessionId: session.sessionId,
@@ -436,7 +333,16 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
436
333
  const argsPreview = summarizeArgs(name, args);
437
334
  send({ type: 'tool_call_start', toolCallId, name, argsPreview });
438
335
 
439
- const output = await executeTool(name, args, ctx);
336
+ const toolCtx = { ...ctx, signal };
337
+ if (name === 'ask_user' && typeof ctx.askUser === 'function') {
338
+ toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
339
+ sessionId: session.sessionId,
340
+ interactionId: toolCallId,
341
+ send,
342
+ signal,
343
+ });
344
+ }
345
+ const output = await executeTool(name, args, toolCtx);
440
346
  send({ type: 'tool_result', toolCallId, name, result: output });
441
347
  session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: output });
442
348
  }
@@ -0,0 +1,151 @@
1
+ // Web 智能体面板(agentChat.js)必须与 CLI 的 `g ai` 共用同一条**上下文**和**传输**口径。
2
+ //
3
+ // 这两处曾经各有一份自己的实现:
4
+ // 上下文:只按条数硬切 40 条 + 纯 splice 丢弃,且**原地**改 session.messages
5
+ // (聊久了模型失忆,磁盘上的会话记录还被永久削掉一截)
6
+ // 传输 :自带的 streamChatOnce 不拦"流被截断但 tool_calls 已部分到达" ——
7
+ // 半截的参数有可能被拿去执行
8
+ // 现在两者都收敛到 src/cli/ai/ 下的共享模块。这个文件跑真实的 runAgentTurn,
9
+ // 把发出去的请求体与产生的事件抓下来,逐条钉住收敛后的口径。
10
+ //
11
+ // 注意:必须在 import agentChat.js **之前**把 USERPROFILE/HOME 指到沙箱,否则
12
+ // configManager / agentSessionStore 会去读真实的 ~/.zen-gitsync。
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { mkdtempSync } from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-shared-'))
20
+ process.env.USERPROFILE = SANDBOX
21
+ process.env.HOME = SANDBOX
22
+ delete process.env.HOMEDRIVE
23
+ delete process.env.HOMEPATH
24
+
25
+ const event = data => `data: ${JSON.stringify(data)}\n\n`
26
+ const sse = chunks => new Response(new ReadableStream({
27
+ start(controller) {
28
+ const bytes = new TextEncoder()
29
+ chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
30
+ controller.close()
31
+ },
32
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
33
+
34
+ const model = { model: 'test', name: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
35
+ const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
36
+ const okStream = () => sse([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
37
+ const withSystem = () => [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
38
+ const longSession = () => {
39
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
40
+ for (let i = 0; i < 60; i++) {
41
+ messages.push({ role: 'assistant', tool_calls: [call(i)] })
42
+ messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
43
+ }
44
+ return messages
45
+ }
46
+
47
+ // 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与产生的事件
48
+ async function runWeb({ session, userMessage, respond, locale = 'zh-CN' }) {
49
+ const { runAgentTurn } = await import('./agentChat.js')
50
+ const events = []
51
+ let sent
52
+ const original = globalThis.fetch
53
+ globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond() }
54
+ try {
55
+ const result = await runAgentTurn({
56
+ session, model, userMessage, locale, cwd: process.cwd(),
57
+ signal: new AbortController().signal, send: e => events.push(e),
58
+ })
59
+ return { sent, events, result }
60
+ } finally {
61
+ globalThis.fetch = original
62
+ }
63
+ }
64
+
65
+ const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
66
+
67
+ // ── 上下文口径 ────────────────────────────────────────────
68
+
69
+ test('请求体与 context.js 的输出逐字段相同(有界 + 摘录)', async () => {
70
+ const { prepareRequestMessages } = await import('../../../../cli/ai/context.js')
71
+ const messages = longSession()
72
+ // 传副本进去:runAgentTurn 会往 session.messages 追加消息,原数组要留作期望值的输入
73
+ const session = newSession('ag-sandbox-test', structuredClone(messages))
74
+
75
+ const { sent } = await runWeb({ session, userMessage: 'next', respond: okStream })
76
+
77
+ assert.ok(sent, '必须发出过一次请求')
78
+ assert.ok(sent.messages.length <= 40, `请求副本未收窄: ${sent.messages.length} 条`)
79
+ assert.equal(sent.messages[0].role, 'system')
80
+ assert.match(sent.messages[1].content, /^\[Earlier conversation excerpts/)
81
+ assert.ok(sent.messages.some(m => m.content === 'original goal'), '原始目标必须保留')
82
+ // 逐字段相同 = 这条链路确实走的是同一个函数,而不是"看起来差不多"的第二份实现
83
+ assert.deepEqual(
84
+ sent.messages,
85
+ prepareRequestMessages([...messages, { role: 'user', content: 'next' }], { locale: 'zh-CN' }),
86
+ )
87
+ })
88
+
89
+ test('会话记录不再被原地裁剪(磁盘口径与 CLI 一致)', async () => {
90
+ const session = newSession('ag-sandbox-keep', longSession())
91
+
92
+ await runWeb({ session, userMessage: 'next', respond: okStream })
93
+
94
+ assert.equal(session.messages.filter(m => m.role === 'tool').length, 60,
95
+ '旧消息必须留在会话记录里(裁剪只作用于请求副本)')
96
+ assert.equal(session.messages[1].content, 'original goal')
97
+ assert.equal(session.messages[2].tool_calls[0].id, 'call0')
98
+ })
99
+
100
+ test('短会话不做任何额外处理,也不塞摘录消息', async () => {
101
+ const session = newSession('ag-sandbox-short', withSystem())
102
+
103
+ const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
104
+
105
+ assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
106
+ })
107
+
108
+ // ── 传输口径 ──────────────────────────────────────────────
109
+
110
+ test('请求体带上 stream_options,与 CLI 一致地统计 usage', async () => {
111
+ const session = newSession('ag-sandbox-usage', withSystem())
112
+
113
+ const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
114
+
115
+ assert.deepEqual(sent.stream_options, { include_usage: true })
116
+ assert.equal(sent.stream, true)
117
+ assert.equal(sent.temperature, 0.3)
118
+ assert.ok(Array.isArray(sent.tools) && sent.tools.length > 0, '内置工具表必须随请求下发')
119
+ })
120
+
121
+ test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截的工具调用', async () => {
122
+ const session = newSession('ag-sandbox-truncated', withSystem())
123
+ // 工具调用参数是完整的、JSON 合法 —— 换在没有这道闸的旧实现里就会被真的执行
124
+ const truncated = sse([event({
125
+ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_x', function: { name: 'read_file', arguments: '{"path":"nope.txt"}' } }] } }],
126
+ })])
127
+
128
+ const { events } = await runWeb({ session, userMessage: '读个文件', respond: () => truncated })
129
+
130
+ const error = events.find(e => e.type === 'error')
131
+ assert.ok(error, '截断的流必须报错,不能静默当成正常结束')
132
+ assert.match(error.error, /中断/)
133
+ assert.equal(events.some(e => e.type === 'tool_call_start'), false, '被截断的调用不得开始执行')
134
+ assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
135
+ })
136
+
137
+ test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
138
+ const session = newSession('ag-sandbox-nofc', withSystem())
139
+ const noFunctionCalling = () => new Response(
140
+ JSON.stringify({ error: { message: 'tools is not supported by this model' } }),
141
+ { status: 400, headers: { 'content-type': 'application/json' } },
142
+ )
143
+
144
+ const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: noFunctionCalling })
145
+
146
+ assert.equal(sent.messages.length, 3, '不该把 stream_options 的降级重试和这条错误混在一起')
147
+ const error = events.find(e => e.type === 'error')
148
+ assert.ok(error)
149
+ assert.match(error.error, /不支持 function calling/)
150
+ assert.match(error.error, /换用支持工具调用的模型/)
151
+ })