zen-gitsync 2.17.46 → 2.17.48
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 +16 -2
- package/package.json +2 -1
- package/src/cli/ai/agent.js +1342 -1342
- package/src/cli/ai/context.js +431 -253
- package/src/cli/ai/context.test.js +376 -258
- package/src/cli/ai/mcp.js +323 -27
- package/src/cli/ai/mcp.test.js +202 -2
- package/src/cli/ai/runtime.test.js +7 -4
- package/src/cli/ai/turn.js +166 -166
- package/src/config.js +906 -871
- package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-B829U5mr.js} +1 -1
- package/src/ui/public/assets/AgentView-r3_ClUmr.js +1 -0
- package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-uHf-KMCH.css} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-NtghusOI.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-D0claXAs.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-BIq6-sqk.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-NqZURH2F.js} +1 -1
- package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
- package/src/ui/public/assets/EditorView-Cny0lDhG.js +1 -0
- package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-B3CAJZ-U.js} +1 -1
- package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-D5sS4Il3.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-6bc0EAJM.js} +1 -1
- package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-BnUV4RaV.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-Cbw079DB.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BCZsJR7n.js +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-BWWPEnSd.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-BhA3-4PZ.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-DHDmNzSh.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-DcVasCdo.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-B313MadX.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-NCE_bk5a.js +20 -0
- package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-Bsx6Zj3t.js +19 -0
- package/src/ui/public/assets/agentConversations-BAhGYmeY.js +8 -0
- package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-DQSKV2_Y.js} +1 -1
- package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-BmYeUlEa.js} +3 -3
- package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-DTtm3XCd.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-JxOspgwx.js} +1 -1
- package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
- package/src/ui/public/assets/{index-BQxIFopZ.js → index-DQuqmPuL.js} +9 -9
- package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-D3R9OM26.js} +1 -1
- package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-CO7mIaWO.js} +1 -1
- package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-gA4KEkK4.js} +1 -1
- package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-CQK4l_1Q.js} +1 -1
- package/src/ui/public/assets/{vendor-yntih_em.js → vendor-O17eRefg.js} +222 -222
- package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
- package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-BG6ZF1It.js} +1 -1
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/routes/config.js +1321 -1314
- package/src/ui/server/routes/workbench/agentChat.js +594 -571
- package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
- package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
- package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
- package/src/ui/server/routes/workbench/liveJobs.js +4 -0
- package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
- package/src/ui/server/routes/workbench/taskRunner.js +44 -7
- package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
- package/src/ui/public/assets/AgentView-BFRGoIVb.js +0 -1
- package/src/ui/public/assets/EditorView-8Rb4n-Wh.css +0 -1
- package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-DFljcYFH.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BSQ87CDi.css +0 -1
- package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
- package/src/ui/public/assets/_plugin-vue_export-helper-Dz1ARW9a.js +0 -19
- package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
- package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
|
@@ -1,233 +1,302 @@
|
|
|
1
|
-
// Web 智能体面板(agentChat.js)必须与 CLI 的 `g ai` 共用同一条**上下文**和**传输**口径。
|
|
2
|
-
//
|
|
3
|
-
// 这两处曾经各有一份自己的实现:
|
|
4
|
-
// 上下文:只按条数硬切 40 条 + 纯 splice 丢弃,且**原地**改 session.messages
|
|
5
|
-
// (聊久了模型失忆,磁盘上的会话记录还被永久削掉一截)
|
|
6
|
-
// 传输 :自带的 streamChatOnce 不拦"流被截断但 tool_calls 已部分到达" ——
|
|
7
|
-
// 半截的参数有可能被拿去执行
|
|
8
|
-
// 现在两者都收敛到 src/cli/ai/ 下的共享模块。这个文件跑真实的 runAgentTurn,
|
|
9
|
-
// 把发出去的请求体与产生的事件抓下来,逐条钉住收敛后的口径。
|
|
10
|
-
//
|
|
11
|
-
// 注意:必须在 import agentChat.js **之前**把 USERPROFILE/HOME 指到沙箱,否则
|
|
12
|
-
// configManager / agentSessionStore 会去读真实的 ~/.zen-gitsync。
|
|
13
|
-
import { test } from 'node:test'
|
|
14
|
-
import assert from 'node:assert/strict'
|
|
15
|
-
import { mkdtempSync } from 'node:fs'
|
|
16
|
-
import os from 'node:os'
|
|
17
|
-
import path from 'node:path'
|
|
18
|
-
|
|
19
|
-
const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-shared-'))
|
|
20
|
-
process.env.USERPROFILE = SANDBOX
|
|
21
|
-
process.env.HOME = SANDBOX
|
|
22
|
-
delete process.env.HOMEDRIVE
|
|
23
|
-
delete process.env.HOMEPATH
|
|
24
|
-
|
|
25
|
-
const event = data => `data: ${JSON.stringify(data)}\n\n`
|
|
26
|
-
const sse = chunks => new Response(new ReadableStream({
|
|
27
|
-
start(controller) {
|
|
28
|
-
const bytes = new TextEncoder()
|
|
29
|
-
chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
|
|
30
|
-
controller.close()
|
|
31
|
-
},
|
|
32
|
-
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
33
|
-
|
|
34
|
-
const model = { model: 'test', name: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
|
|
35
|
-
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
36
|
-
const okStream = () => sse([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
|
|
37
|
-
const withSystem = () => [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
assert.
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
assert.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
assert.equal(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
assert.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
assert.equal(
|
|
233
|
-
|
|
1
|
+
// Web 智能体面板(agentChat.js)必须与 CLI 的 `g ai` 共用同一条**上下文**和**传输**口径。
|
|
2
|
+
//
|
|
3
|
+
// 这两处曾经各有一份自己的实现:
|
|
4
|
+
// 上下文:只按条数硬切 40 条 + 纯 splice 丢弃,且**原地**改 session.messages
|
|
5
|
+
// (聊久了模型失忆,磁盘上的会话记录还被永久削掉一截)
|
|
6
|
+
// 传输 :自带的 streamChatOnce 不拦"流被截断但 tool_calls 已部分到达" ——
|
|
7
|
+
// 半截的参数有可能被拿去执行
|
|
8
|
+
// 现在两者都收敛到 src/cli/ai/ 下的共享模块。这个文件跑真实的 runAgentTurn,
|
|
9
|
+
// 把发出去的请求体与产生的事件抓下来,逐条钉住收敛后的口径。
|
|
10
|
+
//
|
|
11
|
+
// 注意:必须在 import agentChat.js **之前**把 USERPROFILE/HOME 指到沙箱,否则
|
|
12
|
+
// configManager / agentSessionStore 会去读真实的 ~/.zen-gitsync。
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { mkdtempSync } from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-shared-'))
|
|
20
|
+
process.env.USERPROFILE = SANDBOX
|
|
21
|
+
process.env.HOME = SANDBOX
|
|
22
|
+
delete process.env.HOMEDRIVE
|
|
23
|
+
delete process.env.HOMEPATH
|
|
24
|
+
|
|
25
|
+
const event = data => `data: ${JSON.stringify(data)}\n\n`
|
|
26
|
+
const sse = chunks => new Response(new ReadableStream({
|
|
27
|
+
start(controller) {
|
|
28
|
+
const bytes = new TextEncoder()
|
|
29
|
+
chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
|
|
30
|
+
controller.close()
|
|
31
|
+
},
|
|
32
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
33
|
+
|
|
34
|
+
const model = { model: 'test', name: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
|
|
35
|
+
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
36
|
+
const okStream = () => sse([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
|
|
37
|
+
const withSystem = () => [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
|
|
38
|
+
// 工具调用长会话。**组数按当前默认预算反算**,不写死——
|
|
39
|
+
// 默认预算 2026-10-07 一天内改了三次(80k 字符 → 400k 字符 → 1M token),
|
|
40
|
+
// 每次写死的组数都会在某次改完后变成"不够触发裁剪",让下面那些
|
|
41
|
+
// 「请求副本未收窄 / 会报出 droppedMessages」的断言**假绿**。
|
|
42
|
+
//
|
|
43
|
+
// 实测:`'data'.repeat(4000)` = 4,000 纯 ascii ≈ 1,143 token(clip 的 6,000
|
|
44
|
+
// 上限用不上);assistant 那条 tool_calls 极短;两条一组 ≈ 1,200 token。
|
|
45
|
+
// 1M 预算需 ≈850 组才填满 → 给 1,500 组留足余量。
|
|
46
|
+
//
|
|
47
|
+
// ⚠️ 判据:**条数上限 × 单组token 必须明显大于 token 预算**。
|
|
48
|
+
const LONG_SESSION_GROUPS = 1500
|
|
49
|
+
const longSession = () => {
|
|
50
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
|
|
51
|
+
for (let i = 0; i < LONG_SESSION_GROUPS; i++) {
|
|
52
|
+
messages.push({ role: 'assistant', tool_calls: [call(i)] })
|
|
53
|
+
messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
|
|
54
|
+
}
|
|
55
|
+
return messages
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与产生的事件
|
|
59
|
+
// (respond 收到 fetch 的 init,方便测试按 signal 模拟"读到一半被中止")
|
|
60
|
+
async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent }) {
|
|
61
|
+
const { runAgentTurn } = await import('./agentChat.js')
|
|
62
|
+
const events = []
|
|
63
|
+
let sent
|
|
64
|
+
const original = globalThis.fetch
|
|
65
|
+
globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond(init) }
|
|
66
|
+
try {
|
|
67
|
+
const result = await runAgentTurn({
|
|
68
|
+
session, model, userMessage, locale, cwd: process.cwd(),
|
|
69
|
+
signal: signal ?? new AbortController().signal,
|
|
70
|
+
send: e => { events.push(e); onEvent?.(e) },
|
|
71
|
+
})
|
|
72
|
+
return { sent, events, result }
|
|
73
|
+
} finally {
|
|
74
|
+
globalThis.fetch = original
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
|
|
79
|
+
|
|
80
|
+
// ── 上下文口径 ────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
test('请求体与 context.js 的输出逐字段相同(有界 + 摘录)', async () => {
|
|
83
|
+
const { prepareRequestMessages, resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
84
|
+
const messages = longSession()
|
|
85
|
+
// 传副本进去:runAgentTurn 会往 session.messages 追加消息,原数组要留作期望值的输入
|
|
86
|
+
const session = newSession('ag-sandbox-test', structuredClone(messages))
|
|
87
|
+
|
|
88
|
+
const { sent } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
89
|
+
|
|
90
|
+
assert.ok(sent, '必须发出过一次请求')
|
|
91
|
+
// 上限写死成 40 就会在默认值变更那天变成假绿 —— 直接引用解析结果
|
|
92
|
+
assert.ok(sent.messages.length <= resolveRequestBudget(undefined).maxMessages,
|
|
93
|
+
`请求副本未收窄: ${sent.messages.length} 条`)
|
|
94
|
+
assert.equal(sent.messages[0].role, 'system')
|
|
95
|
+
assert.match(sent.messages[1].content, /^\[Earlier conversation excerpts/)
|
|
96
|
+
assert.ok(sent.messages.some(m => m.content === 'original goal'), '原始目标必须保留')
|
|
97
|
+
// 逐字段相同 = 这条链路确实走的是同一个函数,而不是"看起来差不多"的第二份实现
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
sent.messages,
|
|
100
|
+
prepareRequestMessages([...messages, { role: 'user', content: 'next' }], { locale: 'zh-CN' }),
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('会话记录不再被原地裁剪(磁盘口径与 CLI 一致)', async () => {
|
|
105
|
+
const session = newSession('ag-sandbox-keep', longSession())
|
|
106
|
+
const toolCountBefore = session.messages.filter(m => m.role === 'tool').length
|
|
107
|
+
|
|
108
|
+
await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
109
|
+
|
|
110
|
+
// 比"运行前后"而不是写死组数 —— longSession 的组数一改这条就假绿
|
|
111
|
+
assert.equal(session.messages.filter(m => m.role === 'tool').length, toolCountBefore,
|
|
112
|
+
'旧消息必须留在会话记录里(裁剪只作用于请求副本)')
|
|
113
|
+
assert.equal(session.messages[1].content, 'original goal')
|
|
114
|
+
assert.equal(session.messages[2].tool_calls[0].id, 'call0')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('短会话不做任何额外处理,也不塞摘录消息', async () => {
|
|
118
|
+
const session = newSession('ag-sandbox-short', withSystem())
|
|
119
|
+
|
|
120
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ── 上下文占用下发(2026-10-07)────────────────────────────────
|
|
126
|
+
// UI 的占用条全靠这个事件。断它 = 进度条永远停在 0%,而界面不会报任何错。
|
|
127
|
+
|
|
128
|
+
test('每次请求前发一条 context 事件,用量与请求副本一致', async () => {
|
|
129
|
+
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
130
|
+
const session = newSession('ag-sandbox-ctx', withSystem())
|
|
131
|
+
|
|
132
|
+
const { events, sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
133
|
+
|
|
134
|
+
const ctx = events.filter(e => e.type === 'context')
|
|
135
|
+
assert.ok(ctx.length >= 1, '必须至少发一条 context 事件')
|
|
136
|
+
const usage = ctx[0].usage
|
|
137
|
+
const budget = resolveRequestBudget(undefined)
|
|
138
|
+
// 分母必须跟实际裁剪用的是同一份,否则进度条会说谎
|
|
139
|
+
// 主口径是 **token**(maxChars 只是给按字符切的地方用的保守换算值,不是闸门)
|
|
140
|
+
assert.equal(usage.maxTokens, budget.maxTokens)
|
|
141
|
+
assert.equal(usage.maxChars, budget.maxChars)
|
|
142
|
+
assert.equal(usage.maxMessages, budget.maxMessages)
|
|
143
|
+
assert.equal(usage.messages, sent.messages.length, 'usage 里的条数要与真实发出去的请求一致')
|
|
144
|
+
assert.ok(usage.chars > 0 && usage.estTokens > 0)
|
|
145
|
+
assert.ok(usage.tokenRatio > 0 && usage.tokenRatio <= 1)
|
|
146
|
+
assert.equal(usage.droppedMessages, 0, '短会话不该有裁剪')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('provider 返回 usage 时补一条带真实输入 token 的 context', async () => {
|
|
150
|
+
const session = newSession('ag-sandbox-ctx-usage', withSystem())
|
|
151
|
+
const withUsage = () => sse([
|
|
152
|
+
event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }),
|
|
153
|
+
event({ usage: { prompt_tokens: 4321, completion_tokens: 10, total_tokens: 4331 } }),
|
|
154
|
+
'data: [DONE]\n\n',
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
const { events } = await runWeb({ session, userMessage: 'hello', respond: withUsage })
|
|
158
|
+
|
|
159
|
+
const withActual = events.filter(e => e.type === 'context' && typeof e.usage.actualInputTokens === 'number')
|
|
160
|
+
assert.ok(withActual.length >= 1, '必须有一条带上真实 input token')
|
|
161
|
+
assert.equal(withActual[0].usage.actualInputTokens, 4321)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('被裁剪的长会话会报出 droppedMessages(UI 靠它解释"为什么模型忘了")', async () => {
|
|
165
|
+
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
166
|
+
const session = newSession('ag-sandbox-ctx-drop', longSession())
|
|
167
|
+
const toolCount = session.messages.filter(m => m.role === 'tool').length
|
|
168
|
+
|
|
169
|
+
const { events } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
170
|
+
|
|
171
|
+
const usage = events.find(e => e.type === 'context').usage
|
|
172
|
+
assert.ok(usage.droppedMessages > 0, '长会话必须报出被裁掉的条数')
|
|
173
|
+
// longSession = system + 首 user + N×(assistant + tool),再加 runAgentTurn 追加的本轮 user
|
|
174
|
+
assert.equal(usage.transcriptMessages, toolCount * 2 + 3,
|
|
175
|
+
'transcriptMessages 是磁盘上的完整条数(含本轮 user)')
|
|
176
|
+
assert.ok(usage.messages < usage.transcriptMessages, '带入的必须少于磁盘上的,否则说明没裁')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ── 传输口径 ──────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
test('请求体带上 stream_options,与 CLI 一致地统计 usage', async () => {
|
|
182
|
+
const session = newSession('ag-sandbox-usage', withSystem())
|
|
183
|
+
|
|
184
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(sent.stream_options, { include_usage: true })
|
|
187
|
+
assert.equal(sent.stream, true)
|
|
188
|
+
assert.equal(sent.temperature, 0.3)
|
|
189
|
+
assert.ok(Array.isArray(sent.tools) && sent.tools.length > 0, '内置工具表必须随请求下发')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截的工具调用', async () => {
|
|
193
|
+
const session = newSession('ag-sandbox-truncated', withSystem())
|
|
194
|
+
// 工具调用参数是完整的、JSON 合法 —— 换在没有这道闸的旧实现里就会被真的执行
|
|
195
|
+
const truncated = sse([event({
|
|
196
|
+
choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_x', function: { name: 'read_file', arguments: '{"path":"nope.txt"}' } }] } }],
|
|
197
|
+
})])
|
|
198
|
+
|
|
199
|
+
const { events } = await runWeb({ session, userMessage: '读个文件', respond: () => truncated })
|
|
200
|
+
|
|
201
|
+
const error = events.find(e => e.type === 'error')
|
|
202
|
+
assert.ok(error, '截断的流必须报错,不能静默当成正常结束')
|
|
203
|
+
assert.match(error.error, /中断/)
|
|
204
|
+
assert.equal(events.some(e => e.type === 'tool_call_start'), false, '被截断的调用不得开始执行')
|
|
205
|
+
assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// 回归:用户点"停止"(中止正在跑的这一轮)时,已经流出来的正文必须留在会话记录里。
|
|
209
|
+
// 之前中止分支只把当时的状态写盘、不回填正文,重新打开这条会话时刚才生成的内容整段消失。
|
|
210
|
+
test('中止时已流出的正文要留在会话记录里', async () => {
|
|
211
|
+
const session = newSession('ag-sandbox-aborted', withSystem())
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
|
|
214
|
+
// 吐一段正文后挂住(模拟"停在生成中");真 fetch 在 signal abort 时会打断 body 读取,
|
|
215
|
+
// 桩里照做才能真的走到中止分支
|
|
216
|
+
const hanging = init => new Response(new ReadableStream({
|
|
217
|
+
start(stream) {
|
|
218
|
+
const bytes = new TextEncoder()
|
|
219
|
+
stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })))
|
|
220
|
+
init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')))
|
|
221
|
+
},
|
|
222
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
223
|
+
|
|
224
|
+
const { result } = await runWeb({
|
|
225
|
+
session, userMessage: '写一半就停', respond: hanging, signal: controller.signal,
|
|
226
|
+
// 正文事件到达 = 这段已经被读进来,此刻中止最贴近用户点"停止"的时机
|
|
227
|
+
onEvent: e => { if (e.type === 'content') controller.abort() },
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
assert.equal(result.aborted, true)
|
|
231
|
+
const last = session.messages[session.messages.length - 1]
|
|
232
|
+
assert.equal(last.role, 'assistant')
|
|
233
|
+
assert.equal(last.content, '已经写了一半')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
|
|
237
|
+
const session = newSession('ag-sandbox-nofc', withSystem())
|
|
238
|
+
const noFunctionCalling = () => new Response(
|
|
239
|
+
JSON.stringify({ error: { message: 'tools is not supported by this model' } }),
|
|
240
|
+
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: noFunctionCalling })
|
|
244
|
+
|
|
245
|
+
assert.equal(sent.messages.length, 3, '不该把 stream_options 的降级重试和这条错误混在一起')
|
|
246
|
+
const error = events.find(e => e.type === 'error')
|
|
247
|
+
assert.ok(error)
|
|
248
|
+
assert.match(error.error, /不支持 function calling/)
|
|
249
|
+
assert.match(error.error, /换用支持工具调用的模型/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// ── 工具参数的透传契约 ──────────────────────────────────────
|
|
253
|
+
// 两个字段分工不同,两边都不能少:
|
|
254
|
+
// · arguments —— 展开后「参数」框里的原文,**每种工具都发全文**。前端历史回放
|
|
255
|
+
// (useAgentChat 读 session.messages)拿到的本来就是全文,只在流式这一路发摘要,
|
|
256
|
+
// 就会让"正在跑"和"刷新后"看到两份不同的参数。
|
|
257
|
+
// · argsPreview —— 收起态那一行副标题,故意截到 200 字。
|
|
258
|
+
// 计划类工具的 steps 也在 arguments 里(不再单独开一条透传路径)。
|
|
259
|
+
test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截断摘要', async () => {
|
|
260
|
+
const planArgs = JSON.stringify({
|
|
261
|
+
steps: [
|
|
262
|
+
{ content: '读代码', status: 'completed' },
|
|
263
|
+
{ content: '改代码', status: 'in_progress' }
|
|
264
|
+
],
|
|
265
|
+
explanation: '先改库再改宿主'
|
|
266
|
+
})
|
|
267
|
+
const round = [
|
|
268
|
+
sse([event({
|
|
269
|
+
choices: [{ delta: { tool_calls: [
|
|
270
|
+
{ index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
|
|
271
|
+
{ index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }) } }
|
|
272
|
+
] } }, { finish_reason: 'tool_calls' }],
|
|
273
|
+
}), 'data: [DONE]\n\n']),
|
|
274
|
+
okStream(),
|
|
275
|
+
]
|
|
276
|
+
let turn = 0
|
|
277
|
+
const session = newSession('ag-sandbox-plan', withSystem())
|
|
278
|
+
|
|
279
|
+
const { events } = await runWeb({
|
|
280
|
+
session,
|
|
281
|
+
userMessage: '把这个功能做出来',
|
|
282
|
+
respond: () => round[turn++] || okStream(),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const starts = events.filter(e => e.type === 'tool_call_start')
|
|
286
|
+
assert.equal(starts.length, 2)
|
|
287
|
+
const planStart = starts.find(e => e.name === 'update_plan')
|
|
288
|
+
const writeStart = starts.find(e => e.name === 'write_file')
|
|
289
|
+
|
|
290
|
+
// 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
|
|
291
|
+
assert.equal(planStart.arguments, planArgs)
|
|
292
|
+
assert.match(planStart.argsPreview, /1\/2|1 完成/)
|
|
293
|
+
// 其他工具:展开态同样要看到原文,不再只给摘要
|
|
294
|
+
assert.equal(writeStart.arguments, JSON.stringify({ path: 'a.js', content: 'x'.repeat(50) }))
|
|
295
|
+
assert.ok(writeStart.argsPreview.length < writeStart.arguments.length, '摘要仍应是截断过的')
|
|
296
|
+
|
|
297
|
+
const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
|
|
298
|
+
assert.match(planResult.result, /计划已更新/)
|
|
299
|
+
// 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
|
|
300
|
+
const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
|
|
301
|
+
assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
|
|
302
|
+
})
|