zen-gitsync 2.17.8 → 2.17.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +2 -1
- package/src/cli/ai/agent.js +210 -58
- package/src/cli/ai/context.js +58 -0
- package/src/cli/ai/context.test.js +117 -0
- package/src/cli/ai/extensions.js +131 -0
- package/src/cli/ai/extensions.test.js +158 -0
- package/src/cli/ai/mcp.js +498 -0
- package/src/cli/ai/mcp.test.js +275 -0
- package/src/cli/ai/runtime.test.js +32 -0
- package/src/cli/ai/skills.js +207 -0
- package/src/cli/ai/skills.test.js +126 -0
- package/src/cli/ai/termui.js +1 -0
- package/src/cli/ai/tools.js +94 -3
- package/src/cli/ai/tools.test.js +82 -0
- package/src/cli/ai/transport.js +18 -4
- package/src/cli/ai/turn.js +21 -5
- package/src/config.js +27 -3
- package/src/paths.js +11 -0
- package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
- package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
- package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
- package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
- package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
- package/src/ui/public/assets/index-CW3xs-af.css +1 -0
- package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
- package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
- package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
- package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
- package/src/ui/public/index.html +17 -17
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/config.js +16 -3
- package/src/ui/server/routes/exec.js +19 -11
- package/src/ui/server/routes/fileOpen.js +142 -2
- package/src/ui/server/routes/remoteRepos.js +604 -0
- package/src/ui/server/routes/remoteRepos.test.js +386 -0
- package/src/ui/server/routes/workbench/agentChat.js +86 -180
- package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
- package/src/ui/server/routes/workbench/jobStore.js +17 -3
- package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
- package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
- package/src/ui/server/routes/workbench/projectTool.js +198 -0
- package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
- package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
- package/src/ui/server/routes/workbench/taskRunner.js +269 -12
- package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
- package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
- package/src/utils/shellPath.js +253 -0
- package/src/utils/shellPath.test.js +221 -0
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
- package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
- package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
- package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
- package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
- package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
- package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
- package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
- package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
- package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
- package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
- package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
- package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
- package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
- package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
- package/src/ui/public/assets/index-cApp6w0b.css +0 -1
- package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
- package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
- package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
- package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
// Skill / MCP 广场后端的回归测试。
|
|
16
|
+
//
|
|
17
|
+
// 重点钉住三类容易悄悄坏掉的行为:
|
|
18
|
+
// 1) 安装目标落盘位置(项目级 vs g ai 全局级)—— 路径一旦漂移,
|
|
19
|
+
// 前端显示"已安装"而 g ai 读不到,是最难排查的一类 bug;
|
|
20
|
+
// 2) 网络数据落盘前的白名单校验(仓库名 / 包名 / 子路径 / 路径穿越);
|
|
21
|
+
// 3) 单个来源抓取失败不能影响其他来源(按来源分组的核心承诺)。
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { promises as fs } from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
// ── 沙箱 ──────────────────────────────────────────────────────
|
|
30
|
+
// paths.js 在模块加载时就用 os.homedir() 求值,必须在 import 之前改环境变量。
|
|
31
|
+
// HOMEDRIVE/HOMEPATH 是 Windows 上 os.homedir() 的另一条取值路径,必须删掉,
|
|
32
|
+
// 否则 USERPROFILE 改了也不生效。
|
|
33
|
+
const sandboxHome = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mp-home-'));
|
|
34
|
+
const sandboxProject = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mp-project-'));
|
|
35
|
+
process.env.USERPROFILE = sandboxHome;
|
|
36
|
+
process.env.HOME = sandboxHome;
|
|
37
|
+
delete process.env.HOMEDRIVE;
|
|
38
|
+
delete process.env.HOMEPATH;
|
|
39
|
+
|
|
40
|
+
const { registerAgentMarketplaceRoutes } = await import('./agentMarketplace.js');
|
|
41
|
+
const { AI_SKILLS_DIR, AI_MCP_FILE } = await import('../../../../paths.js');
|
|
42
|
+
|
|
43
|
+
// ── 测试脚手架:把注册的 handler 抓出来直接调 ──────────────────
|
|
44
|
+
function harness(currentProject = sandboxProject) {
|
|
45
|
+
const handlers = new Map();
|
|
46
|
+
const app = {
|
|
47
|
+
get(route, handler) { handlers.set(`GET ${route}`, handler); },
|
|
48
|
+
post(route, handler) { handlers.set(`POST ${route}`, handler); },
|
|
49
|
+
delete(route, handler) { handlers.set(`DELETE ${route}`, handler); },
|
|
50
|
+
};
|
|
51
|
+
registerAgentMarketplaceRoutes({ app, getCurrentProjectPath: () => currentProject });
|
|
52
|
+
|
|
53
|
+
const call = async (key, { query = {}, body = {}, params = {} } = {}) => {
|
|
54
|
+
const handler = handlers.get(key);
|
|
55
|
+
assert.ok(handler, `未注册路由: ${key}`);
|
|
56
|
+
let payload = null;
|
|
57
|
+
let status = 200;
|
|
58
|
+
const res = {
|
|
59
|
+
status(code) { status = code; return this; },
|
|
60
|
+
json(data) { payload = data; return this; },
|
|
61
|
+
};
|
|
62
|
+
await handler({ method: key.split(' ')[0], path: key.split(' ')[1], query, body, params }, res);
|
|
63
|
+
return { status, payload };
|
|
64
|
+
};
|
|
65
|
+
return { call };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function exists(target) {
|
|
69
|
+
return await fs.stat(target).then(() => true).catch(() => false);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('catalog: 只查内置来源时完全离线,并保留按来源分组的形状', async () => {
|
|
73
|
+
const { call } = harness();
|
|
74
|
+
const { status, payload } = await call('GET /api/agent/marketplace/catalog', {
|
|
75
|
+
query: { type: 'skill', sources: 'builtin', cwd: sandboxProject },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(status, 200);
|
|
79
|
+
assert.equal(payload.success, true);
|
|
80
|
+
assert.equal(payload.groups.length, 1, '指定单个来源时只应返回该组');
|
|
81
|
+
|
|
82
|
+
const [group] = payload.groups;
|
|
83
|
+
assert.equal(group.id, 'builtin');
|
|
84
|
+
assert.equal(group.status, 'ok');
|
|
85
|
+
assert.ok(group.items.length > 0, '内置精选不能是空列表(否则离线时广场全空)');
|
|
86
|
+
|
|
87
|
+
// 内置条目必须带可安装信息,否则前端会给出一个必然失败的按钮
|
|
88
|
+
for (const item of group.items) {
|
|
89
|
+
assert.ok(item.installable, `${item.name} 应可安装`);
|
|
90
|
+
assert.ok(item.repository || item.package, `${item.name} 缺少仓库或包名`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('catalog: 未指定来源时返回全部来源,每个来源是独立的一组', async () => {
|
|
95
|
+
const { call } = harness();
|
|
96
|
+
const { payload } = await call('GET /api/agent/marketplace/catalog', {
|
|
97
|
+
query: { type: 'mcp', sources: 'builtin', cwd: sandboxProject },
|
|
98
|
+
});
|
|
99
|
+
const ids = payload.groups.map(group => group.id);
|
|
100
|
+
assert.deepEqual(ids, ['builtin']);
|
|
101
|
+
|
|
102
|
+
// 不带 sources 时应覆盖该类型的全部来源定义
|
|
103
|
+
const { payload: all } = await call('GET /api/agent/marketplace/sources', { query: { type: 'mcp' } });
|
|
104
|
+
assert.ok(all.sources.length >= 4, 'MCP 至少应有 4 个来源');
|
|
105
|
+
assert.equal(all.sources[0].id, 'builtin');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('sources: 拒绝未知类型', async () => {
|
|
109
|
+
const { call } = harness();
|
|
110
|
+
const { status, payload } = await call('GET /api/agent/marketplace/sources', { query: { type: 'plugin' } });
|
|
111
|
+
assert.equal(status, 400);
|
|
112
|
+
assert.equal(payload.success, false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('install skill: 项目级落到 <cwd>/.claude/skills,全局级落到 ~/.zen-gitsync/ai/skills', async () => {
|
|
116
|
+
const { call } = harness();
|
|
117
|
+
const content = '---\nname: demo\n description: x\n---\n# demo\n';
|
|
118
|
+
const body = { type: 'skill', item: { id: 'demo-skill', name: 'demo', content } };
|
|
119
|
+
|
|
120
|
+
const project = await call('POST /api/agent/marketplace/install', {
|
|
121
|
+
body: { ...body, target: 'project', cwd: sandboxProject },
|
|
122
|
+
});
|
|
123
|
+
assert.equal(project.status, 200);
|
|
124
|
+
assert.ok(await exists(path.join(sandboxProject, '.claude', 'skills', 'demo-skill', 'SKILL.md')));
|
|
125
|
+
|
|
126
|
+
const global = await call('POST /api/agent/marketplace/install', {
|
|
127
|
+
body: { ...body, target: 'global', cwd: sandboxProject },
|
|
128
|
+
});
|
|
129
|
+
assert.equal(global.status, 200);
|
|
130
|
+
assert.ok(await exists(path.join(AI_SKILLS_DIR, 'demo-skill', 'SKILL.md')));
|
|
131
|
+
// 全局级必须先解析到 paths.js 的常量,而不是某个自己拼的路径
|
|
132
|
+
assert.equal(global.payload.item.dir, path.join(AI_SKILLS_DIR, 'demo-skill'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('install skill: 重复安装同一目标返回 409', async () => {
|
|
136
|
+
const { call } = harness();
|
|
137
|
+
const install = () => call('POST /api/agent/marketplace/install', {
|
|
138
|
+
body: { type: 'skill', target: 'project', cwd: sandboxProject, item: { id: 'dup-skill', content: '---\nname: d\n---\n' } },
|
|
139
|
+
});
|
|
140
|
+
assert.equal((await install()).status, 200);
|
|
141
|
+
const second = await install();
|
|
142
|
+
assert.equal(second.status, 409);
|
|
143
|
+
assert.match(second.payload.error, /已安装/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('install skill: 没有仓库也没有正文时拒绝,不产生空目录', async () => {
|
|
147
|
+
const { call } = harness();
|
|
148
|
+
const { status, payload } = await call('POST /api/agent/marketplace/install', {
|
|
149
|
+
body: { type: 'skill', target: 'project', cwd: sandboxProject, item: { id: 'useless', name: 'useless' } },
|
|
150
|
+
});
|
|
151
|
+
assert.equal(status, 400);
|
|
152
|
+
assert.match(payload.error, /没有可安装的仓库地址/);
|
|
153
|
+
assert.equal(await exists(path.join(sandboxProject, '.claude', 'skills', 'useless')), false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('install skill: 仓库名与子路径都要过白名单', async () => {
|
|
157
|
+
const { call } = harness();
|
|
158
|
+
const cases = [
|
|
159
|
+
{ id: 'bad-repo-1', repository: 'https://evil.example.com/a/b' },
|
|
160
|
+
{ id: 'bad-repo-2', repository: '../../etc' },
|
|
161
|
+
{ id: 'bad-repo-3', repository: 'owner' },
|
|
162
|
+
];
|
|
163
|
+
for (const item of cases) {
|
|
164
|
+
const { status } = await call('POST /api/agent/marketplace/install', {
|
|
165
|
+
body: { type: 'skill', target: 'project', cwd: sandboxProject, item },
|
|
166
|
+
});
|
|
167
|
+
assert.equal(status, 400, `${JSON.stringify(item)} 应被拒绝`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('install mcp: 写入目标文件、补齐 name/description,并把 ${project} 展开成真实路径', async () => {
|
|
172
|
+
const { call } = harness();
|
|
173
|
+
const { status, payload } = await call('POST /api/agent/marketplace/install', {
|
|
174
|
+
body: {
|
|
175
|
+
type: 'mcp',
|
|
176
|
+
target: 'project',
|
|
177
|
+
cwd: sandboxProject,
|
|
178
|
+
// 用 builtin 里带 ${project} 占位符的形态,但包名换成不会真去下载的名字
|
|
179
|
+
item: {
|
|
180
|
+
id: 'mcp-fake-fs',
|
|
181
|
+
name: 'Fake FS',
|
|
182
|
+
description: '测试用',
|
|
183
|
+
package: '@modelcontextprotocol/server-filesystem',
|
|
184
|
+
args: ['-y', '@modelcontextprotocol/server-filesystem', '${project}'],
|
|
185
|
+
envKeys: ['SOME_TOKEN'],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.equal(status, 200);
|
|
191
|
+
const file = path.join(sandboxProject, '.mcp.json');
|
|
192
|
+
const config = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
193
|
+
const server = config.mcpServers['mcp-fake-fs'];
|
|
194
|
+
assert.ok(server, '应写入 mcpServers');
|
|
195
|
+
assert.equal(server.command, 'npx');
|
|
196
|
+
assert.equal(server.args[2], sandboxProject, '${project} 必须被替换成项目真实路径');
|
|
197
|
+
assert.deepEqual(server.requiredEnv, ['SOME_TOKEN']);
|
|
198
|
+
assert.equal(payload.item.file, file);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('install mcp: 非法包名被拒绝,且不写出配置文件', async () => {
|
|
202
|
+
const { call } = harness();
|
|
203
|
+
const before = await exists(path.join(sandboxProject, '.mcp.json'));
|
|
204
|
+
const { status } = await call('POST /api/agent/marketplace/install', {
|
|
205
|
+
body: { type: 'mcp', target: 'project', cwd: sandboxProject, item: { id: 'evil', package: 'pkg; rm -rf /' } },
|
|
206
|
+
});
|
|
207
|
+
assert.equal(status, 400);
|
|
208
|
+
if (!before) assert.equal(await exists(path.join(sandboxProject, '.mcp.json')), false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('install mcp: 只有远程端点时用 mcp-remote 桥接,而不是报错或装出一个无效配置', async () => {
|
|
212
|
+
const { call } = harness();
|
|
213
|
+
const { status } = await call('POST /api/agent/marketplace/install', {
|
|
214
|
+
body: {
|
|
215
|
+
type: 'mcp',
|
|
216
|
+
target: 'global',
|
|
217
|
+
cwd: sandboxProject,
|
|
218
|
+
item: { id: 'remote-only', name: 'Remote', remoteUrl: 'https://example.com/mcp', transport: 'remote' },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
assert.equal(status, 200);
|
|
222
|
+
const config = JSON.parse(await fs.readFile(AI_MCP_FILE, 'utf8'));
|
|
223
|
+
const server = config.mcpServers['remote-only'];
|
|
224
|
+
assert.equal(server.command, 'npx');
|
|
225
|
+
assert.deepEqual(server.args, ['-y', 'mcp-remote', 'https://example.com/mcp']);
|
|
226
|
+
assert.equal(server.transport, 'remote');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('install mcp: 既没有包也没有远程端点时拒绝', async () => {
|
|
230
|
+
const { call } = harness();
|
|
231
|
+
const { status } = await call('POST /api/agent/marketplace/install', {
|
|
232
|
+
body: { type: 'mcp', target: 'project', cwd: sandboxProject, item: { id: 'nothing', name: 'Nothing' } },
|
|
233
|
+
});
|
|
234
|
+
assert.equal(status, 422);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('installed: 同时列出项目级与全局级,并标明 target', async () => {
|
|
238
|
+
const { call } = harness();
|
|
239
|
+
const { status, payload } = await call('GET /api/agent/marketplace/installed', {
|
|
240
|
+
query: { type: 'skill', cwd: sandboxProject },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(status, 200);
|
|
243
|
+
const ids = payload.items.map(item => `${item.target}:${item.id}`);
|
|
244
|
+
assert.ok(ids.includes('project:demo-skill'));
|
|
245
|
+
assert.ok(ids.includes('global:demo-skill'));
|
|
246
|
+
|
|
247
|
+
const { payload: mcpPayload } = await call('GET /api/agent/marketplace/installed', {
|
|
248
|
+
query: { type: 'mcp', cwd: sandboxProject },
|
|
249
|
+
});
|
|
250
|
+
const mcpIds = mcpPayload.items.map(item => `${item.target}:${item.id}`);
|
|
251
|
+
assert.ok(mcpIds.includes('project:mcp-fake-fs'));
|
|
252
|
+
assert.ok(mcpIds.includes('global:remote-only'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('uninstall: 只删指定目标的那一份,另一份保留', async () => {
|
|
256
|
+
const { call } = harness();
|
|
257
|
+
const { status } = await call('DELETE /api/agent/marketplace/item/:type/:id', {
|
|
258
|
+
params: { type: 'skill', id: 'demo-skill' },
|
|
259
|
+
query: { target: 'project', cwd: sandboxProject },
|
|
260
|
+
});
|
|
261
|
+
assert.equal(status, 200);
|
|
262
|
+
assert.equal(await exists(path.join(sandboxProject, '.claude', 'skills', 'demo-skill')), false);
|
|
263
|
+
assert.ok(await exists(path.join(AI_SKILLS_DIR, 'demo-skill', 'SKILL.md')), '全局那份不应被连坐删掉');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('uninstall: MCP 从配置里摘掉对应键,保留其他 server', async () => {
|
|
267
|
+
const { call } = harness();
|
|
268
|
+
const { status } = await call('DELETE /api/agent/marketplace/item/:type/:id', {
|
|
269
|
+
params: { type: 'mcp', id: 'mcp-fake-fs' },
|
|
270
|
+
query: { target: 'project', cwd: sandboxProject },
|
|
271
|
+
});
|
|
272
|
+
assert.equal(status, 200);
|
|
273
|
+
const config = JSON.parse(await fs.readFile(path.join(sandboxProject, '.mcp.json'), 'utf8'));
|
|
274
|
+
assert.equal(Object.prototype.hasOwnProperty.call(config.mcpServers, 'mcp-fake-fs'), false);
|
|
275
|
+
assert.ok(await exists(AI_MCP_FILE), '全局配置文件应保持不变');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('uninstall: 路径穿越形态的 id 被拒绝', async () => {
|
|
279
|
+
const { call } = harness();
|
|
280
|
+
for (const id of ['..', '../..', 'a/../../b', 'a\\b']) {
|
|
281
|
+
const { status } = await call('DELETE /api/agent/marketplace/item/:type/:id', {
|
|
282
|
+
params: { type: 'skill', id: encodeURIComponent(id) },
|
|
283
|
+
query: { target: 'project', cwd: sandboxProject },
|
|
284
|
+
});
|
|
285
|
+
assert.equal(status, 400, `id=${id} 应被拒绝`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('uninstall: 未安装的条目返回 404', async () => {
|
|
290
|
+
const { call } = harness();
|
|
291
|
+
const { status } = await call('DELETE /api/agent/marketplace/item/:type/:id', {
|
|
292
|
+
params: { type: 'skill', id: 'never-installed' },
|
|
293
|
+
query: { target: 'global', cwd: sandboxProject },
|
|
294
|
+
});
|
|
295
|
+
assert.equal(status, 404);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('install: 项目不存在时拒绝,不会把 .claude/skills 建到奇怪的地方', async () => {
|
|
299
|
+
const { call } = harness();
|
|
300
|
+
const missing = path.join(sandboxProject, 'no-such-dir');
|
|
301
|
+
const { status, payload } = await call('POST /api/agent/marketplace/install', {
|
|
302
|
+
body: { type: 'skill', target: 'project', cwd: missing, item: { id: 'x', content: '---\nname: x\n---\n' } },
|
|
303
|
+
});
|
|
304
|
+
assert.equal(status, 400);
|
|
305
|
+
assert.match(payload.error, /项目目录不存在/);
|
|
306
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// CLI(`g ai`)与 Web 智能体面板之间**共享口径**的一致性守卫。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:同一套逻辑在本仓库反复出现"两边各写一份"的历史 ——
|
|
4
|
+
// 路径归一 : projectRegistry.js ↔ 前端 utils/path.ts
|
|
5
|
+
// 智能体提示词: agent.js(zh/en) ↔ agentChat.js(zh/en),共四处
|
|
6
|
+
// 上下文准备 : CLI 已改成「条数/字符双预算 + 丢弃项摘录成梗概」,
|
|
7
|
+
// Web 侧还停在「按条数硬切 40 + 纯 splice 丢弃」,而且它的 splice 是
|
|
8
|
+
// **原地**改 session.messages —— 聊久了模型失忆,磁盘上的会话记录还被永久削掉一截
|
|
9
|
+
// LLM 传输 : Web 侧自带的 streamChatOnce 不拦"流被截断但 tool_calls 已部分到达",
|
|
10
|
+
// 半截的参数有可能被拿去执行
|
|
11
|
+
// 这类分叉的失效方式是「**不报错,两个入口表现不一样**」,很难被发现。所以这里不验证
|
|
12
|
+
// 模型行为(也没法验),只钉住三件事:压缩/传输逻辑各只有一份实现、两个入口都必须走它、
|
|
13
|
+
// 口径参数不许在调用点各自硬写。谁要是想再抄一份,就会撞上下面的第三条测试。
|
|
14
|
+
//
|
|
15
|
+
// 共享模块:src/cli/ai/context.js(上下文)、src/cli/ai/transport.js(传输)
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SRC_ROOT = path.resolve(here, '..', '..', '..', '..');
|
|
25
|
+
const SHARED_DIR = path.resolve(SRC_ROOT, 'cli', 'ai');
|
|
26
|
+
|
|
27
|
+
const CONTEXT_FILE = path.join(SHARED_DIR, 'context.js');
|
|
28
|
+
const TRANSPORT_FILE = path.join(SHARED_DIR, 'transport.js');
|
|
29
|
+
|
|
30
|
+
// 两个入口。CLI 侧的循环在 turn.js,Web 侧在 agentChat.js。
|
|
31
|
+
const ENTRY_POINTS = [
|
|
32
|
+
{ label: 'CLI(g ai) turn.js', file: path.join(SHARED_DIR, 'turn.js') },
|
|
33
|
+
{ label: 'Web 智能体面板 agentChat.js', file: path.join(here, 'agentChat.js') },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// 每条共享口径:模块 + 导出名 + 调用点的额外要求
|
|
37
|
+
const SHARED = [
|
|
38
|
+
{
|
|
39
|
+
what: '上下文准备',
|
|
40
|
+
module: CONTEXT_FILE,
|
|
41
|
+
exported: 'prepareRequestMessages',
|
|
42
|
+
// 预算只留在共享模块里,调用点必须把 locale 传进去(旧图片占位符要分语言)
|
|
43
|
+
mustPass: [/locale/],
|
|
44
|
+
mustNotPass: [/maxMessages/, /maxChars/],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
what: 'LLM 传输',
|
|
48
|
+
module: TRANSPORT_FILE,
|
|
49
|
+
exported: 'streamChatOnce',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const SCAN_EXT = /\.(js|mjs|cjs|ts)$/;
|
|
54
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', 'public']);
|
|
55
|
+
|
|
56
|
+
function walk(dir, acc = []) {
|
|
57
|
+
let entries;
|
|
58
|
+
try { entries = readdirSync(dir); } catch { return acc; }
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
const full = path.join(dir, name);
|
|
61
|
+
let st;
|
|
62
|
+
try { st = statSync(full); } catch { continue; }
|
|
63
|
+
if (st.isDirectory()) {
|
|
64
|
+
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
|
65
|
+
walk(full, acc);
|
|
66
|
+
} else if (SCAN_EXT.test(name)) acc.push(full);
|
|
67
|
+
}
|
|
68
|
+
return acc;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 取出 `name(...)` 的实参文本(够用的括号配对,调用点都很简单)。 */
|
|
72
|
+
function callArgs(src, name) {
|
|
73
|
+
const at = src.indexOf(name + '(');
|
|
74
|
+
if (at < 0) return null;
|
|
75
|
+
let depth = 0;
|
|
76
|
+
for (let i = at + name.length; i < src.length; i++) {
|
|
77
|
+
if (src[i] === '(') depth++;
|
|
78
|
+
else if (src[i] === ')') { depth--; if (depth === 0) return src.slice(at + name.length + 1, i); }
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const importLineFor = (src, name) => src.split(/\r?\n/)
|
|
84
|
+
.find(l => /^import\s/.test(l) && new RegExp(`\\b${name}\\b`).test(l));
|
|
85
|
+
|
|
86
|
+
test('两个入口都从同一份共享模块导入,并真的用它', () => {
|
|
87
|
+
for (const { what, module, exported } of SHARED) {
|
|
88
|
+
for (const { label, file } of ENTRY_POINTS) {
|
|
89
|
+
const src = readFileSync(file, 'utf8');
|
|
90
|
+
const line = importLineFor(src, exported);
|
|
91
|
+
assert.ok(line, `${label} 没有导入 ${exported}(${what}):必须收敛到共享模块`);
|
|
92
|
+
const spec = /from\s*['"]([^'"]+)['"]/.exec(line)?.[1];
|
|
93
|
+
assert.ok(spec, `${label} 的 import 语句里找不到模块路径`);
|
|
94
|
+
assert.equal(
|
|
95
|
+
path.resolve(path.dirname(file), spec),
|
|
96
|
+
module,
|
|
97
|
+
`${label} 从别处导入了 ${exported}:两个入口必须用同一份实现`,
|
|
98
|
+
);
|
|
99
|
+
// 除了 import 那行之外还得真的出现在别处(被调用、被赋值给变量都算)
|
|
100
|
+
const body = src.split(/\r?\n/).filter(l => l !== line).join('\n');
|
|
101
|
+
assert.match(body, new RegExp(`\\b${exported}\\b`), `${label} 导入了 ${exported} 却没用`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('口径参数只在共享模块里定义,调用点不许自己硬写', () => {
|
|
107
|
+
const { mustPass = [], mustNotPass = [] } = SHARED[0];
|
|
108
|
+
for (const { label, file } of ENTRY_POINTS) {
|
|
109
|
+
const args = callArgs(readFileSync(file, 'utf8'), 'prepareRequestMessages');
|
|
110
|
+
assert.ok(args !== null, `${label} 没有调用 prepareRequestMessages`);
|
|
111
|
+
for (const re of mustPass) assert.match(args, re, `${label} 的调用漏了 ${re}(旧图片占位符会退化成中文)`);
|
|
112
|
+
for (const re of mustNotPass) {
|
|
113
|
+
assert.doesNotMatch(
|
|
114
|
+
args,
|
|
115
|
+
re,
|
|
116
|
+
`${label} 自己指定了预算(${re}):预算口径必须留在 context.js,否则两侧又会慢慢漂开`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('压缩与传输逻辑在生产代码里各只有一处实现', () => {
|
|
123
|
+
// 这些是"再分叉一次"会留下的脚印:本地函数定义与本地上限常量。
|
|
124
|
+
// 只认定义(const/function),不认注释里的提及 —— 注释提到历史是正常的。
|
|
125
|
+
const PRIMITIVES = [
|
|
126
|
+
{ what: '本地 sanitizeMessages 定义', re: /function\s+sanitizeMessages\s*\(/ },
|
|
127
|
+
{ what: '本地 stripStaleImages 定义', re: /function\s+stripStaleImages\s*\(/ },
|
|
128
|
+
{ what: '本地历史裁剪函数', re: /function\s+trimHistory\s*\(/ },
|
|
129
|
+
{ what: '本地历史条数上限常量', re: /const\s+MAX_HISTORY_MESSAGES\s*=/ },
|
|
130
|
+
{ what: '本地请求副本构造函数', re: /function\s+buildRequestMessages\s*\(/ },
|
|
131
|
+
{ what: '本地 LLM 传输函数', re: /function\s+streamChatOnce\s*\(/ },
|
|
132
|
+
{ what: '本地 LLM 超时常量', re: /const\s+LLM_TIMEOUT_MS\s*=/ },
|
|
133
|
+
];
|
|
134
|
+
const owners = new Set([CONTEXT_FILE, TRANSPORT_FILE]);
|
|
135
|
+
const offenders = [];
|
|
136
|
+
for (const file of walk(SRC_ROOT)) {
|
|
137
|
+
if (owners.has(file) || /\.test\./.test(file)) continue;
|
|
138
|
+
const src = readFileSync(file, 'utf8');
|
|
139
|
+
for (const { what, re } of PRIMITIVES) {
|
|
140
|
+
if (re.test(src)) offenders.push(`${what} → ${path.relative(SRC_ROOT, file)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
offenders,
|
|
145
|
+
[],
|
|
146
|
+
'检测到第二份压缩/传输实现。CLI 与 Web 必须共用 src/cli/ai/ 下的共享模块,不要各写一遍 —— ' +
|
|
147
|
+
'这条链路的失效方式是"不报错但两个入口表现不一样",很难被发现。\n' +
|
|
148
|
+
offenders.join('\n'),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('共享模块本身必须导出这些口径', () => {
|
|
153
|
+
const expectations = [
|
|
154
|
+
[CONTEXT_FILE, ['prepareRequestMessages', 'sanitizeMessages', 'stripStaleImages', 'buildRequestMessages']],
|
|
155
|
+
[TRANSPORT_FILE, ['streamChatOnce', 'httpFailure']],
|
|
156
|
+
];
|
|
157
|
+
for (const [file, names] of expectations) {
|
|
158
|
+
const src = readFileSync(file, 'utf8');
|
|
159
|
+
for (const name of names) {
|
|
160
|
+
assert.match(
|
|
161
|
+
src,
|
|
162
|
+
new RegExp(`(export function|function) ${name}\\s*\\(`),
|
|
163
|
+
`${path.basename(file)} 不再定义 ${name}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
@@ -25,6 +25,8 @@ import path from 'node:path';
|
|
|
25
25
|
import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
|
|
26
26
|
import { agentSessionStore } from './agentSessionStore.js';
|
|
27
27
|
import { runAgentTurn } from './agentChat.js';
|
|
28
|
+
import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
|
|
29
|
+
import { createProjectListProvider } from './projectTool.js';
|
|
28
30
|
import { nowIso } from './shared.js';
|
|
29
31
|
|
|
30
32
|
const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
|
|
@@ -45,6 +47,56 @@ function sessionBelongsTo(sessionCwd, projectCwd) {
|
|
|
45
47
|
return Boolean(project && s === project);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// An ask_user call keeps its chat SSE request open until this map receives the
|
|
51
|
+
// matching response. The entry is removed on every completion or disconnect.
|
|
52
|
+
const pendingAgentQuestions = new Map();
|
|
53
|
+
|
|
54
|
+
function interactionKey(sessionId, interactionId) {
|
|
55
|
+
return `${String(sessionId)}:${String(interactionId)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, send, signal }) {
|
|
59
|
+
const key = interactionKey(sessionId, interactionId);
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
pendingAgentQuestions.delete(key);
|
|
64
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
65
|
+
};
|
|
66
|
+
const settle = (answer) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
cleanup();
|
|
70
|
+
resolve(answer);
|
|
71
|
+
};
|
|
72
|
+
const onAbort = () => settle('Cancelled by user; the question was not answered.');
|
|
73
|
+
|
|
74
|
+
pendingAgentQuestions.set(key, {
|
|
75
|
+
question,
|
|
76
|
+
options,
|
|
77
|
+
allowFreeText,
|
|
78
|
+
settle,
|
|
79
|
+
});
|
|
80
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
81
|
+
send({ type: 'ask_user', interactionId, question, options, allowFreeText });
|
|
82
|
+
if (signal?.aborted) onAbort();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function submitAgentAnswer({ sessionId, interactionId, answer }) {
|
|
87
|
+
const key = interactionKey(sessionId, interactionId);
|
|
88
|
+
const pending = pendingAgentQuestions.get(key);
|
|
89
|
+
if (!pending) return { ok: false, status: 404, error: 'No pending question for this session.' };
|
|
90
|
+
|
|
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.' };
|
|
95
|
+
}
|
|
96
|
+
pending.settle(value);
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
48
100
|
/**
|
|
49
101
|
* 注册智能体路由。
|
|
50
102
|
* @param {Object} deps
|
|
@@ -54,6 +106,13 @@ function sessionBelongsTo(sessionCwd, projectCwd) {
|
|
|
54
106
|
*/
|
|
55
107
|
export function registerAgentRoutes({ app, getCurrentProjectPath, configManager }) {
|
|
56
108
|
|
|
109
|
+
registerAgentMarketplaceRoutes({ app, getCurrentProjectPath });
|
|
110
|
+
|
|
111
|
+
// list_projects 的数据源。在这里建一次、复用:它要读最近目录(配置)、tasks.json
|
|
112
|
+
// 与看板统计,这些依赖只有本层拿得到 —— 与 taskRunner 的 setEnvContextProvider 同理。
|
|
113
|
+
// 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
|
|
114
|
+
const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
|
|
115
|
+
|
|
57
116
|
// ════════════════════════════════════════════════════════════════════════
|
|
58
117
|
// §1. 会话列表
|
|
59
118
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -93,6 +152,22 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
93
152
|
res.json({ success: true, session });
|
|
94
153
|
}));
|
|
95
154
|
|
|
155
|
+
// Submit the answer for an ask_user tool call while its SSE request is paused.
|
|
156
|
+
app.post('/api/agent/respond', async (req, res) => {
|
|
157
|
+
const sessionId = String(req.body?.sessionId || '').trim();
|
|
158
|
+
const interactionId = String(req.body?.interactionId || '').trim();
|
|
159
|
+
if (!sessionId || !interactionId) {
|
|
160
|
+
return res.status(400).json({ success: false, error: 'Missing sessionId or interactionId.' });
|
|
161
|
+
}
|
|
162
|
+
const result = submitAgentAnswer({
|
|
163
|
+
sessionId,
|
|
164
|
+
interactionId,
|
|
165
|
+
answer: req.body?.answer,
|
|
166
|
+
});
|
|
167
|
+
if (!result.ok) return res.status(result.status).json({ success: false, error: result.error });
|
|
168
|
+
return res.json({ success: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
96
171
|
// ════════════════════════════════════════════════════════════════════════
|
|
97
172
|
// §5. SSE 流式聊天(含工具调用循环)
|
|
98
173
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -225,7 +300,17 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
225
300
|
locale,
|
|
226
301
|
signal: abortController.signal,
|
|
227
302
|
send,
|
|
228
|
-
onChild: (child) => { activeChild = child; }
|
|
303
|
+
onChild: (child) => { activeChild = child; },
|
|
304
|
+
askUser: (args, meta = {}) => waitForAgentAnswer({
|
|
305
|
+
sessionId: session.sessionId,
|
|
306
|
+
interactionId: meta.interactionId,
|
|
307
|
+
question: args.question,
|
|
308
|
+
options: Array.isArray(args.options) ? args.options : [],
|
|
309
|
+
allowFreeText: args.allowFreeText !== false,
|
|
310
|
+
send,
|
|
311
|
+
signal: abortController.signal,
|
|
312
|
+
}),
|
|
313
|
+
listProjects
|
|
229
314
|
});
|
|
230
315
|
|
|
231
316
|
if (aborted) {
|
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { registerAgentRoutes } from './agentRoutes.js';
|
|
4
|
+
import { registerAgentRoutes, waitForAgentAnswer } from './agentRoutes.js';
|
|
5
|
+
|
|
6
|
+
test('agent respond resolves a pending ask_user question and validates answers', async () => {
|
|
7
|
+
let respondHandler;
|
|
8
|
+
const app = {
|
|
9
|
+
get() {},
|
|
10
|
+
delete() {},
|
|
11
|
+
put() {},
|
|
12
|
+
post(route, handler) {
|
|
13
|
+
if (route === '/api/agent/respond') respondHandler = handler;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
registerAgentRoutes({
|
|
17
|
+
app,
|
|
18
|
+
getCurrentProjectPath: () => path.resolve('active-project'),
|
|
19
|
+
configManager: null
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const sent = [];
|
|
23
|
+
const pending = waitForAgentAnswer({
|
|
24
|
+
sessionId: 'session-test',
|
|
25
|
+
interactionId: 'call-test',
|
|
26
|
+
question: 'Pick one',
|
|
27
|
+
options: ['A', 'B'],
|
|
28
|
+
allowFreeText: false,
|
|
29
|
+
send: event => sent.push(event),
|
|
30
|
+
});
|
|
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 }]);
|
|
33
|
+
|
|
34
|
+
let statusCode = 200;
|
|
35
|
+
let responseBody;
|
|
36
|
+
const response = {
|
|
37
|
+
status(code) { statusCode = code; return this; },
|
|
38
|
+
json(body) { responseBody = body; return body; },
|
|
39
|
+
};
|
|
40
|
+
await respondHandler({ body: { sessionId: 'session-test', interactionId: 'call-test', answer: 'C' } }, response);
|
|
41
|
+
assert.equal(statusCode, 400);
|
|
42
|
+
assert.equal(responseBody.success, false);
|
|
43
|
+
|
|
44
|
+
statusCode = 200;
|
|
45
|
+
await respondHandler({ body: { sessionId: 'session-test', interactionId: 'call-test', answer: 'B' } }, response);
|
|
46
|
+
assert.equal(statusCode, 200);
|
|
47
|
+
assert.deepEqual(responseBody, { success: true });
|
|
48
|
+
assert.equal(await pending, 'B');
|
|
49
|
+
|
|
50
|
+
await respondHandler({ body: { sessionId: 'session-test', interactionId: 'call-test', answer: 'B' } }, response);
|
|
51
|
+
assert.equal(statusCode, 404);
|
|
52
|
+
assert.equal(responseBody.success, false);
|
|
53
|
+
});
|
|
5
54
|
|
|
6
55
|
test('agent chat rejects a cwd that differs from the active server project', async () => {
|
|
7
56
|
let chatHandler;
|