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.
Files changed (70) hide show
  1. package/README.md +39 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/cli/ai/agent.js +108 -324
  5. package/src/cli/ai/cli.integration.test.js +124 -0
  6. package/src/cli/ai/context.js +90 -0
  7. package/src/cli/ai/modelSetup.js +38 -0
  8. package/src/cli/ai/runtime.test.js +181 -0
  9. package/src/cli/ai/safety.js +27 -4
  10. package/src/cli/ai/safety.test.js +13 -0
  11. package/src/cli/ai/telemetry.js +43 -0
  12. package/src/cli/ai/termui.js +181 -51
  13. package/src/cli/ai/termui.test.js +165 -16
  14. package/src/cli/ai/tools.js +27 -5
  15. package/src/cli/ai/tools.test.js +23 -0
  16. package/src/cli/ai/transport.js +89 -0
  17. package/src/cli/ai/turn.js +126 -0
  18. package/src/config.js +34 -4
  19. package/src/ui/public/assets/AgentView-CdJaeRh0.js +5 -0
  20. package/src/ui/public/assets/AgentView-DAY4S5WN.css +1 -0
  21. package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-CiijSYfM.js} +2 -2
  22. package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-BS7ajwvv.js} +1 -1
  23. package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-CEOSqXCA.js} +3 -3
  24. package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-CAaEi-4T.js} +1 -1
  25. package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-DyaFyRDD.js} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BvX9cQjR.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-uXrzZjsW.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-DIBpUSt_.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-CnoW2on6.js} +1 -1
  30. package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-D2espSVo.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-CZF-rZVs.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DjLr7qOG.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DzsU-QIO.js} +1 -1
  34. package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CuuG2ucT.js} +1 -1
  35. package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-BGf8L0of.js} +1 -1
  36. package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CFb02m6z.js} +1 -1
  37. package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-SRAeYjnN.js} +1 -1
  38. package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-gedqAXl8.js} +1 -1
  39. package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-BOCuMQW6.js} +1 -1
  40. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +13 -0
  41. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  42. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +19 -0
  43. package/src/ui/public/assets/configStore-CuMElTgV.js +1 -0
  44. package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-CpcS-MzM.js} +1 -1
  45. package/src/ui/public/assets/index-cApp6w0b.css +1 -0
  46. package/src/ui/public/assets/index-yXm9JLGv.js +24 -0
  47. package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-DkXYEpLP.js} +0 -0
  48. package/src/ui/public/assets/{toolsStore-BiC2mmC3.js → toolsStore-DGxlRe3Y.js} +1 -1
  49. package/src/ui/public/assets/vendor-4ojsjDYc.css +1 -0
  50. package/src/ui/public/assets/{vendor-92fKgtnz.js → vendor-dO4B7wQf.js} +28 -28
  51. package/src/ui/public/index.html +14 -14
  52. package/src/ui/server/routes/config.js +8 -3
  53. package/src/ui/server/routes/fileOpen.js +48 -2
  54. package/src/ui/server/routes/fs.js +37 -0
  55. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  56. package/src/ui/server/routes/workbench/index.js +37 -6
  57. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  58. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  59. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  60. package/src/ui/server/utils/directoryFetch.js +209 -0
  61. package/src/ui/server/utils/directoryFetch.test.js +232 -0
  62. package/src/ui/public/assets/AgentView-BC3F0F6m.js +0 -4
  63. package/src/ui/public/assets/AgentView-CzUeyQfR.css +0 -1
  64. package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
  65. package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
  66. package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
  67. package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
  68. package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
  69. package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
  70. 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.ok(lines.some(l => l.startsWith('🤖 第一行')))
120
- assert.ok(lines.some(l => l.startsWith(' 第二行')))
121
- assert.ok(lines.some(l => l.startsWith(' 第三行')))
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, '🤖 答案', `首行应为 🤖 答案,实际: ${JSON.stringify(lines)}`)
229
+ assert.equal(firstContent, '◆ 回答')
198
230
  // "答案"与"下一段"之间最多一个空行,且空行不带缩进
199
- const midBlank = lines.slice(1, lines.indexOf(' 下一段')).filter(l => l === '').length
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('▶ run_command'))
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(' │ ') || l === ''), `每行都应为 │ 槽线,实际: ${JSON.stringify(lines)}`)
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: 传入 durationMs 时末尾追加 ⏱ 计时行', () => {
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('⏱'), `应包含 ⏱,实际: ${lastLine}`)
322
+ assert.ok(lastLine.includes('完成'), `应包含状态,实际: ${lastLine}`)
262
323
  assert.ok(lastLine.includes('1.5s'), `应包含 1.5s,实际: ${lastLine}`)
263
- assert.ok(lastLine.startsWith(' │ '), `计时行应与结果块同缩进,实际: ${lastLine}`)
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'), [])
@@ -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 child = exec(command, { ...options, encoding: 'buffer' }, (err, stdout, stderr) => {
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: !!err?.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 options.onChild === 'function') options.onChild(child)
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 {
@@ -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
+ }