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
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
for (let i = 1; i <= 15; i++) {
|
|
31
|
+
for (const chunk of [`Inspection step ${i}: `, 'check the requested file.\n\n']) {
|
|
32
|
+
send({ choices: [{ delta: { reasoning_content: chunk } }] })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
send({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'read1', type: 'function', function: { name: 'read_file', arguments: '{"path":"demo.txt"}' } }] }, finish_reason: 'tool_calls' }] })
|
|
36
|
+
send({ choices: [], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } })
|
|
37
|
+
} else {
|
|
38
|
+
send({ choices: [{ delta: { content: 'Fixture answer.\n' }, finish_reason: 'stop' }] })
|
|
39
|
+
send({ choices: [], usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 } })
|
|
40
|
+
}
|
|
41
|
+
res.end('data: [DONE]\n\n')
|
|
42
|
+
})
|
|
43
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
44
|
+
const baseURL = `http://127.0.0.1:${server.address().port}/v1`
|
|
45
|
+
await fs.writeFile(path.join(profile, '.zen-gitsync', 'config.json'), JSON.stringify({
|
|
46
|
+
locale: 'en-US', models: [{ name: 'fixture', model: 'fixture', baseURL, apiKey: 'test-only', isDefault: true }],
|
|
47
|
+
}))
|
|
48
|
+
t.after(async () => {
|
|
49
|
+
await new Promise(resolve => server.close(resolve))
|
|
50
|
+
assert.equal(path.dirname(directory), path.resolve(os.tmpdir()))
|
|
51
|
+
assert.ok(path.basename(directory).startsWith('zen-ai-cli-'))
|
|
52
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
53
|
+
})
|
|
54
|
+
return { directory, profile, project, requests }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function run(f, args, onOutput) {
|
|
58
|
+
const child = spawn(process.execPath, [entry, 'ai', ...args], {
|
|
59
|
+
cwd: f.directory, windowsHide: true,
|
|
60
|
+
env: { ...process.env, USERPROFILE: f.profile, HOME: f.profile, NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
})
|
|
63
|
+
let stdout = '', stderr = ''
|
|
64
|
+
child.stdout.on('data', data => { stdout += data; onOutput?.(stdout, child) })
|
|
65
|
+
child.stderr.on('data', data => { stderr += data })
|
|
66
|
+
const timer = setTimeout(() => child.kill(), 12000)
|
|
67
|
+
const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve) })
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
assert.equal(code, 0, stdout + stderr)
|
|
70
|
+
assert.doesNotMatch(stderr, /fatal: not a git repository/)
|
|
71
|
+
return stdout
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('CLI uses switched directory, saves metrics and loads local project instructions', { timeout: 15000 }, async t => {
|
|
75
|
+
const f = await fixture(t)
|
|
76
|
+
let stage = 0
|
|
77
|
+
const output = await run(f, [], (stdout, child) => {
|
|
78
|
+
if (stage === 0 && stdout.includes('Zen GitSync')) { stage++; child.stdin.write(`/cd ${f.project}\n`) }
|
|
79
|
+
else if (stage === 1 && stdout.includes('Working directory changed:')) { stage++; child.stdin.write('Read demo.txt\n') }
|
|
80
|
+
else if (stage === 2 && stdout.includes('Token 180')) { stage++; child.stdin.write('/exit\n') }
|
|
81
|
+
})
|
|
82
|
+
assert.match(output, /first token/)
|
|
83
|
+
assert.match(output, /first answer/)
|
|
84
|
+
assert.match(output, /Token 180/)
|
|
85
|
+
assert.match(output, /Inspection step 15:/)
|
|
86
|
+
assert.doesNotMatch(output, /first 12 lines shown/)
|
|
87
|
+
assert.equal(f.requests.length, 2)
|
|
88
|
+
assert.match(f.requests[0].messages[0].content, /Fixture project rule/)
|
|
89
|
+
assert.ok(f.requests[1].messages.some(m => m.role === 'tool' && m.content.includes('fixture text')))
|
|
90
|
+
const dir = path.join(f.profile, '.zen-gitsync', 'agent-sessions')
|
|
91
|
+
const files = (await fs.readdir(dir)).filter(file => file.endsWith('.json'))
|
|
92
|
+
assert.equal(files.length, 1)
|
|
93
|
+
const saved = JSON.parse(await fs.readFile(path.join(dir, files[0]), 'utf8'))
|
|
94
|
+
assert.equal(saved.cwd, f.project)
|
|
95
|
+
assert.equal(saved.lastTurnStats.usage.totalTokens, 180)
|
|
96
|
+
assert.equal(saved.sessionStats.turns, 1)
|
|
97
|
+
assert.equal(saved.messages.at(-1).content, 'Fixture answer.\n')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('one-shot CLI shows full thinking by default and prints the usage footer', { timeout: 15000 }, async t => {
|
|
101
|
+
const f = await fixture(t)
|
|
102
|
+
const output = await run(f, ['inspect'])
|
|
103
|
+
assert.match(output, /Token 180/)
|
|
104
|
+
assert.match(output, /One-shot done/)
|
|
105
|
+
assert.match(output, /Inspection step 15:/)
|
|
106
|
+
assert.doesNotMatch(output, /first 12 lines shown/)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('CLI help lists thinking modes and /think full shows reasoning beyond the preview', { timeout: 15000 }, async t => {
|
|
110
|
+
const f = await fixture(t)
|
|
111
|
+
let stage = 0
|
|
112
|
+
const output = await run(f, [], (stdout, child) => {
|
|
113
|
+
if (stage === 0 && stdout.includes('Zen GitSync')) { stage++; child.stdin.write('/help\n') }
|
|
114
|
+
else if (stage === 1 && stdout.includes('/think compact')) { stage++; child.stdin.write('/think full\n') }
|
|
115
|
+
else if (stage === 2 && stdout.includes('Thinking for future requests: full')) { stage++; child.stdin.write('Read demo.txt\n') }
|
|
116
|
+
else if (stage === 3 && stdout.includes('Token 180')) { stage++; child.stdin.write('/exit\n') }
|
|
117
|
+
})
|
|
118
|
+
assert.equal(stage, 4)
|
|
119
|
+
assert.match(output, /\/think full/)
|
|
120
|
+
assert.match(output, /\/think off/)
|
|
121
|
+
assert.match(output, /Inspection step 15: check the requested file\./)
|
|
122
|
+
assert.doesNotMatch(output, /first 12 lines shown/)
|
|
123
|
+
assert.equal(f.requests.length, 2, 'display commands must not call the model')
|
|
124
|
+
})
|
|
@@ -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
|
+
}
|
package/src/cli/ai/modelSetup.js
CHANGED
|
@@ -49,6 +49,7 @@ export const PROVIDERS = [
|
|
|
49
49
|
{ id: 'gemini', label: 'Google (Gemini)', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
50
50
|
{ id: 'xai', label: 'xAI (Grok)', url: 'https://api.x.ai/v1' },
|
|
51
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' },
|
|
52
53
|
{ id: 'mistral', label: 'Mistral AI', url: 'https://api.mistral.ai/v1' },
|
|
53
54
|
{ id: 'minimax', label: 'MiniMax', url: 'https://api.minimaxi.com/v1' },
|
|
54
55
|
{ id: 'moonshot', label: 'Moonshot (Kimi)', url: 'https://api.moonshot.cn/v1' },
|
|
@@ -58,6 +59,8 @@ export const PROVIDERS = [
|
|
|
58
59
|
{ id: 'groq', label: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
|
59
60
|
{ id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1' },
|
|
60
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' },
|
|
61
64
|
{ id: 'ollama', label: 'Ollama (本地)', url: 'http://localhost:11434/v1' },
|
|
62
65
|
]
|
|
63
66
|
|
|
@@ -115,9 +118,44 @@ export const BUILTIN_MODELS = {
|
|
|
115
118
|
'meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
|
116
119
|
'deepseek-ai/DeepSeek-V3',
|
|
117
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
|
+
],
|
|
118
124
|
'http://localhost:11434/v1': [
|
|
119
125
|
'qwen2.5', 'llama3.1', 'mistral', 'deepseek-r1:7b', 'qwen3', 'llama4',
|
|
120
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
|
+
],
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
// ──────────────────────────────────────────────
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
import { createAssistantWriter, stripAnsi } from './termui.js'
|
|
8
|
+
|
|
9
|
+
const model = { model: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
|
|
10
|
+
const event = data => `data: ${JSON.stringify(data)}\n\n`
|
|
11
|
+
const response = chunks => new Response(new ReadableStream({ start(controller) {
|
|
12
|
+
const bytes = new TextEncoder()
|
|
13
|
+
chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
|
|
14
|
+
controller.close()
|
|
15
|
+
} }))
|
|
16
|
+
|
|
17
|
+
test('usage includes cache/reasoning subsets without double counting', () => {
|
|
18
|
+
const usage = normalizeUsage({ prompt_tokens: 100, completion_tokens: 20, total_tokens: 120,
|
|
19
|
+
prompt_tokens_details: { cached_tokens: 80 }, completion_tokens_details: { reasoning_tokens: 10 } })
|
|
20
|
+
assert.deepEqual(usage, { inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedTokens: 80, reasoningTokens: 10 })
|
|
21
|
+
assert.equal(addUsage(usage, usage).totalTokens, 240)
|
|
22
|
+
assert.equal(normalizeUsage({}), null)
|
|
23
|
+
assert.equal(normalizeUsage({ input_tokens: 2, output_tokens: 3 }).totalTokens, 5)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('SSE supports split events, final usage-only events and an unterminated final line', async () => {
|
|
27
|
+
let sent
|
|
28
|
+
const content = event({ choices: [{ delta: { content: '你好' } }] })
|
|
29
|
+
const result = await streamChatOnce({ model, messages: [], fetchFn: async (_url, init) => {
|
|
30
|
+
sent = JSON.parse(init.body)
|
|
31
|
+
return response([content.slice(0, 12), content.slice(12),
|
|
32
|
+
event({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
|
|
33
|
+
event({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }),
|
|
34
|
+
'data: [DONE]'])
|
|
35
|
+
} })
|
|
36
|
+
assert.deepEqual(sent.stream_options, { include_usage: true })
|
|
37
|
+
assert.equal(result.content, '你好')
|
|
38
|
+
assert.equal(result.usage.totalTokens, 13)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('cumulative usage events replace snapshots instead of adding them', async () => {
|
|
42
|
+
const result = await streamChatOnce({ model, messages: [], fetchFn: async () => response([
|
|
43
|
+
event({ usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } }),
|
|
44
|
+
event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }),
|
|
45
|
+
]) })
|
|
46
|
+
assert.equal(result.usage.totalTokens, 12)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('unsupported stream_options retries once without requesting usage', async () => {
|
|
50
|
+
const bodies = []
|
|
51
|
+
const result = await streamChatOnce({ model: { ...model, model: 'legacy' }, messages: [], fetchFn: async (_url, init) => {
|
|
52
|
+
bodies.push(JSON.parse(init.body))
|
|
53
|
+
return bodies.length === 1
|
|
54
|
+
? new Response(JSON.stringify({ error: { message: 'unknown parameter stream_options' } }), { status: 400 })
|
|
55
|
+
: response([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] })])
|
|
56
|
+
} })
|
|
57
|
+
assert.equal(bodies.length, 2)
|
|
58
|
+
assert.equal(bodies[1].stream_options, undefined)
|
|
59
|
+
assert.equal(result.usage, null)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('truncated streams do not yield executable tool calls', async () => {
|
|
63
|
+
await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: async () => response([
|
|
64
|
+
event({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'x', function: { name: 'write_file', arguments: '{}' } }] } }] }),
|
|
65
|
+
]) }), /中断/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('timeout and explicit cancellation have different outcomes', async () => {
|
|
69
|
+
const hanging = async (_url, { signal }) => new Promise((_, reject) => {
|
|
70
|
+
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
|
|
71
|
+
if (signal.aborted) abort()
|
|
72
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
73
|
+
})
|
|
74
|
+
await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: hanging, timeoutMs: 10 }), /超时/)
|
|
75
|
+
const result = await streamChatOnce({ model, messages: [], fetchFn: hanging, signal: AbortSignal.abort() })
|
|
76
|
+
assert.equal(result.aborted, true)
|
|
77
|
+
assert.deepEqual(result.toolCalls, [])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
81
|
+
const strings = { waiting: '', toolRunning: x => x, toolIterLimit: n => String(n), llmError: x => x, emptyResponse: 'empty' }
|
|
82
|
+
const noop = () => {}
|
|
83
|
+
const quiet = { startSpinner: () => ({ stop: noop }), createAssistantWriter: () => ({ writeContent: noop, writeThinking: noop, finish: noop }),
|
|
84
|
+
printToolHeader: noop, printToolResult: noop, printWarn: noop, printError: noop, printTurnSummary: noop }
|
|
85
|
+
const state = () => ({ messages: [{ role: 'system', content: 'rules' }], ctx: { cwd: '.' }, abortController: new AbortController(), maxToolIterations: 4 })
|
|
86
|
+
|
|
87
|
+
test('turn shows 12 nonblank preview rows, supports full/off, and keeps all reasoning in history', async () => {
|
|
88
|
+
const reasoning = Array.from({ length: 15 }, (_, i) => `Inspection step ${i + 1}.`).join('\n\n')
|
|
89
|
+
for (const mode of ['compact', 'full', 'off']) {
|
|
90
|
+
const s = { ...state(), thinkingMode: mode, showThinking: mode !== 'off' }
|
|
91
|
+
let output = ''
|
|
92
|
+
const stats = await runAgentTurn(s, 'task', strings, [], {
|
|
93
|
+
ui: { ...quiet, createAssistantWriter: options => createAssistantWriter({ ...options, write: text => { output += text } }) },
|
|
94
|
+
chat: async ({ onToken }) => {
|
|
95
|
+
for (const chunk of reasoning) onToken({ thinking: chunk })
|
|
96
|
+
onToken({ content: 'Final answer.' })
|
|
97
|
+
return { content: 'Final answer.', reasoning, toolCalls: [] }
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
assert.equal(stats.status, 'completed')
|
|
101
|
+
assert.equal(s.messages.at(-1).reasoning_content, reasoning)
|
|
102
|
+
const text = stripAnsi(output)
|
|
103
|
+
assert.match(text, /Final answer/)
|
|
104
|
+
if (mode === 'compact') {
|
|
105
|
+
assert.match(text, /Inspection step 12\./)
|
|
106
|
+
assert.doesNotMatch(text, /Inspection step 13\./)
|
|
107
|
+
assert.match(text, /\/think full/)
|
|
108
|
+
} else if (mode === 'full') {
|
|
109
|
+
assert.match(text, /Inspection step 15\./)
|
|
110
|
+
assert.doesNotMatch(text, /\/think full/)
|
|
111
|
+
} else assert.doesNotMatch(text, /Inspection step/)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('turn aggregates all model calls, persists progress and records response timings', async () => {
|
|
116
|
+
const s = state()
|
|
117
|
+
let requests = 0, saves = 0
|
|
118
|
+
s.persistSession = async () => { saves++ }
|
|
119
|
+
const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet, execute: async () => 'file contents', chat: async ({ onToken }) => {
|
|
120
|
+
requests++
|
|
121
|
+
onToken({ content: requests === 1 ? 'checking\n' : 'answer\n' })
|
|
122
|
+
return { content: requests === 1 ? 'checking' : 'answer', toolCalls: requests === 1 ? [call(1)] : [], usage: normalizeUsage({ prompt_tokens: 10, completion_tokens: 2 }) }
|
|
123
|
+
} })
|
|
124
|
+
assert.equal(stats.status, 'completed')
|
|
125
|
+
assert.equal(stats.requests, 2)
|
|
126
|
+
assert.equal(stats.toolCalls, 1)
|
|
127
|
+
assert.equal(stats.usage.totalTokens, 24)
|
|
128
|
+
assert.equal(stats.usageRequests, 2)
|
|
129
|
+
assert.ok(stats.firstTokenMs >= 0)
|
|
130
|
+
assert.ok(stats.firstAnswerMs >= stats.firstTokenMs)
|
|
131
|
+
assert.ok(stats.totalMs >= stats.llmMs + stats.toolsMs)
|
|
132
|
+
assert.ok(saves >= 4)
|
|
133
|
+
assert.equal(s.sessionStats.usage.totalTokens, 24)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('cancellation during a tool skips the remaining batch and keeps valid tool history', async () => {
|
|
137
|
+
const s = state(), executed = []
|
|
138
|
+
const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
|
|
139
|
+
chat: async () => ({ content: '', toolCalls: [call(1), call(2)] }),
|
|
140
|
+
execute: async name => { executed.push(name); s.abortController.abort(); return 'first result' },
|
|
141
|
+
})
|
|
142
|
+
assert.equal(stats.status, 'cancelled')
|
|
143
|
+
assert.equal(executed.length, 1)
|
|
144
|
+
assert.equal(s.messages.filter(m => m.role === 'tool').length, 2)
|
|
145
|
+
assert.match(s.messages.at(-1).content, /not executed/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('failed turns retain the user request and report partial usage honestly', async () => {
|
|
149
|
+
const s = state()
|
|
150
|
+
let requests = 0
|
|
151
|
+
const stats = await runAgentTurn(s, 'keep my task', strings, [], { ui: quiet, execute: async () => 'ok', chat: async () => {
|
|
152
|
+
if (++requests === 2) throw new Error('offline')
|
|
153
|
+
return { content: '', toolCalls: [call(1)], usage: normalizeUsage({ prompt_tokens: 1, completion_tokens: 1 }) }
|
|
154
|
+
} })
|
|
155
|
+
assert.equal(stats.status, 'failed')
|
|
156
|
+
assert.equal(stats.requests, 2)
|
|
157
|
+
assert.equal(stats.usageRequests, 1)
|
|
158
|
+
assert.ok(s.messages.some(m => m.content === 'keep my task'))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('long tool turns have bounded request history without losing original goals or mutating the transcript', () => {
|
|
162
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
|
|
163
|
+
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) })
|
|
164
|
+
const before = structuredClone(messages)
|
|
165
|
+
const compact = buildRequestMessages(messages)
|
|
166
|
+
assert.ok(compact.length <= 40)
|
|
167
|
+
assert.ok(JSON.stringify(compact).length < 90000)
|
|
168
|
+
assert.ok(compact.some(m => m.content === 'original goal'))
|
|
169
|
+
assert.deepEqual(messages, before)
|
|
170
|
+
for (let i = 0; i < compact.length; i++) {
|
|
171
|
+
if (compact[i].tool_calls) assert.equal(compact[i + 1].tool_call_id, compact[i].tool_calls[0].id)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('resume marks missing tool results without replaying actions', () => {
|
|
176
|
+
const repaired = repairToolHistory([{ role: 'user', content: 'task' }, { role: 'assistant', tool_calls: [call(1), call(2)] },
|
|
177
|
+
{ role: 'tool', tool_call_id: 'call1', content: 'saved' }])
|
|
178
|
+
assert.equal(repaired[2].content, 'saved')
|
|
179
|
+
assert.equal(repaired[3].tool_call_id, 'call2')
|
|
180
|
+
assert.match(repaired[3].content, /status is unknown/)
|
|
181
|
+
})
|
package/src/cli/ai/safety.js
CHANGED
|
@@ -67,15 +67,25 @@ function isRootTargetToken(token) {
|
|
|
67
67
|
|
|
68
68
|
// 从命令串中抽出所有 "命令段"(按 ; && || | 切分),返回小写化前的原文段
|
|
69
69
|
function splitCommandSegments(cmd) {
|
|
70
|
-
|
|
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+(
|
|
78
|
-
const name = (m?.[1] ||
|
|
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
|
+
}
|