zen-gitsync 2.17.15 → 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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/cli/ai/agent.js +24 -2
- package/src/cli/ai/runtime.test.js +1 -1
- package/src/cli/ai/tools.js +7 -1
- package/src/cli/ai/tools.test.js +11 -0
- package/src/config.js +4 -4
- package/src/ui/public/assets/AgentView-DOyN97Ve.js +5 -0
- package/src/ui/public/assets/{AgentView-vvVhbIQ5.css → AgentView-DkM2KXuc.css} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-D5iXcAEV.js → AppVersionBadge-oON3sAj4.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-DP4u1f_B.js → BranchSelector-Cpj7eGqY.js} +1 -1
- package/src/ui/public/assets/{CommitForm-boOw8uGS.js → CommitForm-D_ovYhVw.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-B_qUsIGr.js → CommonDialog-ChZbqfRF.js} +1 -1
- package/src/ui/public/assets/{EditorView-ZULtfIzl.js → EditorView-BPzpI0GV.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-C4xxq5S0.js → FileDiffViewer-B_qEKA9L.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BmfstdaT.js → FlowExecutionViewer-Drw4hlJG.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-28kErqrn.js → FlowOrchestrationWorkspace-C7yTD1gj.js} +1 -1
- package/src/ui/public/assets/{IconButton-DcOAqpq0.js → IconButton-DEARmMkZ.js} +1 -1
- package/src/ui/public/assets/{LogList-FEP7EeJt.js → LogList-BA_gowxE.js} +1 -1
- package/src/ui/public/assets/{MindmapView-NDJvfSCn.js → MindmapView-K-XcLLXr.js} +1 -1
- package/src/ui/public/assets/{MonitorView-DU4aBCK0.js → MonitorView-C0RL5K9W.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-JQk4Etsu.js → ProjectStartupButton-DC36_vvU.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-Di2WizMS.js → RemoteManagerDialog-Bkqja660.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-DxB8Q8yE.js → RemoteRepoCard-Bsm7cj_H.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CfmrPMBm.js → SourceMapView-Y5NKdIk6.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-yefEMV0-.js → SvgIcon-D_mFHXP4.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-CCy_WcSK.js → TemplateManager-BzKVXZhy.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-3Gv_9YZx.js → UserInputNode-BPLK4aYx.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-Dgp6A83v.js → WorkbenchView-xbGdiVMQ.js} +25 -25
- package/src/ui/public/assets/{_plugin-vue_export-helper-B1mHBne9.js → _plugin-vue_export-helper-dg2VUnSQ.js} +3 -3
- package/src/ui/public/assets/configStore-DtjAc3w9.js +1 -0
- package/src/ui/public/assets/{gitStore-D5CWxPLv.js → gitStore-BZo9_zyy.js} +1 -1
- package/src/ui/public/assets/{index-gpuX9fhG.js → index-CHuCYLWm.js} +45 -45
- package/src/ui/public/assets/{previewSandbox-qLKxghfr.js → previewSandbox-BTTTXooN.js} +0 -0
- package/src/ui/public/assets/{vendor-C5qX_I8r.css → vendor-DnB9EJa3.css} +1 -1
- package/src/ui/public/assets/{vendor-IQQMq-1c.js → vendor-bdHhLiED.js} +43 -43
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/routes/workbench/agentChat.js +3 -2
- package/src/ui/server/routes/workbench/agentMarketplace.js +23 -9
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +32 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +14 -7
- package/src/ui/server/routes/workbench/agentRoutes.test.js +47 -1
- package/src/ui/server/routes/workbench/taskRunner.codex.test.js +173 -0
- package/src/ui/server/routes/workbench/taskRunner.js +259 -10
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +4 -2
- package/src/ui/public/assets/AgentView-Bz9LFamz.js +0 -5
- package/src/ui/public/assets/configStore-BDxel6Co.js +0 -1
package/src/ui/public/index.html
CHANGED
|
@@ -10,27 +10,27 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-CHuCYLWm.js"></script>
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/element-plus-DxBPQwL7.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/monaco-BScz9v63.js">
|
|
17
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-
|
|
19
|
-
<link rel="modulepreload" crossorigin href="/assets/SvgIcon-
|
|
20
|
-
<link rel="modulepreload" crossorigin href="/assets/IconButton-
|
|
21
|
-
<link rel="modulepreload" crossorigin href="/assets/configStore-
|
|
17
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-bdHhLiED.js">
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-dg2VUnSQ.js">
|
|
19
|
+
<link rel="modulepreload" crossorigin href="/assets/SvgIcon-D_mFHXP4.js">
|
|
20
|
+
<link rel="modulepreload" crossorigin href="/assets/IconButton-DEARmMkZ.js">
|
|
21
|
+
<link rel="modulepreload" crossorigin href="/assets/configStore-DtjAc3w9.js">
|
|
22
22
|
<link rel="modulepreload" crossorigin href="/assets/socket-io-BFqQXqAp.js">
|
|
23
|
-
<link rel="modulepreload" crossorigin href="/assets/gitStore-
|
|
23
|
+
<link rel="modulepreload" crossorigin href="/assets/gitStore-BZo9_zyy.js">
|
|
24
24
|
<link rel="modulepreload" crossorigin href="/assets/useThemeObserver-E7Hk50l3.js">
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/flow-mindmap-BqFb1WO5.js">
|
|
26
|
-
<link rel="modulepreload" crossorigin href="/assets/previewSandbox-
|
|
27
|
-
<link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-
|
|
28
|
-
<link rel="modulepreload" crossorigin href="/assets/CommonDialog-
|
|
29
|
-
<link rel="modulepreload" crossorigin href="/assets/TemplateManager-
|
|
26
|
+
<link rel="modulepreload" crossorigin href="/assets/previewSandbox-BTTTXooN.js">
|
|
27
|
+
<link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-B_qEKA9L.js">
|
|
28
|
+
<link rel="modulepreload" crossorigin href="/assets/CommonDialog-ChZbqfRF.js">
|
|
29
|
+
<link rel="modulepreload" crossorigin href="/assets/TemplateManager-BzKVXZhy.js">
|
|
30
30
|
<link rel="modulepreload" crossorigin href="/assets/vue-flow-fLUQ0_kE.js">
|
|
31
31
|
<link rel="stylesheet" crossorigin href="/assets/element-plus-Cwwq-Gje.css">
|
|
32
32
|
<link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
|
|
33
|
-
<link rel="stylesheet" crossorigin href="/assets/vendor-
|
|
33
|
+
<link rel="stylesheet" crossorigin href="/assets/vendor-DnB9EJa3.css">
|
|
34
34
|
<link rel="stylesheet" crossorigin href="/assets/SvgIcon-CDNsk4-Q.css">
|
|
35
35
|
<link rel="stylesheet" crossorigin href="/assets/IconButton-tjkOjzkC.css">
|
|
36
36
|
<link rel="stylesheet" crossorigin href="/assets/flow-mindmap-DnzM9SlK.css">
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// - { type: 'tool_call_start', toolCallId, name, argsPreview }
|
|
25
25
|
// - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
|
|
26
26
|
// - { type: 'tool_result', toolCallId, name, result }
|
|
27
|
-
// - { type: 'ask_user', interactionId, question, options, allowFreeText }
|
|
27
|
+
// - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
|
|
28
28
|
// - { type: 'done', content } — 本轮最终完成
|
|
29
29
|
// - { type: 'error', error }
|
|
30
30
|
|
|
@@ -135,6 +135,7 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
|
|
|
135
135
|
# 与用户交互
|
|
136
136
|
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
|
|
137
137
|
- 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
|
|
138
|
+
- 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
|
|
138
139
|
- 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
|
|
139
140
|
- 用户可能随消息附带图片:图片以 image_url 部件出现在 user 消息里;如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
|
|
140
141
|
- 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
|
|
@@ -205,7 +206,7 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
|
|
|
205
206
|
|
|
206
207
|
# How you work
|
|
207
208
|
- Act first, ask later: use tools to investigate before asking the user
|
|
208
|
-
- 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.
|
|
209
210
|
- After modifying code, verify: run tests, build, or at least syntax check
|
|
210
211
|
- Prefer edit_file for precise replacements; read_file first to confirm original text
|
|
211
212
|
- Use offset/limit for large files
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
+
});
|