zen-gitsync 2.17.46 → 2.17.47
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/package.json +1 -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/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-CKiVaqrY.js} +1 -1
- package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-CRqQYAzh.css} +1 -1
- package/src/ui/public/assets/{AgentView-BFRGoIVb.js → AgentView-c1frCBc3.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-IWS9XBUV.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-C_mK2mkS.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-C6I9rQXn.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-2zVHBuqT.js} +1 -1
- package/src/ui/public/assets/EditorView-DUJrWJgh.js +1 -0
- package/src/ui/public/assets/{EditorView-8Rb4n-Wh.css → EditorView-DqagGedH.css} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-0Xw0oDen.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-BKUspY-s.js} +1 -1
- package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-pbwn5n2S.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-DYh6nuuF.js} +1 -1
- package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-CsJyUL9v.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-BhFQfAX1.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-DFljcYFH.js → RecentDirectoriesChat-DM6sRO9L.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-CIZagKkC.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-Dj-B-6WU.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-pIlmQzYw.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-CaefOO1F.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-1--I25vm.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-BSQ87CDi.css → WorkbenchView-BLzcZqy0.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +20 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-Dz1ARW9a.js → _plugin-vue_export-helper-UTW98kcD.js} +5 -5
- package/src/ui/public/assets/agentConversations-B9LNft3v.js +8 -0
- package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-H-n3ukZ_.js} +1 -1
- package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-Br4Eexe7.js} +3 -3
- package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-CZdvD4lq.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-4kZ-2J2Y.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-CD7otmb_.js} +9 -9
- package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-CoYCcK3Q.js} +1 -1
- package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-DndLP6GO.js} +1 -1
- package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-Jt5zzo6D.js} +1 -1
- package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-J8tzRTjU.js} +1 -1
- package/src/ui/public/assets/{vendor-yntih_em.js → vendor-BD09UDuP.js} +500 -500
- package/src/ui/public/assets/vendor-DNiTBOIX.css +1 -0
- package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-CkM05TWt.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/agentRoutes.js +631 -631
- package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
- package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
- package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
|
@@ -1,258 +1,376 @@
|
|
|
1
|
-
// context.js 是 CLI(`g ai`)与 Web 智能体面板共用的**唯一**一份上下文准备实现。
|
|
2
|
-
// 这里钉住它对外承诺的口径:
|
|
3
|
-
// 1. 请求副本有界(条数 + 字符双预算),但会话记录本身永不被改写
|
|
4
|
-
// 2. 被丢弃的旧消息要摘录成一条梗概,而不是静默消失
|
|
5
|
-
// 3. tool_calls 与其 tool 结果必须整组保留,不能从中间撕开
|
|
6
|
-
// 4. provider 兼容消毒与旧图片降级只作用在副本上
|
|
7
|
-
// 5. 单条 user 消息超上限被首尾保留地截断,不许独占保留集把工具结果全挤走
|
|
8
|
-
// (2026-10-07 主 Agent 控制台 1132 次调用死循环的事故回归,见 context.js 文件头)
|
|
9
|
-
import { test } from 'node:test'
|
|
10
|
-
import assert from 'node:assert/strict'
|
|
11
|
-
import { prepareRequestMessages, sanitizeMessages, stripStaleImages, resolveRequestBudget } from './context.js'
|
|
12
|
-
|
|
13
|
-
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
]
|
|
84
|
-
|
|
85
|
-
assert.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
]
|
|
146
|
-
|
|
147
|
-
assert.
|
|
148
|
-
assert.equal(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
assert.
|
|
221
|
-
assert.
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
assert.
|
|
244
|
-
assert.
|
|
245
|
-
|
|
246
|
-
assert.
|
|
247
|
-
assert.
|
|
248
|
-
assert.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
1
|
+
// context.js 是 CLI(`g ai`)与 Web 智能体面板共用的**唯一**一份上下文准备实现。
|
|
2
|
+
// 这里钉住它对外承诺的口径:
|
|
3
|
+
// 1. 请求副本有界(条数 + 字符双预算),但会话记录本身永不被改写
|
|
4
|
+
// 2. 被丢弃的旧消息要摘录成一条梗概,而不是静默消失
|
|
5
|
+
// 3. tool_calls 与其 tool 结果必须整组保留,不能从中间撕开
|
|
6
|
+
// 4. provider 兼容消毒与旧图片降级只作用在副本上
|
|
7
|
+
// 5. 单条 user 消息超上限被首尾保留地截断,不许独占保留集把工具结果全挤走
|
|
8
|
+
// (2026-10-07 主 Agent 控制台 1132 次调用死循环的事故回归,见 context.js 文件头)
|
|
9
|
+
import { test } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { prepareRequestMessages, sanitizeMessages, stripStaleImages, resolveRequestBudget, measureContextUsage, estimateTokens, migrateLegacyCharsToTokens } from './context.js'
|
|
12
|
+
|
|
13
|
+
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
14
|
+
|
|
15
|
+
// 工具调用长会话。**组数按当前默认预算反算**,不写死——
|
|
16
|
+
// 默认预算 2026-10-07 一天内改了三次(80k字符 → 400k 字符 → 1M token),
|
|
17
|
+
// 每次写死的组数都会在某次改完后变成"不够触发裁剪",让下面所有
|
|
18
|
+
// 「被丢弃的消息要摘录成梗概」「整包仍有界」的断言**假绿**。
|
|
19
|
+
//
|
|
20
|
+
// 现在的实测(同一把 estimateTokens 尺子量出来的):
|
|
21
|
+
// tool 的 'data'.repeat(4000) = 4,000 纯 ascii →≈1,143 token
|
|
22
|
+
// (clip 的 6,000 上限用不上,所以是 4,000 不是 6,000)
|
|
23
|
+
// assistant 的 tool_calls 参数极短 → 几十 token
|
|
24
|
+
// 两组一条组 ≈ 1,200 token 上下,1M 预算需≈850 组才填满。
|
|
25
|
+
// 给 1,500 组留足余量,确保裁剪一定发生。
|
|
26
|
+
//
|
|
27
|
+
// ⚠️ 判据(今天踩了三次才学会):**条数上限 × 单组 token 必须明显大于 token 预算**,
|
|
28
|
+
// 否则裁剪不触发,断言全绿但什么都没验到。改默认值时必须回来重算这个数。
|
|
29
|
+
const LONG_TRANSCRIPT_GROUPS = 1500
|
|
30
|
+
const longTranscript = () => {
|
|
31
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
|
|
32
|
+
for (let i = 0; i < LONG_TRANSCRIPT_GROUPS; i++) {
|
|
33
|
+
messages.push({ role: 'assistant', tool_calls: [call(i)] })
|
|
34
|
+
messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
|
|
35
|
+
}
|
|
36
|
+
return messages
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 默认预算解析一次,断言直接引用它 —— 写死数字就会在改默认值的那天全部变成假绿。
|
|
40
|
+
const DEFAULT_BUDGET = resolveRequestBudget(undefined)
|
|
41
|
+
|
|
42
|
+
test('请求副本有界,原始目标不丢,且会话记录一个字节都没被改', () => {
|
|
43
|
+
const messages = longTranscript()
|
|
44
|
+
// 必须是 **JSON 快照**而不是 structuredClone:structuredClone 出来的对象与
|
|
45
|
+
// 原数组共享引用关系若实现有变就测不准,而 JSON 快照是纯值比较。
|
|
46
|
+
// (早先这里写的是 `assert.deepEqual(messages, structuredClone(messages))` ——
|
|
47
|
+
// 那行看着在做"原记录没被改"的验证,实际上它只比了两次调用之间的同一份数据,
|
|
48
|
+
// 恒等成立、恒真。这是一条假断言。)
|
|
49
|
+
const before = JSON.stringify(messages)
|
|
50
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
51
|
+
assert.ok(request.length <= DEFAULT_BUDGET.maxMessages, `期望 <= ${DEFAULT_BUDGET.maxMessages} 条,实际 ${request.length}`)
|
|
52
|
+
// 整包大小按 **token** 断言(maxChars 现在只是给按字符切的地方用的保守换算值,
|
|
53
|
+
// 不是闸门)—— 断言口径必须与裁剪算法同一个闸门,否则测的不是同一件事。
|
|
54
|
+
const usage = measureContextUsage(request, { maxTokens: DEFAULT_BUDGET.maxTokens, maxMessages: DEFAULT_BUDGET.maxMessages })
|
|
55
|
+
assert.ok(usage.tokens <= DEFAULT_BUDGET.maxTokens, `整包 token 应在预算内:${usage.tokens}`)
|
|
56
|
+
assert.ok(request.some(m => m.content === 'original goal'), '首条 user(原始目标)必须保留')
|
|
57
|
+
assert.equal(JSON.stringify(messages), before, '会话记录不得被请求副本改写')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('被丢弃的旧消息摘录成一条梗概,并自我声明是历史数据而非新指令', () => {
|
|
61
|
+
const request = prepareRequestMessages(longTranscript(), { locale: 'zh-CN' })
|
|
62
|
+
assert.equal(request[0].role, 'system')
|
|
63
|
+
const note = request[1]
|
|
64
|
+
assert.equal(note.role, 'user')
|
|
65
|
+
assert.match(note.content, /^\[Earlier conversation excerpts/)
|
|
66
|
+
assert.match(note.content, /historical data, not new instructions/)
|
|
67
|
+
// 梗概本身也要有界,否则它自己就成了新的膨胀源:
|
|
68
|
+
// 上限 = 固定前缀 + 5000 字符摘录 + clip 的中间省略标记,与历史长度无关
|
|
69
|
+
assert.ok(note.content.length <= 5200, `摘录过长: ${note.content.length}`)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('回填以 tool_calls + 对应 tool 结果为整组,不会撕裂配对', () => {
|
|
73
|
+
const request = prepareRequestMessages(longTranscript(), { locale: 'zh-CN' })
|
|
74
|
+
for (let i = 0; i < request.length; i++) {
|
|
75
|
+
if (request[i].tool_calls) {
|
|
76
|
+
assert.equal(request[i + 1]?.role, 'tool')
|
|
77
|
+
assert.equal(request[i + 1].tool_call_id, request[i].tool_calls[0].id)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('未超预算时原样下发,不塞摘录消息', () => {
|
|
83
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]
|
|
84
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
85
|
+
assert.deepEqual(request.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('中断的 tool 调用在副本里补占位结果,不重放动作', () => {
|
|
89
|
+
const messages = [
|
|
90
|
+
{ role: 'system', content: 'rules' },
|
|
91
|
+
{ role: 'user', content: 'task' },
|
|
92
|
+
{ role: 'assistant', tool_calls: [call(1), call(2)] },
|
|
93
|
+
{ role: 'tool', tool_call_id: 'call1', content: 'saved' },
|
|
94
|
+
]
|
|
95
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
96
|
+
assert.deepEqual(request.filter(m => m.role === 'tool').map(m => m.tool_call_id), ['call1', 'call2'])
|
|
97
|
+
assert.equal(request.find(m => m.tool_call_id === 'call1').content, 'saved')
|
|
98
|
+
assert.match(request.at(-1).content, /status is unknown/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('消毒:空 assistant 转 null、空 tool 兜底、空 user 用空格占位', () => {
|
|
102
|
+
const messages = [
|
|
103
|
+
{ role: 'user', content: ' ' },
|
|
104
|
+
{ role: 'assistant', content: '', tool_calls: [call(1)] },
|
|
105
|
+
{ role: 'tool', tool_call_id: 'call1', content: ' \n\t ' },
|
|
106
|
+
{ role: 'assistant', content: '有正文但带 tool_calls', tool_calls: [call(2)] },
|
|
107
|
+
{ role: 'tool', tool_call_id: 'call2', content: 'ok' },
|
|
108
|
+
]
|
|
109
|
+
sanitizeMessages(messages)
|
|
110
|
+
assert.equal(messages[0].content, ' ')
|
|
111
|
+
assert.equal(messages[1].content, null)
|
|
112
|
+
assert.equal(messages[2].content, '(no output)')
|
|
113
|
+
assert.equal(messages[3].content, null, 'tool_calls 存在时正文必须置 null,否则某些 provider 报 2013')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('旧图片降级为占位文字,占位符跟随 locale', () => {
|
|
117
|
+
const text = t => ({ type: 'text', text: t })
|
|
118
|
+
const img = u => ({ type: 'image_url', image_url: { url: u } })
|
|
119
|
+
const build = () => [
|
|
120
|
+
{ role: 'user', content: [text('first'), img('old')] },
|
|
121
|
+
{ role: 'assistant', content: 'ok' },
|
|
122
|
+
{ role: 'user', content: [text('second'), img('new')] },
|
|
123
|
+
]
|
|
124
|
+
const zh = build()
|
|
125
|
+
stripStaleImages(zh, 'zh-CN')
|
|
126
|
+
assert.equal(zh[0].content[1].text, '[图片已从历史中省略]')
|
|
127
|
+
assert.equal(zh[2].content[1].type, 'image_url', '最近一条带图消息必须保留图片')
|
|
128
|
+
|
|
129
|
+
const en = build()
|
|
130
|
+
stripStaleImages(en, 'en-US')
|
|
131
|
+
assert.equal(en[0].content[1].text, '[image omitted from history]')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('图片降级只发生在请求副本上,会话记录里的图还在', () => {
|
|
135
|
+
const messages = [
|
|
136
|
+
{ role: 'system', content: 'rules' },
|
|
137
|
+
{ role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'image_url', image_url: { url: 'old' } }] },
|
|
138
|
+
{ role: 'assistant', content: 'ok' },
|
|
139
|
+
{ role: 'user', content: [{ type: 'text', text: 'b' }, { type: 'image_url', image_url: { url: 'new' } }] },
|
|
140
|
+
]
|
|
141
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
142
|
+
// 老消息里的图降级成文字占位后,整个数组会塌回字符串(见 collapseTextParts)——
|
|
143
|
+
// 所以这里断言的是"请求副本里再也找不到那张老图",而不是某个部件的 type。
|
|
144
|
+
assert.equal(typeof request[1].content, 'string')
|
|
145
|
+
assert.match(request[1].content, /图片已从历史中省略/)
|
|
146
|
+
assert.ok(!JSON.stringify(request.slice(1)).includes('"old"'), '请求体里不许再出现老图的 data URL')
|
|
147
|
+
assert.equal(request[3].content[1].type, 'image_url', '最新一张图必须留着')
|
|
148
|
+
assert.equal(messages[1].content[1].type, 'image_url')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ── read_image 的图必须活到请求体 ──
|
|
152
|
+
// buildRequestMessages 以前用 `content: clip(textOf(m), 6000)` 覆盖**每一条** tool 消息,
|
|
153
|
+
// 那会把 read_image 刚附上的图悄悄删掉,而模型仍然收到"已读取图片 xxx.png"的文本,
|
|
154
|
+
// 于是理直气壮地编内容 —— 不报错、只答错。下面三条钉住这个静默丢图不许回来。
|
|
155
|
+
|
|
156
|
+
const toolImage = (n, payload = `PAYLOAD${n}`) => ({
|
|
157
|
+
role: 'tool', tool_call_id: `call${n}`, name: 'read_image',
|
|
158
|
+
content: [
|
|
159
|
+
{ type: 'text', text: `已读取图片 shot${n}.png` },
|
|
160
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${payload}` } },
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('tool 消息里的图必须活到请求体', () => {
|
|
165
|
+
const messages = [
|
|
166
|
+
{ role: 'system', content: 'rules' },
|
|
167
|
+
{ role: 'user', content: '看下 shot1.png' },
|
|
168
|
+
{ role: 'assistant', content: null, tool_calls: [call(1)] },
|
|
169
|
+
toolImage(1),
|
|
170
|
+
]
|
|
171
|
+
const tool = prepareRequestMessages(messages, { locale: 'zh-CN' }).find(m => m.role === 'tool')
|
|
172
|
+
assert.ok(Array.isArray(tool.content), 'tool 消息应保持多模态数组')
|
|
173
|
+
assert.equal(tool.content[1].type, 'image_url')
|
|
174
|
+
assert.match(tool.content[1].image_url.url, /PAYLOAD1/)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('tool 消息正文超长被截断时,图仍然留着', () => {
|
|
178
|
+
const messages = [
|
|
179
|
+
{ role: 'system', content: 'rules' },
|
|
180
|
+
{ role: 'user', content: 'x' },
|
|
181
|
+
{ role: 'assistant', content: null, tool_calls: [call(9)] },
|
|
182
|
+
{ role: 'tool', tool_call_id: 'call9', name: 'read_image', content: [
|
|
183
|
+
{ type: 'text', text: 'y'.repeat(20000) },
|
|
184
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,KEEP' } },
|
|
185
|
+
] },
|
|
186
|
+
]
|
|
187
|
+
const tool = prepareRequestMessages(messages, { locale: 'zh-CN' }).find(m => m.role === 'tool')
|
|
188
|
+
assert.ok(tool.content[0].text.length < 20000, '正文该截还是得截')
|
|
189
|
+
assert.match(tool.content[1].image_url.url, /KEEP/)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('图片名额由 user 与 tool 共用:只留最新一张,老图连 data URL 一起消失', () => {
|
|
193
|
+
const messages = [
|
|
194
|
+
{ role: 'system', content: 'rules' },
|
|
195
|
+
{ role: 'user', content: [
|
|
196
|
+
{ type: 'text', text: '看这两张' },
|
|
197
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,USERIMG' } },
|
|
198
|
+
] },
|
|
199
|
+
{ role: 'assistant', content: null, tool_calls: [call(1)] },
|
|
200
|
+
toolImage(1),
|
|
201
|
+
]
|
|
202
|
+
const body = JSON.stringify(prepareRequestMessages(messages, { locale: 'zh-CN' }))
|
|
203
|
+
assert.ok(!body.includes('USERIMG'), '更早的 user 图应被降级')
|
|
204
|
+
assert.match(body, /PAYLOAD1/, '最新的 tool 图要留着')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('已经不含图片的多模态数组塌回字符串(贴着最保守的线格式)', () => {
|
|
208
|
+
const messages = [
|
|
209
|
+
{ role: 'system', content: 'rules' },
|
|
210
|
+
{ role: 'user', content: [{ type: 'text', text: 'a' }] }, // 本来就是纯文本数组
|
|
211
|
+
{ role: 'assistant', content: null, tool_calls: [call(1)] },
|
|
212
|
+
toolImage(1), // 会被后来的图顶掉
|
|
213
|
+
{ role: 'user', content: [
|
|
214
|
+
{ type: 'text', text: '再看这张' },
|
|
215
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,NEWER' } },
|
|
216
|
+
] },
|
|
217
|
+
]
|
|
218
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
219
|
+
assert.equal(request[1].content, 'a')
|
|
220
|
+
assert.equal(request[3].content, '已读取图片 shot1.png\n[图片已从历史中省略]')
|
|
221
|
+
assert.equal(request[4].content[1].type, 'image_url', '最新那张仍是多模态数组')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ── 单条巨消息不许挤掉全部工具结果(2026-10-07 事故回归)──
|
|
225
|
+
// 事故现场:11 万字符的任务导出作为最后一条 user 消息进入对话,保留集
|
|
226
|
+
// (system + 首条 user + 最后一条 user)开局就超预算 → 所有工具消息组被拒 →
|
|
227
|
+
// 模型每一轮都看不到自己刚读到的内容 → 反复重读同一个文件 1132 次。
|
|
228
|
+
// 修复后:单条 user 被首尾保留地截断,工具结果永远有存活位。
|
|
229
|
+
|
|
230
|
+
test('单条超长 user 消息被首尾保留地截断,工具结果不再被全部挤走', () => {
|
|
231
|
+
// 巨消息按**当前默认预算的单条线**放大,不写死字符数:默认线 2026-10-07 从
|
|
232
|
+
// 24,000 字符(token 口径下折算)变成了 300,000 字符,写死 200,000 的数据
|
|
233
|
+
// 在新默认下**根本不会被截断**,这条测试会变成"测的是不该截的路径"——
|
|
234
|
+
// 断言全绿但什么都没验到。跟longTranscript 那个坑同源。
|
|
235
|
+
const huge = 'HEAD指令' + 'x'.repeat(DEFAULT_BUDGET.maxUserChars + 10000) + 'TAIL追问'
|
|
236
|
+
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: huge }]
|
|
237
|
+
for (let i = 0; i < 30; i++) {
|
|
238
|
+
messages.push({ role: 'assistant', tool_calls: [call(i)] })
|
|
239
|
+
messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
|
|
240
|
+
}
|
|
241
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
242
|
+
const user = request.find(m => typeof m.content === 'string' && m.content.includes('HEAD指令'))
|
|
243
|
+
assert.ok(user, '被截断的 user 消息本体必须还在')
|
|
244
|
+
assert.ok(user.content.length <= DEFAULT_BUDGET.maxUserChars + 40,
|
|
245
|
+
`单条 user 应截到默认线 ${DEFAULT_BUDGET.maxUserChars},实际 ${user.content.length}`)
|
|
246
|
+
assert.match(user.content, /^HEAD指令/, '开头(指令)要保留')
|
|
247
|
+
assert.match(user.content, /TAIL追问$/, '结尾(最新追问)要保留')
|
|
248
|
+
assert.match(user.content, /earlier output omitted/, '中间省略处要有明确标记')
|
|
249
|
+
const tools = request.filter(m => m.role === 'tool').length
|
|
250
|
+
assert.ok(tools >= 3, `巨消息之后工具结果必须还有存活位,实际只剩 ${tools} 条`)
|
|
251
|
+
assert.ok(JSON.stringify(request).length < DEFAULT_BUDGET.maxChars * 1.1,
|
|
252
|
+
`整包仍在默认预算的量级内(${JSON.stringify(request).length} < ${Math.round(DEFAULT_BUDGET.maxChars * 1.1)})`)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('多模态 user 消息截断时图仍然留着(图不参与字符预算)', () => {
|
|
256
|
+
const messages = [
|
|
257
|
+
{ role: 'system', content: 'rules' },
|
|
258
|
+
{ role: 'user', content: [
|
|
259
|
+
{ type: 'text', text: 'y'.repeat(DEFAULT_BUDGET.maxUserChars + 20000) },
|
|
260
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,USERKEEP' } },
|
|
261
|
+
] },
|
|
262
|
+
{ role: 'assistant', content: 'ok' },
|
|
263
|
+
]
|
|
264
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
265
|
+
const user = request.find(m => Array.isArray(m.content) && m.content.some(p => p.type === 'image_url'))
|
|
266
|
+
assert.ok(user, '最新一条带图 user 消息必须还活着')
|
|
267
|
+
assert.ok(user.content[0].text.length <= DEFAULT_BUDGET.maxUserChars + 40, '正文该截还是得截')
|
|
268
|
+
assert.match(user.content[1].image_url.url, /USERKEEP/, '图不许被截断逻辑碰掉')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('resolveRequestBudget:默认口径与等比缩放,越界夹取/无法解析回落', () => {
|
|
272
|
+
// 默认 **800,000 token**:用户模型都是 1M 档(MiniMax-M3 / DeepSeek V4 Flash),
|
|
273
|
+
// 但顶格 1M 不留输出余量 —— 模型的回复和 thinking 的 reasoning 都占同一个窗口。
|
|
274
|
+
assert.equal(DEFAULT_BUDGET.maxTokens, 800000, '默认 800k(1M 窗口的 80%,留 200k 给输出+reasoning)')
|
|
275
|
+
assert.equal(DEFAULT_BUDGET.maxMessages, 2000, '800k / 400 = 2000 条(分母见 resolveRequestBudget 注释)')
|
|
276
|
+
// maxChars 是给按字符切的地方(单条截断线)用的保守换算值,不是主口径
|
|
277
|
+
assert.ok(DEFAULT_BUDGET.maxChars > DEFAULT_BUDGET.maxTokens,
|
|
278
|
+
`保守换算应大于token 数(保守系数 1.5),实际 maxChars=${DEFAULT_BUDGET.maxChars}`)
|
|
279
|
+
// 越界夹取:手改成天文数字的意图是"想更大",夹到上限而不是回落默认
|
|
280
|
+
assert.equal(resolveRequestBudget(1).maxTokens, 20000, '越界值夹取到下限而不是回落默认')
|
|
281
|
+
assert.equal(resolveRequestBudget(99999999).maxTokens, 1000000, '越界值夹取到上限 1M(区间不随默认缩放)')
|
|
282
|
+
assert.equal(resolveRequestBudget('abc').maxTokens, 800000, '无法解析才回落默认')
|
|
283
|
+
// 等比缩放:条数随 token 预算走
|
|
284
|
+
// 下限 40 现在**碰不到**了:最小合法预算 20,000 ÷ 400 = 50 > 40。
|
|
285
|
+
// 这不是 bug —— 下限 40 是字符口径时代的护栏(那时最小预算 20,000/2000 = 10 会
|
|
286
|
+
// 撞下限),token 口径下同样的 20,000 预算能装 50 条,原护栏已无意义。
|
|
287
|
+
// 留着它是为了「预算被改小时不至于只剩个位数条数」,而 20,000 的下限本身
|
|
288
|
+
// 已经把最坏情况兜住了。断言写成"下限护栏仍存在但当前碰不到":
|
|
289
|
+
const minBudget = resolveRequestBudget(undefined)
|
|
290
|
+
assert.ok(minBudget.maxMessages >= 40, '条数下限护栏仍在')
|
|
291
|
+
assert.equal(resolveRequestBudget(20000).maxMessages, 50, '最小合法预算 → 50 条(20000/400)')
|
|
292
|
+
const mid = resolveRequestBudget(100000)
|
|
293
|
+
assert.equal(mid.maxMessages, 250, '100k token → 250 条(100000/400)')
|
|
294
|
+
assert.ok(mid.maxUserChars < DEFAULT_BUDGET.maxUserChars, '单条线随预算缩放')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('旧配置项 aiMaxRequestChars 能迁成 token 值(2026-10-07 口径变更回归)', () => {
|
|
298
|
+
// 本机历史值 80,000 字符 → 约 34k token。**不能回落默认 1M**,否则等于
|
|
299
|
+
// 静默把用户的设置清了一轮 —— 那正是口径变更最容易犯的错。
|
|
300
|
+
assert.equal(migrateLegacyCharsToTokens(80000), 33755)
|
|
301
|
+
assert.equal(migrateLegacyCharsToTokens(null), null)
|
|
302
|
+
assert.equal(migrateLegacyCharsToTokens(0), null)
|
|
303
|
+
assert.equal(migrateLegacyCharsToTokens('abc'), null)
|
|
304
|
+
// 迁出来的值必须落在合法区间内(超上限要被夹回,而不是照单全收)
|
|
305
|
+
assert.equal(resolveRequestBudget(migrateLegacyCharsToTokens(99999999)).maxTokens, 1000000)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('条数上限不再提前撞满 token 预算(2026-10-07 回归)', () => {
|
|
309
|
+
// 旧公式(字符口径 /2000)让 80k 预算只填到 72% 就被 40 条卡住,实测只覆盖
|
|
310
|
+
// 2.1% 的工具调用轮 —— 字符预算没用满就被条数卡死。
|
|
311
|
+
//
|
|
312
|
+
// token 口径下"条数上限"是**同样的瓶颈**:分母取大就会复现同一个病。
|
|
313
|
+
// 实测单组 token(不是估算):纯 ascii 的 tool被 clip 到 6,000 字符 = 1,715 token
|
|
314
|
+
// + assistant 的 tool_calls 24 → 1,740/组。
|
|
315
|
+
// 分母 2500 → 400 条 × 584 = 23 万(23%)
|
|
316
|
+
// 分母 1200 → 833 条 × 584 = 49 万(49%)
|
|
317
|
+
// 分母 700 → 1429 条 × 584 = 83.5 万(83.5%)
|
|
318
|
+
// 分母 400 → 2500 条 × 1,143 = 286 万,token 闸门先耗尽(100%)
|
|
319
|
+
//
|
|
320
|
+
// ⚠️ 造数据时先算这笔账:条数上限 × 单条 token 必须 ≥ token 预算,否则
|
|
321
|
+
// "token 先耗尽"永远不成立,而失败原因(数据不够大)极易被误读成公式错。
|
|
322
|
+
const messages = [
|
|
323
|
+
{ role: 'system', content: 'rules' },
|
|
324
|
+
{ role: 'user', content: 'goal' },
|
|
325
|
+
]
|
|
326
|
+
// 本形态实测单条 ≈584 token(4,000 ascii 字符 clip 后≈1,143,但 tool_calls
|
|
327
|
+
// 参数极短,assistant 那条只几十 token,两条平均下来 584)。
|
|
328
|
+
// 2,500 条 × 584 = 146万 > 1M,所以 token 闸门会先耗尽 —— 断言要验的正是这个。
|
|
329
|
+
// 给 1200 组(2,400 条):既超够填满 token 预算,也超够接近条数上限,
|
|
330
|
+
// 数据不够大就验不出"token 先耗尽"。
|
|
331
|
+
for (let i = 0; i < 1200; i++) {
|
|
332
|
+
messages.push({ role: 'assistant', tool_calls: [call(i)] })
|
|
333
|
+
messages.push({ role: 'tool', tool_call_id: `call${i}`, content: 'x'.repeat(4200) })
|
|
334
|
+
}
|
|
335
|
+
const request = prepareRequestMessages(messages, { locale: 'zh-CN' })
|
|
336
|
+
// 用 measureContextUsage 量,与 UI 看到的是同一个口径 ——
|
|
337
|
+
// 断言"条数不该提前卡住"这件事本身就是在验这套测量算得对不对。
|
|
338
|
+
const usage = measureContextUsage(request, {
|
|
339
|
+
maxTokens: DEFAULT_BUDGET.maxTokens, maxMessages: DEFAULT_BUDGET.maxMessages,
|
|
340
|
+
})
|
|
341
|
+
assert.ok(usage.tokenRatio > 0.9,
|
|
342
|
+
`token 预算应当基本耗尽(实测只用到 ${(usage.tokenRatio * 100).toFixed(1)}%),条数不该提前卡住`)
|
|
343
|
+
assert.ok(request.length < DEFAULT_BUDGET.maxMessages, `条数也不该撞顶:${request.length}`)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('measureContextUsage:只量请求副本,不含图片,并算出被裁掉多少', () => {
|
|
347
|
+
const transcript = [
|
|
348
|
+
{ role: 'system', content: 'r'.repeat(1000) },
|
|
349
|
+
{ role: 'user', content: 'goal' },
|
|
350
|
+
{ role: 'assistant', content: 'a'.repeat(500) },
|
|
351
|
+
{ role: 'user', content: [
|
|
352
|
+
{ type: 'text', text: '看图' },
|
|
353
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + 'A'.repeat(50000) } },
|
|
354
|
+
] },
|
|
355
|
+
]
|
|
356
|
+
const request = transcript.slice(0, 3)
|
|
357
|
+
const usage = measureContextUsage(request, { maxTokens: 800000, maxMessages: 2000, transcript })
|
|
358
|
+
assert.equal(usage.messages, 3)
|
|
359
|
+
assert.equal(usage.images, 0, '副本里没有图')
|
|
360
|
+
// 图片 base64 不该计入字符预算(否则一张截图就把进度条顶满)
|
|
361
|
+
assert.ok(usage.chars < 5000, `实际 ${usage.chars} —— 图片 base64 似乎被算进去了`)
|
|
362
|
+
assert.equal(usage.transcriptMessages, 4)
|
|
363
|
+
assert.equal(usage.droppedMessages, 1)
|
|
364
|
+
assert.ok(usage.tokens > 0 && usage.tokens < usage.chars, '估算 token 应落在 0 与字符数之间')
|
|
365
|
+
assert.equal(usage.tokenRatio, usage.tokens / 800000)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('estimateTokens:中文一字一 token,ascii 按 3.5 字符一 token', () => {
|
|
369
|
+
assert.equal(estimateTokens('中'.repeat(500)), 500)
|
|
370
|
+
assert.equal(estimateTokens('a'.repeat(3500)), 1000)
|
|
371
|
+
assert.equal(estimateTokens(''), 0)
|
|
372
|
+
assert.equal(estimateTokens(null), 0)
|
|
373
|
+
// 混合:中英各一半应落在两者之间(100 中文 + 350 ascii ≈ 100 + 100 = 200)
|
|
374
|
+
const mixed = estimateTokens('中'.repeat(100) + 'a'.repeat(350))
|
|
375
|
+
assert.ok(mixed > 100 && mixed <= 200, `实际 ${mixed}`)
|
|
376
|
+
})
|