zen-gitsync 2.18.8 → 2.18.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 (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +7 -0
  4. package/src/cli/ai/runtime.test.js +22 -1
  5. package/src/cli/ai/tools.js +153 -2
  6. package/src/cli/ai/tools.test.js +71 -2
  7. package/src/cli/ai/transport.js +8 -3
  8. package/src/config.js +7 -5
  9. package/src/mcp/tools/workbench.js +1 -1
  10. package/src/ui/public/assets/{AgentView-DZx3n-So.css → AgentView-BnEgu3NT.css} +1 -1
  11. package/src/ui/public/assets/AgentView-DQQe_ivW.js +1 -0
  12. package/src/ui/public/assets/{AppVersionBadge-Bg5lrtKb.js → AppVersionBadge-D7PiBP0p.js} +2 -2
  13. package/src/ui/public/assets/{BranchSelector-CxjTqAkl.js → BranchSelector-BzHoMBOc.js} +1 -1
  14. package/src/ui/public/assets/{CommitForm-BWWrOvj5.js → CommitForm-CNTjLYTR.js} +2 -2
  15. package/src/ui/public/assets/{CommonDialog-x5jfL4Lq.js → CommonDialog-j_53ORvj.js} +1 -1
  16. package/src/ui/public/assets/CopySessionButton-Dy5t8Mjy.js +20 -0
  17. package/src/ui/public/assets/EditorView-CwcdA0Ei.js +1 -0
  18. package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
  19. package/src/ui/public/assets/{FlowExecutionViewer-BkZ20jxN.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  20. package/src/ui/public/assets/{FlowOrchestrationWorkspace-aZBYu29g.js → FlowOrchestrationWorkspace-CJHpEBCw.js} +1 -1
  21. package/src/ui/public/assets/{LogList-CSIbmUMK.js → LogList-KUePBjAt.js} +4 -4
  22. package/src/ui/public/assets/{MindmapView-JYLp185X.js → MindmapView-CwdvsuEK.js} +1 -1
  23. package/src/ui/public/assets/{MonitorView-BS2Mh-I7.js → MonitorView-CjEnnrTL.js} +1 -1
  24. package/src/ui/public/assets/{ProjectStartupButton-DAEP6ZU5.js → ProjectStartupButton-B36lRb6-.js} +1 -1
  25. package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
  26. package/src/ui/public/assets/RecentDirectoriesChat-DNiji9Pr.js +1 -0
  27. package/src/ui/public/assets/{RemoteManagerDialog-aaNmsKpy.js → RemoteManagerDialog-Dj_GpA-c.js} +1 -1
  28. package/src/ui/public/assets/{RemoteRepoCard-DndI8rNZ.js → RemoteRepoCard-C8u9M6dn.js} +1 -1
  29. package/src/ui/public/assets/{SourceMapView-BTD9-R8t.js → SourceMapView-eng0B9U2.js} +1 -1
  30. package/src/ui/public/assets/{SvgIcon-0n6uuKFm.js → SvgIcon-5AlG3IfR.js} +1 -1
  31. package/src/ui/public/assets/{UserInputNode-B-mDg9Wy.js → UserInputNode-BKP869D6.js} +1 -1
  32. package/src/ui/public/assets/WorkbenchView-CZO18THH.js +6 -0
  33. package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
  34. package/src/ui/public/assets/{_plugin-vue_export-helper-C3o7kvRL.js → _plugin-vue_export-helper-Csn085FF.js} +4 -4
  35. package/src/ui/public/assets/agentConversations-De3wKTsN.js +10 -0
  36. package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
  37. package/src/ui/public/assets/index-DQ82h1aq.js +57 -0
  38. package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
  39. package/src/ui/public/assets/{useOrchestrator-CS9sn7nV.js → useOrchestrator-BjC7oHpt.js} +1 -1
  40. package/src/ui/public/index.html +6 -6
  41. package/src/ui/server/index.js +8 -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/git/clone.js +5 -13
  45. package/src/ui/server/routes/workbench/agentChat.js +75 -10
  46. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  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 +99 -7
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  53. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  54. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  55. package/src/ui/server/routes/workbench/index.js +17 -5
  56. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  57. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  58. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  59. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  60. package/src/ui/server/routes/workbench/scheduler.js +3 -0
  61. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  62. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  63. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  64. package/src/utils/gitRepoName.js +33 -0
  65. package/src/ui/public/assets/AgentView-BRAUZCtA.js +0 -1
  66. package/src/ui/public/assets/CopySessionButton-D0wFpk8a.js +0 -20
  67. package/src/ui/public/assets/EditorView-DmaGiI-z.js +0 -1
  68. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  69. package/src/ui/public/assets/RecentDirectoriesChat-CVFTShrU.js +0 -1
  70. package/src/ui/public/assets/WorkbenchView-CfSrCIbp.js +0 -8
  71. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  72. package/src/ui/public/assets/agentConversations-DpiGplZB.js +0 -8
  73. package/src/ui/public/assets/configStore-BdL3uW2E.js +0 -1
  74. package/src/ui/public/assets/index-BS7BDFG4.js +0 -57
  75. 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
+ })
@@ -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) {
@@ -0,0 +1,157 @@
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
+ // Web 端(g ui 服务端)的扩展层(Skill 清单 + MCP 工具)加载缓存。
16
+ //
17
+ // 背景:AgentExtensions.load 原先只有 CLI 的 `g ai` 调(agent.js 启动时一次),
18
+ // 而 Web 智能体面板的 runAgentTurn 与定时任务的执行体共用同一个函数却不加载
19
+ // 扩展 —— 表现就是「MCP 广场显示已安装,对话框里就是没有那些工具」
20
+ // (2026-10-12 用户实测:agent 只能自己读 mcp.json 按 HTTP 协议直调)。
21
+ // 这个模块把同一份加载逻辑接到 Web 侧,并按 cwd 做进程内缓存。
22
+ //
23
+ // 为什么按 cwd 缓存而不是每轮加载:
24
+ // - MCP 握手不便宜:stdio 要起子进程(npx 首次还要下载包,实测 20s+),
25
+ // HTTP 也要 initialize + tools/list 两个往返。每轮都付一次的话,
26
+ // 装了两三个 MCP 的用户每轮对话都要先等好几秒。
27
+ // - CLI 的口径是「进程启动时加载一次,/cwd 切换时重载」。Web 没有进程重启
28
+ // 这个自然边界,用缓存等价实现:同一 cwd 只加载一次,配置变化靠失效。
29
+ //
30
+ // 缓存什么时候失效:
31
+ // - 广场安装/卸载(agentMarketplace.js 调 invalidateWebExtensions)——
32
+ // 不接这一路的话,刚装的 MCP 要等 g gui 重启才在对话里生效;
33
+ // - 手动编辑 ~/.zen-gitsync/ai/mcp.json(不走广场的那种)—— 下次 g gui
34
+ // 重启生效,与 CLI「下次启动时生效」一致。
35
+ //
36
+ // 生命周期:stdio 子进程走 cli/cleanup.js 的 trackChild,不在 g ui 自己的
37
+ // runningProcesses 表里,shutdown 的 drainRunningProcesses 兜不到 —— 所以
38
+ // index.js 的优雅退出里显式调 closeWebExtensions(),否则 Ctrl+C 之后会留下
39
+ // 一堆 npx 孤儿子进程。
40
+
41
+ import path from 'node:path';
42
+ import { AgentExtensions } from '../../../../cli/ai/extensions.js';
43
+ import { logger } from './shared.js';
44
+
45
+ /** cwd(绝对路径) → { extensions: AgentExtensions } */
46
+ const cache = new Map();
47
+ /** cwd → 进行中的加载。同 cwd 的并发首轮只允许一次真实加载(去重)。 */
48
+ const inflight = new Map();
49
+ /**
50
+ * 加载期间被 invalidate 标记过的 cwd。任务收尾时看到自己在这张表里 →
51
+ * 不写回缓存、直接关掉刚建好的连接,只服务当前这一个调用方。
52
+ *
53
+ * 为什么需要它:不能改用"cache 里没有这个 key"来判断 —— 首次加载时缓存
54
+ * 本来就是空的,两种状态长得一模一样。2026-10-12 第一版就是这么写的,
55
+ * 后果是每次首轮加载完都把刚握好的 MCP 连接关掉(还不进缓存),
56
+ * 症状是"工具定义下发成功了、一调用就报连接已关闭"。
57
+ */
58
+ const poisoned = new Set();
59
+
60
+ const normalizeCwd = (cwd) => {
61
+ try {
62
+ return path.resolve(cwd || process.cwd());
63
+ } catch {
64
+ return String(cwd || process.cwd());
65
+ }
66
+ };
67
+
68
+ /**
69
+ * 取某个 cwd 的扩展层;没有则加载(同 cwd 并发只加载一次)。
70
+ *
71
+ * 失败不抛:AgentExtensions.load 内部已对 skill / MCP 各自降级,这里再包一层
72
+ * 是防读取路径之外的意外把整轮对话打挂 —— 扩展是增量能力,不该让用户消息发不出去。
73
+ *
74
+ * @param {{ cwd?: string, locale?: string }} [options]
75
+ * @returns {Promise<AgentExtensions>}
76
+ */
77
+ export async function getWebExtensions({ cwd, locale } = {}) {
78
+ const key = normalizeCwd(cwd);
79
+ const hit = cache.get(key);
80
+ if (hit) return hit.extensions;
81
+ const pending = inflight.get(key);
82
+ if (pending) return pending;
83
+
84
+ const task = (async () => {
85
+ const extensions = await AgentExtensions.load({
86
+ cwd: key,
87
+ locale,
88
+ onWarn: (message) => logger.warn(`[agentExtensions] ${message}`),
89
+ }).catch((err) => {
90
+ logger.warn(`[agentExtensions] 加载失败(${key}),本轮按无扩展继续: ${err?.message || err}`);
91
+ return new AgentExtensions();
92
+ });
93
+ // 加载期间被 invalidate 过 → 不把这份刚建好的连接写回缓存(下一个调用方
94
+ // 会按新配置重新加载),直接关掉它,只服务当前这一个调用方。
95
+ // has → delete → set 之间没有 await,整段是原子的,不会被 invalidate 插进来。
96
+ if (poisoned.has(key)) {
97
+ poisoned.delete(key);
98
+ await extensions.mcp?.close().catch(() => {});
99
+ } else {
100
+ cache.set(key, { extensions });
101
+ }
102
+ return extensions;
103
+ })();
104
+
105
+ inflight.set(key, task);
106
+ try {
107
+ return await task;
108
+ } finally {
109
+ inflight.delete(key);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 让扩展缓存失效(广场安装 / 卸载后调)。
115
+ *
116
+ * 先摘缓存再关旧连接 —— 反过来会让下一轮拿到正在关闭的连接。
117
+ * 传 cwd 只失效那一个目录(项目级安装),不传失效全部(全局安装影响所有目录)。
118
+ *
119
+ * @param {string} [cwd]
120
+ */
121
+ export async function invalidateWebExtensions(cwd) {
122
+ // 受影响对象 = 缓存键 + 在途加载键。只遍历缓存的话,"加载前刚被失效过
123
+ // (缓存已空)、又在加载中被失效"这一下会漏掉在途那个,让它把按旧配置
124
+ // 建好的连接写回缓存。
125
+ const keys = cwd ? [normalizeCwd(cwd)] : [...new Set([...cache.keys(), ...inflight.keys()])];
126
+ const victims = [];
127
+ for (const key of keys) {
128
+ const entry = cache.get(key);
129
+ if (entry) {
130
+ cache.delete(key);
131
+ victims.push(entry.extensions);
132
+ }
133
+ // 有加载在进行中 → 标记它收尾时别写回缓存(见 getWebExtensions 里的 poisonous 分支)
134
+ if (inflight.has(key)) poisoned.add(key);
135
+ }
136
+ await Promise.all(victims.map(async (extensions) => {
137
+ // 正在跑的某一轮可能还持有这份对象:它的 in-flight tools/call 会收到
138
+ // "连接已关闭"(以错误字符串回喂给模型,不会打挂对话)。装/卸配置与对话
139
+ // 并发是罕见边角,为它做代际管理不值这个复杂度。
140
+ try {
141
+ await extensions.mcp?.close();
142
+ } catch (err) {
143
+ logger.warn(`[agentExtensions] 关闭旧连接失败: ${err?.message || err}`);
144
+ }
145
+ }));
146
+ }
147
+
148
+ /**
149
+ * 关掉全部缓存的 MCP 连接。g gui 优雅退出时调(见 index.js 的 shutdown)。
150
+ */
151
+ export async function closeWebExtensions() {
152
+ const all = [...cache.values()];
153
+ cache.clear();
154
+ await Promise.all(all.map(async ({ extensions }) => {
155
+ try { await extensions.mcp?.close(); } catch (_) {}
156
+ }));
157
+ }
@@ -44,6 +44,10 @@ import { execFile, spawn } from 'node:child_process';
44
44
  import { promisify } from 'node:util';
45
45
  import open from 'open';
46
46
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
47
+ import { logger } from './shared.js';
48
+ // 装/卸之后让 Web 端扩展缓存失效:否则刚装的 MCP/Skill 要等 g gui 重启
49
+ // 才在智能体对话里生效("广场显示已安装、对话框里没有工具"就是这么来的)。
50
+ import { invalidateWebExtensions } from './agentExtensions.js';
47
51
  import { AI_SKILLS_DIR, AI_MCP_FILE, projectSkillsDir, projectMcpFile } from '../../../../paths.js';
48
52
 
49
53
  const execFileAsync = promisify(execFile);
@@ -1097,6 +1101,11 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
1097
1101
 
1098
1102
  // 装完立刻让缓存失效,避免刚装的条目在列表里还是"未安装"
1099
1103
  cache.clear();
1104
+ // 同一用意再往下一层:Web 智能体对话的扩展缓存也要失效,否则新装的
1105
+ // MCP/Skill 要等 g gui 重启才在对话里出现。全局安装影响所有目录 → 全量失效;
1106
+ // 项目安装只影响该目录。关旧连接是异步的,不拖慢这个响应,失败只记账。
1107
+ invalidateWebExtensions(target === 'project' ? cwd : undefined)
1108
+ .catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
1100
1109
  res.json({ success: true, target, cwd, item: installed });
1101
1110
  }));
1102
1111
 
@@ -1135,6 +1144,9 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
1135
1144
  const id = decodeURIComponent(String(req.params.id || ''));
1136
1145
  const result = await uninstall({ type, id, target, cwd });
1137
1146
  cache.clear();
1147
+ // 与安装同一用意:卸完也让对话侧的扩展缓存失效(口径见 install 路由里的注释)
1148
+ invalidateWebExtensions(target === 'project' ? cwd : undefined)
1149
+ .catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
1138
1150
  res.json({ success: true, ...result });
1139
1151
  }));
1140
1152
  }
@@ -181,3 +181,19 @@ test('两个入口都把推理内容写进 assistant 历史消息', () => {
181
181
  assert.match(code, /reasoning_content/, `${label} 没有把推理内容写进 assistant 历史消息`);
182
182
  }
183
183
  });
184
+
185
+ test('两个入口都把扩展工具叠加进请求,并按 owns() 分流', () => {
186
+ // 2026-10-12:Web 侧曾经**完全不加载**扩展层 —— MCP 广场显示"已安装",
187
+ // 对话框里却没有那些工具(agent 只能自己读 mcp.json 按 HTTP 直调)。
188
+ // 失效方式和这个文件的其它分叉一样:不报错,只是两个入口表现不一样。
189
+ // 所以钉住两个入口都要有:extraTools(叠加进请求)+ owns(按名字分流)。
190
+ // 只认代码行,注释里的提及不算。
191
+ for (const { label, file } of ENTRY_POINTS) {
192
+ const code = readFileSync(file, 'utf8')
193
+ .split(/\r?\n/)
194
+ .filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line))
195
+ .join('\n');
196
+ assert.match(code, /extraTools/, `${label} 没有把扩展工具叠加进请求(extraTools)`);
197
+ assert.match(code, /owns/, `${label} 没有按 owns() 分流扩展工具`);
198
+ }
199
+ });