zen-gitsync 2.17.14 → 2.17.16

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 (89) hide show
  1. package/README.md +211 -185
  2. package/package.json +2 -2
  3. package/scripts/release.js +33 -16
  4. package/src/cli/ai/agent.js +24 -2
  5. package/src/cli/ai/runtime.test.js +1 -1
  6. package/src/cli/ai/tools.js +69 -14
  7. package/src/cli/ai/tools.test.js +46 -1
  8. package/src/config.js +8 -4
  9. package/src/gitCommit.js +18 -1
  10. package/src/ui/public/assets/AgentView-DOyN97Ve.js +5 -0
  11. package/src/ui/public/assets/{AgentView-vvVhbIQ5.css → AgentView-DkM2KXuc.css} +1 -1
  12. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +2 -0
  13. package/src/ui/public/assets/{BranchSelector-XIN87Mkw.js → BranchSelector-Cpj7eGqY.js} +1 -1
  14. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +6 -0
  15. package/src/ui/public/assets/{CommonDialog-BiNTGpA-.js → CommonDialog-ChZbqfRF.js} +1 -1
  16. package/src/ui/public/assets/EditorView-BPzpI0GV.js +1 -0
  17. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +15 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-D9Vzgh91.js → FlowExecutionViewer-Drw4hlJG.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +21 -0
  20. package/src/ui/public/assets/{IconButton-DysyaC1j.js → IconButton-DEARmMkZ.js} +1 -1
  21. package/src/ui/public/assets/{LogList-QDgok-UJ.js → LogList-BA_gowxE.js} +5 -5
  22. package/src/ui/public/assets/MindmapView-8X6z245q.css +1 -0
  23. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +2 -0
  24. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +1 -0
  25. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +1 -0
  26. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +4 -0
  27. package/src/ui/public/assets/{RemoteRepoCard-Bn9cKt1D.js → RemoteRepoCard-Bsm7cj_H.js} +2 -2
  28. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +3 -0
  29. package/src/ui/public/assets/{SvgIcon-CvtoU5yo.js → SvgIcon-D_mFHXP4.js} +1 -1
  30. package/src/ui/public/assets/{TemplateManager-saqC9kdQ.js → TemplateManager-BzKVXZhy.js} +1 -1
  31. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +1 -0
  32. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +56 -0
  33. package/src/ui/public/assets/_plugin-vue_export-helper-dg2VUnSQ.js +19 -0
  34. package/src/ui/public/assets/configStore-DtjAc3w9.js +1 -0
  35. package/src/ui/public/assets/{css.worker-DFdt6_zu.js → css.worker-BDCmgL4U.js} +9 -9
  36. package/src/ui/public/assets/editor.worker-GCPm6rMv.js +26 -0
  37. package/src/ui/public/assets/{element-plus-Dc1iTOWq.css → element-plus-Cwwq-Gje.css} +1 -1
  38. package/src/ui/public/assets/element-plus-DxBPQwL7.js +48 -0
  39. package/src/ui/public/assets/{flow-mindmap-CHxAWdLw.js → flow-mindmap-BqFb1WO5.js} +1 -1
  40. package/src/ui/public/assets/{gitStore-BgC8OY0k.js → gitStore-BZo9_zyy.js} +5 -5
  41. package/src/ui/public/assets/{html.worker-JUzsMP-j.js → html.worker-DG8hnSJR.js} +8 -8
  42. package/src/ui/public/assets/index-CHuCYLWm.js +111 -0
  43. package/src/ui/public/assets/index-CmbatbSR.css +1 -0
  44. package/src/ui/public/assets/json.worker-CxwlJRnL.js +58 -0
  45. package/src/ui/public/assets/{monaco-DkJuNcCr.js → monaco-BScz9v63.js} +6 -6
  46. package/src/ui/public/assets/{office-docx-CmBY5bpF.js → office-docx-CZXLkCf5.js} +1 -1
  47. package/src/ui/public/assets/{office-excel-BEhsBUNX.js → office-excel-CBKszjVA.js} +1 -1
  48. package/src/ui/public/assets/{office-pptx-DWfi413x.js → office-pptx-NeUSnN3k.js} +3 -3
  49. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  50. package/src/ui/public/assets/{ts.worker-CmEKzkmF.js → ts.worker-DgFMIcEs.js} +49 -49
  51. package/src/ui/public/assets/{useThemeObserver-DhCU3SIo.js → useThemeObserver-E7Hk50l3.js} +1 -1
  52. package/src/ui/public/assets/vendor-DnB9EJa3.css +1 -0
  53. package/src/ui/public/assets/{vendor-CZnXb2r9.js → vendor-bdHhLiED.js} +28 -28
  54. package/src/ui/public/assets/{vue-flow-7_n4vJqi.js → vue-flow-fLUQ0_kE.js} +1 -1
  55. package/src/ui/public/index.html +19 -19
  56. package/src/ui/server/index.js +4 -0
  57. package/src/ui/server/routes/explorerContextMenu.js +208 -0
  58. package/src/ui/server/routes/explorerContextMenu.test.js +180 -0
  59. package/src/ui/server/routes/workbench/agentChat.js +14 -3
  60. package/src/ui/server/routes/workbench/agentMarketplace.js +23 -9
  61. package/src/ui/server/routes/workbench/agentMarketplace.test.js +32 -0
  62. package/src/ui/server/routes/workbench/agentRoutes.js +14 -7
  63. package/src/ui/server/routes/workbench/agentRoutes.test.js +47 -1
  64. package/src/ui/server/routes/workbench/taskRunner.codex.test.js +173 -0
  65. package/src/ui/server/routes/workbench/taskRunner.js +259 -10
  66. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +4 -2
  67. package/src/ui/public/assets/AgentView-DLHlVWRg.js +0 -5
  68. package/src/ui/public/assets/AppVersionBadge-JxpMuGwP.js +0 -2
  69. package/src/ui/public/assets/CommitForm-CW53Er5m.js +0 -6
  70. package/src/ui/public/assets/EditorView-D0JoJhgt.js +0 -1
  71. package/src/ui/public/assets/FileDiffViewer-Yas2nO2U.js +0 -15
  72. package/src/ui/public/assets/FlowOrchestrationWorkspace-DQ3fg5b9.js +0 -21
  73. package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
  74. package/src/ui/public/assets/MindmapView-CeZsQNBK.js +0 -2
  75. package/src/ui/public/assets/MonitorView-D1tgmjjS.js +0 -1
  76. package/src/ui/public/assets/ProjectStartupButton-COntA4q1.js +0 -1
  77. package/src/ui/public/assets/RemoteManagerDialog-3dihAOab.js +0 -4
  78. package/src/ui/public/assets/SourceMapView-C8UdzQXF.js +0 -3
  79. package/src/ui/public/assets/UserInputNode-CvEfvX3i.js +0 -1
  80. package/src/ui/public/assets/WorkbenchView-B7WAu1Ay.js +0 -56
  81. package/src/ui/public/assets/_plugin-vue_export-helper-BdTxl34g.js +0 -19
  82. package/src/ui/public/assets/configStore-AQMSazCM.js +0 -1
  83. package/src/ui/public/assets/editor.worker-O-CH8JUO.js +0 -26
  84. package/src/ui/public/assets/element-plus-CMSkP_w2.js +0 -48
  85. package/src/ui/public/assets/index-Dpx9Bcqn.css +0 -1
  86. package/src/ui/public/assets/index-qAmzFO10.js +0 -104
  87. package/src/ui/public/assets/json.worker-B4dEhy0e.js +0 -58
  88. package/src/ui/public/assets/previewSandbox-CyuMMYaq.js +0 -0
  89. package/src/ui/public/assets/vendor-4ojsjDYc.css +0 -1
@@ -0,0 +1,180 @@
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
+ // 全程用假的 execFileFn —— 真的往 HKCU 写注册表会污染跑测试的这台机器。
17
+ import { test } from 'node:test'
18
+ import assert from 'node:assert/strict'
19
+ import {
20
+ buildMenuCommand,
21
+ getExplorerContextMenuStatus,
22
+ installExplorerContextMenu,
23
+ MENU_REGISTRY_ROOTS,
24
+ registerExplorerContextMenuRoutes,
25
+ sanitizeMenuLabel,
26
+ uninstallExplorerContextMenu,
27
+ } from './explorerContextMenu.js'
28
+
29
+ /** 记录所有 reg.exe 调用并返回空输出的假实现 */
30
+ function createFakeReg({ fail = () => false } = {}) {
31
+ const calls = []
32
+ const execFileFn = async (file, args) => {
33
+ calls.push({ file, args })
34
+ const error = fail(args)
35
+ if (error) throw error
36
+ return { stdout: '', stderr: '' }
37
+ }
38
+ return { calls, execFileFn }
39
+ }
40
+
41
+ /** 从假调用记录里取出某个 reg 子命令的目标键 */
42
+ function targetsOf(calls, subcommand) {
43
+ return calls.filter((c) => c.args[0] === subcommand).map((c) => c.args[1])
44
+ }
45
+
46
+ test('sanitizeMenuLabel: 空值/非字符串退回默认标题', () => {
47
+ const fallback = '用 g UI 打开'
48
+ for (const input of [undefined, null, '', ' ', 42, {}, '\n\t']) {
49
+ assert.equal(sanitizeMenuLabel(input), fallback)
50
+ }
51
+ })
52
+
53
+ test('sanitizeMenuLabel: 控制字符换空格、首尾裁剪、超长截断', () => {
54
+ assert.equal(sanitizeMenuLabel(' 用 g UI 打开 '), '用 g UI 打开')
55
+ assert.equal(sanitizeMenuLabel('用 g UI\n打开'), '用 g UI 打开')
56
+ assert.equal(sanitizeMenuLabel('x'.repeat(200)).length, 64)
57
+ })
58
+
59
+ test('buildMenuCommand: 指向本包 CLI 的 ui 子命令,目录交给 %V', () => {
60
+ const command = buildMenuCommand({ nodePath: 'C:\\Program Files\\nodejs\\node.exe', entryPath: 'D:\\a b\\gitCommit.js' })
61
+ assert.equal(command, '"C:\\Program Files\\nodejs\\node.exe" "D:\\a b\\gitCommit.js" ui --path="%V"')
62
+ // 不带 cmd.exe / start:%V 由 Explorer 展开后才交给 node,中间没有第二层词法
63
+ assert.doesNotMatch(command, /cmd\.exe/i)
64
+ })
65
+
66
+ test('install: 两个落点都写入标题 + 图标 + command 子键', async () => {
67
+ const { calls, execFileFn } = createFakeReg()
68
+ const { label } = await installExplorerContextMenu({ label: '用 g UI 打开', execFileFn, nodePath: 'C:\\node\\node.exe', entryPath: 'D:\\zen\\gitCommit.js' })
69
+
70
+ assert.equal(label, '用 g UI 打开')
71
+ const menuKeys = targetsOf(calls, 'add').filter((k) => !k.endsWith('\\command'))
72
+ assert.deepEqual([...new Set(menuKeys)], [...MENU_REGISTRY_ROOTS])
73
+ assert.deepEqual(targetsOf(calls, 'add').filter((k) => k.endsWith('\\command')), MENU_REGISTRY_ROOTS.map((k) => `${k}\\command`))
74
+ for (const call of calls) {
75
+ assert.equal(call.file, 'reg.exe')
76
+ assert.ok(call.args.includes('/f'), 'reg add 必须带 /f,否则第二次点击会弹覆盖确认')
77
+ }
78
+ const titleCall = calls.find((c) => c.args[1] === MENU_REGISTRY_ROOTS[0] && c.args[2] === '/ve')
79
+ assert.deepEqual(titleCall.args.slice(-2), ['用 g UI 打开', '/f'])
80
+ const commandCall = calls.find((c) => c.args[1] === `${MENU_REGISTRY_ROOTS[0]}\\command`)
81
+ assert.equal(commandCall.args.at(-2), '"C:\\node\\node.exe" "D:\\zen\\gitCommit.js" ui --path="%V"')
82
+ })
83
+
84
+ test('install: 客户端传来的脏标题被清洗后写入', async () => {
85
+ const { calls, execFileFn } = createFakeReg()
86
+ await installExplorerContextMenu({ label: 'Open with g UI\r\nrm -rf /', execFileFn })
87
+ const titleCall = calls.find((c) => c.args[1] === MENU_REGISTRY_ROOTS[0] && c.args[2] === '/ve')
88
+ assert.equal(titleCall.args.at(-2), 'Open with g UI rm -rf /')
89
+ })
90
+
91
+ test('uninstall: 两个落点都删,键不存在(退出码 1)不算失败', async () => {
92
+ const missing = Object.assign(new Error('reg.exe exited with code 1'), { code: 1 })
93
+ const { calls, execFileFn } = createFakeReg({ fail: (args) => (args[0] === 'delete' && args[1].includes('Background') ? missing : false) })
94
+ await uninstallExplorerContextMenu({ execFileFn })
95
+ assert.deepEqual(targetsOf(calls, 'delete'), [...MENU_REGISTRY_ROOTS])
96
+ })
97
+
98
+ test('uninstall: 真正的删除失败(非"键不存在")必须抛出来', async () => {
99
+ const { execFileFn } = createFakeReg({ fail: () => Object.assign(new Error('拒绝访问'), { code: 5 }) })
100
+ await assert.rejects(() => uninstallExplorerContextMenu({ execFileFn }), /拒绝访问/)
101
+ })
102
+
103
+ test('status: 两个键都在才算已安装', async () => {
104
+ const both = createFakeReg()
105
+ assert.equal((await getExplorerContextMenuStatus({ execFileFn: both.execFileFn })).installed, true)
106
+ assert.deepEqual(targetsOf(both.calls, 'query'), [...MENU_REGISTRY_ROOTS])
107
+
108
+ const half = createFakeReg({ fail: (args) => (args[1].includes('Background') ? Object.assign(new Error('missing'), { code: 1 }) : false) })
109
+ const status = await getExplorerContextMenuStatus({ execFileFn: half.execFileFn })
110
+ assert.equal(status.installed, false)
111
+ assert.equal(status.keys[MENU_REGISTRY_ROOTS[1]], false)
112
+ })
113
+
114
+ // ── 路由层 ────────────────────────────────────────────────────────────────
115
+
116
+ function createRouteHarness(options) {
117
+ const routes = new Map()
118
+ const app = {
119
+ get(path, handler) { routes.set(`GET ${path}`, handler) },
120
+ post(path, handler) { routes.set(`POST ${path}`, handler) },
121
+ }
122
+ registerExplorerContextMenuRoutes({ app, ...options })
123
+ async function request(key, body) {
124
+ const response = { code: 200, body: null, status(code) { this.code = code; return this }, json(value) { this.body = value; return this } }
125
+ await routes.get(key)({ method: key.split(' ')[0], path: key.split(' ')[1], body }, response, () => {})
126
+ return response
127
+ }
128
+ return { request }
129
+ }
130
+
131
+ test('route: 非 Windows 返回 supported=false,写操作直接 400', async () => {
132
+ const { calls, execFileFn } = createFakeReg()
133
+ const { request } = createRouteHarness({ platform: 'darwin', execFileFn })
134
+
135
+ const status = await request('GET /api/explorer-context-menu')
136
+ assert.equal(status.body.supported, false)
137
+ assert.equal(status.body.installed, false)
138
+
139
+ for (const key of ['POST /api/explorer-context-menu/install', 'POST /api/explorer-context-menu/uninstall']) {
140
+ const response = await request(key)
141
+ assert.equal(response.code, 400)
142
+ assert.equal(response.body.success, false)
143
+ }
144
+ assert.equal(calls.length, 0, '非 Windows 不该调用 reg.exe')
145
+ })
146
+
147
+ test('route: 安装→查询→卸载 的状态流转', async () => {
148
+ const installedKeys = new Set()
149
+ const execFileFn = async (file, args) => {
150
+ assert.equal(file, 'reg.exe')
151
+ if (args[0] === 'add') return { stdout: '', stderr: '' }
152
+ if (args[0] === 'delete') { installedKeys.delete(args[1]); return { stdout: '', stderr: '' } }
153
+ if (installedKeys.has(args[1])) return { stdout: '', stderr: '' }
154
+ throw Object.assign(new Error('missing'), { code: 1 })
155
+ }
156
+ // add 之后把键记成已存在,模拟真实注册表
157
+ const wrapping = async (file, args) => { const r = await execFileFn(file, args); if (args[0] === 'add' && !args[1].endsWith('\\command')) installedKeys.add(args[1]); return r }
158
+
159
+ const { request } = createRouteHarness({ platform: 'win32', execFileFn: wrapping, nodePath: 'C:\\node\\node.exe', entryPath: 'D:\\zen\\gitCommit.js' })
160
+
161
+ assert.equal((await request('GET /api/explorer-context-menu')).body.installed, false)
162
+
163
+ const install = await request('POST /api/explorer-context-menu/install', { label: '用 g UI 打开' })
164
+ assert.equal(install.body.success, true)
165
+ assert.match(install.body.message, /用 g UI 打开/)
166
+
167
+ assert.equal((await request('GET /api/explorer-context-menu')).body.installed, true)
168
+
169
+ const uninstall = await request('POST /api/explorer-context-menu/uninstall')
170
+ assert.equal(uninstall.body.success, true)
171
+ assert.equal((await request('GET /api/explorer-context-menu')).body.installed, false)
172
+ })
173
+
174
+ test('route: reg.exe 写失败时返回 400 和原因', async () => {
175
+ const { execFileFn } = createFakeReg({ fail: () => Object.assign(new Error('拒绝访问注册表'), { code: 5 }) })
176
+ const { request } = createRouteHarness({ platform: 'win32', execFileFn })
177
+ const response = await request('POST /api/explorer-context-menu/install', { label: '用 g UI 打开' })
178
+ assert.equal(response.code, 400)
179
+ assert.match(response.body.error, /拒绝访问注册表/)
180
+ })
@@ -22,8 +22,9 @@
22
22
  // - { type: 'thinking', delta } — 推理过程增量
23
23
  // - { type: 'content', delta } — 正文增量
24
24
  // - { type: 'tool_call_start', toolCallId, name, argsPreview }
25
+ // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
25
26
  // - { type: 'tool_result', toolCallId, name, result }
26
- // - { type: 'ask_user', interactionId, question, options, allowFreeText }
27
+ // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
27
28
  // - { type: 'done', content } — 本轮最终完成
28
29
  // - { type: 'error', error }
29
30
 
@@ -134,6 +135,7 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
134
135
  # 与用户交互
135
136
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
136
137
  - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
138
+ - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
137
139
  - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
138
140
  - 用户可能随消息附带图片:图片以 image_url 部件出现在 user 消息里;如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
139
141
  - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
@@ -204,7 +206,7 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
204
206
 
205
207
  # How you work
206
208
  - Act first, ask later: use tools to investigate before asking the user
207
- - 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.
209
+ - 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. Pass multiple: true when the user should pick several options at once.
208
210
  - After modifying code, verify: run tests, build, or at least syntax check
209
211
  - Prefer edit_file for precise replacements; read_file first to confirm original text
210
212
  - Use offset/limit for large files
@@ -355,7 +357,16 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
355
357
  const argsPreview = summarizeArgs(name, args);
356
358
  send({ type: 'tool_call_start', toolCallId, name, argsPreview });
357
359
 
358
- const toolCtx = { ...ctx, signal };
360
+ const toolCtx = {
361
+ ...ctx,
362
+ signal,
363
+ // run_command 执行期间的增量输出 → 前端实时显示。
364
+ // 只用于展示,不进会话历史 —— 历史里存的仍是带 exit code 的最终结果。
365
+ onOutput: (chunk) => {
366
+ const text = String(chunk || '');
367
+ if (text) send({ type: 'tool_output', toolCallId, name, chunk: text });
368
+ },
369
+ };
359
370
  if (name === 'ask_user' && typeof ctx.askUser === 'function') {
360
371
  toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
361
372
  sessionId: session.sessionId,
@@ -225,6 +225,15 @@ function isSafeSubpath(value) {
225
225
  return SAFE_SUBPATH.test(String(value || ''));
226
226
  }
227
227
 
228
+ // 本地关键词匹配:与 awesomeSkills 同一套语义(小写子串)。
229
+ // 静态来源(builtin / Anthropic 官方仓库)也必须过这一关 ——
230
+ // 它们不走目录站的 query 参数,漏掉过滤就等于搜索框对这两组完全失效。
231
+ function matchesQuery(query, ...fields) {
232
+ const q = String(query || '').trim().toLowerCase();
233
+ if (!q) return true;
234
+ return fields.join(' ').toLowerCase().includes(q);
235
+ }
236
+
228
237
  function resolveProjectPath(value, fallback) {
229
238
  const raw = String(value || fallback || '').trim();
230
239
  if (!raw) throw new HttpError(400, '项目路径不能为空');
@@ -292,15 +301,17 @@ async function cached(key, loader) {
292
301
  // ── provider: Skill ───────────────────────────────────────────
293
302
 
294
303
  // builtin:纯静态,不联网
295
- function builtinSkills() {
296
- return BUILTIN_SKILLS.map(item => ({ ...item, type: 'skill' }));
304
+ function builtinSkills(query) {
305
+ return BUILTIN_SKILLS
306
+ .filter(item => matchesQuery(query, item.name, item.description, ...(item.tags || [])))
307
+ .map(item => ({ ...item, type: 'skill' }));
297
308
  }
298
309
 
299
310
  // Anthropic 官方仓库:用 GitHub contents API 列出 skills/ 下的目录。
300
311
  // 这是所有 skill 的"源头",目录结构稳定(path = skills/<name>)。
301
312
  // contents API 不返回描述,所以用内置表里的描述补上(两边都以目录名为键),
302
313
  // 补不到的条目在前端只显示名字,不会假装有描述。
303
- async function officialSkills() {
314
+ async function officialSkills(query) {
304
315
  const data = await fetchJson('https://api.github.com/repos/anthropics/skills/contents/skills', { headers: githubHeaders() });
305
316
  if (!Array.isArray(data)) throw new Error('GitHub 返回结构异常');
306
317
  const described = new Map(ANTHROPIC_SKILLS);
@@ -315,7 +326,8 @@ async function officialSkills() {
315
326
  subpath: `skills/${entry.name}`,
316
327
  upstream: entry.html_url,
317
328
  tags: ['official'],
318
- }));
329
+ }))
330
+ .filter(item => matchesQuery(query, item.name, item.description));
319
331
  }
320
332
 
321
333
  // SkillsMP:第三方 skill 目录站,给的是 owner/repo + 仓库内路径,可直接安装。
@@ -467,8 +479,10 @@ function normalizeNpmSearch(data, type) {
467
479
 
468
480
  // ── provider: MCP ─────────────────────────────────────────────
469
481
 
470
- function builtinMcps() {
471
- return BUILTIN_MCPS.map(item => ({ ...item, type: 'mcp', command: 'npx' }));
482
+ function builtinMcps(query) {
483
+ return BUILTIN_MCPS
484
+ .filter(item => matchesQuery(query, item.name, item.description, item.package, ...(item.tags || [])))
485
+ .map(item => ({ ...item, type: 'mcp', command: 'npx' }));
472
486
  }
473
487
 
474
488
  // MCP 官方注册表:canonical 数据源,直接给出 npm identifier 与所需环境变量,
@@ -562,15 +576,15 @@ async function npmMcps(query) {
562
576
 
563
577
  const PROVIDERS = {
564
578
  skill: {
565
- builtin: () => builtinSkills(),
566
- official: () => officialSkills(),
579
+ builtin: ({ query }) => builtinSkills(query),
580
+ official: ({ query }) => officialSkills(query),
567
581
  skillsmp: ({ query }) => skillsmpSkills(query),
568
582
  awesome: ({ query }) => awesomeSkills(query),
569
583
  github: ({ query }) => (query ? githubSkills(query) : Promise.resolve([])),
570
584
  npm: ({ query }) => npmSkills(query),
571
585
  },
572
586
  mcp: {
573
- builtin: () => builtinMcps(),
587
+ builtin: ({ query }) => builtinMcps(query),
574
588
  registry: ({ query }) => registryMcps(query),
575
589
  smithery: ({ query }) => smitheryMcps(query),
576
590
  npm: ({ query }) => npmMcps(query),
@@ -91,6 +91,38 @@ test('catalog: 只查内置来源时完全离线,并保留按来源分组的形
91
91
  }
92
92
  });
93
93
 
94
+ test('catalog: 内置来源也按关键词过滤(skill / MCP 都不例外)', async () => {
95
+ const { call } = harness();
96
+
97
+ const all = await call('GET /api/agent/marketplace/catalog', {
98
+ query: { type: 'skill', sources: 'builtin', cwd: sandboxProject },
99
+ });
100
+ const unfiltered = all.payload.groups[0].items.length;
101
+
102
+ const { payload } = await call('GET /api/agent/marketplace/catalog', {
103
+ query: { type: 'skill', sources: 'builtin', cwd: sandboxProject, q: 'pdf' },
104
+ });
105
+ const [group] = payload.groups;
106
+ assert.equal(group.status, 'ok');
107
+ assert.ok(group.items.length > 0, 'pdf 是内置条目,不该被过滤掉');
108
+ assert.ok(group.items.length < unfiltered, '关键词必须真的收窄结果');
109
+ assert.equal(group.items.some(item => item.name === 'docx'), false, '关键词 pdf 不该命中 docx');
110
+ for (const item of group.items) {
111
+ assert.match(`${item.name} ${item.description}`, /pdf/i, `${item.name} 与关键词无关`);
112
+ }
113
+
114
+ const { payload: mcp } = await call('GET /api/agent/marketplace/catalog', {
115
+ query: { type: 'mcp', sources: 'builtin', cwd: sandboxProject, q: 'memory' },
116
+ });
117
+ assert.deepEqual(mcp.groups[0].items.map(item => item.name), ['Memory']);
118
+
119
+ // 清空关键词必须回到全量,否则用户删掉搜索词后会看到一片空
120
+ const { payload: cleared } = await call('GET /api/agent/marketplace/catalog', {
121
+ query: { type: 'mcp', sources: 'builtin', cwd: sandboxProject, q: '' },
122
+ });
123
+ assert.ok(cleared.groups[0].items.length > 1, '空关键词应返回全部内置 MCP');
124
+ });
125
+
94
126
  test('catalog: 未指定来源时返回全部来源,每个来源是独立的一组', async () => {
95
127
  const { call } = harness();
96
128
  const { payload } = await call('GET /api/agent/marketplace/catalog', {
@@ -55,7 +55,7 @@ function interactionKey(sessionId, interactionId) {
55
55
  return `${String(sessionId)}:${String(interactionId)}`;
56
56
  }
57
57
 
58
- export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, send, signal }) {
58
+ export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, multiple, send, signal }) {
59
59
  const key = interactionKey(sessionId, interactionId);
60
60
  return new Promise((resolve) => {
61
61
  let settled = false;
@@ -75,10 +75,11 @@ export function waitForAgentAnswer({ sessionId, interactionId, question, options
75
75
  question,
76
76
  options,
77
77
  allowFreeText,
78
+ multiple: multiple === true,
78
79
  settle,
79
80
  });
80
81
  signal?.addEventListener?.('abort', onAbort, { once: true });
81
- send({ type: 'ask_user', interactionId, question, options, allowFreeText });
82
+ send({ type: 'ask_user', interactionId, question, options, allowFreeText, multiple: multiple === true });
82
83
  if (signal?.aborted) onAbort();
83
84
  });
84
85
  }
@@ -88,12 +89,17 @@ export function submitAgentAnswer({ sessionId, interactionId, answer }) {
88
89
  const pending = pendingAgentQuestions.get(key);
89
90
  if (!pending) return { ok: false, status: 404, error: 'No pending question for this session.' };
90
91
 
91
- const value = String(answer || '').trim();
92
- if (!value) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
93
- if (!pending.allowFreeText && pending.options.length > 0 && !pending.options.includes(value)) {
94
- return { ok: false, status: 400, error: 'Choose one of the listed options.' };
92
+ // 单选发字符串、多选发数组;这里统一归一成"非空的字符串列表"
93
+ const values = (Array.isArray(answer) ? answer : [answer])
94
+ .map((v) => String(v ?? '').trim())
95
+ .filter(Boolean);
96
+ if (!values.length) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
97
+ if (!pending.allowFreeText && pending.options.length > 0) {
98
+ const unknown = values.filter((v) => !pending.options.includes(v));
99
+ if (unknown.length) return { ok: false, status: 400, error: 'Choose one of the listed options.' };
95
100
  }
96
- pending.settle(value);
101
+ // 多选回给模型的是 JSON 数组字符串(单选/自由输入保持原样字符串)
102
+ pending.settle(pending.multiple ? JSON.stringify(values) : values[0]);
97
103
  return { ok: true };
98
104
  }
99
105
 
@@ -307,6 +313,7 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
307
313
  question: args.question,
308
314
  options: Array.isArray(args.options) ? args.options : [],
309
315
  allowFreeText: args.allowFreeText !== false,
316
+ multiple: args.multiple === true,
310
317
  send,
311
318
  signal: abortController.signal,
312
319
  }),
@@ -29,7 +29,7 @@ test('agent respond resolves a pending ask_user question and validates answers',
29
29
  send: event => sent.push(event),
30
30
  });
31
31
  await new Promise(resolve => setImmediate(resolve));
32
- assert.deepEqual(sent, [{ type: 'ask_user', interactionId: 'call-test', question: 'Pick one', options: ['A', 'B'], allowFreeText: false }]);
32
+ assert.deepEqual(sent, [{ type: 'ask_user', interactionId: 'call-test', question: 'Pick one', options: ['A', 'B'], allowFreeText: false, multiple: false }]);
33
33
 
34
34
  let statusCode = 200;
35
35
  let responseBody;
@@ -52,6 +52,52 @@ test('agent respond resolves a pending ask_user question and validates answers',
52
52
  assert.equal(responseBody.success, false);
53
53
  });
54
54
 
55
+ test('agent respond: 多选提交逐项校验,回给模型的是 JSON 数组字符串', async () => {
56
+ let respondHandler;
57
+ const app = {
58
+ get() {},
59
+ delete() {},
60
+ put() {},
61
+ post(route, handler) {
62
+ if (route === '/api/agent/respond') respondHandler = handler;
63
+ }
64
+ };
65
+ registerAgentRoutes({
66
+ app,
67
+ getCurrentProjectPath: () => path.resolve('active-project'),
68
+ configManager: null
69
+ });
70
+
71
+ const sent = [];
72
+ const pending = waitForAgentAnswer({
73
+ sessionId: 'session-multi',
74
+ interactionId: 'call-multi',
75
+ question: 'Pick some',
76
+ options: ['A', 'B', 'C'],
77
+ allowFreeText: false,
78
+ multiple: true,
79
+ send: event => sent.push(event),
80
+ });
81
+ await new Promise(resolve => setImmediate(resolve));
82
+ assert.equal(sent[0].multiple, true);
83
+
84
+ let statusCode = 200;
85
+ let responseBody;
86
+ const response = {
87
+ status(code) { statusCode = code; return this; },
88
+ json(body) { responseBody = body; return body; },
89
+ };
90
+
91
+ // 只要有一项不在选项里就整体拒绝
92
+ await respondHandler({ body: { sessionId: 'session-multi', interactionId: 'call-multi', answer: ['A', 'X'] } }, response);
93
+ assert.equal(statusCode, 400);
94
+ assert.equal(responseBody.success, false);
95
+
96
+ await respondHandler({ body: { sessionId: 'session-multi', interactionId: 'call-multi', answer: ['A', 'C'] } }, response);
97
+ assert.deepEqual(responseBody, { success: true });
98
+ assert.equal(await pending, '["A","C"]');
99
+ });
100
+
55
101
  test('agent chat rejects a cwd that differs from the active server project', async () => {
56
102
  let chatHandler;
57
103
  const app = {
@@ -0,0 +1,173 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+
16
+ // codex 执行器事件处理器的回归测试(2026-09-27)。
17
+ //
18
+ // 钉住 codex `exec --json` 适配层的五条口径(事件样例取自 codex-cli 0.157.x 实测输出):
19
+ // 1. thread.started 的 thread_id 落 job.claudeSessionId(续接 exec resume 的取值来源)
20
+ // 2. agent_message / reasoning 分别落 output / thinking,且按 item.id 去重累积推送
21
+ // 3. command_execution / file_change / mcp_tool_call / web_search 归一成工具调用,
22
+ // item.started(status=in_progress) → item.completed 更新同一条
23
+ // 4. error 类事件(顶层 error / item.type=error)是非致命信息,只贴 output,
24
+ // 绝不写 job.agentError —— 否则配置告警会被误判成任务失败
25
+ // 5. turn.failed 才是终态失败:error.message 写进 job.agentError
26
+ //
27
+ // 隔离策略同 taskRunner.opencode.test.js:import taskRunner.js 之前把
28
+ // USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
29
+
30
+ import { test } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import path from 'node:path';
33
+ import os from 'node:os';
34
+ import { mkdtempSync } from 'node:fs';
35
+ import { fileURLToPath, pathToFileURL } from 'node:url';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const projectRoot = path.resolve(__dirname, '../../../../..');
39
+
40
+ // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
41
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-codex-test-'));
42
+ process.env.USERPROFILE = sandbox;
43
+ process.env.HOME = sandbox;
44
+ delete process.env.HOMEDRIVE;
45
+ delete process.env.HOMEPATH;
46
+
47
+ const { createCodexEventHandler } = await import(
48
+ pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
49
+ );
50
+
51
+ /** 可断言的 appendOutput/appendThinking 收集器 */
52
+ function makeSink() {
53
+ const sink = { output: [], thinking: [] };
54
+ sink.appendOutput = (t) => { if (t) sink.output.push(t); };
55
+ sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
56
+ return sink;
57
+ }
58
+
59
+ test('codex 事件:thread_id 落 claudeSessionId,正文/思考分别归位', () => {
60
+ const job = {};
61
+ const sink = makeSink();
62
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
63
+
64
+ handle({ type: 'thread.started', thread_id: '01a0e088-32fc-7511-aa6e-4e836bf138f7' });
65
+ handle({ type: 'turn.started' });
66
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'reasoning', text: '先看一眼目录' } });
67
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: '改好了' } });
68
+ handle({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } });
69
+
70
+ assert.equal(job.claudeSessionId, '01a0e088-32fc-7511-aa6e-4e836bf138f7');
71
+ assert.deepEqual(sink.output, ['改好了']);
72
+ assert.deepEqual(sink.thinking, ['先看一眼目录']);
73
+ assert.equal(job.agentError, undefined);
74
+ });
75
+
76
+ test('codex 事件:同一 item.id 累积推送只追加增量', () => {
77
+ const job = {};
78
+ const sink = makeSink();
79
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
80
+
81
+ // 累积语义:第二次的 text 是第一次的前缀延长
82
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello' } });
83
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello, world' } });
84
+ // 重复事件(完全相同):不产生任何输出
85
+ handle({ type: 'item.updated', item: { id: 'item_0', type: 'agent_message', text: 'Hello, world' } });
86
+ // 不同 item.id:各自独立,整段落下
87
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'Hello' } });
88
+
89
+ assert.deepEqual(sink.output, ['Hello', ', world', 'Hello']);
90
+ });
91
+
92
+ test('codex 事件:command_execution 的 started → completed 更新同一条工具调用', () => {
93
+ const job = {};
94
+ const sink = makeSink();
95
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
96
+
97
+ handle({ type: 'item.started', item: { id: 'item_0', type: 'command_execution', command: 'npm test', aggregated_output: '', exit_code: null, status: 'in_progress' } });
98
+ assert.equal(job.toolCalls.length, 1);
99
+ assert.equal(job.toolCalls[0].status, 'running');
100
+ assert.equal(job.toolCalls[0].argsPreview, 'npm test');
101
+
102
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'npm test', aggregated_output: 'ok 12 tests', exit_code: 0, status: 'completed' } });
103
+ assert.equal(job.toolCalls.length, 1, '同一 id 不新增记录');
104
+ assert.equal(job.toolCalls[0].status, 'done');
105
+ assert.equal(job.toolCalls[0].result, 'ok 12 tests');
106
+ assert.ok(!job.toolCalls[0].error);
107
+ });
108
+
109
+ test('codex 事件:命令失败/文件改动的工具调用落 error、文件清单作结果', () => {
110
+ const job = {};
111
+ const sink = makeSink();
112
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
113
+
114
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'npm run build', aggregated_output: 'boom', exit_code: 1, status: 'failed' } });
115
+ assert.equal(job.toolCalls[0].status, 'error');
116
+ assert.match(job.toolCalls[0].error, /exit_code=1/);
117
+
118
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'file_change', changes: [{ path: 'src/a.js', kind: 'update' }, { path: 'src/b.js', kind: 'add' }], status: 'completed' } });
119
+ assert.equal(job.toolCalls[1].status, 'done');
120
+ assert.equal(job.toolCalls[1].result, 'update src/a.js\nadd src/b.js');
121
+ });
122
+
123
+ test('codex 事件:mcp_tool_call / web_search 归一成工具调用', () => {
124
+ const job = {};
125
+ const sink = makeSink();
126
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
127
+
128
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'mcp_tool_call', server: 'node_repl', tool: 'run', arguments: { code: '1+1' }, result: { content: [{ type: 'text', text: '2' }] }, status: 'completed' } });
129
+ assert.equal(job.toolCalls[0].name, 'node_repl.run');
130
+ assert.equal(job.toolCalls[0].status, 'done');
131
+ assert.equal(job.toolCalls[0].result, '2');
132
+
133
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'web_search', query: 'codex exec json', action: 'search', status: 'completed' } });
134
+ assert.equal(job.toolCalls[1].name, 'web_search');
135
+ assert.equal(job.toolCalls[1].argsPreview, 'codex exec json');
136
+ });
137
+
138
+ test('codex 事件:告警只贴 output,不置 agentError;turn.failed 才判失败', () => {
139
+ const job = {};
140
+ const sink = makeSink();
141
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
142
+
143
+ // 配置告警(item.type=error)与重试提示(顶层 error)都是非终止信息
144
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'error', message: 'Codex is ignoring 2 unrecognized configuration settings.' } });
145
+ handle({ type: 'error', message: 'Reconnecting... 1/5 (unexpected status 401 Unauthorized)' });
146
+ assert.equal(job.agentError, undefined, '告警不能把任务判成失败');
147
+ assert.equal(sink.output.length, 2);
148
+
149
+ // 终态失败
150
+ handle({ type: 'turn.failed', error: { message: 'unexpected status 401 Unauthorized' } });
151
+ assert.equal(job.agentError, 'unexpected status 401 Unauthorized');
152
+ // 只记第一条
153
+ handle({ type: 'turn.failed', error: { message: 'second one' } });
154
+ assert.equal(job.agentError, 'unexpected status 401 Unauthorized');
155
+ });
156
+
157
+ test('codex 事件:未知 item 类型 / 缺字段事件不抛错也不产生输出', () => {
158
+ const job = {};
159
+ const sink = makeSink();
160
+ const handle = createCodexEventHandler(job, sink.appendOutput, sink.appendThinking);
161
+
162
+ handle({ type: 'item.completed', item: { id: 'item_0', type: 'todo_list', items: [{ text: 'step', completed: false }] } });
163
+ handle({ type: 'item.completed', item: { id: 'item_1', type: 'collab_tool_call', tool: 'wait', status: 'completed' } });
164
+ handle({ type: 'item.completed', item: { id: 'item_2', type: 'some_future_item_type' } });
165
+ handle({ type: 'item.completed' });
166
+ handle({ type: 'turn.started' });
167
+ handle(null);
168
+
169
+ assert.deepEqual(sink.output, []);
170
+ assert.deepEqual(sink.thinking, []);
171
+ assert.equal(job.claudeSessionId, undefined);
172
+ assert.equal(job.agentError, undefined);
173
+ });