zen-gitsync 2.17.5 → 2.17.7

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 (71) hide show
  1. package/README.md +35 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/aiCommit.js +6 -3
  5. package/src/cli/ai/agent.js +106 -311
  6. package/src/cli/ai/cli.integration.test.js +99 -0
  7. package/src/cli/ai/context.js +90 -0
  8. package/src/cli/ai/modelSetup.js +44 -8
  9. package/src/cli/ai/runtime.test.js +152 -0
  10. package/src/cli/ai/safety.js +27 -4
  11. package/src/cli/ai/safety.test.js +13 -0
  12. package/src/cli/ai/telemetry.js +43 -0
  13. package/src/cli/ai/termui.js +154 -41
  14. package/src/cli/ai/termui.test.js +63 -14
  15. package/src/cli/ai/tools.js +27 -5
  16. package/src/cli/ai/tools.test.js +23 -0
  17. package/src/cli/ai/transport.js +89 -0
  18. package/src/cli/ai/turn.js +126 -0
  19. package/src/config.js +88 -5
  20. package/src/ui/public/assets/{AgentView-DJOdvJEV.js → AgentView-CZc191Xm.js} +3 -3
  21. package/src/ui/public/assets/AgentView-CzUeyQfR.css +1 -0
  22. package/src/ui/public/assets/{AppVersionBadge-D2Li5_R-.js → AppVersionBadge-Gs5lUl7P.js} +2 -2
  23. package/src/ui/public/assets/{BranchSelector-CSd5uaVm.js → BranchSelector-IjaFk0S-.js} +1 -1
  24. package/src/ui/public/assets/{CommitForm-Ckg9-zay.js → CommitForm-C-sowOXH.js} +3 -3
  25. package/src/ui/public/assets/{CommonDialog-B8XOXtz_.js → CommonDialog-QyptFD1R.js} +1 -1
  26. package/src/ui/public/assets/{EditorView-Crj8S2TI.js → EditorView-CvvluFCY.js} +1 -1
  27. package/src/ui/public/assets/{FileDiffViewer-DL07IMz5.js → FileDiffViewer-BXVsdCxE.js} +1 -1
  28. package/src/ui/public/assets/{FlowExecutionViewer-DvVT19Qv.js → FlowExecutionViewer-CRxipXru.js} +1 -1
  29. package/src/ui/public/assets/{FlowOrchestrationWorkspace-kRhK9IyL.js → FlowOrchestrationWorkspace-AE1ZHTKF.js} +1 -1
  30. package/src/ui/public/assets/{IconButton-pqSYKRT8.js → IconButton-BmSueF_H.js} +1 -1
  31. package/src/ui/public/assets/{LogList-BrN6qtFb.js → LogList-iaVA-_aC.js} +1 -1
  32. package/src/ui/public/assets/{MindmapView-CSsT0NDZ.js → MindmapView-DcG4jpCh.js} +1 -1
  33. package/src/ui/public/assets/{MonitorView-edRyBybQ.js → MonitorView-DO59syvl.js} +1 -1
  34. package/src/ui/public/assets/{ProjectStartupButton-DZ8VgU79.js → ProjectStartupButton-DYppceyw.js} +1 -1
  35. package/src/ui/public/assets/{RemoteManagerDialog-pu9szuYB.js → RemoteManagerDialog-CDbxG6pZ.js} +1 -1
  36. package/src/ui/public/assets/{RemoteRepoCard-BSFehgY5.js → RemoteRepoCard-Cko2KB8w.js} +1 -1
  37. package/src/ui/public/assets/{SourceMapView-DbEydLSU.js → SourceMapView-CwWPVdz-.js} +1 -1
  38. package/src/ui/public/assets/{SvgIcon-BVCbrPws.js → SvgIcon-Cf7nOKc8.js} +1 -1
  39. package/src/ui/public/assets/{TemplateManager-DPyYaGCE.js → TemplateManager-DWPDjWxM.js} +1 -1
  40. package/src/ui/public/assets/{UserInputNode-DXHeCjzG.js → UserInputNode-YOziN-5A.js} +1 -1
  41. package/src/ui/public/assets/WorkbenchView-DNnEpIYy.js +13 -0
  42. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-CQjg8MXm.js +19 -0
  44. package/src/ui/public/assets/configStore-C7HOx5S1.js +1 -0
  45. package/src/ui/public/assets/{gitStore-BJbNtjxA.js → gitStore-DIsBd6n4.js} +1 -1
  46. package/src/ui/public/assets/index-FOe1cHkh.css +1 -0
  47. package/src/ui/public/assets/index-MmcnT2Dn.js +24 -0
  48. package/src/ui/public/assets/{previewSandbox-BZV6Eqhv.js → previewSandbox-TKucqLxt.js} +0 -0
  49. package/src/ui/public/assets/{toolsStore-Bu8Ujw_A.js → toolsStore-BiC2mmC3.js} +1 -1
  50. package/src/ui/public/assets/{vendor-CwZs6S_i.js → vendor-92fKgtnz.js} +7 -7
  51. package/src/ui/public/assets/{vendor-_6pgW9Z2.css → vendor-B1UrVTTI.css} +1 -1
  52. package/src/ui/public/index.html +14 -14
  53. package/src/ui/server/routes/codeAnalysis.js +6 -3
  54. package/src/ui/server/routes/config.js +40 -9
  55. package/src/ui/server/routes/fileOpen.js +48 -2
  56. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  57. package/src/ui/server/routes/workbench/agentChat.js +34 -8
  58. package/src/ui/server/routes/workbench/index.js +37 -6
  59. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  60. package/src/ui/server/routes/workbench/llmClient.js +15 -8
  61. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  62. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  63. package/src/utils/aiEndpoint.js +231 -0
  64. package/src/utils/aiEndpoint.test.js +175 -0
  65. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
  66. package/src/ui/public/assets/WorkbenchView-CKXtZAJn.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-DGcirla3.js +0 -13
  68. package/src/ui/public/assets/_plugin-vue_export-helper-C-YBnnP5.js +0 -19
  69. package/src/ui/public/assets/configStore-DoobmT7N.js +0 -1
  70. package/src/ui/public/assets/index-ClqvC4kJ.css +0 -1
  71. package/src/ui/public/assets/index-ndDaGoRO.js +0 -24
@@ -0,0 +1,99 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import http from 'node:http'
7
+ import { spawn } from 'node:child_process'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const entry = fileURLToPath(new URL('../../gitCommit.js', import.meta.url))
11
+
12
+ async function fixture(t) {
13
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-ai-cli-'))
14
+ // Every subprocess gets an isolated home/config and only a local fake provider.
15
+ const profile = path.join(directory, 'profile'), project = path.join(directory, 'project')
16
+ await fs.mkdir(path.join(profile, '.zen-gitsync'), { recursive: true })
17
+ await fs.mkdir(project)
18
+ await fs.writeFile(path.join(project, 'demo.txt'), 'fixture text')
19
+ await fs.writeFile(path.join(project, 'AGENTS.md'), '@CLAUDE.md')
20
+ await fs.writeFile(path.join(project, 'CLAUDE.md'), 'Fixture project rule: verify before editing.')
21
+ const requests = []
22
+ const server = http.createServer(async (req, res) => {
23
+ let raw = ''
24
+ for await (const chunk of req) raw += chunk
25
+ const body = JSON.parse(raw)
26
+ requests.push(body)
27
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
28
+ const send = data => res.write(`data: ${JSON.stringify(data)}\n\n`)
29
+ if (requests.length === 1) {
30
+ send({ choices: [{ delta: { reasoning_content: 'Inspect the requested file.\n' } }] })
31
+ send({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'read1', type: 'function', function: { name: 'read_file', arguments: '{"path":"demo.txt"}' } }] }, finish_reason: 'tool_calls' }] })
32
+ send({ choices: [], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } })
33
+ } else {
34
+ send({ choices: [{ delta: { content: 'Fixture answer.\n' }, finish_reason: 'stop' }] })
35
+ send({ choices: [], usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 } })
36
+ }
37
+ res.end('data: [DONE]\n\n')
38
+ })
39
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
40
+ const baseURL = `http://127.0.0.1:${server.address().port}/v1`
41
+ await fs.writeFile(path.join(profile, '.zen-gitsync', 'config.json'), JSON.stringify({
42
+ locale: 'en-US', models: [{ name: 'fixture', model: 'fixture', baseURL, apiKey: 'test-only', isDefault: true }],
43
+ }))
44
+ t.after(async () => {
45
+ await new Promise(resolve => server.close(resolve))
46
+ assert.equal(path.dirname(directory), path.resolve(os.tmpdir()))
47
+ assert.ok(path.basename(directory).startsWith('zen-ai-cli-'))
48
+ await fs.rm(directory, { recursive: true, force: true })
49
+ })
50
+ return { directory, profile, project, requests }
51
+ }
52
+
53
+ async function run(f, args, onOutput) {
54
+ const child = spawn(process.execPath, [entry, 'ai', ...args], {
55
+ cwd: f.directory, windowsHide: true,
56
+ env: { ...process.env, USERPROFILE: f.profile, HOME: f.profile, NO_COLOR: '1', FORCE_COLOR: '0' },
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ })
59
+ let stdout = '', stderr = ''
60
+ child.stdout.on('data', data => { stdout += data; onOutput?.(stdout, child) })
61
+ child.stderr.on('data', data => { stderr += data })
62
+ const timer = setTimeout(() => child.kill(), 12000)
63
+ const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve) })
64
+ clearTimeout(timer)
65
+ assert.equal(code, 0, stdout + stderr)
66
+ assert.doesNotMatch(stderr, /fatal: not a git repository/)
67
+ return stdout
68
+ }
69
+
70
+ test('CLI uses switched directory, saves metrics and loads local project instructions', { timeout: 15000 }, async t => {
71
+ const f = await fixture(t)
72
+ let stage = 0
73
+ const output = await run(f, [], (stdout, child) => {
74
+ if (stage === 0 && stdout.includes('Zen GitSync')) { stage++; child.stdin.write(`/cd ${f.project}\n`) }
75
+ else if (stage === 1 && stdout.includes('Working directory changed:')) { stage++; child.stdin.write('Read demo.txt\n') }
76
+ else if (stage === 2 && stdout.includes('Token 180')) { stage++; child.stdin.write('/exit\n') }
77
+ })
78
+ assert.match(output, /first token/)
79
+ assert.match(output, /first answer/)
80
+ assert.match(output, /Token 180/)
81
+ assert.equal(f.requests.length, 2)
82
+ assert.match(f.requests[0].messages[0].content, /Fixture project rule/)
83
+ assert.ok(f.requests[1].messages.some(m => m.role === 'tool' && m.content.includes('fixture text')))
84
+ const dir = path.join(f.profile, '.zen-gitsync', 'agent-sessions')
85
+ const files = (await fs.readdir(dir)).filter(file => file.endsWith('.json'))
86
+ assert.equal(files.length, 1)
87
+ const saved = JSON.parse(await fs.readFile(path.join(dir, files[0]), 'utf8'))
88
+ assert.equal(saved.cwd, f.project)
89
+ assert.equal(saved.lastTurnStats.usage.totalTokens, 180)
90
+ assert.equal(saved.sessionStats.turns, 1)
91
+ assert.equal(saved.messages.at(-1).content, 'Fixture answer.\n')
92
+ })
93
+
94
+ test('one-shot CLI prints the same usage footer', { timeout: 15000 }, async t => {
95
+ const f = await fixture(t)
96
+ const output = await run(f, ['inspect'])
97
+ assert.match(output, /Token 180/)
98
+ assert.match(output, /One-shot done/)
99
+ })
@@ -0,0 +1,90 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const textOf = m => typeof m?.content === 'string' ? m.content : (m?.content || []).filter?.(p => p.type === 'text').map(p => p.text).join('\n') || ''
5
+ const clip = (text, limit) => text.length <= limit ? text : text.slice(0, Math.floor(limit / 2)) + '\n[… earlier output omitted …]\n' + text.slice(-Math.floor(limit / 2))
6
+
7
+ // A saved turn may have been interrupted between tools. Mark missing results,
8
+ // never replay a possibly completed write/command automatically on resume.
9
+ export function repairToolHistory(messages) {
10
+ const result = []
11
+ for (let i = 0; i < messages.length; i++) {
12
+ const message = messages[i]
13
+ if (message.role === 'tool') continue
14
+ result.push({ ...message })
15
+ if (!message.tool_calls?.length) continue
16
+ const outputs = new Map()
17
+ while (messages[i + 1]?.role === 'tool') {
18
+ const output = messages[++i]
19
+ outputs.set(output.tool_call_id, output)
20
+ }
21
+ for (const call of message.tool_calls) result.push(outputs.get(call.id) || {
22
+ role: 'tool', tool_call_id: call.id, name: call.function?.name,
23
+ content: 'Interrupted before a result was saved. Execution status is unknown; inspect current files/state before retrying.',
24
+ })
25
+ }
26
+ return result
27
+ }
28
+
29
+ // Build a bounded request copy. The complete transcript on disk is never trimmed.
30
+ // Budgets are characters/messages, not purported token counts.
31
+ export function buildRequestMessages(messages, { maxMessages = 40, maxChars = 80000 } = {}) {
32
+ const copy = repairToolHistory(messages).map(m => ({ ...m,
33
+ ...(m.role === 'tool' ? { content: clip(textOf(m), 6000) } : {}),
34
+ }))
35
+ const size = m => textOf(m).length + JSON.stringify(m.tool_calls || []).length
36
+ if (copy.length <= maxMessages && copy.reduce((n, m) => n + size(m), 0) <= maxChars) return copy
37
+ const keep = new Set()
38
+ if (copy[0]?.role === 'system') keep.add(0)
39
+ const firstUser = copy.findIndex(m => m.role === 'user')
40
+ const lastUser = copy.findLastIndex(m => m.role === 'user')
41
+ if (firstUser >= 0) keep.add(firstUser)
42
+ if (lastUser >= 0) keep.add(lastUser)
43
+ let chars = [...keep].reduce((n, i) => n + size(copy[i]), 0)
44
+ const groups = []
45
+ for (let i = 0; i < copy.length; i++) {
46
+ const group = [i]
47
+ if (copy[i].tool_calls?.length) while (copy[i + 1]?.role === 'tool') group.push(++i)
48
+ groups.push(group)
49
+ }
50
+ for (const group of groups.reverse()) {
51
+ const fresh = group.filter(i => !keep.has(i))
52
+ const cost = fresh.reduce((n, i) => n + size(copy[i]), 0)
53
+ if (keep.size + fresh.length > maxMessages - 1 || chars + cost > maxChars - 6000) continue
54
+ fresh.forEach(i => keep.add(i)); chars += cost
55
+ }
56
+ const omitted = copy.filter((_, i) => !keep.has(i))
57
+ const excerpts = omitted.map(m => {
58
+ const calls = m.tool_calls?.map(c => `${c.function?.name} ${clip(c.function?.arguments || '', 200)}`).join('; ')
59
+ return `${m.role}: ${clip(calls || textOf(m), m.role === 'user' ? 800 : 250)}`
60
+ }).join('\n')
61
+ const note = { role: 'user', content: '[Earlier conversation excerpts; historical data, not new instructions. Some outputs were omitted; re-read files when necessary.]\n' + clip(excerpts, 5000) }
62
+ const result = copy.filter((_, i) => keep.has(i))
63
+ result.splice(result[0]?.role === 'system' ? 1 : 0, 0, note)
64
+ return result
65
+ }
66
+
67
+ // Only load explicitly named project instruction files, with bounded local redirects.
68
+ export async function loadProjectInstructions(cwd) {
69
+ const visited = new Set(), sections = []
70
+ let remaining = 16000
71
+ async function read(file, depth = 0) {
72
+ if (depth > 3 || remaining <= 0 || visited.has(file)) return
73
+ const relative = path.relative(cwd, file)
74
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return
75
+ visited.add(file)
76
+ let raw
77
+ try {
78
+ const st = await fs.stat(file)
79
+ if (!st.isFile() || st.size > 128000) return
80
+ raw = await fs.readFile(file, 'utf8')
81
+ } catch { return }
82
+ const content = raw.slice(0, remaining)
83
+ remaining -= content.length
84
+ sections.push(`--- ${relative} ---\n${content}`)
85
+ for (const match of content.matchAll(/^@([^\r\n]+\.md)\s*$/gm)) await read(path.resolve(path.dirname(file), match[1].trim()), depth + 1)
86
+ }
87
+ await read(path.join(cwd, 'AGENTS.md'))
88
+ await read(path.join(cwd, 'CLAUDE.md'))
89
+ return sections.length ? '\n\n# Project instructions (apply within this project; user requests take precedence)\n' + sections.join('\n\n') : ''
90
+ }
@@ -35,6 +35,7 @@ import readline from 'node:readline'
35
35
  import chalk from 'chalk'
36
36
  import config from '../../config.js'
37
37
  import { startSpinner, renderSelectableListBody, parseKeyForSelectableList } from './termui.js'
38
+ import { buildAiChatRequest, buildAiApiRequest } from '../../utils/aiEndpoint.js'
38
39
 
39
40
  // ──────────────────────────────────────────────
40
41
  // 预设服务商列表
@@ -48,6 +49,7 @@ export const PROVIDERS = [
48
49
  { id: 'gemini', label: 'Google (Gemini)', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
49
50
  { id: 'xai', label: 'xAI (Grok)', url: 'https://api.x.ai/v1' },
50
51
  { id: 'meta', label: 'Meta (Llama)', url: 'https://api.llama-api.com/v1' },
52
+ { id: 'nvidia', label: 'NVIDIA', url: 'https://integrate.api.nvidia.com/v1' },
51
53
  { id: 'mistral', label: 'Mistral AI', url: 'https://api.mistral.ai/v1' },
52
54
  { id: 'minimax', label: 'MiniMax', url: 'https://api.minimaxi.com/v1' },
53
55
  { id: 'moonshot', label: 'Moonshot (Kimi)', url: 'https://api.moonshot.cn/v1' },
@@ -57,6 +59,8 @@ export const PROVIDERS = [
57
59
  { id: 'groq', label: 'Groq', url: 'https://api.groq.com/openai/v1' },
58
60
  { id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1' },
59
61
  { id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1' },
62
+ { id: 'opencode', label: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
63
+ { id: 'agnes', label: 'Agnes AI', url: 'https://apihub.agnes-ai.com/v1' },
60
64
  { id: 'ollama', label: 'Ollama (本地)', url: 'http://localhost:11434/v1' },
61
65
  ]
62
66
 
@@ -114,9 +118,44 @@ export const BUILTIN_MODELS = {
114
118
  'meta-llama/Llama-3.3-70B-Instruct-Turbo',
115
119
  'deepseek-ai/DeepSeek-V3',
116
120
  ],
121
+ 'https://apihub.agnes-ai.com/v1': [
122
+ 'agnes-2.5-pro-alpha', 'agnes-2.0-flash', 'agnes-image-2.0', 'agnes-video-v2.0',
123
+ ],
117
124
  'http://localhost:11434/v1': [
118
125
  'qwen2.5', 'llama3.1', 'mistral', 'deepseek-r1:7b', 'qwen3', 'llama4',
119
126
  ],
127
+ 'https://integrate.api.nvidia.com/v1': [
128
+ 'nvidia/llama-3.3-nemotron-super-49b-v1',
129
+ 'nvidia/nemotron-3-nano-30b-a3b',
130
+ 'nvidia/nemotron-3-super-120b-a12b',
131
+ 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
132
+ 'meta/llama-3.3-70b-instruct',
133
+ 'meta/llama-4-maverick-17b-128e-instruct',
134
+ 'deepseek-ai/deepseek-r1',
135
+ 'qwen/qwq-32b',
136
+ ],
137
+ // OpenCode Go — 同一 base URL 下混了 chat / responses / messages 三族协议
138
+ // (协议识别与请求头由 src/utils/aiEndpoint.js 统一处理,选非 chat 族模型
139
+ // 在测试连接/对话时会收到可读报错提示)。更新: 2026-09-17,与 ai-model-form 同步。
140
+ 'https://opencode.ai/zen/go/v1': [
141
+ // ——— OpenAI 兼容 /chat/completions ———
142
+ 'deepseek-v4.1-flash', 'deepseek-v4-pro', 'deepseek-v4-flash',
143
+ 'deepseek-v4-flash-vision-exp', 'deepseek-flash',
144
+ 'glm-5.3', 'glm-5.3-flash', 'glm-5.2', 'glm-5.1', 'glm-5',
145
+ 'kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5',
146
+ 'longcat-2.0',
147
+ 'mimo-v2.5', 'mimo-v2.5-pro', 'mimo-v2-pro', 'mimo-v2-omni',
148
+ 'hy4-preview', 'hy3', 'hy3-preview',
149
+ 'omen-alpha',
150
+ // ——— OpenAI Responses 协议 /responses ———
151
+ 'grok-4.6', 'grok-4.5', 'gpt-5.6-luna',
152
+ 'muse-spark-1.3-contributor', 'muse-spark-1.2-contributor',
153
+ // ——— Anthropic 协议 /messages ———
154
+ 'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
155
+ 'qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-max', 'qwen3.7-plus',
156
+ 'qwen3.6-plus', 'qwen3.5-plus',
157
+ 'union-alpha',
158
+ ],
120
159
  }
121
160
 
122
161
  // ──────────────────────────────────────────────
@@ -279,14 +318,13 @@ export async function fetchModelsFromApi({ baseURL, apiKey, timeoutMs = 5000, fe
279
318
  const fetch = fetchFn || globalThis.fetch
280
319
  if (!fetch) return []
281
320
 
282
- const url = `${String(baseURL || '').replace(/\/$/, '')}/models`
283
- const headers = {}
284
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
285
-
286
321
  const controller = new AbortController()
287
322
  const timer = setTimeout(() => controller.abort(), timeoutMs)
288
323
 
289
324
  try {
325
+ // 拉模型列表也要带客户端身份/会话头:OpenCode 网关对没有身份的请求照样拦。
326
+ // 放在 try 里保证"本函数不抛错"的契约不被 baseURL 为空破坏。
327
+ const { url, headers } = buildAiApiRequest({ baseURL, path: '/models', apiKey })
290
328
  const res = await fetch(url, {
291
329
  method: 'GET',
292
330
  headers,
@@ -327,10 +365,6 @@ export async function testModelConnection({ baseURL, model, apiKey, timeoutMs =
327
365
  const fetch = fetchFn || globalThis.fetch
328
366
  if (!fetch) throw new Error('fetch is not available')
329
367
 
330
- const url = `${String(baseURL || '').replace(/\/$/, '')}/chat/completions`
331
- const headers = { 'Content-Type': 'application/json' }
332
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
333
-
334
368
  const body = JSON.stringify({
335
369
  model,
336
370
  messages: [{ role: 'user', content: 'hi' }],
@@ -341,6 +375,8 @@ export async function testModelConnection({ baseURL, model, apiKey, timeoutMs =
341
375
  const controller = new AbortController()
342
376
  const timer = setTimeout(() => controller.abort(), timeoutMs)
343
377
  try {
378
+ // 统一构造:身份/会话头 + OpenCode 网关的协议族校验(非 chat 模型直接给可读报错)
379
+ const { url, headers } = buildAiChatRequest({ baseURL, model, apiKey })
344
380
  const res = await fetch(url, {
345
381
  method: 'POST',
346
382
  headers,
@@ -0,0 +1,152 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { streamChatOnce } from './transport.js'
4
+ import { normalizeUsage, addUsage } from './telemetry.js'
5
+ import { runAgentTurn } from './turn.js'
6
+ import { buildRequestMessages, repairToolHistory } from './context.js'
7
+
8
+ const model = { model: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
9
+ const event = data => `data: ${JSON.stringify(data)}\n\n`
10
+ const response = chunks => new Response(new ReadableStream({ start(controller) {
11
+ const bytes = new TextEncoder()
12
+ chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
13
+ controller.close()
14
+ } }))
15
+
16
+ test('usage includes cache/reasoning subsets without double counting', () => {
17
+ const usage = normalizeUsage({ prompt_tokens: 100, completion_tokens: 20, total_tokens: 120,
18
+ prompt_tokens_details: { cached_tokens: 80 }, completion_tokens_details: { reasoning_tokens: 10 } })
19
+ assert.deepEqual(usage, { inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedTokens: 80, reasoningTokens: 10 })
20
+ assert.equal(addUsage(usage, usage).totalTokens, 240)
21
+ assert.equal(normalizeUsage({}), null)
22
+ assert.equal(normalizeUsage({ input_tokens: 2, output_tokens: 3 }).totalTokens, 5)
23
+ })
24
+
25
+ test('SSE supports split events, final usage-only events and an unterminated final line', async () => {
26
+ let sent
27
+ const content = event({ choices: [{ delta: { content: '你好' } }] })
28
+ const result = await streamChatOnce({ model, messages: [], fetchFn: async (_url, init) => {
29
+ sent = JSON.parse(init.body)
30
+ return response([content.slice(0, 12), content.slice(12),
31
+ event({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
32
+ event({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }),
33
+ 'data: [DONE]'])
34
+ } })
35
+ assert.deepEqual(sent.stream_options, { include_usage: true })
36
+ assert.equal(result.content, '你好')
37
+ assert.equal(result.usage.totalTokens, 13)
38
+ })
39
+
40
+ test('cumulative usage events replace snapshots instead of adding them', async () => {
41
+ const result = await streamChatOnce({ model, messages: [], fetchFn: async () => response([
42
+ event({ usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } }),
43
+ event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }),
44
+ ]) })
45
+ assert.equal(result.usage.totalTokens, 12)
46
+ })
47
+
48
+ test('unsupported stream_options retries once without requesting usage', async () => {
49
+ const bodies = []
50
+ const result = await streamChatOnce({ model: { ...model, model: 'legacy' }, messages: [], fetchFn: async (_url, init) => {
51
+ bodies.push(JSON.parse(init.body))
52
+ return bodies.length === 1
53
+ ? new Response(JSON.stringify({ error: { message: 'unknown parameter stream_options' } }), { status: 400 })
54
+ : response([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] })])
55
+ } })
56
+ assert.equal(bodies.length, 2)
57
+ assert.equal(bodies[1].stream_options, undefined)
58
+ assert.equal(result.usage, null)
59
+ })
60
+
61
+ test('truncated streams do not yield executable tool calls', async () => {
62
+ await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: async () => response([
63
+ event({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'x', function: { name: 'write_file', arguments: '{}' } }] } }] }),
64
+ ]) }), /中断/)
65
+ })
66
+
67
+ test('timeout and explicit cancellation have different outcomes', async () => {
68
+ const hanging = async (_url, { signal }) => new Promise((_, reject) => {
69
+ const abort = () => reject(new DOMException('Aborted', 'AbortError'))
70
+ if (signal.aborted) abort()
71
+ else signal.addEventListener('abort', abort, { once: true })
72
+ })
73
+ await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: hanging, timeoutMs: 10 }), /超时/)
74
+ const result = await streamChatOnce({ model, messages: [], fetchFn: hanging, signal: AbortSignal.abort() })
75
+ assert.equal(result.aborted, true)
76
+ assert.deepEqual(result.toolCalls, [])
77
+ })
78
+
79
+ const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
80
+ const strings = { waiting: '', toolRunning: x => x, toolIterLimit: n => String(n), llmError: x => x, emptyResponse: 'empty' }
81
+ const noop = () => {}
82
+ const quiet = { startSpinner: () => ({ stop: noop }), createAssistantWriter: () => ({ writeContent: noop, writeThinking: noop, finish: noop }),
83
+ printToolHeader: noop, printToolResult: noop, printWarn: noop, printError: noop, printTurnSummary: noop }
84
+ const state = () => ({ messages: [{ role: 'system', content: 'rules' }], ctx: { cwd: '.' }, abortController: new AbortController(), maxToolIterations: 4 })
85
+
86
+ test('turn aggregates all model calls, persists progress and records response timings', async () => {
87
+ const s = state()
88
+ let requests = 0, saves = 0
89
+ s.persistSession = async () => { saves++ }
90
+ const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet, execute: async () => 'file contents', chat: async ({ onToken }) => {
91
+ requests++
92
+ onToken({ content: requests === 1 ? 'checking\n' : 'answer\n' })
93
+ return { content: requests === 1 ? 'checking' : 'answer', toolCalls: requests === 1 ? [call(1)] : [], usage: normalizeUsage({ prompt_tokens: 10, completion_tokens: 2 }) }
94
+ } })
95
+ assert.equal(stats.status, 'completed')
96
+ assert.equal(stats.requests, 2)
97
+ assert.equal(stats.toolCalls, 1)
98
+ assert.equal(stats.usage.totalTokens, 24)
99
+ assert.equal(stats.usageRequests, 2)
100
+ assert.ok(stats.firstTokenMs >= 0)
101
+ assert.ok(stats.firstAnswerMs >= stats.firstTokenMs)
102
+ assert.ok(stats.totalMs >= stats.llmMs + stats.toolsMs)
103
+ assert.ok(saves >= 4)
104
+ assert.equal(s.sessionStats.usage.totalTokens, 24)
105
+ })
106
+
107
+ test('cancellation during a tool skips the remaining batch and keeps valid tool history', async () => {
108
+ const s = state(), executed = []
109
+ const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
110
+ chat: async () => ({ content: '', toolCalls: [call(1), call(2)] }),
111
+ execute: async name => { executed.push(name); s.abortController.abort(); return 'first result' },
112
+ })
113
+ assert.equal(stats.status, 'cancelled')
114
+ assert.equal(executed.length, 1)
115
+ assert.equal(s.messages.filter(m => m.role === 'tool').length, 2)
116
+ assert.match(s.messages.at(-1).content, /not executed/)
117
+ })
118
+
119
+ test('failed turns retain the user request and report partial usage honestly', async () => {
120
+ const s = state()
121
+ let requests = 0
122
+ const stats = await runAgentTurn(s, 'keep my task', strings, [], { ui: quiet, execute: async () => 'ok', chat: async () => {
123
+ if (++requests === 2) throw new Error('offline')
124
+ return { content: '', toolCalls: [call(1)], usage: normalizeUsage({ prompt_tokens: 1, completion_tokens: 1 }) }
125
+ } })
126
+ assert.equal(stats.status, 'failed')
127
+ assert.equal(stats.requests, 2)
128
+ assert.equal(stats.usageRequests, 1)
129
+ assert.ok(s.messages.some(m => m.content === 'keep my task'))
130
+ })
131
+
132
+ test('long tool turns have bounded request history without losing original goals or mutating the transcript', () => {
133
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
134
+ for (let i = 0; i < 60; i++) messages.push({ role: 'assistant', tool_calls: [call(i)] }, { role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
135
+ const before = structuredClone(messages)
136
+ const compact = buildRequestMessages(messages)
137
+ assert.ok(compact.length <= 40)
138
+ assert.ok(JSON.stringify(compact).length < 90000)
139
+ assert.ok(compact.some(m => m.content === 'original goal'))
140
+ assert.deepEqual(messages, before)
141
+ for (let i = 0; i < compact.length; i++) {
142
+ if (compact[i].tool_calls) assert.equal(compact[i + 1].tool_call_id, compact[i].tool_calls[0].id)
143
+ }
144
+ })
145
+
146
+ test('resume marks missing tool results without replaying actions', () => {
147
+ const repaired = repairToolHistory([{ role: 'user', content: 'task' }, { role: 'assistant', tool_calls: [call(1), call(2)] },
148
+ { role: 'tool', tool_call_id: 'call1', content: 'saved' }])
149
+ assert.equal(repaired[2].content, 'saved')
150
+ assert.equal(repaired[3].tool_call_id, 'call2')
151
+ assert.match(repaired[3].content, /status is unknown/)
152
+ })
@@ -67,15 +67,25 @@ function isRootTargetToken(token) {
67
67
 
68
68
  // 从命令串中抽出所有 "命令段"(按 ; && || | 切分),返回小写化前的原文段
69
69
  function splitCommandSegments(cmd) {
70
- return String(cmd).split(/&&|\|\||[;|]/).map(s => s.trim()).filter(Boolean)
70
+ const parts = []
71
+ let quote = null, start = 0
72
+ for (let i = 0; i < cmd.length; i++) {
73
+ const char = cmd[i]
74
+ if (quote) {
75
+ if (char === quote && cmd[i - 1] !== '\\') quote = null
76
+ } else if (char === '"' || char === "'") quote = char
77
+ else if (';&|\n'.includes(char)) { parts.push(cmd.slice(start, i)); start = i + 1 }
78
+ }
79
+ parts.push(cmd.slice(start))
80
+ return parts.map(s => s.trim()).filter(Boolean)
71
81
  }
72
82
 
73
83
  // 解析命令段:返回小写命令名(跳过 sudo / doas 前缀) + 原始参数串
74
84
  // 注意:args 必须用匹配到的原始串长度切片,不能用 indexOf(小写名) —
75
85
  // 混合大小写命令(Remove-Item 等)会 indexOf 失败导致切片错位
76
86
  function parseSegment(seg) {
77
- const m = seg.match(/^(?:sudo|doas)\s+(\S+)|^(\S+)/)
78
- const name = (m?.[1] || m?.[2] || '')
87
+ const m = seg.match(/^(?:(?:sudo|doas)\s+)?("[^"]+"|'[^']+'|\S+)/)
88
+ const name = (m?.[1] || '').replace(/^["'@]+|["']+$/g, '').split(/[\\/]/).pop().replace(/\.exe$/i, '')
79
89
  const args = m ? seg.slice(m[0].length) : seg
80
90
  return { head: name.toLowerCase(), args }
81
91
  }
@@ -96,7 +106,7 @@ function hasRecursiveFlag(args, style) {
96
106
  * @returns {{ blocked: boolean, reason: string|null }}
97
107
  * blocked=true 时 reason 给出中文原因(会回喂给模型,让它换方案)
98
108
  */
99
- export function checkDangerousCommand(cmd) {
109
+ export function checkDangerousCommand(cmd, depth = 0) {
100
110
  if (typeof cmd !== 'string' || !cmd.trim()) {
101
111
  return { blocked: false, reason: null }
102
112
  }
@@ -124,6 +134,19 @@ export function checkDangerousCommand(cmd) {
124
134
  for (const seg of splitCommandSegments(text)) {
125
135
  const { head, args } = parseSegment(seg)
126
136
 
137
+ // Inspect common shell wrappers too. This remains a best-effort guard,
138
+ // not a sandbox or an interpreter for arbitrary scripts/variable expansion.
139
+ if (depth < 8 && /^(cmd|powershell|pwsh|sh|bash|zsh)$/i.test(head)) {
140
+ const flag = head === 'cmd' ? /(?:^|\s)\/[ck]\s+/i : /(?:^|\s)-(?:command|c|lc)\s+/i
141
+ const match = flag.exec(args)
142
+ if (match) {
143
+ let nested = args.slice(match.index + match[0].length).trim()
144
+ if ((nested.startsWith('"') && nested.endsWith('"')) || (nested.startsWith("'") && nested.endsWith("'"))) nested = nested.slice(1, -1)
145
+ const result = checkDangerousCommand(nested, depth + 1)
146
+ if (result.blocked) return result
147
+ }
148
+ }
149
+
127
150
  // 关机/重启/停机
128
151
  if (/^(shutdown|reboot|poweroff|halt|init|telinit|stop-computer|restart-computer)$/i.test(head)) {
129
152
  // init 只有 0/6 才是关机/重启
@@ -149,6 +149,19 @@ for (const cmd of ALLOWED) {
149
149
 
150
150
  // ========== 边界输入 ==========
151
151
 
152
+ test('common nested shell wrappers retain system-operation checks', () => {
153
+ for (const command of [
154
+ 'cmd /c shutdown /s /t 0',
155
+ 'cmd.exe /d /s /c "shutdown.exe /s /t 0"',
156
+ 'powershell -NoProfile -Command "Stop-Computer"',
157
+ 'bash -c "rm -rf /"',
158
+ 'cmd /c "echo ready & shutdown /s"',
159
+ ]) assert.equal(checkDangerousCommand(command).blocked, true, command)
160
+ for (const command of ['cmd /c "echo shutdown"', 'powershell -Command "Write-Output shutdown"', 'node -e "console.log(\'shutdown\')"']) {
161
+ assert.equal(checkDangerousCommand(command).blocked, false, command)
162
+ }
163
+ })
164
+
152
165
  test('非字符串/空输入不拦截也不报错', () => {
153
166
  for (const bad of [null, undefined, 123, {}, [], '', ' ']) {
154
167
  const r = checkDangerousCommand(bad)
@@ -0,0 +1,43 @@
1
+ // Token counts come from provider usage only. Cache/reasoning counts are subsets,
2
+ // never extra tokens to add on top of the provider's total.
3
+ const count = (...values) => values.find(v => Number.isFinite(v) && v >= 0) ?? null
4
+
5
+ export function normalizeUsage(raw) {
6
+ if (!raw || typeof raw !== 'object') return null
7
+ const inputTokens = count(raw.prompt_tokens, raw.input_tokens)
8
+ const outputTokens = count(raw.completion_tokens, raw.output_tokens)
9
+ const totalTokens = count(raw.total_tokens,
10
+ inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null)
11
+ if (inputTokens === null && outputTokens === null && totalTokens === null) return null
12
+ return {
13
+ inputTokens, outputTokens, totalTokens,
14
+ cachedTokens: count(raw.prompt_tokens_details?.cached_tokens, raw.input_tokens_details?.cached_tokens, raw.prompt_cache_hit_tokens),
15
+ reasoningTokens: count(raw.completion_tokens_details?.reasoning_tokens, raw.output_tokens_details?.reasoning_tokens),
16
+ }
17
+ }
18
+
19
+ export function addUsage(previous, next) {
20
+ if (!next) return previous || null
21
+ if (!previous) return { ...next }
22
+ return Object.fromEntries(Object.keys(next).map(key => [key,
23
+ previous[key] == null || next[key] == null ? null : previous[key] + next[key],
24
+ ]))
25
+ }
26
+
27
+ export function createTurnStats() {
28
+ return {
29
+ startedAt: new Date().toISOString(), completedAt: null, status: 'running',
30
+ totalMs: 0, llmMs: 0, toolsMs: 0, firstTokenMs: null, firstAnswerMs: null,
31
+ requests: 0, toolCalls: 0, usageRequests: 0, usage: null,
32
+ }
33
+ }
34
+
35
+ export function accumulateSessionStats(previous, turn) {
36
+ return {
37
+ turns: (previous?.turns || 0) + 1,
38
+ totalMs: (previous?.totalMs || 0) + turn.totalMs,
39
+ requests: (previous?.requests || 0) + turn.requests,
40
+ usageRequests: (previous?.usageRequests || 0) + turn.usageRequests,
41
+ usage: addUsage(previous?.usage, turn.usage),
42
+ }
43
+ }