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,117 @@
|
|
|
1
|
+
// context.js 是 CLI(`g ai`)与 Web 智能体面板共用的**唯一**一份上下文准备实现。
|
|
2
|
+
// 这里钉住它对外承诺的口径:
|
|
3
|
+
// 1. 请求副本有界(条数 + 字符双预算),但会话记录本身永不被改写
|
|
4
|
+
// 2. 被丢弃的旧消息要摘录成一条梗概,而不是静默消失
|
|
5
|
+
// 3. tool_calls 与其 tool 结果必须整组保留,不能从中间撕开
|
|
6
|
+
// 4. provider 兼容消毒与旧图片降级只作用在副本上
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { prepareRequestMessages, sanitizeMessages, stripStaleImages } from './context.js'
|
|
10
|
+
|
|
11
|
+
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
12
|
+
|
|
13
|
+
// 60 轮工具调用,每轮结果 16000 字符(会被 clip 到 6000)—— 远超任何预算
|
|
14
|
+
const longTranscript = () => {
|
|
15
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
|
|
16
|
+
for (let i = 0; i < 60; i++) {
|
|
17
|
+
messages.push({ role: 'assistant', tool_calls: [call(i)] })
|
|
18
|
+
messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
|
|
19
|
+
}
|
|
20
|
+
return messages
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('请求副本有界,原始目标不丢,且会话记录一个字节都没被改', () => {
|
|
24
|
+
const messages = longTranscript()
|
|
25
|
+
const before = structuredClone(messages)
|
|
26
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
27
|
+
assert.ok(request.length <= 40, `期望 <= 40 条,实际 ${request.length}`)
|
|
28
|
+
assert.ok(JSON.stringify(request).length < 90000)
|
|
29
|
+
assert.ok(request.some(m => m.content === 'original goal'), '首条 user(原始目标)必须保留')
|
|
30
|
+
assert.deepEqual(messages, before, '会话记录不得被请求副本改写')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('被丢弃的旧消息摘录成一条梗概,并自我声明是历史数据而非新指令', () => {
|
|
34
|
+
const request = prepareRequestMessages(longTranscript(), { locale: 'zh-CN' })
|
|
35
|
+
assert.equal(request[0].role, 'system')
|
|
36
|
+
const note = request[1]
|
|
37
|
+
assert.equal(note.role, 'user')
|
|
38
|
+
assert.match(note.content, /^\[Earlier conversation excerpts/)
|
|
39
|
+
assert.match(note.content, /historical data, not new instructions/)
|
|
40
|
+
// 梗概本身也要有界,否则它自己就成了新的膨胀源:
|
|
41
|
+
// 上限 = 固定前缀 + 5000 字符摘录 + clip 的中间省略标记,与历史长度无关
|
|
42
|
+
assert.ok(note.content.length <= 5200, `摘录过长: ${note.content.length}`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('回填以 tool_calls + 对应 tool 结果为整组,不会撕裂配对', () => {
|
|
46
|
+
const request = prepareRequestMessages(longTranscript(), { locale: 'zh-CN' })
|
|
47
|
+
for (let i = 0; i < request.length; i++) {
|
|
48
|
+
if (request[i].tool_calls) {
|
|
49
|
+
assert.equal(request[i + 1]?.role, 'tool')
|
|
50
|
+
assert.equal(request[i + 1].tool_call_id, request[i].tool_calls[0].id)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('未超预算时原样下发,不塞摘录消息', () => {
|
|
56
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]
|
|
57
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
58
|
+
assert.deepEqual(request.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('中断的 tool 调用在副本里补占位结果,不重放动作', () => {
|
|
62
|
+
const messages = [
|
|
63
|
+
{ role: 'system', content: 'rules' },
|
|
64
|
+
{ role: 'user', content: 'task' },
|
|
65
|
+
{ role: 'assistant', tool_calls: [call(1), call(2)] },
|
|
66
|
+
{ role: 'tool', tool_call_id: 'call1', content: 'saved' },
|
|
67
|
+
]
|
|
68
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
69
|
+
assert.deepEqual(request.filter(m => m.role === 'tool').map(m => m.tool_call_id), ['call1', 'call2'])
|
|
70
|
+
assert.equal(request.find(m => m.tool_call_id === 'call1').content, 'saved')
|
|
71
|
+
assert.match(request.at(-1).content, /status is unknown/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('消毒:空 assistant 转 null、空 tool 兜底、空 user 用空格占位', () => {
|
|
75
|
+
const messages = [
|
|
76
|
+
{ role: 'user', content: ' ' },
|
|
77
|
+
{ role: 'assistant', content: '', tool_calls: [call(1)] },
|
|
78
|
+
{ role: 'tool', tool_call_id: 'call1', content: ' \n\t ' },
|
|
79
|
+
{ role: 'assistant', content: '有正文但带 tool_calls', tool_calls: [call(2)] },
|
|
80
|
+
{ role: 'tool', tool_call_id: 'call2', content: 'ok' },
|
|
81
|
+
]
|
|
82
|
+
sanitizeMessages(messages)
|
|
83
|
+
assert.equal(messages[0].content, ' ')
|
|
84
|
+
assert.equal(messages[1].content, null)
|
|
85
|
+
assert.equal(messages[2].content, '(no output)')
|
|
86
|
+
assert.equal(messages[3].content, null, 'tool_calls 存在时正文必须置 null,否则某些 provider 报 2013')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('旧图片降级为占位文字,占位符跟随 locale', () => {
|
|
90
|
+
const text = t => ({ type: 'text', text: t })
|
|
91
|
+
const img = u => ({ type: 'image_url', image_url: { url: u } })
|
|
92
|
+
const build = () => [
|
|
93
|
+
{ role: 'user', content: [text('first'), img('old')] },
|
|
94
|
+
{ role: 'assistant', content: 'ok' },
|
|
95
|
+
{ role: 'user', content: [text('second'), img('new')] },
|
|
96
|
+
]
|
|
97
|
+
const zh = build()
|
|
98
|
+
stripStaleImages(zh, 'zh-CN')
|
|
99
|
+
assert.equal(zh[0].content[1].text, '[图片已从历史中省略]')
|
|
100
|
+
assert.equal(zh[2].content[1].type, 'image_url', '最近一条带图消息必须保留图片')
|
|
101
|
+
|
|
102
|
+
const en = build()
|
|
103
|
+
stripStaleImages(en, 'en-US')
|
|
104
|
+
assert.equal(en[0].content[1].text, '[image omitted from history]')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('图片降级只发生在请求副本上,会话记录里的图还在', () => {
|
|
108
|
+
const messages = [
|
|
109
|
+
{ role: 'system', content: 'rules' },
|
|
110
|
+
{ role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'image_url', image_url: { url: 'old' } }] },
|
|
111
|
+
{ role: 'assistant', content: 'ok' },
|
|
112
|
+
{ role: 'user', content: [{ type: 'text', text: 'b' }, { type: 'image_url', image_url: { url: 'new' } }] },
|
|
113
|
+
]
|
|
114
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
115
|
+
assert.equal(request[1].content[1].type, 'text')
|
|
116
|
+
assert.equal(messages[1].content[1].type, 'image_url')
|
|
117
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
// g ai 的「扩展」聚合层 —— 把 Skill 与 MCP 两类外部能力收敛成一个对象,
|
|
16
|
+
// 交给 agent 循环统一消费。存在的意义是**把生命周期收在一处**:
|
|
17
|
+
// - 加载顺序固定(skill 是纯文本,立刻可用;MCP 要起子进程,可能慢/可能失败)
|
|
18
|
+
// - 失败一律降级,绝不让扩展问题变成 "g ai 起不来"
|
|
19
|
+
// - /cwd 切换工作目录时要能重新解析项目级扩展,同时关掉旧目录起的子进程
|
|
20
|
+
//
|
|
21
|
+
// 与 tools.js 的关系:内置 7 个工具是**底座**,永远存在。
|
|
22
|
+
// 这里的 MCP 工具是**叠加**,名字以 mcp__ 开头,由 agent 循环按名字分流;
|
|
23
|
+
// 名字不认识时返回 null,让调用方回落内置工具 —— 所以扩展全挂也不影响原有能力。
|
|
24
|
+
|
|
25
|
+
import { loadSkills, buildSkillsPrompt } from './skills.js';
|
|
26
|
+
import { McpManager, loadMcpServers } from './mcp.js';
|
|
27
|
+
|
|
28
|
+
export class AgentExtensions {
|
|
29
|
+
constructor({ skills = [], errors = [], mcp = null, cwd = '' } = {}) {
|
|
30
|
+
this.skills = skills;
|
|
31
|
+
this.errors = errors;
|
|
32
|
+
this.mcp = mcp;
|
|
33
|
+
this.cwd = cwd;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 加载当前目录下的全部扩展。
|
|
38
|
+
*
|
|
39
|
+
* @param {{ cwd?: string, locale?: string, onWarn?: (message: string) => void,
|
|
40
|
+
* onNotice?: (message: string) => void }} [options]
|
|
41
|
+
* @returns {Promise<AgentExtensions>}
|
|
42
|
+
*/
|
|
43
|
+
static async load({ cwd, locale, onWarn, onNotice } = {}) {
|
|
44
|
+
const { skills, errors } = await loadSkills({ cwd }).catch(err => ({
|
|
45
|
+
skills: [],
|
|
46
|
+
errors: [{ root: String(cwd || ''), message: err.message }],
|
|
47
|
+
}));
|
|
48
|
+
if (errors.length && onWarn) {
|
|
49
|
+
for (const error of errors) onWarn(`Skill 目录读取失败(${error.root}): ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let mcp = null;
|
|
53
|
+
try {
|
|
54
|
+
const { servers, sources } = await loadMcpServers({ cwd });
|
|
55
|
+
const enabledCount = Object.values(servers).filter(cfg => !cfg.disabled).length;
|
|
56
|
+
// MCP 建连是启动路径上的同步阻塞点(npx 首次还要下载包,实测可到 20s+),
|
|
57
|
+
// 有事可做时先说一声,免得用户以为 g ai 卡死了。
|
|
58
|
+
if (enabledCount > 0) onNotice?.(enabledCount);
|
|
59
|
+
mcp = await McpManager.create({ cwd, servers, sources, onWarn });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
onWarn?.(`MCP 初始化失败(已跳过): ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 没有任何 MCP 工具且没有连接失败信息时,把空管理器丢掉,少一层判断
|
|
65
|
+
if (mcp && mcp.connections.length === 0) mcp = null;
|
|
66
|
+
return new AgentExtensions({ skills, errors, mcp, cwd });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 追加到系统提示词末尾的内容(无扩展时是空串) */
|
|
70
|
+
promptSuffix(locale) {
|
|
71
|
+
const skillBlock = buildSkillsPrompt(this.skills, { locale });
|
|
72
|
+
const mcpBlock = this.mcp ? this.mcp.describe({ locale }) : '';
|
|
73
|
+
return `${skillBlock}${mcpBlock}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 叠加到内置工具表之后的 OpenAI function calling 定义 */
|
|
77
|
+
get tools() {
|
|
78
|
+
return this.mcp ? this.mcp.toolDefinitions : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 是否是本层接管的名字。agent 循环据此决定走扩展还是内置工具。 */
|
|
82
|
+
owns(name) {
|
|
83
|
+
return !!this.mcp?.has(name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 执行一个扩展工具。名字不属于扩展时返回 null(调用方回落内置工具)。
|
|
88
|
+
* @returns {Promise<string|null>}
|
|
89
|
+
*/
|
|
90
|
+
async execute(name, args) {
|
|
91
|
+
if (!this.mcp?.has(name)) return null;
|
|
92
|
+
return await this.mcp.call(name, args);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 切换工作目录:重读项目级 skill 与 .mcp.json,先关掉旧目录起的子进程。
|
|
97
|
+
* 全局级配置不受影响,但一起重载更简单也更好推理。
|
|
98
|
+
*
|
|
99
|
+
* @param {{ cwd: string, locale?: string, onWarn?: (message: string) => void,
|
|
100
|
+
* onNotice?: (count: number) => void }} options
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
async reload({ cwd, locale, onWarn, onNotice } = {}) {
|
|
104
|
+
await this.mcp?.close().catch(() => {});
|
|
105
|
+
const next = await AgentExtensions.load({ cwd, locale, onWarn, onNotice });
|
|
106
|
+
this.skills = next.skills;
|
|
107
|
+
this.errors = next.errors;
|
|
108
|
+
this.mcp = next.mcp;
|
|
109
|
+
this.cwd = next.cwd;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 诊断摘要(/skills 与 /mcp 命令用) */
|
|
113
|
+
summary(locale) {
|
|
114
|
+
const zh = !String(locale || '').startsWith('en');
|
|
115
|
+
return {
|
|
116
|
+
skills: this.skills.map(skill => ({
|
|
117
|
+
name: skill.name,
|
|
118
|
+
scope: skill.scope,
|
|
119
|
+
description: skill.description,
|
|
120
|
+
})),
|
|
121
|
+
servers: this.mcp ? this.mcp.status() : [],
|
|
122
|
+
zh,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async close() {
|
|
127
|
+
await this.mcp?.close().catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default { AgentExtensions };
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
// · transport 有没有把 MCP 工具塞进请求体
|
|
17
|
+
// · turn 有没有按名字把 mcp__ 调用分给扩展,而不是丢给内置工具表(结果是"未知工具")
|
|
18
|
+
// · 没装扩展时行为必须与改动前完全一致(不能因为引入扩展而改变默认请求)
|
|
19
|
+
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { streamChatOnce } from './transport.js';
|
|
23
|
+
import { runAgentTurn } from './turn.js';
|
|
24
|
+
import { AgentExtensions } from './extensions.js';
|
|
25
|
+
import { TOOL_DEFINITIONS } from './tools.js';
|
|
26
|
+
|
|
27
|
+
const model = { model: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' };
|
|
28
|
+
const event = data => `data: ${JSON.stringify(data)}\n\n`;
|
|
29
|
+
const response = chunks => new Response(new ReadableStream({ start(controller) {
|
|
30
|
+
const bytes = new TextEncoder();
|
|
31
|
+
chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)));
|
|
32
|
+
controller.close();
|
|
33
|
+
} }));
|
|
34
|
+
const noop = () => {};
|
|
35
|
+
const quiet = {
|
|
36
|
+
startSpinner: () => ({ stop: noop }),
|
|
37
|
+
createAssistantWriter: () => ({ writeContent: noop, writeThinking: noop, finish: noop }),
|
|
38
|
+
printToolHeader: noop, printToolResult: noop, printWarn: noop, printError: noop, printTurnSummary: noop,
|
|
39
|
+
};
|
|
40
|
+
const strings = new Proxy({}, { get: () => (typeof value === 'string' ? value : String), has: () => true });
|
|
41
|
+
const newState = () => ({ messages: [{ role: 'system', content: 'rules' }], ctx: { cwd: '.' }, abortController: new AbortController(), maxToolIterations: 4 });
|
|
42
|
+
const toolCall = (id, name) => ({ id, type: 'function', function: { name, arguments: '{}' } });
|
|
43
|
+
|
|
44
|
+
// 一个只认 mcp__ 前缀的假扩展,用来观察分流。
|
|
45
|
+
// 刻意用鸭子类型而不是继承 AgentExtensions —— 这样顺带钉住
|
|
46
|
+
// "turn.js 只依赖 { owns, execute, tools } 这套接口",换实现不会被测试绑死。
|
|
47
|
+
function fakeExtensions({ tools = [], calls = [] } = {}) {
|
|
48
|
+
return {
|
|
49
|
+
tools,
|
|
50
|
+
owns: name => name.startsWith('mcp__'),
|
|
51
|
+
execute: async (name, args) => { calls.push({ name, args }); return `mcp-result:${name}`; },
|
|
52
|
+
promptSuffix: () => '\n\n# 已接入的 MCP 服务\n- fake: 1 个工具',
|
|
53
|
+
close: async () => {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('transport: 没有扩展工具时请求体与改动前完全一致(不含空 tools 数组)', async () => {
|
|
58
|
+
let sent;
|
|
59
|
+
await streamChatOnce({ model, messages: [], fetchFn: async (_url, init) => {
|
|
60
|
+
sent = JSON.parse(init.body);
|
|
61
|
+
return response([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]']);
|
|
62
|
+
} });
|
|
63
|
+
assert.deepEqual(sent.tools, TOOL_DEFINITIONS);
|
|
64
|
+
|
|
65
|
+
// 显式传空数组也不能退化
|
|
66
|
+
await streamChatOnce({ model, messages: [], extraTools: [], fetchFn: async (_url, init) => {
|
|
67
|
+
sent = JSON.parse(init.body);
|
|
68
|
+
return response([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]']);
|
|
69
|
+
} });
|
|
70
|
+
assert.deepEqual(sent.tools, TOOL_DEFINITIONS);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('transport: 扩展工具叠加在内置工具之后,内置工具不被挤掉', async () => {
|
|
74
|
+
let sent;
|
|
75
|
+
const extra = [{ type: 'function', function: { name: 'mcp__fake__echo', description: 'x', parameters: { type: 'object' } } }];
|
|
76
|
+
await streamChatOnce({ model, messages: [], extraTools: extra, fetchFn: async (_url, init) => {
|
|
77
|
+
sent = JSON.parse(init.body);
|
|
78
|
+
return response([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]']);
|
|
79
|
+
} });
|
|
80
|
+
|
|
81
|
+
const names = sent.tools.map(tool => tool.function.name);
|
|
82
|
+
assert.deepEqual(names.slice(0, TOOL_DEFINITIONS.length), TOOL_DEFINITIONS.map(tool => tool.function.name));
|
|
83
|
+
assert.equal(names.at(-1), 'mcp__fake__echo');
|
|
84
|
+
assert.equal(names.length, TOOL_DEFINITIONS.length + 1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('turn: mcp__ 调用被分给扩展,普通工具名仍走内置执行器', async () => {
|
|
88
|
+
const calls = [];
|
|
89
|
+
const extensions = fakeExtensions({ calls });
|
|
90
|
+
const executed = [];
|
|
91
|
+
const s = { ...newState(), extensions };
|
|
92
|
+
let round = 0;
|
|
93
|
+
|
|
94
|
+
const stats = await runAgentTurn(s, 'task', strings, [], {
|
|
95
|
+
ui: quiet,
|
|
96
|
+
execute: async name => { executed.push(name); return 'builtin-result'; },
|
|
97
|
+
chat: async () => {
|
|
98
|
+
round++;
|
|
99
|
+
if (round === 1) return { content: '', toolCalls: [toolCall('a', 'mcp__fake__echo'), toolCall('b', 'read_file')] };
|
|
100
|
+
return { content: 'done', toolCalls: [] };
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(stats.status, 'completed');
|
|
105
|
+
assert.deepEqual(calls.map(item => item.name), ['mcp__fake__echo'], 'MCP 工具必须走扩展');
|
|
106
|
+
assert.deepEqual(executed, ['read_file'], '内置工具不能被扩展层截胡');
|
|
107
|
+
|
|
108
|
+
const toolMessages = s.messages.filter(message => message.role === 'tool').map(message => message.content);
|
|
109
|
+
assert.deepEqual(toolMessages, ['mcp-result:mcp__fake__echo', 'builtin-result']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('turn: 扩展未接管的名字会回落到内置执行器(而不是报未知工具)', async () => {
|
|
113
|
+
const extensions = new AgentExtensions({ skills: [] });
|
|
114
|
+
const s = { ...newState(), extensions };
|
|
115
|
+
const executed = [];
|
|
116
|
+
let round = 0;
|
|
117
|
+
|
|
118
|
+
await runAgentTurn(s, 'task', strings, [], {
|
|
119
|
+
ui: quiet,
|
|
120
|
+
execute: async name => { executed.push(name); return 'ok'; },
|
|
121
|
+
chat: async () => (++round === 1 ? { content: '', toolCalls: [toolCall('a', 'list_files')] } : { content: 'done', toolCalls: [] }),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(executed, ['list_files']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('turn: 传了 extensions 但没有工具时,chat 收到的 extraTools 是空数组', async () => {
|
|
128
|
+
const s = { ...newState(), extensions: new AgentExtensions({ skills: [] }) };
|
|
129
|
+
const seen = [];
|
|
130
|
+
let round = 0;
|
|
131
|
+
await runAgentTurn(s, 'task', strings, [], {
|
|
132
|
+
ui: quiet,
|
|
133
|
+
chat: async payload => {
|
|
134
|
+
seen.push(payload.extraTools);
|
|
135
|
+
return ++round === 1 ? { content: 'hi', toolCalls: [] } : { content: '', toolCalls: [] };
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
assert.deepEqual(seen, [[]]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('AgentExtensions: 没有任何扩展时 promptSuffix 为空串、tools 为空', () => {
|
|
142
|
+
const extensions = new AgentExtensions({ skills: [] });
|
|
143
|
+
assert.equal(extensions.promptSuffix('zh-CN'), '');
|
|
144
|
+
assert.deepEqual(extensions.tools, []);
|
|
145
|
+
assert.equal(extensions.owns('read_file'), false);
|
|
146
|
+
assert.equal(extensions.promptSuffix('en-US'), '');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('AgentExtensions: skill 与 MCP 的提示词片段拼接顺序稳定,并带 locale 区分', () => {
|
|
150
|
+
const mcp = { describe: ({ locale }) => (String(locale).startsWith('en') ? '\n\n# Connected MCP servers\n- x' : '\n\n# 已接入的 MCP 服务\n- x') };
|
|
151
|
+
const extensions = new AgentExtensions({
|
|
152
|
+
skills: [{ id: 'a', name: 'pdf', description: 'd', file: '/x/SKILL.md', scope: 'global' }],
|
|
153
|
+
mcp,
|
|
154
|
+
});
|
|
155
|
+
const zh = extensions.promptSuffix('zh-CN');
|
|
156
|
+
assert.ok(zh.indexOf('已安装的 Skill') < zh.indexOf('已接入的 MCP 服务'), 'skill 片段应在 MCP 片段之前');
|
|
157
|
+
assert.match(extensions.promptSuffix('en'), /Connected MCP servers/);
|
|
158
|
+
});
|