zen-gitsync 2.18.12 → 2.18.13
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 +1385 -1385
- package/src/cli/ai/context.js +431 -431
- package/src/cli/ai/context.test.js +376 -376
- package/src/cli/ai/turn.js +190 -190
- package/src/ui/public/assets/AgentView-CuEM6xzC.js +11 -0
- package/src/ui/public/assets/AppVersionBadge-BK1Fiv3b.js +2 -0
- package/src/ui/public/assets/BranchSelector-avxY_m05.js +1 -0
- package/src/ui/public/assets/CommitForm-hThTnoNt.js +14 -0
- package/src/ui/public/assets/CommonDialog-zaBdFGFH.js +1 -0
- package/src/ui/public/assets/CopySessionButton-CAxaqLQL.js +21 -0
- package/src/ui/public/assets/EditorView-iSA2SXJR.js +37 -0
- package/src/ui/public/assets/{FlowExecutionViewer-Ce4Q5ia6.js → FlowExecutionViewer-C5ylYV2C.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CiQ07ZQv.js +21 -0
- package/src/ui/public/assets/LogList-Sjb8vmvq.js +7 -0
- package/src/ui/public/assets/MindmapView-B5rChFMP.js +2 -0
- package/src/ui/public/assets/MonitorView-Dcc-i2Fo.js +3 -0
- package/src/ui/public/assets/ProjectStartupButton-fz6eLCZU.js +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-BDnNp9jh.js +3 -0
- package/src/ui/public/assets/RemoteManagerDialog-NA1jRC7O.js +4 -0
- package/src/ui/public/assets/RemoteRepoCard-CKm4KqHs.js +7 -0
- package/src/ui/public/assets/SourceMapView-Drfato_u.js +3 -0
- package/src/ui/public/assets/SvgIcon-DR_79iBR.js +1 -0
- package/src/ui/public/assets/UserInputNode-DroKvDOa.js +1 -0
- package/src/ui/public/assets/WorkbenchView-CW7OACCU.js +326 -0
- package/src/ui/public/assets/{WorkbenchView-DzBtCzay.css → WorkbenchView-C_u6BptZ.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-BY0jlMBE.js → _plugin-vue_export-helper-bQQyMRS6.js} +5 -5
- package/src/ui/public/assets/{agentConversations-CC01a7SP.js → agentConversations-wYAksLq1.js} +8 -8
- package/src/ui/public/assets/{configStore-EvRYufor.js → configStore-BeHB-gV4.js} +1 -1
- package/src/ui/public/assets/{css.worker-BDCmgL4U.js → css.worker-DFdt6_zu.js} +9 -9
- package/src/ui/public/assets/{dagre-Ct-SQ-7k.js → dagre-CXi8gaG9.js} +4 -4
- package/src/ui/public/assets/editor.worker-O-CH8JUO.js +26 -0
- package/src/ui/public/assets/{element-plus-D-6gla1z.css → element-plus-DJ3ZQa98.css} +1 -1
- package/src/ui/public/assets/element-plus-DgSY_Wt4.js +48 -0
- package/src/ui/public/assets/{flow-mindmap-C8x6ucsA.js → flow-mindmap-BsTRkgat.js} +56 -56
- package/src/ui/public/assets/{html.worker-DG8hnSJR.js → html.worker-JUzsMP-j.js} +8 -8
- package/src/ui/public/assets/index-BRQstBzy.js +211 -0
- package/src/ui/public/assets/json.worker-B4dEhy0e.js +58 -0
- package/src/ui/public/assets/{monaco-BV4P06MT.js → monaco-CnWeufxc.js} +6 -6
- package/src/ui/public/assets/{office-docx-B8BoOvth.js → office-docx-EUfAIM4w.js} +6 -6
- package/src/ui/public/assets/{office-excel-CFRkxKv8.js → office-excel-BZW0bXQo.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DXdbDvLM.js → office-pptx-CBvDNZ4m.js} +3 -3
- package/src/ui/public/assets/{ts.worker-DgFMIcEs.js → ts.worker-CmEKzkmF.js} +49 -49
- package/src/ui/public/assets/{useOrchestrator-C8BD2JSO.js → useOrchestrator-Bhl7DGKW.js} +1 -1
- package/src/ui/public/assets/{vendor-DYtaPSUU.js → vendor-CZlKgriV.js} +43 -43
- package/src/ui/public/assets/vue-flow-DawpR_uk.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/routes/config.js +1348 -1348
- package/src/ui/server/routes/workbench/agentChat.js +752 -752
- package/src/ui/server/routes/workbench/agentChatShared.test.js +496 -496
- package/src/ui/server/routes/workbench/agentRoutes.js +13 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +75 -0
- package/src/ui/server/routes/workbench/index.js +2 -1
- package/src/ui/server/routes/workbench/scheduleRoutes.js +2 -2
- package/src/ui/server/routes/workbench/shared.js +17 -0
- package/src/ui/public/assets/AgentView-CVbcrngz.js +0 -11
- package/src/ui/public/assets/AppVersionBadge-7gFB6t_t.js +0 -2
- package/src/ui/public/assets/BranchSelector-BIRNxA6U.js +0 -1
- package/src/ui/public/assets/CommitForm-CWpaazi7.js +0 -14
- package/src/ui/public/assets/CommonDialog-DrxsmpDL.js +0 -1
- package/src/ui/public/assets/CopySessionButton-C_01fcd3.js +0 -21
- package/src/ui/public/assets/EditorView-D80wG480.js +0 -37
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cq1zEt5K.js +0 -21
- package/src/ui/public/assets/LogList-DtmJG27U.js +0 -7
- package/src/ui/public/assets/MindmapView-BR2_C4Du.js +0 -2
- package/src/ui/public/assets/MonitorView-Cs_kwPBu.js +0 -3
- package/src/ui/public/assets/ProjectStartupButton-BfQJ0i5X.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-_gNQGqkM.js +0 -3
- package/src/ui/public/assets/RemoteManagerDialog-KY5I3Ftv.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-BUgdGtwC.js +0 -7
- package/src/ui/public/assets/SourceMapView-RkGgdvnN.js +0 -3
- package/src/ui/public/assets/SvgIcon-CP7WAT4C.js +0 -1
- package/src/ui/public/assets/UserInputNode-CwSqPh4O.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CBfccRIP.js +0 -320
- package/src/ui/public/assets/editor.worker-GCPm6rMv.js +0 -26
- package/src/ui/public/assets/element-plus-CDrtgG55.js +0 -48
- package/src/ui/public/assets/index-CyEcaEC9.js +0 -211
- package/src/ui/public/assets/json.worker-CxwlJRnL.js +0 -58
- package/src/ui/public/assets/vue-flow-B7Hdmt-y.js +0 -39
|
@@ -1,496 +1,496 @@
|
|
|
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, mkdirSync, writeFileSync, rmSync, existsSync } 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
|
-
//
|
|
61
|
-
// streamFn:传输层现在默认会自动重试(最多 10 次、指数退避,见 cli/ai/transport.js)。
|
|
62
|
-
// 这一组用例关心的是"失败时怎么收场",不是"重试几次" —— 真等退避会让一个用例跑到分钟级。
|
|
63
|
-
// 所以统一注入 maxRetries: 0 的那一份;重试自身的口径在 cli/ai/runtime.test.js 里逐条钉。
|
|
64
|
-
async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent, resume = false, streamFn, promptSection }) {
|
|
65
|
-
const { runAgentTurn } = await import('./agentChat.js')
|
|
66
|
-
const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
|
|
67
|
-
const events = []
|
|
68
|
-
let sent
|
|
69
|
-
const original = globalThis.fetch
|
|
70
|
-
globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond(init) }
|
|
71
|
-
try {
|
|
72
|
-
const result = await runAgentTurn({
|
|
73
|
-
session, model, userMessage, locale, cwd: process.cwd(),
|
|
74
|
-
signal: signal ?? new AbortController().signal,
|
|
75
|
-
send: e => { events.push(e); onEvent?.(e) },
|
|
76
|
-
resume,
|
|
77
|
-
streamFn: streamFn ?? (opts => streamChatOnce({ ...opts, maxRetries: 0 })),
|
|
78
|
-
// 不传 = 让 runAgentTurn 自己读沙箱里的配置(要验默认取数时就别传)
|
|
79
|
-
...(promptSection === undefined ? {} : { promptSection }),
|
|
80
|
-
})
|
|
81
|
-
return { sent, events, result }
|
|
82
|
-
} finally {
|
|
83
|
-
globalThis.fetch = original
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
|
|
88
|
-
|
|
89
|
-
// ── 上下文口径 ────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
test('请求体与 context.js 的输出逐字段相同(有界 + 摘录)', async () => {
|
|
92
|
-
const { prepareRequestMessages, resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
93
|
-
const messages = longSession()
|
|
94
|
-
// 传副本进去:runAgentTurn 会往 session.messages 追加消息,原数组要留作期望值的输入
|
|
95
|
-
const session = newSession('ag-sandbox-test', structuredClone(messages))
|
|
96
|
-
|
|
97
|
-
const { sent } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
98
|
-
|
|
99
|
-
assert.ok(sent, '必须发出过一次请求')
|
|
100
|
-
// 上限写死成 40 就会在默认值变更那天变成假绿 —— 直接引用解析结果
|
|
101
|
-
assert.ok(sent.messages.length <= resolveRequestBudget(undefined).maxMessages,
|
|
102
|
-
`请求副本未收窄: ${sent.messages.length} 条`)
|
|
103
|
-
assert.equal(sent.messages[0].role, 'system')
|
|
104
|
-
assert.match(sent.messages[1].content, /^\[Earlier conversation excerpts/)
|
|
105
|
-
assert.ok(sent.messages.some(m => m.content === 'original goal'), '原始目标必须保留')
|
|
106
|
-
// 逐字段相同 = 这条链路确实走的是同一个函数,而不是"看起来差不多"的第二份实现
|
|
107
|
-
assert.deepEqual(
|
|
108
|
-
sent.messages,
|
|
109
|
-
prepareRequestMessages([...messages, { role: 'user', content: 'next' }], { locale: 'zh-CN' }),
|
|
110
|
-
)
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
test('会话记录不再被原地裁剪(磁盘口径与 CLI 一致)', async () => {
|
|
114
|
-
const session = newSession('ag-sandbox-keep', longSession())
|
|
115
|
-
const toolCountBefore = session.messages.filter(m => m.role === 'tool').length
|
|
116
|
-
|
|
117
|
-
await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
118
|
-
|
|
119
|
-
// 比"运行前后"而不是写死组数 —— longSession 的组数一改这条就假绿
|
|
120
|
-
assert.equal(session.messages.filter(m => m.role === 'tool').length, toolCountBefore,
|
|
121
|
-
'旧消息必须留在会话记录里(裁剪只作用于请求副本)')
|
|
122
|
-
assert.equal(session.messages[1].content, 'original goal')
|
|
123
|
-
assert.equal(session.messages[2].tool_calls[0].id, 'call0')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('短会话不做任何额外处理,也不塞摘录消息', async () => {
|
|
127
|
-
const session = newSession('ag-sandbox-short', withSystem())
|
|
128
|
-
|
|
129
|
-
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
130
|
-
|
|
131
|
-
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// ── 上下文占用下发(2026-10-07)────────────────────────────────
|
|
135
|
-
// UI 的占用条全靠这个事件。断它 = 进度条永远停在 0%,而界面不会报任何错。
|
|
136
|
-
|
|
137
|
-
test('每次请求前发一条 context 事件,用量与请求副本一致', async () => {
|
|
138
|
-
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
139
|
-
const session = newSession('ag-sandbox-ctx', withSystem())
|
|
140
|
-
|
|
141
|
-
const { events, sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
142
|
-
|
|
143
|
-
const ctx = events.filter(e => e.type === 'context')
|
|
144
|
-
assert.ok(ctx.length >= 1, '必须至少发一条 context 事件')
|
|
145
|
-
const usage = ctx[0].usage
|
|
146
|
-
const budget = resolveRequestBudget(undefined)
|
|
147
|
-
// 分母必须跟实际裁剪用的是同一份,否则进度条会说谎
|
|
148
|
-
// 主口径是 **token**(maxChars 只是给按字符切的地方用的保守换算值,不是闸门)
|
|
149
|
-
assert.equal(usage.maxTokens, budget.maxTokens)
|
|
150
|
-
assert.equal(usage.maxChars, budget.maxChars)
|
|
151
|
-
assert.equal(usage.maxMessages, budget.maxMessages)
|
|
152
|
-
assert.equal(usage.messages, sent.messages.length, 'usage 里的条数要与真实发出去的请求一致')
|
|
153
|
-
assert.ok(usage.chars > 0 && usage.estTokens > 0)
|
|
154
|
-
assert.ok(usage.tokenRatio > 0 && usage.tokenRatio <= 1)
|
|
155
|
-
assert.equal(usage.droppedMessages, 0, '短会话不该有裁剪')
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
test('provider 返回 usage 时补一条带真实输入 token 的 context', async () => {
|
|
159
|
-
const session = newSession('ag-sandbox-ctx-usage', withSystem())
|
|
160
|
-
const withUsage = () => sse([
|
|
161
|
-
event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }),
|
|
162
|
-
event({ usage: { prompt_tokens: 4321, completion_tokens: 10, total_tokens: 4331 } }),
|
|
163
|
-
'data: [DONE]\n\n',
|
|
164
|
-
])
|
|
165
|
-
|
|
166
|
-
const { events } = await runWeb({ session, userMessage: 'hello', respond: withUsage })
|
|
167
|
-
|
|
168
|
-
const withActual = events.filter(e => e.type === 'context' && typeof e.usage.actualInputTokens === 'number')
|
|
169
|
-
assert.ok(withActual.length >= 1, '必须有一条带上真实 input token')
|
|
170
|
-
assert.equal(withActual[0].usage.actualInputTokens, 4321)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('被裁剪的长会话会报出 droppedMessages(UI 靠它解释"为什么模型忘了")', async () => {
|
|
174
|
-
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
175
|
-
const session = newSession('ag-sandbox-ctx-drop', longSession())
|
|
176
|
-
const toolCount = session.messages.filter(m => m.role === 'tool').length
|
|
177
|
-
|
|
178
|
-
const { events } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
179
|
-
|
|
180
|
-
const usage = events.find(e => e.type === 'context').usage
|
|
181
|
-
assert.ok(usage.droppedMessages > 0, '长会话必须报出被裁掉的条数')
|
|
182
|
-
// longSession = system + 首 user + N×(assistant + tool),再加 runAgentTurn 追加的本轮 user
|
|
183
|
-
assert.equal(usage.transcriptMessages, toolCount * 2 + 3,
|
|
184
|
-
'transcriptMessages 是磁盘上的完整条数(含本轮 user)')
|
|
185
|
-
assert.ok(usage.messages < usage.transcriptMessages, '带入的必须少于磁盘上的,否则说明没裁')
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
// ── 传输口径 ──────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
test('请求体带上 stream_options,与 CLI 一致地统计 usage', async () => {
|
|
191
|
-
const session = newSession('ag-sandbox-usage', withSystem())
|
|
192
|
-
|
|
193
|
-
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
194
|
-
|
|
195
|
-
assert.deepEqual(sent.stream_options, { include_usage: true })
|
|
196
|
-
assert.equal(sent.stream, true)
|
|
197
|
-
assert.equal(sent.temperature, 0.3)
|
|
198
|
-
assert.ok(Array.isArray(sent.tools) && sent.tools.length > 0, '内置工具表必须随请求下发')
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截的工具调用', async () => {
|
|
202
|
-
const session = newSession('ag-sandbox-truncated', withSystem())
|
|
203
|
-
// 工具调用参数是完整的、JSON 合法 —— 换在没有这道闸的旧实现里就会被真的执行
|
|
204
|
-
const truncated = sse([event({
|
|
205
|
-
choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_x', function: { name: 'read_file', arguments: '{"path":"nope.txt"}' } }] } }],
|
|
206
|
-
})])
|
|
207
|
-
|
|
208
|
-
const { events } = await runWeb({ session, userMessage: '读个文件', respond: () => truncated })
|
|
209
|
-
|
|
210
|
-
const error = events.find(e => e.type === 'error')
|
|
211
|
-
assert.ok(error, '截断的流必须报错,不能静默当成正常结束')
|
|
212
|
-
assert.match(error.error, /中断/)
|
|
213
|
-
assert.equal(events.some(e => e.type === 'tool_call_start'), false, '被截断的调用不得开始执行')
|
|
214
|
-
assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
// 回归:用户点"停止"(中止正在跑的这一轮)时,已经流出来的正文必须留在会话记录里。
|
|
218
|
-
// 之前中止分支只把当时的状态写盘、不回填正文,重新打开这条会话时刚才生成的内容整段消失。
|
|
219
|
-
test('中止时已流出的正文要留在会话记录里', async () => {
|
|
220
|
-
const session = newSession('ag-sandbox-aborted', withSystem())
|
|
221
|
-
const controller = new AbortController()
|
|
222
|
-
|
|
223
|
-
// 吐一段正文后挂住(模拟"停在生成中");真 fetch 在 signal abort 时会打断 body 读取,
|
|
224
|
-
// 桩里照做才能真的走到中止分支
|
|
225
|
-
const hanging = init => new Response(new ReadableStream({
|
|
226
|
-
start(stream) {
|
|
227
|
-
const bytes = new TextEncoder()
|
|
228
|
-
stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })))
|
|
229
|
-
init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')))
|
|
230
|
-
},
|
|
231
|
-
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
232
|
-
|
|
233
|
-
const { result } = await runWeb({
|
|
234
|
-
session, userMessage: '写一半就停', respond: hanging, signal: controller.signal,
|
|
235
|
-
// 正文事件到达 = 这段已经被读进来,此刻中止最贴近用户点"停止"的时机
|
|
236
|
-
onEvent: e => { if (e.type === 'content') controller.abort() },
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
assert.equal(result.aborted, true)
|
|
240
|
-
const last = session.messages[session.messages.length - 1]
|
|
241
|
-
assert.equal(last.role, 'assistant')
|
|
242
|
-
assert.equal(last.content, '已经写了一半')
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
|
|
246
|
-
const session = newSession('ag-sandbox-nofc', withSystem())
|
|
247
|
-
const noFunctionCalling = () => new Response(
|
|
248
|
-
JSON.stringify({ error: { message: 'tools is not supported by this model' } }),
|
|
249
|
-
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: noFunctionCalling })
|
|
253
|
-
|
|
254
|
-
assert.equal(sent.messages.length, 3, '不该把 stream_options 的降级重试和这条错误混在一起')
|
|
255
|
-
const error = events.find(e => e.type === 'error')
|
|
256
|
-
assert.ok(error)
|
|
257
|
-
assert.match(error.error, /不支持 function calling/)
|
|
258
|
-
assert.match(error.error, /换用支持工具调用的模型/)
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
// ── 工具参数的透传契约 ──────────────────────────────────────
|
|
262
|
-
// 两个字段分工不同,两边都不能少:
|
|
263
|
-
// · arguments —— 展开后「参数」框里的原文,**每种工具都发全文**。前端历史回放
|
|
264
|
-
// (useAgentChat 读 session.messages)拿到的本来就是全文,只在流式这一路发摘要,
|
|
265
|
-
// 就会让"正在跑"和"刷新后"看到两份不同的参数。
|
|
266
|
-
// · argsPreview —— 收起态那一行副标题,故意截到 200 字。
|
|
267
|
-
// 计划类工具的 steps 也在 arguments 里(不再单独开一条透传路径)。
|
|
268
|
-
test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截断摘要', async () => {
|
|
269
|
-
// 桩的 write_file 落点:沙箱里(见 round 里的说明)。用完删掉,不留 tmp 垃圾。
|
|
270
|
-
const stubWritePath = path.join(SANDBOX, 'stub-write-target.js')
|
|
271
|
-
const planArgs = JSON.stringify({
|
|
272
|
-
steps: [
|
|
273
|
-
{ content: '读代码', status: 'completed' },
|
|
274
|
-
{ content: '改代码', status: 'in_progress' }
|
|
275
|
-
],
|
|
276
|
-
explanation: '先改库再改宿主'
|
|
277
|
-
})
|
|
278
|
-
const round = [
|
|
279
|
-
sse([event({
|
|
280
|
-
choices: [{ delta: { tool_calls: [
|
|
281
|
-
{ index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
|
|
282
|
-
// ⚠️ 这个桩会走**真实** executeTool 落盘(这一层跑的是真 runAgentTurn,
|
|
283
|
-
// 不是解析 Mock)—— 路径必须指到沙箱。曾经这里写的是相对路径 'a.js':
|
|
284
|
-
// resolvePath 按 process.cwd() 解析,于是每次跑测试都在当前目录留一个
|
|
285
|
-
// 50 字节的 a.js(仓库根跑留根目录、routes/workbench 跑留那里),
|
|
286
|
-
// 全组用例还都是绿的,只能靠 git status 发现。
|
|
287
|
-
{ index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }) } }
|
|
288
|
-
] } }, { finish_reason: 'tool_calls' }],
|
|
289
|
-
}), 'data: [DONE]\n\n']),
|
|
290
|
-
okStream(),
|
|
291
|
-
]
|
|
292
|
-
let turn = 0
|
|
293
|
-
const session = newSession('ag-sandbox-plan', withSystem())
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
const { events } = await runWeb({
|
|
297
|
-
session,
|
|
298
|
-
userMessage: '把这个功能做出来',
|
|
299
|
-
respond: () => round[turn++] || okStream(),
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
const starts = events.filter(e => e.type === 'tool_call_start')
|
|
303
|
-
assert.equal(starts.length, 2)
|
|
304
|
-
const planStart = starts.find(e => e.name === 'update_plan')
|
|
305
|
-
const writeStart = starts.find(e => e.name === 'write_file')
|
|
306
|
-
|
|
307
|
-
// 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
|
|
308
|
-
assert.equal(planStart.arguments, planArgs)
|
|
309
|
-
assert.match(planStart.argsPreview, /1\/2|1 完成/)
|
|
310
|
-
// 其他工具:展开态同样要看到原文,不再只给摘要
|
|
311
|
-
assert.equal(writeStart.arguments, JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }))
|
|
312
|
-
assert.ok(writeStart.argsPreview.length < writeStart.arguments.length, '摘要仍应是截断过的')
|
|
313
|
-
// 顺带证明桩调用真的执行了、且只落在沙箱(这条红了 = 有人把路径改成会污染仓库的)
|
|
314
|
-
assert.ok(existsSync(stubWritePath), '桩的 write_file 应真实落在沙箱里')
|
|
315
|
-
|
|
316
|
-
const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
|
|
317
|
-
assert.match(planResult.result, /计划已更新/)
|
|
318
|
-
// 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
|
|
319
|
-
const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
|
|
320
|
-
assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
|
|
321
|
-
} finally {
|
|
322
|
-
rmSync(stubWritePath, { force: true })
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
// ── 重试与续跑(2026-10-09)────────────────────────────────────
|
|
327
|
-
// 「重试」要能真的把上一轮跑下去,靠的是服务端两条口径:
|
|
328
|
-
// 1. 失败时**不撤**那条 user 消息 —— 撤了的话 resume 会退化成"重跑上一轮"
|
|
329
|
-
// 2. resume 时**不再压** user 消息 —— 压了就变成"同一条请求出现两次"
|
|
330
|
-
// 两条各自都有明确的失效症状,所以各钉一条。
|
|
331
|
-
|
|
332
|
-
test('失败时不再撤掉本轮压入的 user 消息(否则重试会退化成重跑上一轮)', async () => {
|
|
333
|
-
const session = newSession('ag-sandbox-keep-user', withSystem())
|
|
334
|
-
const boom = () => new Response(JSON.stringify({ error: { message: 'bad request: model not found' } }),
|
|
335
|
-
{ status: 404, headers: { 'content-type': 'application/json' } })
|
|
336
|
-
|
|
337
|
-
const { events } = await runWeb({ session, userMessage: '这条不能消失', respond: boom })
|
|
338
|
-
|
|
339
|
-
assert.ok(events.some(e => e.type === 'error'))
|
|
340
|
-
const last = session.messages[session.messages.length - 1]
|
|
341
|
-
assert.equal(last.role, 'user')
|
|
342
|
-
assert.equal(last.content, '这条不能消失')
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
test('resume 不再压 user 消息,接着会话现有的尾巴跑', async () => {
|
|
346
|
-
const session = newSession('ag-sandbox-resume', withSystem())
|
|
347
|
-
const usersBefore = session.messages.filter(m => m.role === 'user').length
|
|
348
|
-
|
|
349
|
-
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
350
|
-
|
|
351
|
-
assert.equal(sent.messages.length, 2, 'resume 不该往请求里加消息')
|
|
352
|
-
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'])
|
|
353
|
-
// 会话记录里也不能多出一条 user(多出来的那条只会是本轮 assistant 的回答)
|
|
354
|
-
assert.equal(session.messages.filter(m => m.role === 'user').length, usersBefore)
|
|
355
|
-
assert.equal(session.messages[session.messages.length - 1].role, 'assistant')
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
test('每次 LLM 请求前发一条 attempt(前端靠它记重试断点),自动重试时发 retry', async () => {
|
|
359
|
-
const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
|
|
360
|
-
let calls = 0
|
|
361
|
-
// 第一次吐半截就断流(可重试),第二次正常返回 —— 真链路走 streamChatOnce,
|
|
362
|
-
// 只把"真等退避"换掉,好让这条用例秒级跑完
|
|
363
|
-
const flaky = opts => streamChatOnce({
|
|
364
|
-
...opts,
|
|
365
|
-
sleepFn: async () => {},
|
|
366
|
-
fetchFn: async () => {
|
|
367
|
-
calls++
|
|
368
|
-
if (calls === 1) return sse([event({ choices: [{ delta: { content: '半截' } }] })])
|
|
369
|
-
return okStream()
|
|
370
|
-
},
|
|
371
|
-
})
|
|
372
|
-
const session = newSession('ag-sandbox-retry', withSystem())
|
|
373
|
-
|
|
374
|
-
const { events } = await runWeb({ session, userMessage: '开始', streamFn: flaky })
|
|
375
|
-
|
|
376
|
-
assert.equal(calls, 2, '第一次断流后必须再试一次')
|
|
377
|
-
// attempt 是**每次工具循环的请求开始前**发的一条(不是每次尝试):
|
|
378
|
-
// 自动重试沿用的还是同一个断点 —— 它要丢的正是这一轮请求吐的全部内容。
|
|
379
|
-
assert.equal(events.filter(e => e.type === 'attempt').length, 1)
|
|
380
|
-
const retry = events.find(e => e.type === 'retry')
|
|
381
|
-
assert.ok(retry, '自动重试必须通知前端,否则界面上会留下上一截的字')
|
|
382
|
-
assert.equal(retry.attempt, 1)
|
|
383
|
-
assert.equal(retry.maxRetries, 10)
|
|
384
|
-
assert.match(retry.reason, /中断/)
|
|
385
|
-
// 断点必须排在正文之前:前端是按"收到 attempt 时气泡里已有的长度"回退的
|
|
386
|
-
const order = events.map(e => e.type)
|
|
387
|
-
assert.ok(order.indexOf('attempt') < order.indexOf('content'), 'attempt 要在正文之前')
|
|
388
|
-
assert.ok(order.indexOf('content') < order.indexOf('retry'), 'retry 落在被丢弃的那段正文之后')
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
test('对跑完的一轮点「重新生成」:把尾巴那条 assistant 摘掉再问一次', async () => {
|
|
392
|
-
// 会话尾巴是一条没有工具调用的 assistant = 上一轮已经答完了。
|
|
393
|
-
// 留着它的话模型会顺着自己的答案往下说,重开会话还会看到两条连着的 assistant。
|
|
394
|
-
const session = newSession('ag-sandbox-regen', [
|
|
395
|
-
{ role: 'system', content: 'rules' },
|
|
396
|
-
{ role: 'user', content: 'hi' },
|
|
397
|
-
{ role: 'assistant', content: '旧答案' },
|
|
398
|
-
])
|
|
399
|
-
|
|
400
|
-
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
401
|
-
|
|
402
|
-
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'], '旧答案必须从请求里消失')
|
|
403
|
-
assert.equal(session.messages.filter(m => m.role === 'assistant').length, 1, '只剩这一次新生成的')
|
|
404
|
-
assert.equal(session.messages[session.messages.length - 1].content, 'ok')
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
test('对中途失败的一轮点「重试」:工具结果留在尾巴上,一条都不摘', async () => {
|
|
408
|
-
// 失败发生在工具循环中途时,尾巴是 assistant(tool_calls) + 一批 tool 结果 ——
|
|
409
|
-
// 这些是这一轮真干过的事,摘掉就等于让模型把工具再跑一遍。
|
|
410
|
-
const session = newSession('ag-sandbox-resume-tools', [
|
|
411
|
-
{ role: 'system', content: 'rules' },
|
|
412
|
-
{ role: 'user', content: 'hi' },
|
|
413
|
-
{ role: 'assistant', content: null, tool_calls: [call(0)] },
|
|
414
|
-
{ role: 'tool', tool_call_id: 'call0', content: 'file contents' },
|
|
415
|
-
])
|
|
416
|
-
|
|
417
|
-
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
418
|
-
|
|
419
|
-
assert.deepEqual(sent.messages.map(m => m.role), ['system', 'user', 'assistant', 'tool'])
|
|
420
|
-
assert.equal(sent.messages[3].content, 'file contents', '已经跑完的工具结果必须原样带上')
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
// ── 用户固定提示词(g ai 默认提示词 + 派发提示词)────────────────
|
|
424
|
-
// 智能体视图「设置」Tab 配的两块提示词,和 g ai 对话时每轮都要注入。
|
|
425
|
-
// 这是**纯 prompt 注入**的能力:没有按钮、没有状态、界面上看不出对错 ——
|
|
426
|
-
// 改 agentChat.js / chatPrompts.js 的人(哪怕只是把一段挪个位置)会无声地把它弄掉。
|
|
427
|
-
// 所以这里跑真实的 runAgentTurn,把发出去的请求体抓下来逐条钉住。
|
|
428
|
-
//
|
|
429
|
-
// 沙箱里写真实文件(config.json / orchestrator.json):这一层要验的正是
|
|
430
|
-
// "默认取数真的接到了配置上",用注入口反而绕过了要验的那一段。
|
|
431
|
-
// 每个用例 finally 里删干净 —— 同文件其它用例断言的是精确的 messages 列表,
|
|
432
|
-
// 残留一份提示词就会让它们莫名其妙地红。
|
|
433
|
-
|
|
434
|
-
const DATA_DIR = path.join(SANDBOX, '.zen-gitsync')
|
|
435
|
-
|
|
436
|
-
function writeSandboxFile(name, obj) {
|
|
437
|
-
mkdirSync(DATA_DIR, { recursive: true })
|
|
438
|
-
writeFileSync(path.join(DATA_DIR, name), JSON.stringify(obj, null, 2))
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function clearSandboxFile(name) {
|
|
442
|
-
rmSync(path.join(DATA_DIR, name), { force: true })
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
test('两块提示词都注入请求副本:g ai 默认提示词 + 派发提示词(含项目级)', async () => {
|
|
446
|
-
const { canonicalProjectPath } = await import('./projectRegistry.js')
|
|
447
|
-
const projectKey = canonicalProjectPath(process.cwd())
|
|
448
|
-
try {
|
|
449
|
-
writeSandboxFile('config.json', { agentDefaultPrompt: '【g ai 默认提示词】回答用中文' })
|
|
450
|
-
writeSandboxFile('orchestrator.json', {
|
|
451
|
-
version: 1,
|
|
452
|
-
defaultPrompt: '【全局派发提示词】改完就提交',
|
|
453
|
-
projectPrompts: { [projectKey]: { path: process.cwd(), prompt: '【本项目派发提示词】包管理器用 pnpm' } },
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
const session = newSession('ag-sandbox-user-prompt', withSystem())
|
|
457
|
-
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
458
|
-
|
|
459
|
-
const sys = sent.messages.find(m => m.role === 'system')
|
|
460
|
-
assert.match(sys.content, /【g ai 默认提示词】回答用中文/, 'g ai 默认提示词必须进请求')
|
|
461
|
-
assert.match(sys.content, /【全局派发提示词】改完就提交/, '全局派发提示词必须进请求')
|
|
462
|
-
assert.match(sys.content, /【本项目派发提示词】包管理器用 pnpm/,
|
|
463
|
-
'项目级那条要按本轮 cwd 生效(与派发时"按落点项目解析"同一口径)')
|
|
464
|
-
// 规则在前、现场在后:固定提示词是每轮都成立的约束,不该排在工作区快照后面
|
|
465
|
-
assert.ok(sys.content.indexOf('【g ai 默认提示词】') < sys.content.indexOf('【全局派发提示词】'),
|
|
466
|
-
'g ai 那段排在派发那段之前')
|
|
467
|
-
// 落盘口径:会话里那条 system 一个字节都不许变 —— 否则改设置后老会话永远带着旧规则
|
|
468
|
-
assert.equal(session.messages[0].content, 'rules', '注入只作用于请求副本,不写回 session.messages')
|
|
469
|
-
} finally {
|
|
470
|
-
clearSandboxFile('config.json')
|
|
471
|
-
clearSandboxFile('orchestrator.json')
|
|
472
|
-
}
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
test('没配就不注入(连一节标题都不出现)—— 这也是其它用例能断言精确 messages 的前提', async () => {
|
|
476
|
-
const session = newSession('ag-sandbox-user-prompt-empty', withSystem())
|
|
477
|
-
|
|
478
|
-
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
479
|
-
|
|
480
|
-
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
test('调用方显式传 promptSection 时不再自己读配置(定时任务 / agentRoutes 走这条路)', async () => {
|
|
484
|
-
try {
|
|
485
|
-
// 磁盘上有一份提示词,但调用方传了 '':意思是"这一轮确定不带",不是"没传、你去读"
|
|
486
|
-
writeSandboxFile('config.json', { agentDefaultPrompt: '【不该出现】' })
|
|
487
|
-
const session = newSession('ag-sandbox-user-prompt-explicit', withSystem())
|
|
488
|
-
|
|
489
|
-
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream, promptSection: '' })
|
|
490
|
-
|
|
491
|
-
assert.doesNotMatch(sent.messages[0].content, /【不该出现】/)
|
|
492
|
-
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
493
|
-
} finally {
|
|
494
|
-
clearSandboxFile('config.json')
|
|
495
|
-
}
|
|
496
|
-
})
|
|
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, mkdirSync, writeFileSync, rmSync, existsSync } 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
|
+
//
|
|
61
|
+
// streamFn:传输层现在默认会自动重试(最多 10 次、指数退避,见 cli/ai/transport.js)。
|
|
62
|
+
// 这一组用例关心的是"失败时怎么收场",不是"重试几次" —— 真等退避会让一个用例跑到分钟级。
|
|
63
|
+
// 所以统一注入 maxRetries: 0 的那一份;重试自身的口径在 cli/ai/runtime.test.js 里逐条钉。
|
|
64
|
+
async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent, resume = false, streamFn, promptSection }) {
|
|
65
|
+
const { runAgentTurn } = await import('./agentChat.js')
|
|
66
|
+
const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
|
|
67
|
+
const events = []
|
|
68
|
+
let sent
|
|
69
|
+
const original = globalThis.fetch
|
|
70
|
+
globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond(init) }
|
|
71
|
+
try {
|
|
72
|
+
const result = await runAgentTurn({
|
|
73
|
+
session, model, userMessage, locale, cwd: process.cwd(),
|
|
74
|
+
signal: signal ?? new AbortController().signal,
|
|
75
|
+
send: e => { events.push(e); onEvent?.(e) },
|
|
76
|
+
resume,
|
|
77
|
+
streamFn: streamFn ?? (opts => streamChatOnce({ ...opts, maxRetries: 0 })),
|
|
78
|
+
// 不传 = 让 runAgentTurn 自己读沙箱里的配置(要验默认取数时就别传)
|
|
79
|
+
...(promptSection === undefined ? {} : { promptSection }),
|
|
80
|
+
})
|
|
81
|
+
return { sent, events, result }
|
|
82
|
+
} finally {
|
|
83
|
+
globalThis.fetch = original
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
|
|
88
|
+
|
|
89
|
+
// ── 上下文口径 ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
test('请求体与 context.js 的输出逐字段相同(有界 + 摘录)', async () => {
|
|
92
|
+
const { prepareRequestMessages, resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
93
|
+
const messages = longSession()
|
|
94
|
+
// 传副本进去:runAgentTurn 会往 session.messages 追加消息,原数组要留作期望值的输入
|
|
95
|
+
const session = newSession('ag-sandbox-test', structuredClone(messages))
|
|
96
|
+
|
|
97
|
+
const { sent } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
98
|
+
|
|
99
|
+
assert.ok(sent, '必须发出过一次请求')
|
|
100
|
+
// 上限写死成 40 就会在默认值变更那天变成假绿 —— 直接引用解析结果
|
|
101
|
+
assert.ok(sent.messages.length <= resolveRequestBudget(undefined).maxMessages,
|
|
102
|
+
`请求副本未收窄: ${sent.messages.length} 条`)
|
|
103
|
+
assert.equal(sent.messages[0].role, 'system')
|
|
104
|
+
assert.match(sent.messages[1].content, /^\[Earlier conversation excerpts/)
|
|
105
|
+
assert.ok(sent.messages.some(m => m.content === 'original goal'), '原始目标必须保留')
|
|
106
|
+
// 逐字段相同 = 这条链路确实走的是同一个函数,而不是"看起来差不多"的第二份实现
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
sent.messages,
|
|
109
|
+
prepareRequestMessages([...messages, { role: 'user', content: 'next' }], { locale: 'zh-CN' }),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('会话记录不再被原地裁剪(磁盘口径与 CLI 一致)', async () => {
|
|
114
|
+
const session = newSession('ag-sandbox-keep', longSession())
|
|
115
|
+
const toolCountBefore = session.messages.filter(m => m.role === 'tool').length
|
|
116
|
+
|
|
117
|
+
await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
118
|
+
|
|
119
|
+
// 比"运行前后"而不是写死组数 —— longSession 的组数一改这条就假绿
|
|
120
|
+
assert.equal(session.messages.filter(m => m.role === 'tool').length, toolCountBefore,
|
|
121
|
+
'旧消息必须留在会话记录里(裁剪只作用于请求副本)')
|
|
122
|
+
assert.equal(session.messages[1].content, 'original goal')
|
|
123
|
+
assert.equal(session.messages[2].tool_calls[0].id, 'call0')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('短会话不做任何额外处理,也不塞摘录消息', async () => {
|
|
127
|
+
const session = newSession('ag-sandbox-short', withSystem())
|
|
128
|
+
|
|
129
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
130
|
+
|
|
131
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ── 上下文占用下发(2026-10-07)────────────────────────────────
|
|
135
|
+
// UI 的占用条全靠这个事件。断它 = 进度条永远停在 0%,而界面不会报任何错。
|
|
136
|
+
|
|
137
|
+
test('每次请求前发一条 context 事件,用量与请求副本一致', async () => {
|
|
138
|
+
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
139
|
+
const session = newSession('ag-sandbox-ctx', withSystem())
|
|
140
|
+
|
|
141
|
+
const { events, sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
142
|
+
|
|
143
|
+
const ctx = events.filter(e => e.type === 'context')
|
|
144
|
+
assert.ok(ctx.length >= 1, '必须至少发一条 context 事件')
|
|
145
|
+
const usage = ctx[0].usage
|
|
146
|
+
const budget = resolveRequestBudget(undefined)
|
|
147
|
+
// 分母必须跟实际裁剪用的是同一份,否则进度条会说谎
|
|
148
|
+
// 主口径是 **token**(maxChars 只是给按字符切的地方用的保守换算值,不是闸门)
|
|
149
|
+
assert.equal(usage.maxTokens, budget.maxTokens)
|
|
150
|
+
assert.equal(usage.maxChars, budget.maxChars)
|
|
151
|
+
assert.equal(usage.maxMessages, budget.maxMessages)
|
|
152
|
+
assert.equal(usage.messages, sent.messages.length, 'usage 里的条数要与真实发出去的请求一致')
|
|
153
|
+
assert.ok(usage.chars > 0 && usage.estTokens > 0)
|
|
154
|
+
assert.ok(usage.tokenRatio > 0 && usage.tokenRatio <= 1)
|
|
155
|
+
assert.equal(usage.droppedMessages, 0, '短会话不该有裁剪')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('provider 返回 usage 时补一条带真实输入 token 的 context', async () => {
|
|
159
|
+
const session = newSession('ag-sandbox-ctx-usage', withSystem())
|
|
160
|
+
const withUsage = () => sse([
|
|
161
|
+
event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }),
|
|
162
|
+
event({ usage: { prompt_tokens: 4321, completion_tokens: 10, total_tokens: 4331 } }),
|
|
163
|
+
'data: [DONE]\n\n',
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
const { events } = await runWeb({ session, userMessage: 'hello', respond: withUsage })
|
|
167
|
+
|
|
168
|
+
const withActual = events.filter(e => e.type === 'context' && typeof e.usage.actualInputTokens === 'number')
|
|
169
|
+
assert.ok(withActual.length >= 1, '必须有一条带上真实 input token')
|
|
170
|
+
assert.equal(withActual[0].usage.actualInputTokens, 4321)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('被裁剪的长会话会报出 droppedMessages(UI 靠它解释"为什么模型忘了")', async () => {
|
|
174
|
+
const { resolveRequestBudget } = await import('../../../../cli/ai/context.js')
|
|
175
|
+
const session = newSession('ag-sandbox-ctx-drop', longSession())
|
|
176
|
+
const toolCount = session.messages.filter(m => m.role === 'tool').length
|
|
177
|
+
|
|
178
|
+
const { events } = await runWeb({ session, userMessage: 'next', respond: okStream })
|
|
179
|
+
|
|
180
|
+
const usage = events.find(e => e.type === 'context').usage
|
|
181
|
+
assert.ok(usage.droppedMessages > 0, '长会话必须报出被裁掉的条数')
|
|
182
|
+
// longSession = system + 首 user + N×(assistant + tool),再加 runAgentTurn 追加的本轮 user
|
|
183
|
+
assert.equal(usage.transcriptMessages, toolCount * 2 + 3,
|
|
184
|
+
'transcriptMessages 是磁盘上的完整条数(含本轮 user)')
|
|
185
|
+
assert.ok(usage.messages < usage.transcriptMessages, '带入的必须少于磁盘上的,否则说明没裁')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ── 传输口径 ──────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test('请求体带上 stream_options,与 CLI 一致地统计 usage', async () => {
|
|
191
|
+
const session = newSession('ag-sandbox-usage', withSystem())
|
|
192
|
+
|
|
193
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
194
|
+
|
|
195
|
+
assert.deepEqual(sent.stream_options, { include_usage: true })
|
|
196
|
+
assert.equal(sent.stream, true)
|
|
197
|
+
assert.equal(sent.temperature, 0.3)
|
|
198
|
+
assert.ok(Array.isArray(sent.tools) && sent.tools.length > 0, '内置工具表必须随请求下发')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截的工具调用', async () => {
|
|
202
|
+
const session = newSession('ag-sandbox-truncated', withSystem())
|
|
203
|
+
// 工具调用参数是完整的、JSON 合法 —— 换在没有这道闸的旧实现里就会被真的执行
|
|
204
|
+
const truncated = sse([event({
|
|
205
|
+
choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_x', function: { name: 'read_file', arguments: '{"path":"nope.txt"}' } }] } }],
|
|
206
|
+
})])
|
|
207
|
+
|
|
208
|
+
const { events } = await runWeb({ session, userMessage: '读个文件', respond: () => truncated })
|
|
209
|
+
|
|
210
|
+
const error = events.find(e => e.type === 'error')
|
|
211
|
+
assert.ok(error, '截断的流必须报错,不能静默当成正常结束')
|
|
212
|
+
assert.match(error.error, /中断/)
|
|
213
|
+
assert.equal(events.some(e => e.type === 'tool_call_start'), false, '被截断的调用不得开始执行')
|
|
214
|
+
assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// 回归:用户点"停止"(中止正在跑的这一轮)时,已经流出来的正文必须留在会话记录里。
|
|
218
|
+
// 之前中止分支只把当时的状态写盘、不回填正文,重新打开这条会话时刚才生成的内容整段消失。
|
|
219
|
+
test('中止时已流出的正文要留在会话记录里', async () => {
|
|
220
|
+
const session = newSession('ag-sandbox-aborted', withSystem())
|
|
221
|
+
const controller = new AbortController()
|
|
222
|
+
|
|
223
|
+
// 吐一段正文后挂住(模拟"停在生成中");真 fetch 在 signal abort 时会打断 body 读取,
|
|
224
|
+
// 桩里照做才能真的走到中止分支
|
|
225
|
+
const hanging = init => new Response(new ReadableStream({
|
|
226
|
+
start(stream) {
|
|
227
|
+
const bytes = new TextEncoder()
|
|
228
|
+
stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })))
|
|
229
|
+
init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')))
|
|
230
|
+
},
|
|
231
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
232
|
+
|
|
233
|
+
const { result } = await runWeb({
|
|
234
|
+
session, userMessage: '写一半就停', respond: hanging, signal: controller.signal,
|
|
235
|
+
// 正文事件到达 = 这段已经被读进来,此刻中止最贴近用户点"停止"的时机
|
|
236
|
+
onEvent: e => { if (e.type === 'content') controller.abort() },
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
assert.equal(result.aborted, true)
|
|
240
|
+
const last = session.messages[session.messages.length - 1]
|
|
241
|
+
assert.equal(last.role, 'assistant')
|
|
242
|
+
assert.equal(last.content, '已经写了一半')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
|
|
246
|
+
const session = newSession('ag-sandbox-nofc', withSystem())
|
|
247
|
+
const noFunctionCalling = () => new Response(
|
|
248
|
+
JSON.stringify({ error: { message: 'tools is not supported by this model' } }),
|
|
249
|
+
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: noFunctionCalling })
|
|
253
|
+
|
|
254
|
+
assert.equal(sent.messages.length, 3, '不该把 stream_options 的降级重试和这条错误混在一起')
|
|
255
|
+
const error = events.find(e => e.type === 'error')
|
|
256
|
+
assert.ok(error)
|
|
257
|
+
assert.match(error.error, /不支持 function calling/)
|
|
258
|
+
assert.match(error.error, /换用支持工具调用的模型/)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ── 工具参数的透传契约 ──────────────────────────────────────
|
|
262
|
+
// 两个字段分工不同,两边都不能少:
|
|
263
|
+
// · arguments —— 展开后「参数」框里的原文,**每种工具都发全文**。前端历史回放
|
|
264
|
+
// (useAgentChat 读 session.messages)拿到的本来就是全文,只在流式这一路发摘要,
|
|
265
|
+
// 就会让"正在跑"和"刷新后"看到两份不同的参数。
|
|
266
|
+
// · argsPreview —— 收起态那一行副标题,故意截到 200 字。
|
|
267
|
+
// 计划类工具的 steps 也在 arguments 里(不再单独开一条透传路径)。
|
|
268
|
+
test('tool_call_start 每种工具都带完整 arguments,argsPreview 仍是截断摘要', async () => {
|
|
269
|
+
// 桩的 write_file 落点:沙箱里(见 round 里的说明)。用完删掉,不留 tmp 垃圾。
|
|
270
|
+
const stubWritePath = path.join(SANDBOX, 'stub-write-target.js')
|
|
271
|
+
const planArgs = JSON.stringify({
|
|
272
|
+
steps: [
|
|
273
|
+
{ content: '读代码', status: 'completed' },
|
|
274
|
+
{ content: '改代码', status: 'in_progress' }
|
|
275
|
+
],
|
|
276
|
+
explanation: '先改库再改宿主'
|
|
277
|
+
})
|
|
278
|
+
const round = [
|
|
279
|
+
sse([event({
|
|
280
|
+
choices: [{ delta: { tool_calls: [
|
|
281
|
+
{ index: 0, id: 'p1', function: { name: 'update_plan', arguments: planArgs } },
|
|
282
|
+
// ⚠️ 这个桩会走**真实** executeTool 落盘(这一层跑的是真 runAgentTurn,
|
|
283
|
+
// 不是解析 Mock)—— 路径必须指到沙箱。曾经这里写的是相对路径 'a.js':
|
|
284
|
+
// resolvePath 按 process.cwd() 解析,于是每次跑测试都在当前目录留一个
|
|
285
|
+
// 50 字节的 a.js(仓库根跑留根目录、routes/workbench 跑留那里),
|
|
286
|
+
// 全组用例还都是绿的,只能靠 git status 发现。
|
|
287
|
+
{ index: 1, id: 'p2', function: { name: 'write_file', arguments: JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }) } }
|
|
288
|
+
] } }, { finish_reason: 'tool_calls' }],
|
|
289
|
+
}), 'data: [DONE]\n\n']),
|
|
290
|
+
okStream(),
|
|
291
|
+
]
|
|
292
|
+
let turn = 0
|
|
293
|
+
const session = newSession('ag-sandbox-plan', withSystem())
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const { events } = await runWeb({
|
|
297
|
+
session,
|
|
298
|
+
userMessage: '把这个功能做出来',
|
|
299
|
+
respond: () => round[turn++] || okStream(),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const starts = events.filter(e => e.type === 'tool_call_start')
|
|
303
|
+
assert.equal(starts.length, 2)
|
|
304
|
+
const planStart = starts.find(e => e.name === 'update_plan')
|
|
305
|
+
const writeStart = starts.find(e => e.name === 'write_file')
|
|
306
|
+
|
|
307
|
+
// 计划:全文必须一字不差地带到前端(前端靠它渲染清单)
|
|
308
|
+
assert.equal(planStart.arguments, planArgs)
|
|
309
|
+
assert.match(planStart.argsPreview, /1\/2|1 完成/)
|
|
310
|
+
// 其他工具:展开态同样要看到原文,不再只给摘要
|
|
311
|
+
assert.equal(writeStart.arguments, JSON.stringify({ path: stubWritePath, content: 'x'.repeat(50) }))
|
|
312
|
+
assert.ok(writeStart.argsPreview.length < writeStart.arguments.length, '摘要仍应是截断过的')
|
|
313
|
+
// 顺带证明桩调用真的执行了、且只落在沙箱(这条红了 = 有人把路径改成会污染仓库的)
|
|
314
|
+
assert.ok(existsSync(stubWritePath), '桩的 write_file 应真实落在沙箱里')
|
|
315
|
+
|
|
316
|
+
const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
|
|
317
|
+
assert.match(planResult.result, /计划已更新/)
|
|
318
|
+
// 计划也要进会话记录 —— 重新打开会话时前端靠历史里的 arguments 复原清单
|
|
319
|
+
const assistantWithPlan = session.messages.find(m => m.role === 'assistant' && m.tool_calls?.some(t => t.function.name === 'update_plan'))
|
|
320
|
+
assert.equal(assistantWithPlan.tool_calls[0].function.arguments, planArgs)
|
|
321
|
+
} finally {
|
|
322
|
+
rmSync(stubWritePath, { force: true })
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ── 重试与续跑(2026-10-09)────────────────────────────────────
|
|
327
|
+
// 「重试」要能真的把上一轮跑下去,靠的是服务端两条口径:
|
|
328
|
+
// 1. 失败时**不撤**那条 user 消息 —— 撤了的话 resume 会退化成"重跑上一轮"
|
|
329
|
+
// 2. resume 时**不再压** user 消息 —— 压了就变成"同一条请求出现两次"
|
|
330
|
+
// 两条各自都有明确的失效症状,所以各钉一条。
|
|
331
|
+
|
|
332
|
+
test('失败时不再撤掉本轮压入的 user 消息(否则重试会退化成重跑上一轮)', async () => {
|
|
333
|
+
const session = newSession('ag-sandbox-keep-user', withSystem())
|
|
334
|
+
const boom = () => new Response(JSON.stringify({ error: { message: 'bad request: model not found' } }),
|
|
335
|
+
{ status: 404, headers: { 'content-type': 'application/json' } })
|
|
336
|
+
|
|
337
|
+
const { events } = await runWeb({ session, userMessage: '这条不能消失', respond: boom })
|
|
338
|
+
|
|
339
|
+
assert.ok(events.some(e => e.type === 'error'))
|
|
340
|
+
const last = session.messages[session.messages.length - 1]
|
|
341
|
+
assert.equal(last.role, 'user')
|
|
342
|
+
assert.equal(last.content, '这条不能消失')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('resume 不再压 user 消息,接着会话现有的尾巴跑', async () => {
|
|
346
|
+
const session = newSession('ag-sandbox-resume', withSystem())
|
|
347
|
+
const usersBefore = session.messages.filter(m => m.role === 'user').length
|
|
348
|
+
|
|
349
|
+
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
350
|
+
|
|
351
|
+
assert.equal(sent.messages.length, 2, 'resume 不该往请求里加消息')
|
|
352
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'])
|
|
353
|
+
// 会话记录里也不能多出一条 user(多出来的那条只会是本轮 assistant 的回答)
|
|
354
|
+
assert.equal(session.messages.filter(m => m.role === 'user').length, usersBefore)
|
|
355
|
+
assert.equal(session.messages[session.messages.length - 1].role, 'assistant')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('每次 LLM 请求前发一条 attempt(前端靠它记重试断点),自动重试时发 retry', async () => {
|
|
359
|
+
const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
|
|
360
|
+
let calls = 0
|
|
361
|
+
// 第一次吐半截就断流(可重试),第二次正常返回 —— 真链路走 streamChatOnce,
|
|
362
|
+
// 只把"真等退避"换掉,好让这条用例秒级跑完
|
|
363
|
+
const flaky = opts => streamChatOnce({
|
|
364
|
+
...opts,
|
|
365
|
+
sleepFn: async () => {},
|
|
366
|
+
fetchFn: async () => {
|
|
367
|
+
calls++
|
|
368
|
+
if (calls === 1) return sse([event({ choices: [{ delta: { content: '半截' } }] })])
|
|
369
|
+
return okStream()
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
const session = newSession('ag-sandbox-retry', withSystem())
|
|
373
|
+
|
|
374
|
+
const { events } = await runWeb({ session, userMessage: '开始', streamFn: flaky })
|
|
375
|
+
|
|
376
|
+
assert.equal(calls, 2, '第一次断流后必须再试一次')
|
|
377
|
+
// attempt 是**每次工具循环的请求开始前**发的一条(不是每次尝试):
|
|
378
|
+
// 自动重试沿用的还是同一个断点 —— 它要丢的正是这一轮请求吐的全部内容。
|
|
379
|
+
assert.equal(events.filter(e => e.type === 'attempt').length, 1)
|
|
380
|
+
const retry = events.find(e => e.type === 'retry')
|
|
381
|
+
assert.ok(retry, '自动重试必须通知前端,否则界面上会留下上一截的字')
|
|
382
|
+
assert.equal(retry.attempt, 1)
|
|
383
|
+
assert.equal(retry.maxRetries, 10)
|
|
384
|
+
assert.match(retry.reason, /中断/)
|
|
385
|
+
// 断点必须排在正文之前:前端是按"收到 attempt 时气泡里已有的长度"回退的
|
|
386
|
+
const order = events.map(e => e.type)
|
|
387
|
+
assert.ok(order.indexOf('attempt') < order.indexOf('content'), 'attempt 要在正文之前')
|
|
388
|
+
assert.ok(order.indexOf('content') < order.indexOf('retry'), 'retry 落在被丢弃的那段正文之后')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test('对跑完的一轮点「重新生成」:把尾巴那条 assistant 摘掉再问一次', async () => {
|
|
392
|
+
// 会话尾巴是一条没有工具调用的 assistant = 上一轮已经答完了。
|
|
393
|
+
// 留着它的话模型会顺着自己的答案往下说,重开会话还会看到两条连着的 assistant。
|
|
394
|
+
const session = newSession('ag-sandbox-regen', [
|
|
395
|
+
{ role: 'system', content: 'rules' },
|
|
396
|
+
{ role: 'user', content: 'hi' },
|
|
397
|
+
{ role: 'assistant', content: '旧答案' },
|
|
398
|
+
])
|
|
399
|
+
|
|
400
|
+
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
401
|
+
|
|
402
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi'], '旧答案必须从请求里消失')
|
|
403
|
+
assert.equal(session.messages.filter(m => m.role === 'assistant').length, 1, '只剩这一次新生成的')
|
|
404
|
+
assert.equal(session.messages[session.messages.length - 1].content, 'ok')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('对中途失败的一轮点「重试」:工具结果留在尾巴上,一条都不摘', async () => {
|
|
408
|
+
// 失败发生在工具循环中途时,尾巴是 assistant(tool_calls) + 一批 tool 结果 ——
|
|
409
|
+
// 这些是这一轮真干过的事,摘掉就等于让模型把工具再跑一遍。
|
|
410
|
+
const session = newSession('ag-sandbox-resume-tools', [
|
|
411
|
+
{ role: 'system', content: 'rules' },
|
|
412
|
+
{ role: 'user', content: 'hi' },
|
|
413
|
+
{ role: 'assistant', content: null, tool_calls: [call(0)] },
|
|
414
|
+
{ role: 'tool', tool_call_id: 'call0', content: 'file contents' },
|
|
415
|
+
])
|
|
416
|
+
|
|
417
|
+
const { sent } = await runWeb({ session, userMessage: '', resume: true, respond: okStream })
|
|
418
|
+
|
|
419
|
+
assert.deepEqual(sent.messages.map(m => m.role), ['system', 'user', 'assistant', 'tool'])
|
|
420
|
+
assert.equal(sent.messages[3].content, 'file contents', '已经跑完的工具结果必须原样带上')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// ── 用户固定提示词(g ai 默认提示词 + 派发提示词)────────────────
|
|
424
|
+
// 智能体视图「设置」Tab 配的两块提示词,和 g ai 对话时每轮都要注入。
|
|
425
|
+
// 这是**纯 prompt 注入**的能力:没有按钮、没有状态、界面上看不出对错 ——
|
|
426
|
+
// 改 agentChat.js / chatPrompts.js 的人(哪怕只是把一段挪个位置)会无声地把它弄掉。
|
|
427
|
+
// 所以这里跑真实的 runAgentTurn,把发出去的请求体抓下来逐条钉住。
|
|
428
|
+
//
|
|
429
|
+
// 沙箱里写真实文件(config.json / orchestrator.json):这一层要验的正是
|
|
430
|
+
// "默认取数真的接到了配置上",用注入口反而绕过了要验的那一段。
|
|
431
|
+
// 每个用例 finally 里删干净 —— 同文件其它用例断言的是精确的 messages 列表,
|
|
432
|
+
// 残留一份提示词就会让它们莫名其妙地红。
|
|
433
|
+
|
|
434
|
+
const DATA_DIR = path.join(SANDBOX, '.zen-gitsync')
|
|
435
|
+
|
|
436
|
+
function writeSandboxFile(name, obj) {
|
|
437
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
438
|
+
writeFileSync(path.join(DATA_DIR, name), JSON.stringify(obj, null, 2))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function clearSandboxFile(name) {
|
|
442
|
+
rmSync(path.join(DATA_DIR, name), { force: true })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
test('两块提示词都注入请求副本:g ai 默认提示词 + 派发提示词(含项目级)', async () => {
|
|
446
|
+
const { canonicalProjectPath } = await import('./projectRegistry.js')
|
|
447
|
+
const projectKey = canonicalProjectPath(process.cwd())
|
|
448
|
+
try {
|
|
449
|
+
writeSandboxFile('config.json', { agentDefaultPrompt: '【g ai 默认提示词】回答用中文' })
|
|
450
|
+
writeSandboxFile('orchestrator.json', {
|
|
451
|
+
version: 1,
|
|
452
|
+
defaultPrompt: '【全局派发提示词】改完就提交',
|
|
453
|
+
projectPrompts: { [projectKey]: { path: process.cwd(), prompt: '【本项目派发提示词】包管理器用 pnpm' } },
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const session = newSession('ag-sandbox-user-prompt', withSystem())
|
|
457
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
458
|
+
|
|
459
|
+
const sys = sent.messages.find(m => m.role === 'system')
|
|
460
|
+
assert.match(sys.content, /【g ai 默认提示词】回答用中文/, 'g ai 默认提示词必须进请求')
|
|
461
|
+
assert.match(sys.content, /【全局派发提示词】改完就提交/, '全局派发提示词必须进请求')
|
|
462
|
+
assert.match(sys.content, /【本项目派发提示词】包管理器用 pnpm/,
|
|
463
|
+
'项目级那条要按本轮 cwd 生效(与派发时"按落点项目解析"同一口径)')
|
|
464
|
+
// 规则在前、现场在后:固定提示词是每轮都成立的约束,不该排在工作区快照后面
|
|
465
|
+
assert.ok(sys.content.indexOf('【g ai 默认提示词】') < sys.content.indexOf('【全局派发提示词】'),
|
|
466
|
+
'g ai 那段排在派发那段之前')
|
|
467
|
+
// 落盘口径:会话里那条 system 一个字节都不许变 —— 否则改设置后老会话永远带着旧规则
|
|
468
|
+
assert.equal(session.messages[0].content, 'rules', '注入只作用于请求副本,不写回 session.messages')
|
|
469
|
+
} finally {
|
|
470
|
+
clearSandboxFile('config.json')
|
|
471
|
+
clearSandboxFile('orchestrator.json')
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('没配就不注入(连一节标题都不出现)—— 这也是其它用例能断言精确 messages 的前提', async () => {
|
|
476
|
+
const session = newSession('ag-sandbox-user-prompt-empty', withSystem())
|
|
477
|
+
|
|
478
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream })
|
|
479
|
+
|
|
480
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('调用方显式传 promptSection 时不再自己读配置(定时任务 / agentRoutes 走这条路)', async () => {
|
|
484
|
+
try {
|
|
485
|
+
// 磁盘上有一份提示词,但调用方传了 '':意思是"这一轮确定不带",不是"没传、你去读"
|
|
486
|
+
writeSandboxFile('config.json', { agentDefaultPrompt: '【不该出现】' })
|
|
487
|
+
const session = newSession('ag-sandbox-user-prompt-explicit', withSystem())
|
|
488
|
+
|
|
489
|
+
const { sent } = await runWeb({ session, userMessage: 'hello', respond: okStream, promptSection: '' })
|
|
490
|
+
|
|
491
|
+
assert.doesNotMatch(sent.messages[0].content, /【不该出现】/)
|
|
492
|
+
assert.deepEqual(sent.messages.map(m => m.content), ['rules', 'hi', 'hello'])
|
|
493
|
+
} finally {
|
|
494
|
+
clearSandboxFile('config.json')
|
|
495
|
+
}
|
|
496
|
+
})
|