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,307 @@
|
|
|
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
|
+
// 工具调用(tool_use)采集的回归测试(2026-09-22)。
|
|
17
|
+
//
|
|
18
|
+
// 背景:两个事件处理器原本把 tool_use 整段丢掉,执行流里只剩「思考 + 正文」,
|
|
19
|
+
// 模型闷头跑的几分钟完全看不出它在干什么。现在归一成 job.toolCalls。
|
|
20
|
+
//
|
|
21
|
+
// 钉住的口径:
|
|
22
|
+
// 1. claude:assistant 的 tool_use 块建条目,user 事件里的 tool_result 回填结果
|
|
23
|
+
// 2. claude:user 事件里"用户自己那条 prompt"(content 是字符串)不能误判成工具结果
|
|
24
|
+
// 3. claude:is_error 的 tool_result → status error + error 文案
|
|
25
|
+
// 4. opencode:同一个 part 的 running → completed 只更新一条,不重复建
|
|
26
|
+
// 5. 参数 / 结果都截断(jobs.json 不能被一次长跑撑爆)
|
|
27
|
+
// 6. 超上限后丢弃新调用但保留已有条目
|
|
28
|
+
// 7. flush 只在有增量时发事件;seal 把没收尾的调用收口成终态
|
|
29
|
+
//
|
|
30
|
+
// 隔离策略同 taskRunner.opencode.test.js:import taskRunner.js 之前把
|
|
31
|
+
// USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
|
|
32
|
+
|
|
33
|
+
import { test } from 'node:test';
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import { mkdtempSync } from 'node:fs';
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const projectRoot = path.resolve(__dirname, '../../../../..');
|
|
42
|
+
|
|
43
|
+
// ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
|
|
44
|
+
const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-toolcalls-test-'));
|
|
45
|
+
process.env.USERPROFILE = sandbox;
|
|
46
|
+
process.env.HOME = sandbox;
|
|
47
|
+
delete process.env.HOMEDRIVE;
|
|
48
|
+
delete process.env.HOMEPATH;
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
createClaudeEventHandler,
|
|
52
|
+
createOpencodeEventHandler,
|
|
53
|
+
createToolCallTracker,
|
|
54
|
+
MAX_TOOL_CALLS,
|
|
55
|
+
} = await import(
|
|
56
|
+
pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function makeSink() {
|
|
60
|
+
const sink = { output: [], thinking: [] };
|
|
61
|
+
sink.appendOutput = (t) => { if (t) sink.output.push(t); };
|
|
62
|
+
sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
|
|
63
|
+
return sink;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 建一个带推送记录的 tracker(onUpdates 收集每一批) */
|
|
67
|
+
function makeTracker(job) {
|
|
68
|
+
const batches = [];
|
|
69
|
+
const tracker = createToolCallTracker(job, (updates) => batches.push(updates));
|
|
70
|
+
return { tracker, batches };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('claude:tool_use 建条目,user 事件里的 tool_result 回填结果', () => {
|
|
74
|
+
const job = {};
|
|
75
|
+
const sink = makeSink();
|
|
76
|
+
const handle = createClaudeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
77
|
+
|
|
78
|
+
handle({
|
|
79
|
+
type: 'assistant',
|
|
80
|
+
message: {
|
|
81
|
+
content: [
|
|
82
|
+
{ type: 'thinking', thinking: '先看看文件' },
|
|
83
|
+
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'npm test', description: '跑测试' } },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
assert.equal(job.toolCalls.length, 1);
|
|
88
|
+
assert.equal(job.toolCalls[0].id, 'toolu_1');
|
|
89
|
+
assert.equal(job.toolCalls[0].name, 'Bash');
|
|
90
|
+
assert.equal(job.toolCalls[0].status, 'running');
|
|
91
|
+
// 摘要优先取 command(不是退化成整段 JSON)
|
|
92
|
+
assert.equal(job.toolCalls[0].argsPreview, 'npm test');
|
|
93
|
+
assert.equal(job.toolCalls[0].arguments, '{"command":"npm test","description":"跑测试"}');
|
|
94
|
+
// 思考照旧落 thinking,不受影响
|
|
95
|
+
assert.deepEqual(sink.thinking, ['先看看文件']);
|
|
96
|
+
|
|
97
|
+
handle({
|
|
98
|
+
type: 'user',
|
|
99
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'all tests pass' }] },
|
|
100
|
+
});
|
|
101
|
+
assert.equal(job.toolCalls.length, 1);
|
|
102
|
+
assert.equal(job.toolCalls[0].status, 'done');
|
|
103
|
+
assert.equal(job.toolCalls[0].result, 'all tests pass');
|
|
104
|
+
// 工具结果不能被当成"模型说的话"塞进正文
|
|
105
|
+
assert.deepEqual(sink.output, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('claude:user 事件里的普通 prompt(content 是字符串)不产生工具调用', () => {
|
|
109
|
+
const job = {};
|
|
110
|
+
const sink = makeSink();
|
|
111
|
+
const handle = createClaudeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
112
|
+
|
|
113
|
+
handle({ type: 'user', message: { role: 'user', content: '帮我改个 bug' } });
|
|
114
|
+
handle({ type: 'user', message: { content: [{ type: 'text', text: '普通文本块' }] } });
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(job.toolCalls, []);
|
|
117
|
+
assert.deepEqual(sink.output, []);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('claude:tool_result 的 content 是块数组 / is_error 时分别抽取与标错', () => {
|
|
121
|
+
const job = {};
|
|
122
|
+
const sink = makeSink();
|
|
123
|
+
const handle = createClaudeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
124
|
+
|
|
125
|
+
handle({
|
|
126
|
+
type: 'assistant',
|
|
127
|
+
message: {
|
|
128
|
+
content: [
|
|
129
|
+
{ type: 'tool_use', id: 'ok_1', name: 'Read', input: { file_path: 'src/a.js' } },
|
|
130
|
+
{ type: 'tool_use', id: 'bad_1', name: 'Edit', input: { file_path: 'src/b.js' } },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
handle({
|
|
135
|
+
type: 'user',
|
|
136
|
+
message: {
|
|
137
|
+
content: [
|
|
138
|
+
{ type: 'tool_result', tool_use_id: 'ok_1', content: [{ type: 'text', text: '第一行' }, { type: 'text', text: '第二行' }] },
|
|
139
|
+
{ type: 'tool_result', tool_use_id: 'bad_1', is_error: true, content: 'File has not been read yet' },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.equal(job.toolCalls.length, 2);
|
|
145
|
+
assert.equal(job.toolCalls[0].status, 'done');
|
|
146
|
+
assert.equal(job.toolCalls[0].result, '第一行\n第二行');
|
|
147
|
+
assert.equal(job.toolCalls[0].argsPreview, 'src/a.js');
|
|
148
|
+
assert.equal(job.toolCalls[1].status, 'error');
|
|
149
|
+
assert.equal(job.toolCalls[1].error, 'File has not been read yet');
|
|
150
|
+
assert.equal(job.toolCalls[1].result, 'File has not been read yet');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('opencode:同一个 part 的 running → completed 只更新一条', () => {
|
|
154
|
+
const job = {};
|
|
155
|
+
const sink = makeSink();
|
|
156
|
+
const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
157
|
+
|
|
158
|
+
const part = (state) => ({
|
|
159
|
+
type: 'tool_use',
|
|
160
|
+
sessionID: 'ses_1',
|
|
161
|
+
part: { id: 'prt_1', callID: 'call_1', type: 'tool', tool: 'bash', state },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
handle(part({ status: 'running', input: { command: 'ls -la' } }));
|
|
165
|
+
assert.equal(job.toolCalls.length, 1);
|
|
166
|
+
assert.equal(job.toolCalls[0].id, 'call_1');
|
|
167
|
+
assert.equal(job.toolCalls[0].name, 'bash');
|
|
168
|
+
assert.equal(job.toolCalls[0].status, 'running');
|
|
169
|
+
assert.equal(job.toolCalls[0].argsPreview, 'ls -la');
|
|
170
|
+
|
|
171
|
+
handle(part({ status: 'completed', input: { command: 'ls -la' }, output: 'a.js\nb.js' }));
|
|
172
|
+
assert.equal(job.toolCalls.length, 1, '同一 part 不能重复建条目');
|
|
173
|
+
assert.equal(job.toolCalls[0].status, 'done');
|
|
174
|
+
assert.equal(job.toolCalls[0].result, 'a.js\nb.js');
|
|
175
|
+
|
|
176
|
+
// completed → error(opencode 的错误形态:state.error 是对象)
|
|
177
|
+
handle(part({ status: 'error', error: { message: '命令不存在' } }));
|
|
178
|
+
assert.equal(job.toolCalls.length, 1);
|
|
179
|
+
assert.equal(job.toolCalls[0].status, 'error');
|
|
180
|
+
assert.equal(job.toolCalls[0].error, '命令不存在');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('opencode:part.type 不是 tool 时忽略(不制造空条目)', () => {
|
|
184
|
+
const job = {};
|
|
185
|
+
const sink = makeSink();
|
|
186
|
+
const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
187
|
+
|
|
188
|
+
handle({ type: 'tool_use', sessionID: 'ses_2', part: { id: 'p1', type: 'text', text: 'hi' } });
|
|
189
|
+
assert.deepEqual(job.toolCalls, []);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('参数与结果都按上限截断', () => {
|
|
193
|
+
const job = {};
|
|
194
|
+
const sink = makeSink();
|
|
195
|
+
const handle = createClaudeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
196
|
+
|
|
197
|
+
handle({
|
|
198
|
+
type: 'assistant',
|
|
199
|
+
message: { content: [{ type: 'tool_use', id: 'big', name: 'Bash', input: { command: 'x'.repeat(9000) } }] },
|
|
200
|
+
});
|
|
201
|
+
assert.ok(job.toolCalls[0].arguments.length < 2100, '参数应被截断');
|
|
202
|
+
assert.ok(job.toolCalls[0].arguments.includes('已截断'));
|
|
203
|
+
assert.ok(job.toolCalls[0].argsPreview.length <= 200, '摘要不超过 200 字');
|
|
204
|
+
|
|
205
|
+
handle({
|
|
206
|
+
type: 'user',
|
|
207
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 'big', content: 'y'.repeat(9000) }] },
|
|
208
|
+
});
|
|
209
|
+
assert.ok(job.toolCalls[0].result.length < 4100, '结果应被截断');
|
|
210
|
+
assert.ok(job.toolCalls[0].result.includes('已截断'));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('超过 MAX_TOOL_CALLS 后丢弃新调用,已有条目仍可更新', () => {
|
|
214
|
+
const job = {};
|
|
215
|
+
const sink = makeSink();
|
|
216
|
+
const handle = createClaudeEventHandler(job, sink.appendOutput, sink.appendThinking);
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < MAX_TOOL_CALLS + 5; i++) {
|
|
219
|
+
handle({
|
|
220
|
+
type: 'assistant',
|
|
221
|
+
message: { content: [{ type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: `cmd-${i}` } }] },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
assert.equal(job.toolCalls.length, MAX_TOOL_CALLS);
|
|
225
|
+
|
|
226
|
+
// 已被记下的那条还能收到结果(超限之后不能变成"半截")
|
|
227
|
+
handle({
|
|
228
|
+
type: 'user',
|
|
229
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 't0', content: 'ok' }] },
|
|
230
|
+
});
|
|
231
|
+
assert.equal(job.toolCalls[0].status, 'done');
|
|
232
|
+
assert.equal(job.toolCalls[0].result, 'ok');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('tracker:flush 只在有增量时推送,且推的是快照', () => {
|
|
236
|
+
const job = {};
|
|
237
|
+
const { tracker, batches } = makeTracker(job);
|
|
238
|
+
|
|
239
|
+
tracker.flush();
|
|
240
|
+
assert.deepEqual(batches, [], '没有增量时不该发空事件');
|
|
241
|
+
|
|
242
|
+
tracker.record({ id: 'a', name: 'Read', input: { file_path: 'a.js' }, status: 'running' });
|
|
243
|
+
assert.equal(batches.length, 0, 'record 只入队,不立即推送');
|
|
244
|
+
|
|
245
|
+
tracker.flush();
|
|
246
|
+
assert.equal(batches.length, 1);
|
|
247
|
+
assert.equal(batches[0].length, 1);
|
|
248
|
+
assert.deepEqual(batches[0][0], {
|
|
249
|
+
id: 'a',
|
|
250
|
+
name: 'Read',
|
|
251
|
+
argsPreview: 'a.js',
|
|
252
|
+
arguments: '{"file_path":"a.js"}',
|
|
253
|
+
result: '',
|
|
254
|
+
status: 'running',
|
|
255
|
+
error: undefined,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// 推出去的是快照:后续状态变更不会改掉已经 emit 的那一份
|
|
259
|
+
tracker.record({ id: 'a', name: 'Read', status: 'done', output: '文件内容' });
|
|
260
|
+
tracker.flush();
|
|
261
|
+
assert.equal(batches[0][0].status, 'running');
|
|
262
|
+
assert.equal(batches[1][0].status, 'done');
|
|
263
|
+
assert.equal(batches[1][0].result, '文件内容');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('tracker:seal 把没收尾的调用收口成终态', () => {
|
|
267
|
+
const done = {};
|
|
268
|
+
const t1 = makeTracker(done);
|
|
269
|
+
t1.tracker.record({ id: 'a', name: 'Read', status: 'running' });
|
|
270
|
+
t1.tracker.flush();
|
|
271
|
+
t1.tracker.seal('done');
|
|
272
|
+
assert.equal(done.toolCalls[0].status, 'done');
|
|
273
|
+
assert.equal(t1.batches.length, 2, 'seal 要把终态也推给前端');
|
|
274
|
+
|
|
275
|
+
const cancelled = {};
|
|
276
|
+
const t2 = makeTracker(cancelled);
|
|
277
|
+
t2.tracker.record({ id: 'a', name: 'Read', status: 'running' });
|
|
278
|
+
t2.tracker.seal('cancelled');
|
|
279
|
+
assert.equal(cancelled.toolCalls[0].status, 'error');
|
|
280
|
+
assert.equal(cancelled.toolCalls[0].error, '未返回结果(执行已停止)');
|
|
281
|
+
|
|
282
|
+
const errored = {};
|
|
283
|
+
const t3 = makeTracker(errored);
|
|
284
|
+
t3.tracker.record({ id: 'a', name: 'Read', status: 'running' });
|
|
285
|
+
t3.tracker.seal('error');
|
|
286
|
+
assert.equal(errored.toolCalls[0].status, 'error');
|
|
287
|
+
assert.equal(errored.toolCalls[0].error, '未返回结果(执行出错)');
|
|
288
|
+
|
|
289
|
+
// 已经收尾的条目不被 seal 改写
|
|
290
|
+
const mixed = {};
|
|
291
|
+
const t4 = makeTracker(mixed);
|
|
292
|
+
t4.tracker.record({ id: 'a', name: 'Read', status: 'done', output: 'ok' });
|
|
293
|
+
t4.tracker.flush();
|
|
294
|
+
const before = t4.batches.length;
|
|
295
|
+
t4.tracker.seal('cancelled');
|
|
296
|
+
assert.equal(mixed.toolCalls[0].status, 'done');
|
|
297
|
+
assert.equal(t4.batches.length, before, '没有需要收口的条目时 seal 不该推空事件');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('tracker:没有 id 的事件按序号兜底,不会互相覆盖', () => {
|
|
301
|
+
const job = {};
|
|
302
|
+
const { tracker } = makeTracker(job);
|
|
303
|
+
tracker.record({ name: 'Unknown' });
|
|
304
|
+
tracker.record({ name: 'Unknown' });
|
|
305
|
+
assert.equal(job.toolCalls.length, 2);
|
|
306
|
+
assert.notEqual(job.toolCalls[0].id, job.toolCalls[1].id);
|
|
307
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
import logger from '../utils/logger.js'
|
|
16
16
|
import { ensureWithinCwd } from '../utils/pathGuard.js'
|
|
17
|
+
import { augmentEnvPath } from '../../../utils/shellPath.js'
|
|
17
18
|
|
|
18
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
20
|
// exec_interactive 修复(SEC-INJ-1)
|
|
@@ -73,16 +74,22 @@ function resolveBinAndArgs(command) {
|
|
|
73
74
|
return { bin: head, args: tokens.slice(1) };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
// 交互式子进程的 env。PATH 交给 augmentEnvPath 补一次注册表最新值 ——
|
|
78
|
+
// 服务端进程的环境块是**启动快照**,用户在这个进程活着期间装的 CLI 不在里面,
|
|
79
|
+
// 表现是"GUI 里能探测到、执行却报 '不是内部或外部命令'"(2026-09-22 实测 gh)。
|
|
80
|
+
// 因为要读注册表,这里必须是函数而不是模块级常量(常量会在 import 时定死)。
|
|
81
|
+
async function buildInteractiveEnv() {
|
|
82
|
+
return augmentEnvPath({
|
|
83
|
+
...process.env,
|
|
84
|
+
GIT_CONFIG_PARAMETERS: "'color.ui=always' 'color.status=always' 'core.quotepath=false'",
|
|
85
|
+
FORCE_COLOR: '3',
|
|
86
|
+
NPM_CONFIG_COLOR: 'always',
|
|
87
|
+
TERM: 'xterm-256color',
|
|
88
|
+
COLORTERM: 'truecolor',
|
|
89
|
+
CLICOLOR_FORCE: '1',
|
|
90
|
+
PYTHONUNBUFFERED: '1',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
86
93
|
|
|
87
94
|
function isWindowsBuiltin(command) {
|
|
88
95
|
if (process.platform !== 'win32') return false;
|
|
@@ -180,7 +187,7 @@ export function registerUiSocketHandlers({
|
|
|
180
187
|
|
|
181
188
|
const childProcess = spawn(bin, args, {
|
|
182
189
|
cwd: execDirectory,
|
|
183
|
-
env:
|
|
190
|
+
env: await buildInteractiveEnv(),
|
|
184
191
|
windowsHide: true,
|
|
185
192
|
});
|
|
186
193
|
|
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
// Windows 上「进程 PATH 快照过期」的统一修补层。
|
|
17
|
+
//
|
|
18
|
+
// 问题:安装器(winget / MSI / brew 等)改的是**注册表里的 PATH**,而已经跑起来的
|
|
19
|
+
// node 进程持有的是启动那一刻的环境块快照 —— 它不会自己更新。典型表现(2026-09-22 实测):
|
|
20
|
+
// 用户在 GUI 里一键装完 gh,探测侧能找到(因为探测会读注册表兜底),但真去执行
|
|
21
|
+
// `cmd /c gh auth login` 时照样报「'gh' 不是内部或外部命令」。
|
|
22
|
+
//
|
|
23
|
+
// 当时先打的补丁是「登录接口显式传 pathDirs」—— 那只救了一个入口。同样的病在
|
|
24
|
+
// run_command(智能体)、/api/exec-stream、socket 交互式执行、以及"另起一个终端窗口"
|
|
25
|
+
// 这些地方全都存在。所以统一收在这里:**把注册表 Machine + User 的 PATH 里
|
|
26
|
+
// 「当前进程没有的目录」补到子进程的 PATH 上**,语义等价于"这个进程是现在才启动的"。
|
|
27
|
+
//
|
|
28
|
+
// 为什么补在**末尾**而不是像 pathDirs 那样前置:
|
|
29
|
+
// PATH 的语义是「先命中先赢」。注册表 PATH 是当前进程 PATH 的**超集** ——
|
|
30
|
+
// 进程启动后新装的目录在注册表里有、进程里没有;反过来进程里多出来的是启动后
|
|
31
|
+
// 被卸载的目录。只把缺的补在末尾,原有目录的优先级顺序一个都没动,等于把快照
|
|
32
|
+
// 更新到最新;若前置,反而会改掉用户既有的解析顺序(让某个兜底目录抢在正主前面)。
|
|
33
|
+
// fileOpen.launchCommandInTerminal 的 pathDirs 前置是对的,因为那是另一种语义:
|
|
34
|
+
// "我已经知道要跑的就是这个绝对路径,把它优先"。
|
|
35
|
+
//
|
|
36
|
+
// 为什么每次都重新比对、而不是"补一次就完":
|
|
37
|
+
// 用户可能在同一个进程会话里接着装第二个 CLI,而修补只作用于子进程的 env。
|
|
38
|
+
// 这里刻意**不改动 process.env**,避免影响其它模块(进程级副作用最难排查)。
|
|
39
|
+
// 注册表读取有 TTL 缓存,正常路径零额外开销;tools.js 在"命令找不到"时会
|
|
40
|
+
// force 刷一次缓存重试,覆盖"刚装完、缓存还没过期"的那个窗口。
|
|
41
|
+
//
|
|
42
|
+
// 平台:仅 win32 生效。POSIX 侧的 PATH 由启动它的 shell 决定,没有"安装器改注册表"
|
|
43
|
+
// 这套机制,终端新装的 CLI 一般也落在已有 PATH 目录里 —— 所以那边原样返回。
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
import { execFile } from 'node:child_process';
|
|
47
|
+
import { promisify } from 'node:util';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
|
|
50
|
+
const execFileAsync = promisify(execFile);
|
|
51
|
+
|
|
52
|
+
/** 注册表里两个 PATH 的宿主。缺一个都会漏装到相应作用域的 CLI:
|
|
53
|
+
* winget / MSI 默认写 Machine(HKLM),npm -g / 用户级安装器写 User(HKCU)。 */
|
|
54
|
+
const REG_KEY_MACHINE = 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment';
|
|
55
|
+
const REG_KEY_USER = 'HKCU\\Environment';
|
|
56
|
+
|
|
57
|
+
/** PATH 键名在 Windows 上大小写不定(`Path` / `PATH` 都见过,取决于父进程怎么给的)。
|
|
58
|
+
* 回写时若按错大小写,环境块里会出现**两个** PATH 键,子进程取哪个是未定义的 ——
|
|
59
|
+
* 于是就有了"明明前置了却没生效"的补丁。所以一律按实际存在的那个键名回写。 */
|
|
60
|
+
const PATH_KEY_RE = /^path$/i;
|
|
61
|
+
|
|
62
|
+
/** 注册表目录列表的缓存时长。取值考虑:短到"装完 CLI 基本立刻可用",
|
|
63
|
+
* 长到"不会每条命令都去 spawn 一次 reg.exe"(那是同步阻塞,会卡事件循环)。 */
|
|
64
|
+
const DEFAULT_TTL_MS = 15000;
|
|
65
|
+
|
|
66
|
+
/** @type {{ at: number, dirs: string[] }} */
|
|
67
|
+
let cache = { at: 0, dirs: [] };
|
|
68
|
+
|
|
69
|
+
// ── 纯函数区(都可单测,不碰进程状态) ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** 找出 env 里 PATH 的实际键名;没有则返回 null。 */
|
|
72
|
+
export function findPathKey(env) {
|
|
73
|
+
return Object.keys(env).find((key) => PATH_KEY_RE.test(key)) || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 大小写安全地把 PATH 写回 env:只保留**一个** PATH 键(沿用原来那个键名)。
|
|
78
|
+
* 返回新对象,不改入参。
|
|
79
|
+
*/
|
|
80
|
+
export function withPathValue(env, value) {
|
|
81
|
+
const key = findPathKey(env) || 'PATH';
|
|
82
|
+
const next = {};
|
|
83
|
+
for (const k of Object.keys(env)) { if (!PATH_KEY_RE.test(k)) next[k] = env[k]; }
|
|
84
|
+
next[key] = value;
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 同步取出 env 里的 PATH 值。 */
|
|
89
|
+
export function pathValueOf(env) {
|
|
90
|
+
const key = findPathKey(env);
|
|
91
|
+
return key ? String(env[key] ?? '') : '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** PATH 字符串 → 目录数组(去空白、丢空段)。分隔符按当前平台,注册表值在
|
|
95
|
+
* win32 下就是 `;`。 */
|
|
96
|
+
export function splitPathList(value, { delimiter = path.delimiter } = {}) {
|
|
97
|
+
return String(value ?? '')
|
|
98
|
+
.split(delimiter)
|
|
99
|
+
.map((part) => part.trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 比较用的归一形式:忽略大小写与结尾分隔符(`C:\Tools\` 与 `c:\tools` 是同一个)。
|
|
104
|
+
* Windows 路径这两点都必须归一 —— 只按字符串比对会把同一个目录当成新目录,
|
|
105
|
+
* 于是每次修补都往 PATH 里再塞一遍。 */
|
|
106
|
+
function normalizeDir(dir) {
|
|
107
|
+
return String(dir ?? '').trim().replace(/[\\/]+$/, '').toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 找出 dirs 里 currentValue 还没有的目录,保持原顺序。
|
|
112
|
+
* dirs 内部也去重(Machine 与 User 的 PATH 常有重叠)。
|
|
113
|
+
*/
|
|
114
|
+
export function missingPathDirs(currentValue, dirs, options) {
|
|
115
|
+
const seen = new Set(splitPathList(currentValue, options).map(normalizeDir));
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const dir of dirs) {
|
|
118
|
+
const key = normalizeDir(dir);
|
|
119
|
+
if (!key || seen.has(key)) continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
out.push(String(dir).trim());
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 按环境块大小写不敏感地查一个变量(%SystemRoot% 与 %systemroot% 都该命中)。
|
|
127
|
+
* 展开不出来就原样保留 —— 宁可在子进程里报"路径不存在",也不要静默删掉一段 PATH。 */
|
|
128
|
+
function lookupVar(env, name) {
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(env, name)) return env[name];
|
|
130
|
+
const hit = Object.keys(env).find((k) => k.toLowerCase() === String(name).toLowerCase());
|
|
131
|
+
return hit ? env[hit] : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 注册表 Path 里的 %USERPROFILE% / %SystemRoot% 这类变量不展开就写进子进程会失效。 */
|
|
135
|
+
export function expandVars(raw, env = process.env) {
|
|
136
|
+
return String(raw ?? '').replace(/%([^%]+)%/g, (whole, name) => {
|
|
137
|
+
const value = lookupVar(env, name);
|
|
138
|
+
return value === undefined ? whole : String(value);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 解析 `reg query <key> /v Path` 的输出,取出 Path 的值;没有该值时返回 null。
|
|
144
|
+
*
|
|
145
|
+
* 输出形如(值名与类型之间是连续空白,`REG_EXPAND_SZ` 与 `REG_SZ` 都可能):
|
|
146
|
+
* Path REG_EXPAND_SZ %SystemRoot%\system32;%SystemRoot%;...
|
|
147
|
+
*/
|
|
148
|
+
export function parseRegQueryPath(stdout) {
|
|
149
|
+
const line = String(stdout ?? '')
|
|
150
|
+
.split(/\r?\n/)
|
|
151
|
+
.find((l) => /\sPath\s+REG_(?:EXPAND_)?SZ\s+/i.test(l));
|
|
152
|
+
if (!line) return null;
|
|
153
|
+
return line.replace(/^.*?REG_(?:EXPAND_)?SZ\s+/i, '').trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 判断一段命令输出是否属于「这条命令本身找不到」。
|
|
158
|
+
*
|
|
159
|
+
* 刻意**不收** POSIX 的 "No such file or directory":那条既可能指"命令不存在",
|
|
160
|
+
* 也可能只是 `type missing.txt` 这类**参数**文件缺失。调用方(tools.js)会据此
|
|
161
|
+
* 重试一次命令,而重试对"参数缺失"是白跑、对有副作用的命令更是危险 ——
|
|
162
|
+
* 所以只认能确定"整条命令没被执行过"的几种说法。
|
|
163
|
+
*/
|
|
164
|
+
export function isCommandNotFound(...parts) {
|
|
165
|
+
const text = parts.filter(Boolean).join('\n');
|
|
166
|
+
if (!text) return false;
|
|
167
|
+
return [
|
|
168
|
+
/不是内部或外部命令/, // 中文 Windows cmd
|
|
169
|
+
/is not recognized as an internal or external command/i, // 英文 cmd
|
|
170
|
+
/command not found/i, // POSIX sh / bash
|
|
171
|
+
/\bENOENT\b/, // node spawn 找不到可执行文件
|
|
172
|
+
].some((re) => re.test(text));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 读注册表 ────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async function readRegPath(regKey, { execFileFn = execFileAsync } = {}) {
|
|
178
|
+
try {
|
|
179
|
+
const { stdout } = await execFileFn('reg.exe', ['query', regKey, '/v', 'Path'], {
|
|
180
|
+
encoding: 'utf8',
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
});
|
|
183
|
+
return parseRegQueryPath(stdout);
|
|
184
|
+
} catch {
|
|
185
|
+
// 键或值不存在(reg.exe 退出码 1)、被策略拦下、reg.exe 本身缺失 —— 都不该让
|
|
186
|
+
// 调用方失败,按"这项没有"处理即可
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 清空缓存。装了新 CLI 想立刻生效、或测试之间需要隔离时调用。 */
|
|
192
|
+
export function invalidatePathCache() {
|
|
193
|
+
cache = { at: 0, dirs: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 读注册表 Machine + User 的 PATH,展开变量后返回目录数组(Machine 在前,与
|
|
198
|
+
* Windows 自身的 PATH 拼接顺序一致)。
|
|
199
|
+
*
|
|
200
|
+
* `ttlMs: 0` 表示禁用缓存(每次都真读);`force: true` 忽略现有缓存。
|
|
201
|
+
*/
|
|
202
|
+
export async function readRegistryPathDirs({
|
|
203
|
+
platform = process.platform,
|
|
204
|
+
execFileFn,
|
|
205
|
+
env = process.env,
|
|
206
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
207
|
+
force = false,
|
|
208
|
+
} = {}) {
|
|
209
|
+
if (platform !== 'win32') return [];
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
if (!force && ttlMs > 0 && cache.at && now - cache.at < ttlMs) return cache.dirs;
|
|
212
|
+
|
|
213
|
+
const [machine, user] = await Promise.all([
|
|
214
|
+
readRegPath(REG_KEY_MACHINE, { execFileFn }),
|
|
215
|
+
readRegPath(REG_KEY_USER, { execFileFn }),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const dirs = [];
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
for (const raw of [machine, user]) {
|
|
221
|
+
if (!raw) continue;
|
|
222
|
+
for (const dir of splitPathList(expandVars(raw, env))) {
|
|
223
|
+
const key = normalizeDir(dir);
|
|
224
|
+
if (!key || seen.has(key)) continue;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
dirs.push(dir);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
cache = { at: now, dirs };
|
|
231
|
+
return dirs;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── 主入口 ──────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 返回一份**补过 PATH** 的 env(新对象),语义为"这个子进程是现在才启动的"。
|
|
238
|
+
*
|
|
239
|
+
* 已经是超集时原样返回入参(连引用都不换),调用方无需判断,直接丢给 spawn 即可。
|
|
240
|
+
* 非 win32 一律原样返回。
|
|
241
|
+
*/
|
|
242
|
+
export async function augmentEnvPath(env = process.env, options = {}) {
|
|
243
|
+
const { platform = process.platform } = options;
|
|
244
|
+
if (platform !== 'win32') return env;
|
|
245
|
+
|
|
246
|
+
const current = pathValueOf(env);
|
|
247
|
+
const dirs = await readRegistryPathDirs({ ...options, env });
|
|
248
|
+
const missing = missingPathDirs(current, dirs, options);
|
|
249
|
+
if (!missing.length) return env;
|
|
250
|
+
|
|
251
|
+
const merged = [...splitPathList(current, options), ...missing].join(path.delimiter);
|
|
252
|
+
return withPathValue(env, merged);
|
|
253
|
+
}
|