zen-gitsync 2.18.7 → 2.18.9

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 (76) hide show
  1. package/package.json +4 -1
  2. package/src/cli/ai/agent.js +7 -0
  3. package/src/cli/ai/runtime.test.js +22 -1
  4. package/src/cli/ai/tools.js +1 -1
  5. package/src/cli/ai/tools.test.js +1 -1
  6. package/src/cli/ai/transport.js +8 -3
  7. package/src/config.js +48 -6
  8. package/src/mcp/tools/workbench.js +1 -1
  9. package/src/ui/public/assets/{AgentView-am93H7C2.css → AgentView-CV_8sYX6.css} +1 -1
  10. package/src/ui/public/assets/AgentView-DxmnqJxE.js +1 -0
  11. package/src/ui/public/assets/{AppVersionBadge-D7X53Bf1.js → AppVersionBadge-C4OhcUi8.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-yXxfIs57.js → BranchSelector-CrLieQyJ.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-C8Ez91_c.js → CommitForm-KGQisnPQ.js} +2 -2
  14. package/src/ui/public/assets/{CommonDialog-BUuLsgNS.js → CommonDialog-j_53ORvj.js} +1 -1
  15. package/src/ui/public/assets/CopySessionButton-y5Rzmact.js +20 -0
  16. package/src/ui/public/assets/EditorView-CkZb2kw5.js +1 -0
  17. package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
  18. package/src/ui/public/assets/{FlowExecutionViewer-BuU5kQG8.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  19. package/src/ui/public/assets/{FlowOrchestrationWorkspace-CTBEooj5.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
  20. package/src/ui/public/assets/{LogList-Bq5aLoA-.js → LogList-BxH6QNlK.js} +4 -4
  21. package/src/ui/public/assets/{MindmapView-CSysrruz.js → MindmapView-D9_qjJsR.js} +1 -1
  22. package/src/ui/public/assets/{MonitorView-Cgf5zRxT.js → MonitorView-z_fsnE8Y.js} +1 -1
  23. package/src/ui/public/assets/{ProjectStartupButton-CrLWTK20.js → ProjectStartupButton-CFB_JpYG.js} +1 -1
  24. package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
  25. package/src/ui/public/assets/RecentDirectoriesChat-BYI8lkjQ.js +1 -0
  26. package/src/ui/public/assets/{RemoteManagerDialog-CJZFzQPc.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
  27. package/src/ui/public/assets/{RemoteRepoCard-aSx1ikZ8.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
  28. package/src/ui/public/assets/{SourceMapView-DEljf14g.js → SourceMapView-D6cSweDt.js} +1 -1
  29. package/src/ui/public/assets/{SvgIcon-Du5MOe8A.js → SvgIcon-5AlG3IfR.js} +1 -1
  30. package/src/ui/public/assets/{UserInputNode-oOQwaadU.js → UserInputNode-BKP869D6.js} +1 -1
  31. package/src/ui/public/assets/WorkbenchView-D4UDurhF.js +6 -0
  32. package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
  33. package/src/ui/public/assets/_plugin-vue_export-helper-Csn085FF.js +19 -0
  34. package/src/ui/public/assets/agentConversations-DyzaxUXm.js +10 -0
  35. package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
  36. package/src/ui/public/assets/index-C7Y9odvZ.js +57 -0
  37. package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
  38. package/src/ui/public/assets/useOrchestrator-C236G5P5.js +1 -0
  39. package/src/ui/public/index.html +6 -6
  40. package/src/ui/server/index.js +8 -0
  41. package/src/ui/server/routes/config.js +13 -0
  42. package/src/ui/server/routes/fileOpen.js +57 -1
  43. package/src/ui/server/routes/fileOpen.test.js +1 -1
  44. package/src/ui/server/routes/workbench/agentChat.js +106 -12
  45. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  46. package/src/ui/server/routes/workbench/agentChatShared.test.js +148 -57
  47. package/src/ui/server/routes/workbench/agentEngines.js +8 -4
  48. package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
  49. package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
  50. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  51. package/src/ui/server/routes/workbench/agentRoutes.js +105 -8
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  53. package/src/ui/server/routes/workbench/chatPrompts.js +115 -0
  54. package/src/ui/server/routes/workbench/chatPrompts.test.js +114 -0
  55. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  56. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  57. package/src/ui/server/routes/workbench/index.js +1 -1
  58. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  59. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  60. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  61. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  62. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  63. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  64. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  65. package/src/ui/public/assets/AgentView-BlG0PdkI.js +0 -1
  66. package/src/ui/public/assets/CopySessionButton-Do4vHJm6.js +0 -20
  67. package/src/ui/public/assets/EditorView-DK2dYRVb.js +0 -1
  68. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  69. package/src/ui/public/assets/RecentDirectoriesChat-BlIG_9G4.js +0 -1
  70. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  71. package/src/ui/public/assets/WorkbenchView-DXzxg5_z.js +0 -8
  72. package/src/ui/public/assets/_plugin-vue_export-helper-CDrhhAKC.js +0 -19
  73. package/src/ui/public/assets/agentConversations-BKjyYn-u.js +0 -8
  74. package/src/ui/public/assets/configStore-D0oycG71.js +0 -1
  75. package/src/ui/public/assets/index-DjaZpz97.js +0 -57
  76. package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
@@ -0,0 +1,290 @@
1
+ // Web 端扩展层(Skill + MCP)接入 runAgentTurn 的回归测试。
2
+ //
3
+ // 钉住的行为(2026-10-12 修的问题):MCP 广场显示"已安装",但 Web 智能体
4
+ // 对话/定时任务的工具列表里就是没有那些工具 —— AgentExtensions.load 原先
5
+ // 只有 CLI 的 g ai 调。现在 Web 侧也加载,这里跑真实的 runAgentTurn,
6
+ // 把请求体与事件抓下来逐条钉住:
7
+ // 1) MCP 工具定义随请求下发(mcp__ 前缀),系统提示词出现 MCP 一节;
8
+ // 2) mcp__ 调用分流到 MCP server,内置工具照常走 executeTool;
9
+ // 3) 连接失败只降级(该 server 没工具),轮次照常完成;
10
+ // 4) 同一 cwd 只握手一次(进程内缓存),invalidate 后按新配置重载;
11
+ // 5) 广场安装路由走完,下一轮对话就吃得到(不等 g gui 重启);
12
+ // 6) 扩展摘要只进请求副本,不腌进 session.messages(老会话也吃得到)。
13
+ //
14
+ // 注意:必须在 import agentChat.js **之前**把 USERPROFILE/HOME 指到沙箱,否则
15
+ // paths.js 在模块加载时用 os.homedir() 求值,会去读真实的 ~/.zen-gitsync。
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
19
+ import http from 'node:http'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-ext-'))
24
+ process.env.USERPROFILE = SANDBOX
25
+ process.env.HOME = SANDBOX
26
+ delete process.env.HOMEDRIVE
27
+ delete process.HOMEPATH
28
+
29
+ const DATA_DIR = path.join(SANDBOX, '.zen-gitsync')
30
+ const MCP_FILE = path.join(DATA_DIR, 'ai', 'mcp.json')
31
+
32
+ // ── 脚手架 ────────────────────────────────────────────────────
33
+
34
+ const event = data => `data: ${JSON.stringify(data)}\n\n`
35
+ const sse = chunks => new Response(new ReadableStream({
36
+ start(controller) {
37
+ const bytes = new TextEncoder()
38
+ chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
39
+ controller.close()
40
+ },
41
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
42
+
43
+ const model = { model: 'test', name: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
44
+ const okStream = () => sse([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
45
+ const withSystem = () => [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
46
+ const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
47
+
48
+ // 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与事件。
49
+ // ⚠️ MCP 的连接也走 globalThis.fetch(HttpConnection),所以这个桩按 URL 分流:
50
+ // 打 127.0.0.1 的(桩 server)放真 fetch 过去,其余(LLM)抓请求体 + 回灌 SSE。
51
+ async function runWeb({ session, userMessage, respond }) {
52
+ const { runAgentTurn } = await import('./agentChat.js')
53
+ const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
54
+ const events = []
55
+ let sent
56
+ const realFetch = globalThis.fetch
57
+ globalThis.fetch = async (url, init) => {
58
+ if (String(url).startsWith('http://127.0.0.1')) return realFetch(url, init)
59
+ sent = JSON.parse(init.body)
60
+ return respond(init)
61
+ }
62
+ try {
63
+ await runAgentTurn({
64
+ session, model, userMessage, locale: 'zh-CN', cwd: process.cwd(),
65
+ signal: new AbortController().signal,
66
+ send: e => events.push(e),
67
+ streamFn: opts => streamChatOnce({ ...opts, maxRetries: 0 }),
68
+ })
69
+ return { sent, events }
70
+ } finally {
71
+ globalThis.fetch = realFetch
72
+ }
73
+ }
74
+
75
+ // 最小 Streamable HTTP MCP server 桩:initialize / tools/list / tools/call。
76
+ // 每个响应都带 Mcp-Session-Id,顺带覆盖 HttpConnection 的会话关闭(DELETE)路径。
77
+ function startStubMcp({ onHandshake, onCall, onClose } = {}) {
78
+ const server = http.createServer((req, res) => {
79
+ const chunks = []
80
+ req.on('data', c => chunks.push(c))
81
+ req.on('end', () => {
82
+ if (req.method === 'DELETE') {
83
+ onClose?.()
84
+ res.writeHead(204)
85
+ res.end()
86
+ return
87
+ }
88
+ let msg = {}
89
+ try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch { /* 通知类可能没体 */ }
90
+ const reply = result => {
91
+ res.writeHead(200, {
92
+ 'content-type': 'application/json',
93
+ 'mcp-session-id': `sess-${Math.random().toString(36).slice(2, 8)}`,
94
+ })
95
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id ?? null, result }))
96
+ }
97
+ if (msg.method === 'initialize') {
98
+ onHandshake?.()
99
+ reply({ protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'stub', version: '0' } })
100
+ } else if (msg.method === 'tools/list') {
101
+ reply({ tools: [{ name: 'echo', description: '回声工具', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }] })
102
+ } else if (msg.method === 'tools/call') {
103
+ onCall?.(msg.params)
104
+ reply({ content: [{ type: 'text', text: `stub 收到: ${msg.params?.arguments?.text ?? ''}` }] })
105
+ } else {
106
+ reply({}) // notifications/initialized 等
107
+ }
108
+ })
109
+ })
110
+ return new Promise((resolve) => {
111
+ server.listen(0, '127.0.0.1', () => resolve({
112
+ url: `http://127.0.0.1:${server.address().port}/api/mcp`,
113
+ close: () => new Promise(r => server.close(r)),
114
+ }))
115
+ })
116
+ }
117
+
118
+ function writeMcpFile(url) {
119
+ mkdirSync(path.dirname(MCP_FILE), { recursive: true })
120
+ writeFileSync(MCP_FILE, JSON.stringify({ mcpServers: { stub: { name: 'Stub', type: 'http', url } } }, null, 2))
121
+ }
122
+
123
+ const hasMcpTools = sent => sent.tools.some(t => String(t.function?.name || '').startsWith('mcp__'))
124
+
125
+ // 每个用例开工前清一遍:上一条用例的 finally 也该清,双保险 ——
126
+ // 缓存是模块级的,残留一份配置会让下一条用例莫名其妙吃到 MCP。
127
+ async function resetExtensions() {
128
+ rmSync(MCP_FILE, { force: true })
129
+ const { invalidateWebExtensions } = await import('./agentExtensions.js')
130
+ await invalidateWebExtensions()
131
+ }
132
+
133
+ // ── 挂载与分发 ────────────────────────────────────────────────
134
+
135
+ test('MCP 工具挂进请求、按 mcp__ 名字分发到 server,内置工具不受影响', async () => {
136
+ let handshakes = 0
137
+ const stub = await startStubMcp({ onHandshake: () => handshakes++ })
138
+ try {
139
+ await resetExtensions()
140
+ writeMcpFile(stub.url)
141
+
142
+ // 第一轮:模型同时调一个 MCP 工具和一个内置工具 —— 分流是否正确一轮就看出来
143
+ const round1 = sse([event({
144
+ choices: [{
145
+ delta: { tool_calls: [
146
+ { index: 0, id: 'm1', function: { name: 'mcp__stub__echo', arguments: JSON.stringify({ text: '你好' }) } },
147
+ { index: 1, id: 'p1', function: { name: 'update_plan', arguments: JSON.stringify({ steps: [{ content: '一步', status: 'completed' }] }) } },
148
+ ] },
149
+ finish_reason: 'tool_calls',
150
+ }],
151
+ }), 'data: [DONE]\n\n'])
152
+ let turn = 0
153
+ const session = newSession('ag-ext-dispatch', withSystem())
154
+
155
+ const { sent, events } = await runWeb({
156
+ session, userMessage: '调用 mcp', respond: () => (turn++ === 0 ? round1 : okStream()),
157
+ })
158
+
159
+ // ① 请求里带着 MCP 工具定义(transport 把 extraTools 并进 tools)
160
+ const mcpTool = sent.tools.find(t => t.function?.name === 'mcp__stub__echo')
161
+ assert.ok(mcpTool, 'MCP 工具必须随请求下发')
162
+ assert.match(mcpTool.function.description, /回声/)
163
+ assert.equal(mcpTool.type, 'function')
164
+ // 内置工具一个没少
165
+ assert.ok(sent.tools.some(t => t.function?.name === 'update_plan'), '内置工具表必须还在')
166
+ // ② 系统提示词里出现 MCP 一节,且点名 mcp__ 前缀
167
+ assert.match(sent.messages[0].content, /已接入的 MCP 服务/)
168
+ assert.match(sent.messages[0].content, /mcp__/)
169
+ // ③ MCP 调用真的走到了 stub server
170
+ assert.equal(handshakes, 1)
171
+ const mcpResult = events.find(e => e.type === 'tool_result' && e.name === 'mcp__stub__echo')
172
+ assert.ok(mcpResult, 'MCP 调用必须产出 tool_result')
173
+ assert.match(mcpResult.result, /stub 收到: 你好/)
174
+ // ④ 同一轮里的内置工具照常执行(分流没有把内置的也吞掉)
175
+ const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
176
+ assert.match(planResult.result, /计划已更新/)
177
+ // ⑤ 两个结果都进了会话历史(重开会话能回放)
178
+ const toolNames = session.messages.filter(m => m.role === 'tool').map(m => m.name)
179
+ assert.deepEqual(toolNames, ['mcp__stub__echo', 'update_plan'])
180
+ // ⑥ 摘要只进请求副本,不写回 session.messages
181
+ assert.equal(session.messages[0].content, 'rules', '扩展摘要不该腌进磁盘历史')
182
+ } finally {
183
+ await resetExtensions()
184
+ await stub.close()
185
+ }
186
+ })
187
+
188
+ // ── 降级 ──────────────────────────────────────────────────────
189
+
190
+ test('MCP 连接失败只降级:轮次照常完成,请求里不带 MCP 工具', async () => {
191
+ // 占一个端口再松开,拿一个一定没人听的地址
192
+ const stub = await startStubMcp()
193
+ const deadUrl = stub.url
194
+ await stub.close()
195
+ try {
196
+ await resetExtensions()
197
+ writeMcpFile(deadUrl)
198
+ const session = newSession('ag-ext-dead', withSystem())
199
+
200
+ const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: okStream })
201
+
202
+ assert.ok(!events.some(e => e.type === 'error'), '连接失败不该把轮次打挂')
203
+ assert.equal(hasMcpTools(sent), false, '连不上的 server 不该贡献工具定义')
204
+ assert.doesNotMatch(sent.messages[0].content, /已接入的 MCP 服务/)
205
+ assert.equal(session.messages[session.messages.length - 1].content, 'ok', '轮次要正常收尾')
206
+ } finally {
207
+ await resetExtensions()
208
+ }
209
+ })
210
+
211
+ // ── 缓存与失效 ────────────────────────────────────────────────
212
+
213
+ test('同 cwd 只握手一次(进程内缓存),invalidate 后按新配置重新加载', async () => {
214
+ let handshakes = 0
215
+ let deletes = 0
216
+ const stub = await startStubMcp({ onHandshake: () => handshakes++, onClose: () => deletes++ })
217
+ try {
218
+ await resetExtensions()
219
+ // 没配 MCP:第一轮不该有 mcp 工具,也不该发生握手
220
+ const s1 = newSession('ag-ext-cache-1', withSystem())
221
+ const r1 = await runWeb({ session: s1, userMessage: 'hello', respond: okStream })
222
+ assert.equal(handshakes, 0)
223
+ assert.equal(hasMcpTools(r1.sent), false)
224
+
225
+ // 模拟"广场装完":写配置 + 失效(这一路正是 agentMarketplace.js 调的)
226
+ writeMcpFile(stub.url)
227
+ const { invalidateWebExtensions } = await import('./agentExtensions.js')
228
+ await invalidateWebExtensions()
229
+
230
+ // 第二轮:立刻吃得到,不等重启
231
+ const s2 = newSession('ag-ext-cache-2', withSystem())
232
+ const r2 = await runWeb({ session: s2, userMessage: 'hello', respond: okStream })
233
+ assert.equal(handshakes, 1, '失效后必须重新加载')
234
+ assert.equal(hasMcpTools(r2.sent), true)
235
+ assert.match(r2.sent.messages[0].content, /已接入的 MCP 服务/)
236
+
237
+ // 第三轮:同一 cwd 走缓存,不再握手
238
+ const s3 = newSession('ag-ext-cache-3', withSystem())
239
+ const r3 = await runWeb({ session: s3, userMessage: 'hello', respond: okStream })
240
+ assert.equal(handshakes, 1, '缓存生效时不能反复握手')
241
+ assert.equal(hasMcpTools(r3.sent), true)
242
+
243
+ // 失效时旧连接要真的关掉(HTTP 侧表现为 DELETE 到过 server)
244
+ await invalidateWebExtensions()
245
+ assert.ok(deletes >= 1, 'invalidate 必须关闭旧连接(DELETE 结束会话)')
246
+ } finally {
247
+ await resetExtensions()
248
+ await stub.close()
249
+ }
250
+ })
251
+
252
+ // ── 广场安装路由 → 对话生效(端到端) ───────────────────────────
253
+
254
+ test('广场安装路由走完后,下一轮对话就挂上该 MCP(不等 g gui 重启)', async () => {
255
+ let handshakes = 0
256
+ const stub = await startStubMcp({ onHandshake: () => handshakes++ })
257
+ try {
258
+ await resetExtensions()
259
+ const s1 = newSession('ag-ext-mp-1', withSystem())
260
+ const r1 = await runWeb({ session: s1, userMessage: 'hello', respond: okStream })
261
+ assert.equal(hasMcpTools(r1.sent), false)
262
+
263
+ // 走真实的注册路由(harness 抄自 agentMarketplace.test.js 的打法):
264
+ // 远程端点型 MCP 只写配置文件,不联网
265
+ const { registerAgentMarketplaceRoutes } = await import('./agentMarketplace.js')
266
+ const handlers = new Map()
267
+ const app = {
268
+ get: (r, h) => handlers.set(`GET ${r}`, h),
269
+ post: (r, h) => handlers.set(`POST ${r}`, h),
270
+ delete: (r, h) => handlers.set(`DELETE ${r}`, h),
271
+ }
272
+ registerAgentMarketplaceRoutes({ app, getCurrentProjectPath: () => process.cwd() })
273
+ let payload = null
274
+ const res = { status() { return this }, json(data) { payload = data; return this } }
275
+ await handlers.get('POST /api/agent/marketplace/install')({
276
+ body: { type: 'mcp', target: 'global', cwd: process.cwd(), item: { id: 'stub', name: 'Stub', remoteUrl: stub.url } },
277
+ }, res)
278
+ assert.equal(payload?.success, true, '安装路由要报成功')
279
+ assert.ok(payload?.item?.file, '安装结果要带回落盘路径')
280
+
281
+ // 下一轮对话:工具表里必须有它,且握手真的发生过
282
+ const s2 = newSession('ag-ext-mp-2', withSystem())
283
+ const r2 = await runWeb({ session: s2, userMessage: 'hello', respond: okStream })
284
+ assert.equal(handshakes, 1)
285
+ assert.equal(hasMcpTools(r2.sent), true, '广场装完必须立刻在对话里生效')
286
+ } finally {
287
+ await resetExtensions()
288
+ await stub.close()
289
+ }
290
+ })
@@ -12,7 +12,7 @@
12
12
  // configManager / agentSessionStore 会去读真实的 ~/.zen-gitsync。
13
13
  import { test } from 'node:test'
14
14
  import assert from 'node:assert/strict'
15
- import { mkdtempSync } from 'node:fs'
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
 
@@ -61,7 +61,7 @@ const longSession = () => {
61
61
  // streamFn:传输层现在默认会自动重试(最多 10 次、指数退避,见 cli/ai/transport.js)。
62
62
  // 这一组用例关心的是"失败时怎么收场",不是"重试几次" —— 真等退避会让一个用例跑到分钟级。
63
63
  // 所以统一注入 maxRetries: 0 的那一份;重试自身的口径在 cli/ai/runtime.test.js 里逐条钉。
64
- async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent, resume = false, streamFn }) {
64
+ async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent, resume = false, streamFn, promptSection }) {
65
65
  const { runAgentTurn } = await import('./agentChat.js')
66
66
  const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
67
67
  const events = []
@@ -75,6 +75,8 @@ async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal,
75
75
  send: e => { events.push(e); onEvent?.(e) },
76
76
  resume,
77
77
  streamFn: streamFn ?? (opts => streamChatOnce({ ...opts, maxRetries: 0 })),
78
+ // 不传 = 让 runAgentTurn 自己读沙箱里的配置(要验默认取数时就别传)
79
+ ...(promptSection === undefined ? {} : { promptSection }),
78
80
  })
79
81
  return { sent, events, result }
80
82
  } finally {
@@ -264,6 +266,8 @@ test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JS
264
266
  // · argsPreview —— 收起态那一行副标题,故意截到 200 字。
265
267
  // 计划类工具的 steps 也在 arguments 里(不再单独开一条透传路径)。
266
268
  test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截断摘要', async () => {
269
+ // 桩的 write_file 落点:沙箱里(见 round 里的说明)。用完删掉,不留 tmp 垃圾。
270
+ const stubWritePath = path.join(SANDBOX, 'stub-write-target.js')
267
271
  const planArgs = JSON.stringify({
268
272
  steps: [
269
273
  { content: '读代码', status: 'completed' },
@@ -275,7 +279,12 @@ test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截
275
279
  sse([event({
276
280
  choices: [{ delta: { tool_calls: [
277
281
  { index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
278
- { index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }) } }
282
+ // ⚠️ 这个桩会走**真实** executeTool 落盘(这一层跑的是真 runAgentTurn,
283
+ // 不是解析 Mock)—— 路径必须指到沙箱。曾经这里写的是相对路径 'a.js':
284
+ // resolvePath 按 process.cwd() 解析,于是每次跑测试都在当前目录留一个
285
+ // 50 字节的 a.js(仓库根跑留根目录、routes/workbench 跑留那里),
286
+ // 全组用例还都是绿的,只能靠 git status 发现。
287
+ { index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }) } }
279
288
  ] } }, { finish_reason: 'tool_calls' }],
280
289
  }), 'data: [DONE]\n\n']),
281
290
  okStream(),
@@ -283,29 +292,35 @@ test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截
283
292
  let turn = 0
284
293
  const session = newSession('ag-sandbox-plan', withSystem())
285
294
 
286
- const { events } = await runWeb({
287
- session,
288
- userMessage: '把这个功能做出来',
289
- respond: () => round[turn++] || okStream(),
290
- })
295
+ try {
296
+ const { events } = await runWeb({
297
+ session,
298
+ userMessage: '把这个功能做出来',
299
+ respond: () => round[turn++] || okStream(),
300
+ })
291
301
 
292
- const starts = events.filter(e => e.type === 'tool_call_start')
293
- assert.equal(starts.length, 2)
294
- const planStart = starts.find(e => e.name === 'update_plan')
295
- const writeStart = starts.find(e => e.name === 'write_file')
296
-
297
- // 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
298
- assert.equal(planStart.arguments, planArgs)
299
- assert.match(planStart.argsPreview, /1\/2|1 完成/)
300
- // 其他工具:展开态同样要看到原文,不再只给摘要
301
- assert.equal(writeStart.arguments, JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }))
302
- assert.ok(writeStart.argsPreview.length < writeStart.arguments.length, '摘要仍应是截断过的')
303
-
304
- const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
305
- assert.match(planResult.result, /计划已更新/)
306
- // 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
307
- const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
308
- assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
302
+ const starts = events.filter(e => e.type === 'tool_call_start')
303
+ assert.equal(starts.length, 2)
304
+ const planStart = starts.find(e => e.name === 'update_plan')
305
+ const writeStart = starts.find(e => e.name === 'write_file')
306
+
307
+ // 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
308
+ assert.equal(planStart.arguments, planArgs)
309
+ assert.match(planStart.argsPreview, /1\/2|1 完成/)
310
+ // 其他工具:展开态同样要看到原文,不再只给摘要
311
+ assert.equal(writeStart.arguments, JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }))
312
+ assert.ok(writeStart.argsPreview.length < writeStart.arguments.length, '摘要仍应是截断过的')
313
+ // 顺带证明桩调用真的执行了、且只落在沙箱(这条红了 = 有人把路径改成会污染仓库的)
314
+ assert.ok(existsSync(stubWritePath), '桩的 write_file 应真实落在沙箱里')
315
+
316
+ const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
317
+ assert.match(planResult.result, /计划已更新/)
318
+ // 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
319
+ const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
320
+ assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
321
+ } finally {
322
+ rmSync(stubWritePath, { force: true })
323
+ }
309
324
  })
310
325
 
311
326
  // ── 重试与续跑(2026-10-09)────────────────────────────────────
@@ -372,35 +387,111 @@ test('每次 LLM 请求前发一条 attempt(前端靠它记重试断点),
372
387
  assert.ok(order.indexOf('attempt') < order.indexOf('content'), 'attempt 要在正文之前')
373
388
  assert.ok(order.indexOf('content') < order.indexOf('retry'), 'retry 落在被丢弃的那段正文之后')
374
389
  })
375
-
376
- test('对跑完的一轮点「重新生成」:把尾巴那条 assistant 摘掉再问一次', async () => {
377
- // 会话尾巴是一条没有工具调用的 assistant = 上一轮已经答完了。
378
- // 留着它的话模型会顺着自己的答案往下说,重开会话还会看到两条连着的 assistant。
379
- const session = newSession('ag-sandbox-regen', [
380
- { role: 'system', content: 'rules' },
381
- { role: 'user', content: 'hi' },
382
- { role: 'assistant', content: '旧答案' },
383
- ])
384
-
385
- const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
386
-
387
- assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'], '旧答案必须从请求里消失')
388
- assert.equal(session.messages.filter(m => m.role === 'assistant').length, 1, '只剩这一次新生成的')
389
- assert.equal(session.messages[session.messages.length - 1].content, 'ok')
390
- })
391
-
392
- test('对中途失败的一轮点「重试」:工具结果留在尾巴上,一条都不摘', async () => {
393
- // 失败发生在工具循环中途时,尾巴是 assistant(tool_calls) + 一批 tool 结果 ——
394
- // 这些是这一轮真干过的事,摘掉就等于让模型把工具再跑一遍。
395
- const session = newSession('ag-sandbox-resume-tools', [
396
- { role: 'system', content: 'rules' },
397
- { role: 'user', content: 'hi' },
398
- { role: 'assistant', content: null, tool_calls: [call(0)] },
399
- { role: 'tool', tool_call_id: 'call0', content: 'file contents' },
400
- ])
401
-
402
- const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
403
-
404
- assert.deepEqual(sent.messages.map(m => m.role), ['system', 'user', 'assistant', 'tool'])
405
- assert.equal(sent.messages[3].content, 'file contents', '已经跑完的工具结果必须原样带上')
406
- })
390
+
391
+ test('对跑完的一轮点「重新生成」:把尾巴那条 assistant 摘掉再问一次', async () => {
392
+ // 会话尾巴是一条没有工具调用的 assistant = 上一轮已经答完了。
393
+ // 留着它的话模型会顺着自己的答案往下说,重开会话还会看到两条连着的 assistant。
394
+ const session = newSession('ag-sandbox-regen', [
395
+ { role: 'system', content: 'rules' },
396
+ { role: 'user', content: 'hi' },
397
+ { role: 'assistant', content: '旧答案' },
398
+ ])
399
+
400
+ const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
401
+
402
+ assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'], '旧答案必须从请求里消失')
403
+ assert.equal(session.messages.filter(m => m.role === 'assistant').length, 1, '只剩这一次新生成的')
404
+ assert.equal(session.messages[session.messages.length - 1].content, 'ok')
405
+ })
406
+
407
+ test('对中途失败的一轮点「重试」:工具结果留在尾巴上,一条都不摘', async () => {
408
+ // 失败发生在工具循环中途时,尾巴是 assistant(tool_calls) + 一批 tool 结果 ——
409
+ // 这些是这一轮真干过的事,摘掉就等于让模型把工具再跑一遍。
410
+ const session = newSession('ag-sandbox-resume-tools', [
411
+ { role: 'system', content: 'rules' },
412
+ { role: 'user', content: 'hi' },
413
+ { role: 'assistant', content: null, tool_calls: [call(0)] },
414
+ { role: 'tool', tool_call_id: 'call0', content: 'file contents' },
415
+ ])
416
+
417
+ const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
418
+
419
+ assert.deepEqual(sent.messages.map(m => m.role), ['system', 'user', 'assistant', 'tool'])
420
+ assert.equal(sent.messages[3].content, 'file contents', '已经跑完的工具结果必须原样带上')
421
+ })
422
+
423
+ // ── 用户固定提示词(g ai 默认提示词 + 派发提示词)────────────────
424
+ // 智能体视图「设置」Tab 配的两块提示词,和 g ai 对话时每轮都要注入。
425
+ // 这是**纯 prompt 注入**的能力:没有按钮、没有状态、界面上看不出对错 ——
426
+ // 改 agentChat.js / chatPrompts.js 的人(哪怕只是把一段挪个位置)会无声地把它弄掉。
427
+ // 所以这里跑真实的 runAgentTurn,把发出去的请求体抓下来逐条钉住。
428
+ //
429
+ // 沙箱里写真实文件(config.json / orchestrator.json):这一层要验的正是
430
+ // "默认取数真的接到了配置上",用注入口反而绕过了要验的那一段。
431
+ // 每个用例 finally 里删干净 —— 同文件其它用例断言的是精确的 messages 列表,
432
+ // 残留一份提示词就会让它们莫名其妙地红。
433
+
434
+ const DATA_DIR = path.join(SANDBOX, '.zen-gitsync')
435
+
436
+ function writeSandboxFile(name, obj) {
437
+ mkdirSync(DATA_DIR, { recursive: true })
438
+ writeFileSync(path.join(DATA_DIR, name), JSON.stringify(obj, null, 2))
439
+ }
440
+
441
+ function clearSandboxFile(name) {
442
+ rmSync(path.join(DATA_DIR, name), { force: true })
443
+ }
444
+
445
+ test('两块提示词都注入请求副本:g ai 默认提示词 + 派发提示词(含项目级)', async () => {
446
+ const { canonicalProjectPath } = await import('./projectRegistry.js')
447
+ const projectKey = canonicalProjectPath(process.cwd())
448
+ try {
449
+ writeSandboxFile('config.json', { agentDefaultPrompt: '【g ai 默认提示词】回答用中文' })
450
+ writeSandboxFile('orchestrator.json', {
451
+ version: 1,
452
+ active: true,
453
+ defaultPrompt: '【全局派发提示词】改完就提交',
454
+ projectPrompts: { [projectKey]: { path: process.cwd(), prompt: '【本项目派发提示词】包管理器用 pnpm' } },
455
+ })
456
+
457
+ const session = newSession('ag-sandbox-user-prompt', withSystem())
458
+ const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
459
+
460
+ const sys = sent.messages.find(m => m.role === 'system')
461
+ assert.match(sys.content, /【g ai 默认提示词】回答用中文/, 'g ai 默认提示词必须进请求')
462
+ assert.match(sys.content, /【全局派发提示词】改完就提交/, '全局派发提示词必须进请求')
463
+ assert.match(sys.content, /【本项目派发提示词】包管理器用 pnpm/,
464
+ '项目级那条要按本轮 cwd 生效(与派发时"按落点项目解析"同一口径)')
465
+ // 规则在前、现场在后:固定提示词是每轮都成立的约束,不该排在工作区快照后面
466
+ assert.ok(sys.content.indexOf('【g ai 默认提示词】') < sys.content.indexOf('【全局派发提示词】'),
467
+ 'g ai 那段排在派发那段之前')
468
+ // 落盘口径:会话里那条 system 一个字节都不许变 —— 否则改设置后老会话永远带着旧规则
469
+ assert.equal(session.messages[0].content, 'rules', '注入只作用于请求副本,不写回 session.messages')
470
+ } finally {
471
+ clearSandboxFile('config.json')
472
+ clearSandboxFile('orchestrator.json')
473
+ }
474
+ })
475
+
476
+ test('没配就不注入(连一节标题都不出现)—— 这也是其它用例能断言精确 messages 的前提', async () => {
477
+ const session = newSession('ag-sandbox-user-prompt-empty', withSystem())
478
+
479
+ const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
480
+
481
+ assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
482
+ })
483
+
484
+ test('调用方显式传 promptSection 时不再自己读配置(定时任务 / agentRoutes 走这条路)', async () => {
485
+ try {
486
+ // 磁盘上有一份提示词,但调用方传了 '':意思是"这一轮确定不带",不是"没传、你去读"
487
+ writeSandboxFile('config.json', { agentDefaultPrompt: '【不该出现】' })
488
+ const session = newSession('ag-sandbox-user-prompt-explicit', withSystem())
489
+
490
+ const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream, promptSection: '' })
491
+
492
+ assert.doesNotMatch(sent.messages[0].content, /【不该出现】/)
493
+ assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
494
+ } finally {
495
+ clearSandboxFile('config.json')
496
+ }
497
+ })
@@ -19,15 +19,18 @@
19
19
  // gai ── 内置。**服务端**跑循环:LLM 流式 + 服务端执行工具(src/cli/ai/tools.js),
20
20
  // 前端只渲染事件。默认引擎,历史行为。
21
21
  //
22
- // claude / opencode / codex ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
22
+ // claude / opencode / codex / codebuddy ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
23
23
  // 自己的系统提示词、自己的权限模型。服务端退化成一根管子 —— spawn 出来、
24
24
  // 把它的 NDJSON 翻译成同一组 SSE 事件。见 runExternalTurn.js。
25
+ // (codebuddy = 腾讯 CodeBuddy Code CLI,即 WorkBuddy 桌面版的引擎,
26
+ // 2026-10-12 加入;协议与 claude 的 stream-json 对齐。)
25
27
  //
26
- // ⚠️ 这儿**不新写一份 id 清单**:外部三家直接复用 config.js 的 TASK_EXECUTORS。
28
+ // ⚠️ 这儿**不新写一份 id 清单**:外部四家直接复用 config.js 的 TASK_EXECUTORS。
27
29
  // 本仓库为"同一份口径在多个地方各写一遍"吃过亏(见 agentParity.test.js 的注释:
28
30
  // 提示词四处、路径归一三处,分叉的失效方式是「不报错,两个入口表现不一样」)。
29
- // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第四家时
30
- // 应当只改 config.js 一处。有单测钉住这条。
31
+ // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第五家时
32
+ // 应当只改 config.js 一处(taskRunner.js 的运行时分发表是第二处,见那里的注释)。
33
+ // 有单测钉住这条。
31
34
 
32
35
  import { TASK_EXECUTORS } from '../../../../config.js';
33
36
 
@@ -49,6 +52,7 @@ export const ENGINE_LABELS = {
49
52
  claude: 'Claude Code',
50
53
  opencode: 'OpenCode',
51
54
  codex: 'Codex',
55
+ codebuddy: 'CodeBuddy Code',
52
56
  };
53
57
 
54
58
  export function isAgentEngine(value) {