zen-gitsync 2.17.6 → 2.17.8
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 +39 -2
- package/package.json +1 -1
- package/scripts/release.js +139 -48
- package/src/cli/ai/agent.js +108 -324
- package/src/cli/ai/cli.integration.test.js +124 -0
- package/src/cli/ai/context.js +90 -0
- package/src/cli/ai/modelSetup.js +38 -0
- package/src/cli/ai/runtime.test.js +181 -0
- package/src/cli/ai/safety.js +27 -4
- package/src/cli/ai/safety.test.js +13 -0
- package/src/cli/ai/telemetry.js +43 -0
- package/src/cli/ai/termui.js +181 -51
- package/src/cli/ai/termui.test.js +165 -16
- package/src/cli/ai/tools.js +27 -5
- package/src/cli/ai/tools.test.js +23 -0
- package/src/cli/ai/transport.js +89 -0
- package/src/cli/ai/turn.js +126 -0
- package/src/config.js +34 -4
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +5 -0
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +1 -0
- package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-CiijSYfM.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-BS7ajwvv.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-CEOSqXCA.js} +3 -3
- package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-CAaEi-4T.js} +1 -1
- package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-DyaFyRDD.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BvX9cQjR.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-uXrzZjsW.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-DIBpUSt_.js} +1 -1
- package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-CnoW2on6.js} +1 -1
- package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-D2espSVo.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-CZF-rZVs.js} +1 -1
- package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DjLr7qOG.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DzsU-QIO.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CuuG2ucT.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-BGf8L0of.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CFb02m6z.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-SRAeYjnN.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-gedqAXl8.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-BOCuMQW6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +13 -0
- package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +19 -0
- package/src/ui/public/assets/configStore-CuMElTgV.js +1 -0
- package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-CpcS-MzM.js} +1 -1
- package/src/ui/public/assets/index-cApp6w0b.css +1 -0
- package/src/ui/public/assets/index-yXm9JLGv.js +24 -0
- package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-DkXYEpLP.js} +0 -0
- package/src/ui/public/assets/{toolsStore-BiC2mmC3.js → toolsStore-DGxlRe3Y.js} +1 -1
- package/src/ui/public/assets/vendor-4ojsjDYc.css +1 -0
- package/src/ui/public/assets/{vendor-92fKgtnz.js → vendor-dO4B7wQf.js} +28 -28
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/routes/config.js +8 -3
- package/src/ui/server/routes/fileOpen.js +48 -2
- package/src/ui/server/routes/fs.js +37 -0
- package/src/ui/server/routes/gAiLauncher.test.js +70 -0
- package/src/ui/server/routes/workbench/index.js +37 -6
- package/src/ui/server/routes/workbench/jobStore.js +4 -0
- package/src/ui/server/routes/workbench/taskRunner.js +276 -96
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
- package/src/ui/server/utils/directoryFetch.js +209 -0
- package/src/ui/server/utils/directoryFetch.test.js +232 -0
- package/src/ui/public/assets/AgentView-BC3F0F6m.js +0 -4
- package/src/ui/public/assets/AgentView-CzUeyQfR.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
- package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
- package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
- package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
- package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
- package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
- package/src/ui/public/assets/vendor-B1UrVTTI.css +0 -1
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
import { test } from 'node:test'
|
|
19
19
|
import assert from 'node:assert/strict'
|
|
20
20
|
import chalk from 'chalk'
|
|
21
|
+
import ora from 'ora'
|
|
22
|
+
import stringWidth from 'string-width'
|
|
21
23
|
import {
|
|
22
24
|
stripAnsi, truncateDisplay, summarizeToolArgs,
|
|
23
|
-
createAssistantWriter, printToolHeader, printToolResult,
|
|
24
|
-
formatDuration,
|
|
25
|
+
createAssistantWriter, printToolHeader, printToolResult, startSpinner,
|
|
26
|
+
formatDuration, wrapTerminalText, renderTurnSummary,
|
|
25
27
|
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint, SLASH_COMMANDS,
|
|
26
28
|
renderSelectableListBody, parseKeyForSelectableList,
|
|
27
29
|
} from './termui.js'
|
|
@@ -87,6 +89,35 @@ test('summarizeToolArgs: 未知工具回退 JSON,缺参数不炸', () => {
|
|
|
87
89
|
})
|
|
88
90
|
|
|
89
91
|
// ── createAssistantWriter ──
|
|
92
|
+
test('TTY spinner stops once without moving the cursor over later streamed text', t => {
|
|
93
|
+
const c = collect()
|
|
94
|
+
const cursorOperations = []
|
|
95
|
+
const stream = {
|
|
96
|
+
isTTY: true, columns: 100,
|
|
97
|
+
write: c.write,
|
|
98
|
+
cursorTo: column => cursorOperations.push(['column', column]),
|
|
99
|
+
moveCursor: (x, y) => cursorOperations.push(['move', x, y]),
|
|
100
|
+
clearLine: direction => cursorOperations.push(['clear', direction]),
|
|
101
|
+
}
|
|
102
|
+
const spinner = startSpinner('waiting', {
|
|
103
|
+
spinnerFactory: options => ora({ ...options, stream, isEnabled: true, hideCursor: false }),
|
|
104
|
+
})
|
|
105
|
+
t.after(() => spinner.stop())
|
|
106
|
+
spinner.stop()
|
|
107
|
+
const stoppedOperations = cursorOperations.length
|
|
108
|
+
assert.ok(stoppedOperations > 0, 'must exercise the real TTY cursor operations')
|
|
109
|
+
const writer = createAssistantWriter({ write: c.write })
|
|
110
|
+
for (const chunk of ['先检查 ', 'nginx ', '配置文件。\n', '再确认 ', '项目目录。']) {
|
|
111
|
+
spinner.stop()
|
|
112
|
+
writer.writeThinking(chunk)
|
|
113
|
+
}
|
|
114
|
+
spinner.stop()
|
|
115
|
+
writer.finish()
|
|
116
|
+
assert.equal(cursorOperations.length, stoppedOperations, 'later chunks must not reset the terminal cursor')
|
|
117
|
+
assert.match(c.text(), /先检查 nginx 配置文件。\n/)
|
|
118
|
+
assert.match(c.text(), /再确认 项目目录。/)
|
|
119
|
+
})
|
|
120
|
+
|
|
90
121
|
test('writer: 思考段头部只打印一次,内容直写', () => {
|
|
91
122
|
const c = collect()
|
|
92
123
|
const w = createAssistantWriter({ thinkingHeader: '✻ 思考', write: c.write })
|
|
@@ -110,15 +141,16 @@ test('writer: showThinking=false 时丢弃思考段', () => {
|
|
|
110
141
|
assert.ok(text.includes('正文'))
|
|
111
142
|
})
|
|
112
143
|
|
|
113
|
-
test('writer:
|
|
144
|
+
test('writer: 回答标题独立显示,正文各行缩进对齐', () => {
|
|
114
145
|
const c = collect()
|
|
115
146
|
const w = createAssistantWriter({ write: c.write })
|
|
116
147
|
w.writeContent('第一行\n第二行\n第三行')
|
|
117
148
|
w.finish()
|
|
118
149
|
const lines = c.lines()
|
|
119
|
-
assert.
|
|
120
|
-
assert.ok(lines.
|
|
121
|
-
assert.ok(lines.
|
|
150
|
+
assert.equal(lines.filter(l => l === '◆ 回答').length, 1)
|
|
151
|
+
assert.ok(lines.includes(' 第一行'))
|
|
152
|
+
assert.ok(lines.includes(' 第二行'))
|
|
153
|
+
assert.ok(lines.includes(' 第三行'))
|
|
122
154
|
})
|
|
123
155
|
|
|
124
156
|
test('writer: 行内 markdown — bold 标记被消费,code 反引号被消费', () => {
|
|
@@ -194,19 +226,19 @@ test('writer: 头部空白行被吞掉,连续空白行合并(真实 MiniMax 输
|
|
|
194
226
|
const lines = c.lines()
|
|
195
227
|
// null→content 自动在 🤖 上方加一空行,跳过空行找正文首行
|
|
196
228
|
const firstContent = lines.find(l => l !== '')
|
|
197
|
-
assert.equal(firstContent, '
|
|
229
|
+
assert.equal(firstContent, '◆ 回答')
|
|
198
230
|
// "答案"与"下一段"之间最多一个空行,且空行不带缩进
|
|
199
|
-
const midBlank = lines.slice(1, lines.indexOf('
|
|
231
|
+
const midBlank = lines.slice(lines.indexOf(' 答案') + 1, lines.indexOf(' 下一段')).filter(l => l === '').length
|
|
200
232
|
assert.ok(midBlank <= 1, `空行应被合并,实际行: ${JSON.stringify(lines)}`)
|
|
201
|
-
assert.ok(lines.includes('
|
|
233
|
+
assert.ok(lines.includes(' 下一段'))
|
|
202
234
|
})
|
|
203
235
|
|
|
204
236
|
// ── 工具块 ──
|
|
205
|
-
test('printToolHeader:
|
|
237
|
+
test('printToolHeader: 工具标签与参数分行显示', () => {
|
|
206
238
|
const c = collect()
|
|
207
239
|
printToolHeader('run_command', '$ npm test', c.write)
|
|
208
240
|
const text = c.text()
|
|
209
|
-
assert.ok(text.includes('
|
|
241
|
+
assert.ok(text.includes('▸ 执行命令 run_command'))
|
|
210
242
|
assert.ok(text.includes('$ npm test'))
|
|
211
243
|
})
|
|
212
244
|
|
|
@@ -214,10 +246,39 @@ test('printToolResult: 全部行统一 │ 槽线(无 └─ 拐角)', () => {
|
|
|
214
246
|
const c = collect()
|
|
215
247
|
printToolResult('$ ls\n(exit 0)\nfile1\nfile2', c.write)
|
|
216
248
|
const lines = c.lines()
|
|
217
|
-
assert.ok(lines.every(l => l.startsWith(' │
|
|
249
|
+
assert.ok(lines.every(l => l.startsWith(' │ ') || l === ''), `每行都应为 │ 槽线,实际: ${JSON.stringify(lines)}`)
|
|
218
250
|
assert.ok(!lines.some(l => l.includes('└─')), '不应出现 └─ 拐角字符')
|
|
219
251
|
})
|
|
220
252
|
|
|
253
|
+
test('Windows command output renders like LF output in compact and full modes', () => {
|
|
254
|
+
const names = ['.claude', '.git', '.gitattributes', '.github', '.gitignore', '.npmignore',
|
|
255
|
+
'AGENTS.md', 'docs', 'index.js', 'package.json', 'README.md', '源码笔记.md']
|
|
256
|
+
for (const full of [false, true]) {
|
|
257
|
+
const lf = collect(), windows = collect()
|
|
258
|
+
printToolResult(['$ dir /a /b', '(exit 0)', ...names].join('\n'), lf.write, 38, { full, width: 60 })
|
|
259
|
+
printToolResult(['$ dir /a /b', '(exit 0)', ...names].join('\r\n'), windows.write, 38, { full, width: 60 })
|
|
260
|
+
assert.equal(windows.text(), lf.text(), 'line endings must not change layout or the omitted row count')
|
|
261
|
+
assert.doesNotMatch(windows.text(), /\r/)
|
|
262
|
+
const rows = windows.lines().filter(Boolean)
|
|
263
|
+
assert.ok(rows.slice(0, -1).every(row => row.startsWith(' │ ')))
|
|
264
|
+
assert.match(rows.at(-1), /^ └ 完成 · 38ms · exit 0$/)
|
|
265
|
+
if (full) assert.deepEqual(rows.slice(0, -1), names.map(name => ' │ ' + name))
|
|
266
|
+
else assert.match(windows.text(), /回显省略 7 行/)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('wrapped Windows paths keep every continuation row to the right of the gutter', () => {
|
|
271
|
+
const c = collect()
|
|
272
|
+
const paths = ['C:\\workspace\\项目目录\\src\\components\\DirectorySelector.vue',
|
|
273
|
+
'C:\\workspace\\项目目录\\docs\\终端显示🙂.md']
|
|
274
|
+
printToolResult(paths.join('\r\n'), c.write, undefined, { full: true, width: 24 })
|
|
275
|
+
const rows = c.lines().filter(Boolean)
|
|
276
|
+
assert.ok(rows.every(row => row.startsWith(' │ ')))
|
|
277
|
+
assert.ok(rows.every(row => stringWidth(row) <= 28), 'include the gutter when checking physical width')
|
|
278
|
+
assert.equal(rows.map(row => row.slice(4)).join(''), paths.join(''), 'preserve every path character')
|
|
279
|
+
assert.doesNotMatch(c.text(), /\r/)
|
|
280
|
+
})
|
|
281
|
+
|
|
221
282
|
test('printToolResult: 超长结果被截断并含省略标记', () => {
|
|
222
283
|
const c = collect()
|
|
223
284
|
const long = Array.from({ length: 60 }, (_, i) => `line${i} ` + 'y'.repeat(40)).join('\n')
|
|
@@ -252,15 +313,15 @@ test('formatDuration: 无效输入返回空串', () => {
|
|
|
252
313
|
})
|
|
253
314
|
|
|
254
315
|
// ── printToolResult 带耗时 ──
|
|
255
|
-
test('printToolResult:
|
|
316
|
+
test('printToolResult: 结果末尾显示状态与耗时', () => {
|
|
256
317
|
const c = collect()
|
|
257
318
|
printToolResult('hello world', c.write, 1500)
|
|
258
319
|
// 去掉末尾空行(split('\n') 在末尾 \n 后产生空串)
|
|
259
320
|
const lines = c.lines().filter(l => l !== '')
|
|
260
321
|
const lastLine = lines[lines.length - 1]
|
|
261
|
-
assert.ok(lastLine.includes('
|
|
322
|
+
assert.ok(lastLine.includes('完成'), `应包含状态,实际: ${lastLine}`)
|
|
262
323
|
assert.ok(lastLine.includes('1.5s'), `应包含 1.5s,实际: ${lastLine}`)
|
|
263
|
-
assert.ok(lastLine.startsWith('
|
|
324
|
+
assert.ok(lastLine.startsWith(' └ '), `计时行应与结果块同缩进,实际: ${lastLine}`)
|
|
264
325
|
})
|
|
265
326
|
|
|
266
327
|
test('printToolResult: 不传 durationMs 时无计时行(向后兼容)', () => {
|
|
@@ -272,6 +333,81 @@ test('printToolResult: 不传 durationMs 时无计时行(向后兼容)', () => {
|
|
|
272
333
|
|
|
273
334
|
// ── 斜杠命令即时提示 ──
|
|
274
335
|
|
|
336
|
+
test('compact tools bound physical rows while full output preserves every line', () => {
|
|
337
|
+
const output = Array.from({ length: 80 }, (_, i) => `row ${i}`).join('\n')
|
|
338
|
+
const compact = collect(), full = collect()
|
|
339
|
+
printToolResult(output, compact.write, 10)
|
|
340
|
+
printToolResult(output, full.write, 10, { full: true })
|
|
341
|
+
assert.ok(compact.lines().filter(Boolean).length <= 7)
|
|
342
|
+
assert.ok(compact.text().includes('row 0'))
|
|
343
|
+
assert.ok(compact.text().includes('row 79'))
|
|
344
|
+
assert.ok(full.text().includes('row 40'))
|
|
345
|
+
assert.doesNotMatch(full.text(), /省略/)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('thinking previews stop after their row budget and still render the answer', () => {
|
|
349
|
+
const c = collect()
|
|
350
|
+
const w = createAssistantWriter({ write: c.write, thinkingLimit: 2, width: 30 })
|
|
351
|
+
w.writeThinking('first\nsecond\nthird\nfourth')
|
|
352
|
+
w.writeThinking('hidden more')
|
|
353
|
+
w.writeContent('answer')
|
|
354
|
+
w.finish()
|
|
355
|
+
assert.match(c.text(), /first\n/)
|
|
356
|
+
assert.doesNotMatch(c.text(), /third|fourth|hidden more/)
|
|
357
|
+
assert.match(c.text(), /answer/)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('compact thinking uses its row budget for content even across blank chunks', () => {
|
|
361
|
+
const c = collect()
|
|
362
|
+
const writer = createAssistantWriter({ write: c.write, thinkingLimit: 2 })
|
|
363
|
+
for (const chunk of ['\n\n', 'first', '\n\n', '\nsecond\n', 'third']) writer.writeThinking(chunk)
|
|
364
|
+
writer.finish()
|
|
365
|
+
assert.match(c.text(), /first\n/)
|
|
366
|
+
assert.match(c.text(), /second\n/)
|
|
367
|
+
assert.doesNotMatch(c.text(), /third/)
|
|
368
|
+
assert.equal((c.text().match(/\/think full/g) || []).length, 1)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('full thinking preserves paragraph breaks and text after the preview budget', () => {
|
|
372
|
+
const c = collect()
|
|
373
|
+
const writer = createAssistantWriter({ write: c.write })
|
|
374
|
+
const content = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n\n')
|
|
375
|
+
for (const character of content) writer.writeThinking(character)
|
|
376
|
+
writer.finish()
|
|
377
|
+
assert.match(c.text(), /line 0\n\n/)
|
|
378
|
+
assert.match(c.text(), /line 19/)
|
|
379
|
+
assert.doesNotMatch(c.text(), /\/think full/)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('terminal wrapping respects wide glyphs and preserves text', () => {
|
|
383
|
+
const text = '中文🙂中文abcdef'
|
|
384
|
+
const rows = wrapTerminalText(chalk.cyan(text), 6)
|
|
385
|
+
assert.equal(stripAnsi(rows.join('')), text)
|
|
386
|
+
assert.ok(rows.length >= 3)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('terminal wrapping recognizes CRLF and bare carriage returns as line breaks', () => {
|
|
390
|
+
const text = chalk.cyan('first\r\n第二行🙂\nthird\rfourth\r\n')
|
|
391
|
+
assert.deepEqual(wrapTerminalText(text, 20).map(stripAnsi), ['first', '第二行🙂', 'third', 'fourth', ''])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('turn summaries distinguish missing and partial usage, and show cancellation', () => {
|
|
395
|
+
const stats = { status: 'cancelled', totalMs: 2000, llmMs: 1800, toolsMs: 100,
|
|
396
|
+
firstTokenMs: 200, firstAnswerMs: 500, requests: 2, toolCalls: 1, usageRequests: 0, usage: null }
|
|
397
|
+
let text = stripAnsi(renderTurnSummary(stats, { width: 200 }))
|
|
398
|
+
assert.match(text, /已停止/)
|
|
399
|
+
assert.match(text, /首响应 200ms/)
|
|
400
|
+
assert.match(text, /正文等待 500ms/)
|
|
401
|
+
assert.match(text, /服务端未返回/)
|
|
402
|
+
assert.doesNotMatch(text, /Token 0/)
|
|
403
|
+
stats.usage = { totalTokens: 120, inputTokens: 100, outputTokens: 20, cachedTokens: 80, reasoningTokens: 5 }
|
|
404
|
+
stats.usageRequests = 1
|
|
405
|
+
text = stripAnsi(renderTurnSummary(stats, { width: 200 }))
|
|
406
|
+
assert.match(text, /Token 120/)
|
|
407
|
+
assert.match(text, /部分用量(1\/2/)
|
|
408
|
+
assert.match(text, /输入含缓存 80/)
|
|
409
|
+
})
|
|
410
|
+
|
|
275
411
|
test('filterSlashCommands: 单个 / 返回全部命令', () => {
|
|
276
412
|
const got = filterSlashCommands('/', 'zh').map(m => m.cmd)
|
|
277
413
|
assert.deepEqual(got, SLASH_COMMANDS.map(c => c.cmd))
|
|
@@ -285,11 +421,24 @@ test('filterSlashCommands: 前缀过滤,大小写不敏感', () => {
|
|
|
285
421
|
assert.deepEqual(filterSlashCommands('/exit', 'zh').map(m => m.cmd), ['/exit'])
|
|
286
422
|
})
|
|
287
423
|
|
|
288
|
-
test('filterSlashCommands:
|
|
424
|
+
test('filterSlashCommands: 自由输入的参数不再提示', () => {
|
|
289
425
|
assert.deepEqual(filterSlashCommands('/model 2', 'zh'), [])
|
|
290
426
|
assert.deepEqual(filterSlashCommands('/cd ..', 'zh'), [])
|
|
291
427
|
})
|
|
292
428
|
|
|
429
|
+
test('thinking modes are discoverable from slash menu and complete after a space', () => {
|
|
430
|
+
const modes = ['/think full', '/think compact', '/think off']
|
|
431
|
+
const all = filterSlashCommands('/', 'zh').map(m => m.cmd)
|
|
432
|
+
assert.ok(modes.every(mode => all.includes(mode)))
|
|
433
|
+
assert.deepEqual(filterSlashCommands('/think ', 'zh').map(m => m.cmd), modes)
|
|
434
|
+
assert.deepEqual(filterSlashCommands('/think f', 'zh').map(m => m.cmd), ['/think full'])
|
|
435
|
+
assert.deepEqual(filterSlashCommands('/THINK C', 'en').map(m => m.cmd), ['/think compact'])
|
|
436
|
+
assert.deepEqual(filterSlashCommands('/think full ', 'zh'), [], 'completed command must be ready to submit')
|
|
437
|
+
const lines = stripAnsi(renderSlashHintBody(filterSlashCommands('/think ', 'zh'))).split('\n')
|
|
438
|
+
assert.match(lines[0], /\/think full\s+完整/)
|
|
439
|
+
assert.match(lines[1], /\/think compact\s+预览前 12 行/)
|
|
440
|
+
})
|
|
441
|
+
|
|
293
442
|
test('filterSlashCommands: 非 slash 输入 / 无匹配返回空', () => {
|
|
294
443
|
assert.deepEqual(filterSlashCommands('hello', 'zh'), [])
|
|
295
444
|
assert.deepEqual(filterSlashCommands('', 'zh'), [])
|
package/src/cli/ai/tools.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// - 工具内部异常一律 catch 成字符串返回,不抛给 agent 循环 ——
|
|
25
25
|
// 让模型看到错误信息自己修正,而不是中断整轮对话
|
|
26
26
|
|
|
27
|
-
import { exec } from 'node:child_process'
|
|
27
|
+
import { exec, execFile } from 'node:child_process'
|
|
28
28
|
import { promises as fs } from 'node:fs'
|
|
29
29
|
import path from 'node:path'
|
|
30
30
|
import iconv from 'iconv-lite'
|
|
@@ -196,20 +196,39 @@ function decodeOutput(buf) {
|
|
|
196
196
|
|
|
197
197
|
// 包装 exec 为 Promise,stdout/stderr 合并返回;出错(非零退出)也正常返回输出
|
|
198
198
|
// encoding:'buffer' 拿原始字节,由 decodeOutput 决定真实编码
|
|
199
|
+
export function terminateCommand(child) {
|
|
200
|
+
if (!child?.pid || child.exitCode !== null) return Promise.resolve()
|
|
201
|
+
if (process.platform === 'win32') {
|
|
202
|
+
return new Promise(resolve => execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => resolve()))
|
|
203
|
+
}
|
|
204
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch {} }
|
|
205
|
+
return Promise.resolve()
|
|
206
|
+
}
|
|
207
|
+
|
|
199
208
|
function execCommand(command, options) {
|
|
200
209
|
return new Promise((resolve) => {
|
|
201
|
-
const
|
|
210
|
+
const { signal, timeout, onChild, ...execOptions } = options
|
|
211
|
+
let timedOut = false
|
|
212
|
+
const abort = () => { void terminateCommand(child) }
|
|
213
|
+
const child = exec(command, { ...execOptions, detached: process.platform !== 'win32', encoding: 'buffer' }, (err, stdout, stderr) => {
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
signal?.removeEventListener('abort', abort)
|
|
202
216
|
resolve({
|
|
203
217
|
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
204
|
-
killed:
|
|
218
|
+
killed: timedOut,
|
|
219
|
+
cancelled: !!signal?.aborted,
|
|
205
220
|
stdout: decodeOutput(stdout),
|
|
206
221
|
stderr: decodeOutput(stderr),
|
|
207
222
|
errorMessage: err && !('code' in err) ? err.message : null,
|
|
208
223
|
})
|
|
209
224
|
})
|
|
225
|
+
const timer = setTimeout(() => { timedOut = true; void terminateCommand(child) }, timeout)
|
|
226
|
+
timer.unref()
|
|
227
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
228
|
+
if (signal?.aborted) abort()
|
|
210
229
|
trackChild(child)
|
|
211
230
|
// 把 child 暴露给 agent 循环,支持用户 Ctrl+C 时立即中止当前命令
|
|
212
|
-
if (typeof
|
|
231
|
+
if (typeof onChild === 'function') onChild(child)
|
|
213
232
|
})
|
|
214
233
|
}
|
|
215
234
|
|
|
@@ -253,9 +272,11 @@ async function toolRunCommand(args, ctx) {
|
|
|
253
272
|
windowsHide: true,
|
|
254
273
|
env: { ...process.env, FORCE_COLOR: '0' },
|
|
255
274
|
onChild: ctx.onChild,
|
|
275
|
+
signal: ctx.signal,
|
|
256
276
|
})
|
|
257
277
|
|
|
258
278
|
const parts = []
|
|
279
|
+
if (result.cancelled) parts.push('[命令已由用户停止]')
|
|
259
280
|
if (result.killed) parts.push(`[命令超时被终止(>${timeoutSec}s)]`)
|
|
260
281
|
if (result.stdout) parts.push(result.stdout.trimEnd())
|
|
261
282
|
if (result.stderr) parts.push(`[stderr]\n${result.stderr.trimEnd()}`)
|
|
@@ -333,7 +354,7 @@ async function toolEditFile(args, ctx) {
|
|
|
333
354
|
return `错误: old_string 在 ${filePath} 中出现 ${count} 次,不唯一。请提供更多上下文让它唯一,或显式传 replace_all=true 全部替换。`
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
const updated = args.replace_all ? raw.split(oldStr).join(newStr) : raw.replace(oldStr, newStr)
|
|
357
|
+
const updated = args.replace_all ? raw.split(oldStr).join(newStr) : raw.replace(oldStr, () => newStr)
|
|
337
358
|
try {
|
|
338
359
|
await fs.writeFile(filePath, updated, 'utf-8')
|
|
339
360
|
return `已修改 ${filePath} (替换 ${args.replace_all ? count : 1} 处)`
|
|
@@ -478,6 +499,7 @@ const TOOL_HANDLERS = {
|
|
|
478
499
|
* @param {{ cwd: string, onChild?: (child: object) => void }} ctx
|
|
479
500
|
*/
|
|
480
501
|
export async function executeTool(name, args, ctx) {
|
|
502
|
+
if (ctx.signal?.aborted) return 'Cancelled by user; this tool was not executed.'
|
|
481
503
|
const handler = TOOL_HANDLERS[name]
|
|
482
504
|
if (!handler) return `错误: 未知工具 "${name}",可用工具: ${Object.keys(TOOL_HANDLERS).join(', ')}`
|
|
483
505
|
try {
|
package/src/cli/ai/tools.test.js
CHANGED
|
@@ -35,6 +35,29 @@ after(async () => {
|
|
|
35
35
|
|
|
36
36
|
// ========== schema 基本形态 ==========
|
|
37
37
|
|
|
38
|
+
test('edit_file preserves literal replacement tokens', async () => {
|
|
39
|
+
await executeTool('write_file', { path: 'literal.txt', content: 'before' }, ctx)
|
|
40
|
+
const replacement = '$& $$ $` $\x27'
|
|
41
|
+
await executeTool('edit_file', { path: 'literal.txt', old_string: 'before', new_string: replacement }, ctx)
|
|
42
|
+
assert.equal(await fs.readFile(path.join(tmpDir, 'literal.txt'), 'utf8'), replacement)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('run_command cancellation terminates a running command promptly', async () => {
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const started = performance.now()
|
|
48
|
+
const result = await executeTool('run_command', { command: 'node -e "setInterval(()=>{},1000)"' }, {
|
|
49
|
+
...ctx, signal: controller.signal, onChild: () => setTimeout(() => controller.abort(), 80),
|
|
50
|
+
})
|
|
51
|
+
assert.match(result, /用户停止/)
|
|
52
|
+
assert.ok(performance.now() - started < 5000)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('an already cancelled tool does not write files', async () => {
|
|
56
|
+
const result = await executeTool('write_file', { path: 'cancelled.txt', content: 'no' }, { ...ctx, signal: AbortSignal.abort() })
|
|
57
|
+
assert.match(result, /not executed/)
|
|
58
|
+
await assert.rejects(fs.stat(path.join(tmpDir, 'cancelled.txt')), { code: 'ENOENT' })
|
|
59
|
+
})
|
|
60
|
+
|
|
38
61
|
test('TOOL_DEFINITIONS 符合 OpenAI function calling 格式', () => {
|
|
39
62
|
assert.ok(Array.isArray(TOOL_DEFINITIONS))
|
|
40
63
|
assert.ok(TOOL_DEFINITIONS.length >= 5)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { TOOL_DEFINITIONS } from './tools.js'
|
|
2
|
+
import { buildAiChatRequest, describeAiHttpError } from '../../utils/aiEndpoint.js'
|
|
3
|
+
import { normalizeUsage } from './telemetry.js'
|
|
4
|
+
|
|
5
|
+
// Some compatible gateways reject stream_options. Remember that for this process.
|
|
6
|
+
const withoutStreamUsage = new Set()
|
|
7
|
+
|
|
8
|
+
export async function streamChatOnce({ model, messages, signal, onToken = () => {}, sessionId,
|
|
9
|
+
fetchFn = fetch, timeoutMs = 300000 }) {
|
|
10
|
+
const { url, headers } = buildAiChatRequest({ ...model, sessionId })
|
|
11
|
+
const providerKey = `${url}\n${model.model}`
|
|
12
|
+
const body = { model: model.model, messages, tools: TOOL_DEFINITIONS, temperature: 0.3, stream: true }
|
|
13
|
+
if (!withoutStreamUsage.has(providerKey)) body.stream_options = { include_usage: true }
|
|
14
|
+
const controller = new AbortController()
|
|
15
|
+
let timedOut = false
|
|
16
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
17
|
+
const onAbort = () => controller.abort()
|
|
18
|
+
if (signal?.aborted) controller.abort()
|
|
19
|
+
else signal?.addEventListener('abort', onAbort)
|
|
20
|
+
let content = '', reasoning = '', usage = null, finished = false
|
|
21
|
+
const toolCalls = []
|
|
22
|
+
|
|
23
|
+
const consumeLine = line => {
|
|
24
|
+
const trimmed = line.trim()
|
|
25
|
+
if (!trimmed.startsWith('data:')) return
|
|
26
|
+
const payload = trimmed.slice(5).trim()
|
|
27
|
+
if (payload === '[DONE]') { finished = true; return }
|
|
28
|
+
if (!payload) return
|
|
29
|
+
let evt
|
|
30
|
+
try { evt = JSON.parse(payload) } catch { throw new Error('Invalid JSON in model stream') }
|
|
31
|
+
if (evt.error) throw new Error(evt.error.message || 'Model stream failed')
|
|
32
|
+
// Usage may arrive after finish_reason, in a final event with choices: [].
|
|
33
|
+
// Events report snapshots, so replace fields instead of summing snapshots.
|
|
34
|
+
const nextUsage = normalizeUsage(evt.usage)
|
|
35
|
+
if (nextUsage) usage = Object.fromEntries(Object.entries(nextUsage).map(([key, value]) => [key, value ?? usage?.[key] ?? null]))
|
|
36
|
+
if (evt.choices?.[0]?.finish_reason) finished = true
|
|
37
|
+
const delta = evt.choices?.[0]?.delta || {}
|
|
38
|
+
const thinking = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
|
|
39
|
+
if (thinking) { reasoning += thinking; onToken({ thinking }) }
|
|
40
|
+
if (delta.content) { content += delta.content; onToken({ content: delta.content }) }
|
|
41
|
+
for (const tc of delta.tool_calls || []) {
|
|
42
|
+
const i = tc.index ?? 0
|
|
43
|
+
if (!Number.isInteger(i) || i < 0 || i > 1024) throw new Error('Invalid tool call index')
|
|
44
|
+
if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
|
|
45
|
+
if (tc.id) toolCalls[i].id += tc.id
|
|
46
|
+
if (tc.function?.name) toolCalls[i].function.name += tc.function.name
|
|
47
|
+
if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
|
|
48
|
+
}
|
|
49
|
+
if (delta.tool_calls?.length) onToken({ toolCalls: true })
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const request = () => fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal })
|
|
53
|
+
let resp = await request()
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
const detail = describeAiHttpError(await resp.text().catch(() => ''), resp.status)
|
|
56
|
+
if ([400, 422].includes(resp.status) && body.stream_options && /stream_options|include_usage/i.test(detail)) {
|
|
57
|
+
delete body.stream_options
|
|
58
|
+
withoutStreamUsage.add(providerKey)
|
|
59
|
+
resp = await request()
|
|
60
|
+
} else throw new Error(`HTTP ${resp.status}: ${detail || resp.statusText}`)
|
|
61
|
+
}
|
|
62
|
+
if (!resp.ok || !resp.body) {
|
|
63
|
+
throw new Error(`HTTP ${resp.status}: ${describeAiHttpError(await resp.text().catch(() => ''), resp.status) || resp.statusText}`)
|
|
64
|
+
}
|
|
65
|
+
const decoder = new TextDecoder()
|
|
66
|
+
let buf = ''
|
|
67
|
+
for await (const chunk of resp.body) {
|
|
68
|
+
buf += decoder.decode(chunk, { stream: true })
|
|
69
|
+
const lines = buf.split('\n')
|
|
70
|
+
buf = lines.pop() ?? ''
|
|
71
|
+
for (const line of lines) consumeLine(line)
|
|
72
|
+
}
|
|
73
|
+
buf += decoder.decode()
|
|
74
|
+
if (buf.trim()) consumeLine(buf)
|
|
75
|
+
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
76
|
+
if (!finished) throw new Error('模型响应意外中断,请重试;未执行不完整的工具调用。')
|
|
77
|
+
return { content, reasoning, toolCalls: toolCalls.filter(Boolean), usage, aborted: false }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (controller.signal.aborted && !timedOut) {
|
|
80
|
+
return { content, reasoning, toolCalls: [], usage, aborted: true }
|
|
81
|
+
}
|
|
82
|
+
const error = timedOut ? new Error('模型响应超时,请重试或切换模型。') : err
|
|
83
|
+
error.usage = usage
|
|
84
|
+
throw error
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
signal?.removeEventListener('abort', onAbort)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { executeTool } from './tools.js'
|
|
2
|
+
import { streamChatOnce } from './transport.js'
|
|
3
|
+
import { createThinkFilter } from './streamFilter.js'
|
|
4
|
+
import { imageToDataUrl } from './images.js'
|
|
5
|
+
import { buildRequestMessages } from './context.js'
|
|
6
|
+
import { createTurnStats, addUsage, accumulateSessionStats } from './telemetry.js'
|
|
7
|
+
import * as terminal from './termui.js'
|
|
8
|
+
|
|
9
|
+
export async function runAgentTurn(state, userText, t, images = [], dependencies = {}) {
|
|
10
|
+
const chat = dependencies.chat || streamChatOnce
|
|
11
|
+
const execute = dependencies.execute || executeTool
|
|
12
|
+
const ui = { ...terminal, ...dependencies.ui }
|
|
13
|
+
const stats = createTurnStats()
|
|
14
|
+
const started = performance.now()
|
|
15
|
+
const cancelled = () => state.cancelRequested || state.abortController?.signal.aborted
|
|
16
|
+
const checkpoint = () => state.persistSession?.()
|
|
17
|
+
const recordUsage = usage => {
|
|
18
|
+
if (!usage) return
|
|
19
|
+
stats.usageRequests++
|
|
20
|
+
stats.usage = addUsage(stats.usage, usage)
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
let userContent = userText
|
|
24
|
+
if (images.length) {
|
|
25
|
+
userContent = [{ type: 'text', text: userText }]
|
|
26
|
+
for (const img of images) {
|
|
27
|
+
try { userContent.push({ type: 'image_url', image_url: { url: await imageToDataUrl(img.path) } }) }
|
|
28
|
+
catch { ui.printWarn(t.imageBadPath(img.path)) }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
state.messages.push({ role: 'user', content: userContent })
|
|
32
|
+
await checkpoint()
|
|
33
|
+
const maxIterations = state.maxToolIterations > 0 ? state.maxToolIterations : 200
|
|
34
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
35
|
+
if (cancelled()) { stats.status = 'cancelled'; return stats }
|
|
36
|
+
const spinner = ui.startSpinner(t.waiting)
|
|
37
|
+
const writer = ui.createAssistantWriter({
|
|
38
|
+
showThinking: state.showThinking !== false,
|
|
39
|
+
thinkingHeader: t.thinkingLabel,
|
|
40
|
+
answerHeader: t.answerLabel,
|
|
41
|
+
thinkingLimit: state.thinkingMode === 'compact' ? terminal.THINKING_PREVIEW_LINES : Infinity,
|
|
42
|
+
thinkingHint: t.thinkingHint,
|
|
43
|
+
})
|
|
44
|
+
const filter = createThinkFilter()
|
|
45
|
+
const render = seg => {
|
|
46
|
+
if (seg.content) {
|
|
47
|
+
if (seg.content.trim() && stats.firstAnswerMs === null) stats.firstAnswerMs = performance.now() - started
|
|
48
|
+
writer.writeContent(seg.content)
|
|
49
|
+
} else if (seg.thinking) writer.writeThinking(seg.thinking)
|
|
50
|
+
}
|
|
51
|
+
let result
|
|
52
|
+
const llmStart = performance.now()
|
|
53
|
+
stats.requests++
|
|
54
|
+
try {
|
|
55
|
+
const messages = buildRequestMessages(state.messages)
|
|
56
|
+
state.prepareMessages?.(messages)
|
|
57
|
+
result = await chat({ model: state.model, messages, signal: state.abortController?.signal,
|
|
58
|
+
sessionId: state.sessionId, onToken: token => {
|
|
59
|
+
if (stats.firstTokenMs === null) stats.firstTokenMs = performance.now() - started
|
|
60
|
+
if (token.content || (token.thinking && state.showThinking !== false)) spinner.stop()
|
|
61
|
+
if (token.thinking) render({ thinking: token.thinking })
|
|
62
|
+
if (token.content) filter.feed(token.content).forEach(render)
|
|
63
|
+
} })
|
|
64
|
+
recordUsage(result.usage)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
recordUsage(err.usage)
|
|
67
|
+
throw err
|
|
68
|
+
} finally {
|
|
69
|
+
stats.llmMs += performance.now() - llmStart
|
|
70
|
+
spinner.stop()
|
|
71
|
+
filter.flush().forEach(render)
|
|
72
|
+
writer.finish()
|
|
73
|
+
}
|
|
74
|
+
if (result.aborted || cancelled()) {
|
|
75
|
+
if (result.content) state.messages.push({ role: 'assistant', content: result.content })
|
|
76
|
+
stats.status = 'cancelled'; return stats
|
|
77
|
+
}
|
|
78
|
+
const { content, toolCalls } = result
|
|
79
|
+
const assistant = { role: 'assistant', content: content || null }
|
|
80
|
+
if (result.reasoning) assistant.reasoning_content = result.reasoning
|
|
81
|
+
if (!toolCalls.length) {
|
|
82
|
+
if (!content?.trim()) throw new Error(t.emptyResponse)
|
|
83
|
+
state.messages.push(assistant)
|
|
84
|
+
stats.status = 'completed'; return stats
|
|
85
|
+
}
|
|
86
|
+
// Assign fallback IDs once, so every result references its assistant call.
|
|
87
|
+
toolCalls.forEach((call, i) => { if (!call.id) call.id = `call_${iter}_${i}` })
|
|
88
|
+
state.messages.push({ ...assistant, tool_calls: toolCalls })
|
|
89
|
+
await checkpoint()
|
|
90
|
+
for (const tc of toolCalls) {
|
|
91
|
+
const name = tc.function?.name || ''
|
|
92
|
+
let output
|
|
93
|
+
if (cancelled()) output = 'Cancelled by user; this tool was not executed.'
|
|
94
|
+
else {
|
|
95
|
+
let args
|
|
96
|
+
try { args = JSON.parse(tc.function?.arguments || '{}') }
|
|
97
|
+
catch { output = '错误: 工具参数不是合法 JSON,请修正后重试。' }
|
|
98
|
+
if (output === undefined) {
|
|
99
|
+
ui.printToolHeader(name, ui.summarizeToolArgs(name, args, { chars: t.chars }), undefined, { locale: state.locale })
|
|
100
|
+
const toolSpinner = ui.startSpinner(t.toolRunning(name))
|
|
101
|
+
const toolStart = performance.now()
|
|
102
|
+
stats.toolCalls++
|
|
103
|
+
try { output = await execute(name, args, { ...state.ctx, signal: state.abortController?.signal }) }
|
|
104
|
+
finally { toolSpinner.stop(); stats.toolsMs += performance.now() - toolStart }
|
|
105
|
+
ui.printToolResult(output, undefined, performance.now() - toolStart, { full: state.fullTools, locale: state.locale })
|
|
106
|
+
} else ui.printToolResult(output)
|
|
107
|
+
}
|
|
108
|
+
state.messages.push({ role: 'tool', tool_call_id: tc.id, name, content: output })
|
|
109
|
+
await checkpoint()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
stats.status = cancelled() ? 'cancelled' : 'limit'
|
|
113
|
+
if (!cancelled()) ui.printWarn(t.toolIterLimit(maxIterations))
|
|
114
|
+
} catch (err) {
|
|
115
|
+
stats.status = cancelled() ? 'cancelled' : 'failed'
|
|
116
|
+
if (!cancelled()) ui.printError(t.llmError(err.message))
|
|
117
|
+
} finally {
|
|
118
|
+
stats.totalMs = performance.now() - started
|
|
119
|
+
stats.completedAt = new Date().toISOString()
|
|
120
|
+
state.lastTurnStats = stats
|
|
121
|
+
state.sessionStats = accumulateSessionStats(state.sessionStats, stats)
|
|
122
|
+
await checkpoint()
|
|
123
|
+
ui.printTurnSummary(stats, { locale: state.locale })
|
|
124
|
+
}
|
|
125
|
+
return stats
|
|
126
|
+
}
|