dsh-tap 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,1451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline regression for providers/trae/ (v0.8.x TraeWork CN channel):
4
+ *
5
+ * 1. OAuth device flow against a mock api.trae.cn:
6
+ * - PKCE (code_challenge == SHA256(code_verifier), S256 method)
7
+ * - authUrl shape (client_id / auth_callback_url on 127.0.0.1)
8
+ * - browser callback -> AuthCode exchange -> tokens + account persisted
9
+ * - refresh path with DeviceProof VERIFIED by the mock using the public
10
+ * key our flow registered (proves the self-held ECDSA keypair works)
11
+ * - failure/timeout/logout semantics
12
+ * - security regression: traeLoginHost base gate (javascript: / invalid
13
+ * URL rejected BEFORE the loopback listener opens) and authUrl built
14
+ * via URL parsing (path-absolute /authorization, no // doubling)
15
+ * 2. catalog: fixture state.vscdb -> profiles (BYOK excluded, multimodal,
16
+ * ctx fallback), syncCatalog/catalogView/catalogIds
17
+ * 3. gateway translation against a mock Trae cloud:
18
+ * - outbound headers (Cloud-IDE-JWT + x-cloudide-token + region)
19
+ * - Trae SSE -> OpenAI SSE (stream mode) and aggregation (non-stream)
20
+ * - usage metering, upstream 401 mapping, credential-unavailable 503,
21
+ * GET /v1/models from synced catalog
22
+ * 4. provider factory shape
23
+ *
24
+ * No network beyond 127.0.0.1; no real credentials. Usage:
25
+ * node scripts/verify-trae-provider.mjs
26
+ */
27
+
28
+ import { createServer, request as httpRequest } from 'node:http'
29
+ import { createHash, verify as cryptoVerify, createPublicKey } from 'node:crypto'
30
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'
31
+ import { tmpdir } from 'node:os'
32
+ import { join } from 'node:path'
33
+ import { DatabaseSync } from 'node:sqlite'
34
+
35
+ import { createTraeOAuth } from '../providers/trae/oauth.js'
36
+ import { catalogToProfiles } from '../providers/trae/catalog.js'
37
+ import { buildChatRequest, createTraeStreamParser, createTraeGateway, cumulativeDelta, TRAE_APP_ID, TRAE_IDE_VERSION, TRAE_IDE_VERSION_CODE } from '../providers/trae/gateway.js'
38
+ import { flattenQuery, buildRemoteCreateBody, createRemoteEventParser, createRemoteSession } from '../providers/trae/remote.js'
39
+ import { normalizeTraeError, formatTraeErrorMessage } from '../providers/trae/errors.js'
40
+ import { createTraeProvider } from '../providers/trae/index.js'
41
+
42
+ let failures = 0
43
+ let checks = 0
44
+ function check(label, cond, detail = '') {
45
+ checks++
46
+ if (cond) console.log(` ok ${label}`)
47
+ else { failures++; console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`) }
48
+ }
49
+
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
51
+
52
+ // 原生 http 客户端:fetch 的 Host 是 forbidden header 伪造不了——Host 门测试专用。
53
+ // 响应必须消费(踩坑 #29:不挂 data 监听流会 paused,'end' 永不派发)。
54
+ function rawRequest(port, path, { method = 'GET', headers = {} } = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ const req = httpRequest({ host: '127.0.0.1', port, path, method, headers }, (res) => {
57
+ let body = ''
58
+ res.on('data', (c) => { body += c })
59
+ res.on('end', () => resolve({ status: res.statusCode, body }))
60
+ })
61
+ req.on('error', reject)
62
+ req.end()
63
+ })
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // mock api.trae.cn (OAuth + account)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function mockTraeAuth() {
71
+ const state = {
72
+ issued: [], // {token, refreshToken}
73
+ exchanges: [], // every ExchangeToken request body (for assertions)
74
+ registeredKeys: new Map(), // clientId -> SPKI base64 (from AuthCode exchange)
75
+ userInfoCalls: [],
76
+ }
77
+ const server = createServer((req, res) => {
78
+ let raw = ''
79
+ req.on('data', (c) => { raw += c })
80
+ req.on('end', () => {
81
+ const body = raw ? JSON.parse(raw) : {}
82
+ if (req.url === '/trae/api/v3/oauth/ExchangeToken' && req.method === 'POST') {
83
+ state.exchanges.push(body)
84
+ if (body.ClientID !== 'en1oxy7wnw8j9n' && body.ClientID !== 'ono9krqynydwx5') {
85
+ res.writeHead(400, { 'content-type': 'application/json' })
86
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10101', Message: 'Invalid client.' } } }))
87
+ return
88
+ }
89
+ if (body.AuthCode) {
90
+ if (body.AuthCode !== 'test-auth-code') {
91
+ res.writeHead(400, { 'content-type': 'application/json' })
92
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10101', Message: '无效参数:{__Message.field}.' } } }))
93
+ return
94
+ }
95
+ if (!body.CodeVerifier || !body.DeviceInfo?.DevicePublicKey) {
96
+ res.writeHead(400, { 'content-type': 'application/json' })
97
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10101', Message: 'missing PKCE/device fields' } } }))
98
+ return
99
+ }
100
+ state.registeredKeys.set(body.ClientID, body.DeviceInfo.DevicePublicKey)
101
+ const out = {
102
+ Token: `tok-${state.issued.length + 1}`,
103
+ RefreshToken: `rt-${state.issued.length + 1}`,
104
+ TokenExpireAt: Math.floor(Date.now() / 1000) + 3600,
105
+ RefreshExpireAt: Math.floor(Date.now() / 1000) + 86400,
106
+ }
107
+ state.issued.push(out)
108
+ res.writeHead(200, { 'content-type': 'application/json' })
109
+ res.end(JSON.stringify({ Result: out }))
110
+ return
111
+ }
112
+ if (body.RefreshToken) {
113
+ // DeviceProof 验签:用 AuthCode 阶段注册的公钥验证 ECDSA P-256 签名。
114
+ const spki = state.registeredKeys.get(body.ClientID)
115
+ const proof = body.DeviceProof
116
+ if (!spki || !proof) {
117
+ res.writeHead(400, { 'content-type': 'application/json' })
118
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10102', Message: 'device not registered' } } }))
119
+ return
120
+ }
121
+ const stringToSign = ['POST', '/trae/api/v3/oauth/ExchangeToken', body.ClientID, body.RefreshToken, String(proof.Timestamp), proof.Nonce].join('\n')
122
+ let ok = false
123
+ try {
124
+ ok = cryptoVerify('sha256', Buffer.from(stringToSign, 'utf8'), createPublicKey({ key: Buffer.from(spki, 'base64'), format: 'der', type: 'spki' }), Buffer.from(proof.Signature, 'base64'))
125
+ } catch { ok = false }
126
+ if (!ok) {
127
+ res.writeHead(400, { 'content-type': 'application/json' })
128
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10103', Message: 'device proof signature invalid' } } }))
129
+ return
130
+ }
131
+ const known = state.issued.some((i) => i.RefreshToken === body.RefreshToken)
132
+ if (!known) {
133
+ res.writeHead(401, { 'content-type': 'application/json' })
134
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10104', Message: 'refresh token invalid' } } }))
135
+ return
136
+ }
137
+ const out = {
138
+ Token: `tok-${state.issued.length + 1}`,
139
+ RefreshToken: `rt-${state.issued.length + 1}`,
140
+ TokenExpireAt: Math.floor(Date.now() / 1000) + 3600,
141
+ RefreshExpireAt: Math.floor(Date.now() / 1000) + 86400,
142
+ }
143
+ state.issued.push(out)
144
+ res.writeHead(200, { 'content-type': 'application/json' })
145
+ res.end(JSON.stringify({ Result: out }))
146
+ return
147
+ }
148
+ res.writeHead(400, { 'content-type': 'application/json' })
149
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '10101', Message: 'no AuthCode/RefreshToken' } } }))
150
+ return
151
+ }
152
+ if (req.url === '/cloudide/api/v3/trae/GetUserInfo' && req.method === 'POST') {
153
+ state.userInfoCalls.push(req.headers)
154
+ const token = req.headers['x-cloudide-token']
155
+ const latest = state.issued[state.issued.length - 1]
156
+ if (!token || token !== latest?.Token) {
157
+ res.writeHead(401, { 'content-type': 'application/json' })
158
+ res.end(JSON.stringify({ ResponseMetadata: { Error: { Code: '20310', Message: 'The user is not logged in,' } } }))
159
+ return
160
+ }
161
+ res.writeHead(200, { 'content-type': 'application/json' })
162
+ res.end(JSON.stringify({ Result: { Name: '测试用户', UserId: 'u-001' } }))
163
+ return
164
+ }
165
+ res.writeHead(404)
166
+ res.end('nf')
167
+ })
168
+ })
169
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
170
+ resolve({ server, base: `http://127.0.0.1:${server.address().port}`, state })
171
+ }))
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // mock Trae chat cloud (SSE)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function mockTraeRemote({ authedToken = 'tok-live', failFirstCreates = 0, hangCreate = false } = {}) {
179
+ const state = { creates: [], events: 0, stops: 0 }
180
+ const write = (res, ev, data) => res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`)
181
+ const server = createServer((req, res) => {
182
+ let raw = ''
183
+ req.on('data', (c) => { raw += c })
184
+ req.on('end', () => {
185
+ const url = req.url ?? ''
186
+ if (req.headers['authorization'] !== `Cloud-IDE-JWT ${authedToken}`) {
187
+ res.writeHead(401, { 'content-type': 'application/json' })
188
+ res.end(JSON.stringify({ code: 1001, message: 'auth failed (mock)' }))
189
+ return
190
+ }
191
+ if (req.method === 'POST' && url === '/api/remote/v1/chat_sessions') {
192
+ // hangCreate:接受连接但永不回应(边缘/本地代理死态的真实形态,
193
+ // 2026-08-24 故障取证 docs/diagnosis-trae-3003.md §8)
194
+ if (hangCreate) { state.creates.push({ headers: req.headers, body: null }); return }
195
+ // failFirstCreates:前 N 次 create 回**裸文本 404**(TLB 节点路由漂移的
196
+ // 真实线缆形态,2026-08-24 故障取证 docs/diagnosis-trae-3003.md)
197
+ if (state.creates.length < failFirstCreates) {
198
+ state.creates.push({ headers: req.headers, body: null })
199
+ res.writeHead(404, { 'content-type': 'text/plain' })
200
+ res.end('Not Found')
201
+ return
202
+ }
203
+ state.creates.push({ headers: req.headers, body: raw ? JSON.parse(raw) : null })
204
+ res.writeHead(200, { 'content-type': 'application/json' })
205
+ res.end(JSON.stringify({ code: 0, data: { chat_session_id: 'sess-1', message_id: 'msg-1' }, message: 'success' }))
206
+ return
207
+ }
208
+ if (req.method === 'GET' && url.startsWith('/api/remote/v1/chat_sessions/sess-1/events')) {
209
+ state.events++
210
+ // 事件语法 = 2026-08-24 真实流校准(docs/probes/trae-remote-stream-glm53.txt):
211
+ // thought/reasoning_content 累计快照、finish 工具 params.summary、model_config、token_usage、done
212
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
213
+ write(res, 'status_changed', { chat_session_id: 'sess-1', new_status: 3, old_status: 2 })
214
+ write(res, 'metadata', { message_id: 'm2', turn_id: 't2', session_id: 'sess-1', agent_type: 'solo_agent_remote' })
215
+ write(res, 'model_config', { model_name: 'glm-5.3__dev', config_name: 'glm-5.3' })
216
+ write(res, 'plan_item', { id: 'p1', thought: '', reasoning_content: '思', tool_call_info: { id: 'c1', name: '', params: null } })
217
+ write(res, 'plan_item', { id: 'p1', thought: '你好', reasoning_content: '思考', tool_call_info: { id: 'c1', name: '', params: null } })
218
+ write(res, 'plan_item', { id: 'p1', thought: '你好,世界', reasoning_content: '思考', tool_call_info: { id: 'c1', name: '', params: null } })
219
+ write(res, 'token_usage', { prompt_tokens: 100, completion_tokens: 9, total_tokens: 109 })
220
+ write(res, 'plan_item', { id: 'p1', thought: '你好,世界', reasoning_content: '思考', tool_call_info: { id: 'c1', name: 'finish', params: { summary: '你好,世界' } } })
221
+ write(res, 'done', { status: 'completed', user_message_context: { model_info: { config_name: 'glm-5.3' } } })
222
+ res.end()
223
+ return
224
+ }
225
+ if (req.method === 'POST' && url === '/api/remote/v1/chat_sessions/sess-1/stop') {
226
+ state.stops++
227
+ res.writeHead(200, { 'content-type': 'application/json' })
228
+ res.end('{}')
229
+ return
230
+ }
231
+ res.writeHead(404, { 'content-type': 'application/json' })
232
+ res.end('{}')
233
+ })
234
+ })
235
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
236
+ resolve({ server, base: `http://127.0.0.1:${server.address().port}`, state })
237
+ }))
238
+ }
239
+
240
+ // mock Trae chat cloud (SSE)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function mockTraeChat({ authedToken = 'tok-live', status = 200, withTools = false, sseError = null, hang = false, fnError = null, providerModel = 'kimi-k2.6' } = {}) {
244
+ const state = { requests: [] }
245
+ const server = createServer((req, res) => {
246
+ let raw = ''
247
+ req.on('data', (c) => { raw += c })
248
+ req.on('end', () => {
249
+ state.requests.push({ url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null })
250
+ if (status !== 200) {
251
+ res.writeHead(status, { 'content-type': 'application/json' })
252
+ res.end(JSON.stringify({ code: 1001, message: "We're sorry, but we are not able to authenticate you." }))
253
+ return
254
+ }
255
+ const token = req.headers['x-cloudide-token']
256
+ const authz = req.headers['authorization']
257
+ if (token !== authedToken || authz !== `Cloud-IDE-JWT ${authedToken}`) {
258
+ res.writeHead(401, { 'content-type': 'application/json' })
259
+ res.end(JSON.stringify({ code: 1001, message: 'auth failed (mock)' }))
260
+ return
261
+ }
262
+ // hang:一个字节都不发(比 sseError 更底层的死态——首字节护栏的靶子)
263
+ if (hang) return
264
+ // fnError:仅对指定 function 注入 3003(事故回退测试:inline 坏 / chat_v3 好)
265
+ if (fnError) {
266
+ const fn = (() => { try { return JSON.parse(raw)?.function } catch { return null } })()
267
+ if (fn === fnError) {
268
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
269
+ res.write(`event: error\ndata: ${JSON.stringify({ code: 3003, message: 'all models failed', extra: null })}\n\n`)
270
+ res.write('event: done\ndata: {"finish_reason":"stop"}\n\n')
271
+ res.end()
272
+ return
273
+ }
274
+ }
275
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
276
+ if (sseError) {
277
+ // 真实线缆形态(2026-08-24 inline 面 3003 故障取证):HTTP 200 SSE 里直接
278
+ // error 事件 + done,无 metadata/timing_cost——服务端连模型都没选出来
279
+ res.write(`event: error\ndata: ${JSON.stringify(sseError)}\n\n`)
280
+ res.write('event: done\n')
281
+ res.write('data: {"finish_reason":"stop"}\n\n')
282
+ res.end()
283
+ return
284
+ }
285
+ if (withTools) {
286
+ // 工具调用真实线缆形态(2026-08-24 docs/probes/trae-chat-live-tools-*):
287
+ // function_call 键 + arguments 增量片段 + 续片空 id + done 仍 finish_reason:"stop"
288
+ const tcFirst = { index: 0, id: 'get_x_0', type: 'function', function_call: { name: 'get_x', arguments: '{"a":' } }
289
+ const tcNext = { index: 0, id: '', type: '', function_call: { name: '', arguments: '1}' } }
290
+ res.write('event: output\n')
291
+ res.write(`data: ${JSON.stringify({ response: '我来查一下', tool_calls: null })}\n\n`)
292
+ res.write('event: output\n')
293
+ res.write(`data: ${JSON.stringify({ response: '', tool_calls: [tcFirst] })}\n\n`)
294
+ res.write('event: output\n')
295
+ res.write(`data: ${JSON.stringify({ response: '', tool_calls: [tcNext] })}\n\n`)
296
+ res.write('event: token_usage\n')
297
+ res.write(`data: ${JSON.stringify({ prompt_tokens: 20, completion_tokens: 8, total_tokens: 28 })}\n\n`)
298
+ res.write('event: done\n')
299
+ res.write(`data: ${JSON.stringify({ finish_reason: 'stop' })}\n\n`)
300
+ res.end()
301
+ return
302
+ }
303
+ // 事件语法 = 2026-08-24 带凭据联调校准形态(response/reasoning_content 为累计快照)
304
+ res.write('event: metadata\n')
305
+ res.write(`data: ${JSON.stringify({ model: '', session_id: 's', prompt_completion_id: 0 })}\n\n`)
306
+ res.write('event: timing_cost\n')
307
+ res.write(`data: ${JSON.stringify({ name: 'llm_raw_chat_v2', provider_model_name: providerModel })}\n\n`)
308
+ res.write('event: output\n')
309
+ res.write(`data: ${JSON.stringify({ response: '你好', reasoning_content: null })}\n\n`)
310
+ res.write('event: output\n')
311
+ res.write(`data: ${JSON.stringify({ response: '你好,', reasoning_content: '思' })}\n\n`)
312
+ res.write('event: output\n')
313
+ res.write(`data: ${JSON.stringify({ response: '你好,世界', reasoning_content: '思' })}\n\n`)
314
+ res.write('event: token_usage\n')
315
+ res.write(`data: ${JSON.stringify({ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 })}\n\n`)
316
+ res.write('event: done\n')
317
+ res.write(`data: ${JSON.stringify({ finish_reason: 'stop' })}\n\n`)
318
+ res.end()
319
+ })
320
+ })
321
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
322
+ resolve({ server, base: `http://127.0.0.1:${server.address().port}`, state })
323
+ }))
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // fixture state.vscdb for the catalog
328
+ // ---------------------------------------------------------------------------
329
+
330
+ function buildCatalogFixture(dbPath) {
331
+ const db = new DatabaseSync(dbPath)
332
+ db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)')
333
+ const payload = {
334
+ solo_agent_lite: [
335
+ {
336
+ config_name: 'glm-5.3', name: 'glm-5.3', display_name: 'GLM-5.3', provider: '',
337
+ model_type: 'reasoning_model', multimodal: false, prompt_max_tokens: 936000,
338
+ max_tokens: 64000, max_turn: 500, context_window_size: { default: 200000, max: [1000000] },
339
+ is_preset: true, selectable: true, status: true, fee_model_level: 2,
340
+ reasoning_effort_config: { support_thinking: true, options: ['light', 'high'], default_level: 'high' },
341
+ },
342
+ {
343
+ config_name: 'kimi-k3', name: 'kimi-k3', display_name: 'Kimi-K3', provider: '',
344
+ model_type: 'chat_model', multimodal: true, prompt_max_tokens: 936000,
345
+ max_tokens: 64000, max_turn: 500, context_window_size: { default: null, max: [500000] },
346
+ is_preset: true, selectable: true, status: true,
347
+ },
348
+ {
349
+ config_name: 'deepseek//deepseek-v4-pro', name: 'deepseek//deepseek-v4-pro', display_name: 'DeepSeek-V4-Pro',
350
+ provider: 'deepseek', model_type: 'reasoning_model', multimodal: false, is_preset: false, selectable: true, status: true,
351
+ },
352
+ {
353
+ config_name: 'dead-model', name: 'dead-model', display_name: 'Dead', provider: '',
354
+ multimodal: false, is_preset: true, selectable: false, status: false,
355
+ },
356
+ ],
357
+ }
358
+ db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run('111:AI.agent.model.model_list_map', JSON.stringify(payload))
359
+ db.close()
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // run
364
+ // ---------------------------------------------------------------------------
365
+
366
+ const workDir = mkdtempSync(join(tmpdir(), 'verify-trae-provider-'))
367
+ const authPath = join(workDir, 'trae-auth.json')
368
+
369
+ try {
370
+ // =========================================================================
371
+ console.log('== OAuth 设备流(mock api.trae.cn)==')
372
+ const mock = await mockTraeAuth()
373
+ const settings = {
374
+ traeAuthBaseURL: mock.base,
375
+ traeLoginHost: 'https://login.example.test',
376
+ traeChatBaseURL: 'http://127.0.0.1:1',
377
+ }
378
+ const store = { read: () => JSON.parse(readFileOr(authPath, '{}')) }
379
+ const oauth = createTraeOAuth({
380
+ readAuth: () => store.read(),
381
+ writeAuth: (v) => writeFileSync(authPath, JSON.stringify(v)),
382
+ })
383
+
384
+ const started = await oauth.startOAuth(settings)
385
+ check('startOAuth 返回授权页 URL', typeof started.authUrl === 'string' && started.authUrl.length > 0)
386
+ const authUrl = new URL(started.authUrl)
387
+ check('授权页路径与 host 正确', authUrl.pathname === '/authorization' && authUrl.hostname === 'login.example.test')
388
+ check('PKCE method=S256 + challenge 存在', authUrl.searchParams.get('code_challenge_method') === 'S256' && (authUrl.searchParams.get('code_challenge') ?? '').length > 20)
389
+ check('client_id 用 SOLO Lite 分支', authUrl.searchParams.get('client_id') === 'en1oxy7wnw8j9n')
390
+ const cb = authUrl.searchParams.get('auth_callback_url')
391
+ check('回调 URL 在 127.0.0.1 且路径 /authorize', cb != null && /^http:\/\/127\.0\.0\.1:\d+\/authorize$/.test(cb))
392
+ check('设备双 id 已上 URL(machine_id/device_id,16 位首位非零——审计 [16] 形态锁)', (authUrl.searchParams.get('machine_id') ?? '').length === 64 && /^[1-9]\d{15}$/.test(authUrl.searchParams.get('device_id') ?? ''))
393
+
394
+ // 模拟浏览器 302 回调
395
+ const cbUrl = new URL(cb)
396
+ cbUrl.search = '?' + new URLSearchParams({
397
+ authCodeInfo: JSON.stringify({ AuthCode: 'test-auth-code' }),
398
+ userInfo: JSON.stringify({ name: '测试用户' }),
399
+ consoleHost: mock.base,
400
+ }).toString()
401
+ const cbRes = await fetch(cbUrl)
402
+ check('回调应答 200 成功页', cbRes.status === 200 && (await cbRes.text()).includes('登录成功'))
403
+ await sleep(150) // 回调内后续写入
404
+
405
+ const authAfter = store.read()
406
+ check('令牌与刷新令牌落盘', authAfter.auth?.accessToken === 'tok-1' && authAfter.auth?.refreshToken === 'rt-1')
407
+ check('账号信息落盘(GetUserInfo)', authAfter.account?.nickname === '测试用户' && authAfter.account?.uid === 'u-001')
408
+ check('设备私钥持久化(P-256 PKCS8)', typeof authAfter.device?.privateKeyPem === 'string' && authAfter.device.privateKeyPem.includes('PRIVATE KEY'))
409
+
410
+ const exchange = mock.state.exchanges[0]
411
+ const challenge = authUrl.searchParams.get('code_challenge')
412
+ const verifierMatch = createHash('sha256').update(exchange.CodeVerifier).digest('base64url') === challenge
413
+ check('PKCE:code_challenge == SHA256(code_verifier)(base64url)', verifierMatch)
414
+ check('DeviceInfo 公钥形态(SPKI base64,P-256 长度档)', typeof exchange.DeviceInfo?.DevicePublicKey === 'string' && exchange.DeviceInfo.DevicePublicKey.length > 100 && exchange.DeviceInfo.PlatformCode === 'SOLO_PC')
415
+
416
+ const cred = await oauth.resolveTraeCredential(settings)
417
+ check('resolveTraeCredential 双头形态', cred?.authorization === 'Cloud-IDE-JWT tok-1' && cred?.headers?.['x-cloudide-token'] === 'tok-1' && cred?.headers?.['X-User-Region'] === 'CN')
418
+
419
+ // 刷新:把过期时间拨到过去,resolve 应自动走 DeviceProof 刷新并被 mock 验签通过
420
+ const st = store.read()
421
+ st.auth.expiresAt = Date.now() - 1000
422
+ writeFileSync(authPath, JSON.stringify(st))
423
+ const cred2 = await oauth.resolveTraeCredential(settings)
424
+ check('临期自动刷新拿到新令牌(DeviceProof 验签通过)', cred2?.authorization === 'Cloud-IDE-JWT tok-2')
425
+ check('刷新模式请求带 DeviceProof 三件套', (() => {
426
+ const r = mock.state.exchanges[mock.state.exchanges.length - 1]
427
+ return r?.RefreshToken === 'rt-1' && r?.DeviceProof?.Signature && r?.DeviceProof?.Nonce?.length === 32 && Number.isInteger(r?.DeviceProof?.Timestamp)
428
+ })())
429
+ check('oauthStatus 视图(不泄露令牌/私钥)', (() => {
430
+ const v = oauth.oauthStatus()
431
+ const s = JSON.stringify(v)
432
+ return v.signedIn === true && v.account?.nickname === '测试用户' && !s.includes('tok-2') && !s.includes('PRIVATE KEY')
433
+ })())
434
+
435
+ // 登出:清令牌、保设备
436
+ oauth.logout()
437
+ const afterLogout = store.read()
438
+ check('登出清令牌保设备身份', afterLogout.auth == null && afterLogout.device?.privateKeyPem != null)
439
+
440
+ // 坏 AuthCode 路径
441
+ const oauth2 = createTraeOAuth({
442
+ readAuth: () => store.read(),
443
+ writeAuth: (v) => writeFileSync(authPath, JSON.stringify(v)),
444
+ })
445
+ const s2 = await oauth2.startOAuth(settings)
446
+ const cb2 = new URL(new URL(s2.authUrl).searchParams.get('auth_callback_url'))
447
+ cb2.search = '?' + new URLSearchParams({ authCodeInfo: JSON.stringify({ AuthCode: 'bogus' }) }).toString()
448
+ await fetch(cb2)
449
+ await sleep(150)
450
+ check('坏 AuthCode → pending.error 记录、无令牌', oauth2.oauthStatus().error.includes('10101') && !oauth2.oauthStatus().signedIn)
451
+
452
+ // XSS 回归锁(审计 [10]/[15]/[21]/[24]):回调 query 的 error_code/error_msg
453
+ // 是外部输入,进 text/html 失败页前必须转义——不能出现原始 <script>/<img> 标签。
454
+ const oauthX = createTraeOAuth({
455
+ readAuth: () => store.read(),
456
+ writeAuth: (v) => writeFileSync(authPath, JSON.stringify(v)),
457
+ })
458
+ const sx = await oauthX.startOAuth(settings)
459
+ const cbX = new URL(new URL(sx.authUrl).searchParams.get('auth_callback_url'))
460
+ cbX.search = '?' + new URLSearchParams({ error_code: '<script>', error_msg: '<img src=x onerror=alert(1)>' }).toString()
461
+ const xssRes = await fetch(cbX)
462
+ const xssBody = await xssRes.text()
463
+ check('登录失败页 XSS 转义:query 参数不进原始 HTML',
464
+ xssRes.status === 200 && xssBody.includes('&lt;script&gt;') && xssBody.includes('&lt;img src=x onerror=alert(1)&gt;')
465
+ && !xssBody.includes('<script>') && !xssBody.includes('<img'))
466
+
467
+ // 安全审计回归锁:traeLoginHost 基址门禁 + authUrl 解析构造
468
+ // (providers/trae/oauth.js startOAuth——手改设置文件塞进 javascript: 之类
469
+ // 非法基址时,必须在开回环服务之前响亮失败,而不是拼出可执行授权页 URL
470
+ // 直送浏览器导航;URL 拼接改 new URL() 路径绝对引用,杜绝 //authorization
471
+ // 双斜杠与基址路径串联)。
472
+ const oauth3 = createTraeOAuth({
473
+ readAuth: () => store.read(),
474
+ writeAuth: (v) => writeFileSync(authPath, JSON.stringify(v)),
475
+ })
476
+ let gateErr = null
477
+ await oauth3.startOAuth({ ...settings, traeLoginHost: 'javascript:alert(document.domain)' })
478
+ .catch((e) => { gateErr = e.message })
479
+ check('traeLoginHost=javascript: 在 listen 前被拒(scheme 门)',
480
+ gateErr === 'traeLoginHost 必须使用 http 或 https', gateErr)
481
+ let gateErr2 = null
482
+ await oauth3.startOAuth({ ...settings, traeLoginHost: '::not-a-url::' })
483
+ .catch((e) => { gateErr2 = e.message })
484
+ check('traeLoginHost 非法 URL 被拒(解析门)',
485
+ gateErr2 === 'traeLoginHost 不是合法 URL', gateErr2)
486
+ const s3 = await oauth3.startOAuth({ ...settings, traeLoginHost: 'https://login.example.test/prefix/' })
487
+ const u3 = new URL(s3.authUrl)
488
+ check('authUrl 解析构造:路径绝对引用(无 //authorization、无基址路径串联)',
489
+ u3.hostname === 'login.example.test' && u3.pathname === '/authorization'
490
+ && !s3.authUrl.includes('//authorization'),
491
+ s3.authUrl)
492
+ // 收尾:驱动一次坏回调关闭回环服务(不遗留监听句柄)
493
+ const cb3 = new URL(new URL(s3.authUrl).searchParams.get('auth_callback_url'))
494
+ cb3.search = '?' + new URLSearchParams({ authCodeInfo: JSON.stringify({ AuthCode: 'bogus' }) }).toString()
495
+ await fetch(cb3)
496
+ await sleep(150)
497
+
498
+ mock.server.closeAllConnections?.()
499
+ mock.server.close()
500
+
501
+ // =========================================================================
502
+ console.log('== 目录(fixture state.vscdb)==')
503
+ const dbPath = join(workDir, 'state.vscdb')
504
+ buildCatalogFixture(dbPath)
505
+ const traeRuntime = { running: false, port: null, lastError: null }
506
+ const meterCalls = []
507
+ const traeProvider = createTraeProvider({
508
+ readAuth: () => ({}),
509
+ writeAuth: () => {},
510
+ settings: () => settings,
511
+ withCredentials: async () => ({ cred: null, res: null, err: null }),
512
+ meter: { record: (r) => meterCalls.push(r) },
513
+ runtime: traeRuntime,
514
+ })
515
+ const sync1 = await traeProvider.syncCatalog({ dbPath })
516
+ check('syncCatalog 成功(2 个可路由模型)', sync1.ok === true && sync1.count === 2, JSON.stringify(sync1))
517
+ const view = traeProvider.catalogView()
518
+ const ids = traeProvider.catalogIds()
519
+ check('BYOK 条目被排除(deepseek//…)', !ids.includes('deepseek//deepseek-v4-pro'))
520
+ check('禁用条目被排除(dead-model)', !ids.includes('dead-model'))
521
+ const glm = view.profiles.find((p) => p.id === 'glm-5.3')
522
+ const kimi = view.profiles.find((p) => p.id === 'kimi-k3')
523
+ check('profile 字段映射(ctx/maxTokens/多模态)', glm.contextWindow === 200000 && glm.maxTokens === 64000 && !glm.input && kimi.input[1] === 'image')
524
+ check('ctx 缺失回落 max 数组最大档', kimi.contextWindow === 500000)
525
+ // 档位投影(G3):目录 reasoning_effort_config.options → profile.reasoningEfforts
526
+ //(键=宿主枚举词汇映射 light→low/high→high/extra_high→xhigh,值=目录声明拼写
527
+ // ——宿主 schema 对键有固定枚举,原拼写做键会被整块拒收;同 Qoder 纪律:
528
+ // 不声明不出档,#42)。
529
+ check('reasoningEfforts 投影:宿主枚举键 → 目录声明线值', JSON.stringify(glm.reasoningEfforts) === '{"low":"light","high":"high"}', JSON.stringify(glm.reasoningEfforts))
530
+ check('无声明模型不出 reasoningEfforts 键', !('reasoningEfforts' in kimi))
531
+ check('support_thinking=false 不出档', catalogToProfiles({ models: [{ id: 'm', provider: null, selectable: true, status: true, reasoningEffortConfig: { supportThinking: false, options: ['high'], defaultLevel: 'high' } }] })[0].reasoningEfforts === undefined)
532
+ check('空 options 不出档', catalogToProfiles({ models: [{ id: 'm', provider: null, selectable: true, status: true, reasoningEffortConfig: { supportThinking: true, options: [], defaultLevel: null } }] })[0].reasoningEfforts === undefined)
533
+ check('extra_high 映射 xhigh + 未映射拼写不进宿主表 + max 追加', JSON.stringify(catalogToProfiles({ models: [{ id: 'm', provider: null, selectable: true, status: true, reasoningEffortConfig: { supportThinking: true, options: ['extra_high', 'weird'], defaultLevel: null } }] })[0].reasoningEfforts) === '{"xhigh":"extra_high","max":"max"}')
534
+ check('全部拼写未映射 → 不出档', catalogToProfiles({ models: [{ id: 'm', provider: null, selectable: true, status: true, reasoningEffortConfig: { supportThinking: true, options: ['weird'], defaultLevel: null } }] })[0].reasoningEfforts === undefined)
535
+ check('宿主枚举拼写恒等透传(low/max 本就在枚举内)', JSON.stringify(catalogToProfiles({ models: [{ id: 'm', provider: null, selectable: true, status: true, reasoningEffortConfig: { supportThinking: true, options: ['low', 'max'], defaultLevel: null } }] })[0].reasoningEfforts) === '{"low":"low","max":"max"}')
536
+ check('目录指纹进 view(不泄露 key)', typeof view.candidate === 'string' && view.candidate.startsWith('sha256:'))
537
+ // G3:catalogView 暴露逐模型 efforts(设置卡 select 与校验的共同真源,
538
+ // 同 qoder catalogView().efforts 口径;仅含目录声明了档位的模型)。
539
+ check('catalogView 带逐模型 efforts(仅声明模型)', JSON.stringify(view.efforts) === JSON.stringify({ 'glm-5.3': ['light', 'high'] }), JSON.stringify(view.efforts))
540
+
541
+ // syncView(设置卡模型区的同步状态):失败原因必须随视图带出——此前
542
+ // catalogView() 失败即 null,UI 只能显示泛化「未同步」(goal: trae-work-cn-repair G2)。
543
+ const syncOkView = traeProvider.syncView()
544
+ check('syncView 成功态:计数在、无 error', syncOkView && syncOkView.count === 2 && syncOkView.error === undefined)
545
+ const syncFail = await traeProvider.syncCatalog({ dbPath: join(workDir, 'no-such.vscdb') })
546
+ check('syncCatalog 失败 kept=true(旧清单保留)', syncFail.ok === false && syncFail.kept === true && typeof syncFail.error === 'string', JSON.stringify(syncFail))
547
+ const keptView = traeProvider.syncView()
548
+ check('syncView kept 场景:带 error 且保留旧计数', keptView && keptView.error === syncFail.error && keptView.count === 2, JSON.stringify(keptView))
549
+ const freshProvider = createTraeProvider({
550
+ readAuth: () => ({}),
551
+ writeAuth: () => {},
552
+ settings: () => settings,
553
+ withCredentials: async () => ({ cred: null, res: null, err: null }),
554
+ meter: { record: () => {} },
555
+ runtime: { running: false, port: null, lastError: null },
556
+ })
557
+ const freshFail = await freshProvider.syncCatalog({ dbPath: join(workDir, 'no-such.vscdb') })
558
+ const freshView = freshProvider.syncView()
559
+ check('无旧清单时 syncView 只有 error', freshView && freshView.error === freshFail.error && freshView.count === undefined, JSON.stringify(freshView))
560
+ const freshOk = await freshProvider.syncCatalog({ dbPath })
561
+ check('再次成功同步后 error 清空', freshOk.ok === true && freshProvider.syncView().error === undefined)
562
+
563
+ // catalogToProfiles 纯函数:空目录安全
564
+ check('catalogToProfiles 容忍空输入', catalogToProfiles(null).length === 0 && catalogToProfiles({ models: [] }).length === 0)
565
+
566
+ // =========================================================================
567
+ console.log('== 翻译网关(mock Trae 云端 SSE)==')
568
+ const chatMock = await mockTraeChat()
569
+ const gwSettings = () => ({ ...settings, traeChatBaseURL: chatMock.base, maxConcurrentPerSession: 4 })
570
+ const cred3 = { authorization: 'Cloud-IDE-JWT tok-live', headers: { 'x-cloudide-token': 'tok-live', 'X-User-Region': 'CN' } }
571
+ const gateway = createTraeGateway({
572
+ settings: gwSettings,
573
+ withCredentials: async (attempt) => {
574
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
575
+ },
576
+ readAuthDevice: () => ({ deviceId: '123', machineId: 'abc', deviceBrand: 'b', deviceCpu: 'c', osVersion: 'v' }),
577
+ readAuthMeta: () => ({ uid: 'u-001' }),
578
+ meter: { record: (r) => meterCalls.push(r) },
579
+ runtime: traeRuntime,
580
+ getCatalogIds: () => ids,
581
+ })
582
+ const stop = gateway.listen(0)
583
+ await sleep(80) // listening 事件异步回填 runtime.port
584
+ const gwPort = traeRuntime.port
585
+ check('网关监听临时端口', traeRuntime.running === true && gwPort > 0)
586
+
587
+ // Host 门回归锁(审计 [9]):仅回环 Host 可达网关——非回环 Host 403 且不触上游;
588
+ // 白名单放行形态不误伤(fetch 伪造不了 Host,走原生 http 客户端)。
589
+ const evilHost = await rawRequest(gwPort, '/v1/models', { headers: { Host: 'evil.example.com' } })
590
+ check('Host 门:非回环 Host → 403', evilHost.status === 403, `status=${evilHost.status}`)
591
+ const ipv6Host = await rawRequest(gwPort, '/v1/models', { headers: { Host: '[::1]' } })
592
+ check('Host 门:回环白名单 Host([::1])正常放行', ipv6Host.status === 200 && (() => { try { return JSON.parse(ipv6Host.body).object === 'list' } catch { return false } })())
593
+
594
+ // 流式
595
+ const streamRes = await fetch(`http://127.0.0.1:${gwPort}/v1/chat/completions`, {
596
+ method: 'POST',
597
+ headers: { 'content-type': 'application/json' },
598
+ body: JSON.stringify({ model: 'glm-5.3', stream: true, messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'hi' }] }),
599
+ })
600
+ const streamText = await streamRes.text()
601
+ const sseLines = streamText.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim())
602
+ let assembled = ''
603
+ let reasoning = ''
604
+ let streamUsage = null
605
+ let sawDone = false
606
+ for (const line of sseLines) {
607
+ if (line === '[DONE]') { sawDone = true; continue }
608
+ const c = JSON.parse(line)
609
+ if (c.choices?.[0]?.delta?.content) assembled += c.choices[0].delta.content
610
+ if (c.choices?.[0]?.delta?.reasoning_content) reasoning += c.choices[0].delta.reasoning_content
611
+ if (c.usage) streamUsage = c.usage
612
+ if (c.choices?.[0]?.finish_reason) check('流式 finish_reason=stop', c.choices[0].finish_reason === 'stop')
613
+ }
614
+ check('流式:SSE 200 且 OpenAI chunk 形态', streamRes.status === 200 && sseLines.length >= 4)
615
+ check('流式:文本增量拼接(你好,世界,无改派通知混入)', assembled === '你好,世界')
616
+ check('流式:reasoning_content 透传不混入正文', reasoning === '思')
617
+ check('流式:usage 进末块 + [DONE] 收尾', streamUsage?.total_tokens === 13 && sawDone)
618
+ check('模型改派:SSE 注释行 + 计量记真实模型(timing_cost.provider_model_name)',
619
+ streamText.includes(': trae-reroute requested=glm-5.3 actual=kimi-k2.6')
620
+ && meterCalls.some((m) => m.model === 'kimi-k2.6' && m.usage?.total_tokens === 13))
621
+ const outbound = chatMock.state.requests[0]
622
+ check('出站头:三头同 JWT + IDE 指纹 + 设备头', outbound.headers['authorization'] === 'Cloud-IDE-JWT tok-live'
623
+ && outbound.headers['x-cloudide-token'] === 'tok-live' && outbound.headers['x-ide-token'] === 'tok-live'
624
+ && outbound.headers['x-app-id'] === TRAE_APP_ID
625
+ && outbound.headers['x-ide-version'] === TRAE_IDE_VERSION && outbound.headers['x-ide-version-code'] === TRAE_IDE_VERSION_CODE
626
+ && typeof outbound.headers['x-request-id'] === 'string'
627
+ && outbound.headers['x-device-id'] === '123' && outbound.headers['x-machine-id'] === 'abc'
628
+ && outbound.headers['x-uid'] === 'u-001')
629
+ check('出站路径 = /api/agent/v3/llm_utils_chat', outbound.url === '/api/agent/v3/llm_utils_chat')
630
+ check('出站信封:model/function/session_id/request_id + content 块化保留 system 角色',
631
+ outbound.body.model === 'glm-5.3' && outbound.body.function === 'inline_chat' && outbound.body.stream === true
632
+ && outbound.body.messages[0].role === 'system'
633
+ && outbound.body.messages[1].content?.[0]?.type === 'text' && outbound.body.messages[1].content[0].text === 'hi'
634
+ && typeof outbound.body.session_id === 'string' && typeof outbound.body.request_id === 'string')
635
+
636
+ // 非流式聚合
637
+ const aggRes = await fetch(`http://127.0.0.1:${gwPort}/v1/chat/completions`, {
638
+ method: 'POST',
639
+ headers: { 'content-type': 'application/json' },
640
+ body: JSON.stringify({ model: 'kimi-k3', messages: [{ role: 'user', content: 'hi' }] }),
641
+ })
642
+ const agg = await aggRes.json()
643
+ check('非流式:聚合 chat.completion JSON', aggRes.status === 200 && agg.object === 'chat.completion' && agg.choices[0].message.content === '你好,世界' && agg.choices[0].finish_reason === 'stop')
644
+
645
+ // /models 端点
646
+ const modelsRes = await fetch(`http://127.0.0.1:${gwPort}/v1/models`)
647
+ const modelsBody = await modelsRes.json()
648
+ check('GET /v1/models 回目录清单', modelsRes.status === 200 && modelsBody.data?.length === 2 && modelsBody.data[0].object === 'model')
649
+
650
+ // 上游 401 映射
651
+ const unauthMock = await mockTraeChat({ authedToken: 'wrong' })
652
+ const gwSettings2 = () => ({ ...settings, traeChatBaseURL: unauthMock.base, maxConcurrentPerSession: 4 })
653
+ const rt2 = { running: false, port: null, lastError: null }
654
+ const gateway2 = createTraeGateway({
655
+ settings: gwSettings2,
656
+ withCredentials: async (attempt) => {
657
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
658
+ },
659
+ readAuthDevice: () => null,
660
+ meter: { record: () => {} },
661
+ runtime: rt2,
662
+ getCatalogIds: () => [],
663
+ })
664
+ const stop2 = gateway2.listen(0)
665
+ await sleep(80)
666
+ const errRes = await fetch(`http://127.0.0.1:${rt2.port}/v1/chat/completions`, {
667
+ method: 'POST',
668
+ headers: { 'content-type': 'application/json' },
669
+ body: JSON.stringify({ model: 'x', messages: [{ role: 'user', content: 'hi' }] }),
670
+ })
671
+ const errBody = await errRes.json()
672
+ check('上游 401 → 401 + code 透传', errRes.status === 401 && String(errBody.error?.code) === '1001')
673
+ stop2()
674
+ unauthMock.server.closeAllConnections?.()
675
+ unauthMock.server.close()
676
+
677
+ // 凭据不可用 → 503
678
+ const rt3 = { running: false, port: null, lastError: null }
679
+ const gateway3 = createTraeGateway({
680
+ settings: gwSettings,
681
+ withCredentials: async () => {
682
+ const e = new Error('Trae 凭据不可用')
683
+ e.credentialUnavailable = true
684
+ return { cred: null, res: null, err: e }
685
+ },
686
+ readAuthDevice: () => null,
687
+ meter: { record: () => {} },
688
+ runtime: rt3,
689
+ getCatalogIds: () => [],
690
+ })
691
+ const stop3 = gateway3.listen(0)
692
+ await sleep(80)
693
+ const noCredRes = await fetch(`http://127.0.0.1:${rt3.port}/v1/chat/completions`, {
694
+ method: 'POST',
695
+ headers: { 'content-type': 'application/json' },
696
+ body: JSON.stringify({ model: 'x', messages: [{ role: 'user', content: 'hi' }] }),
697
+ })
698
+ const noCredBody = await noCredRes.json()
699
+ check('凭据不可用 → 503 + 稳定文案', noCredRes.status === 503 && noCredBody.error?.message.includes('Trae 凭据不可用'))
700
+ stop3()
701
+
702
+ // 坏 payload
703
+ const badRes = await fetch(`http://127.0.0.1:${gwPort}/v1/chat/completions`, {
704
+ method: 'POST',
705
+ headers: { 'content-type': 'application/json' },
706
+ body: 'not-json',
707
+ })
708
+ check('坏 payload → 400', badRes.status === 400)
709
+
710
+ // 工具调用:真实线缆形态(function_call 键 + arguments 增量片段 + done 映射 tool_calls)
711
+ const toolMock = await mockTraeChat({ authedToken: 'tok-live', withTools: true })
712
+ const rt4 = { running: false, port: null, lastError: null }
713
+ const gateway4 = createTraeGateway({
714
+ settings: () => ({ ...settings, traeChatBaseURL: toolMock.base, maxConcurrentPerSession: 4 }),
715
+ withCredentials: async (attempt) => {
716
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
717
+ },
718
+ readAuthDevice: () => null,
719
+ meter: { record: () => {} },
720
+ runtime: rt4,
721
+ getCatalogIds: () => [],
722
+ })
723
+ const stop4 = gateway4.listen(0)
724
+ await sleep(80)
725
+ const tcRes = await fetch(`http://127.0.0.1:${rt4.port}/v1/chat/completions`, {
726
+ method: 'POST',
727
+ headers: { 'content-type': 'application/json' },
728
+ body: JSON.stringify({
729
+ model: 'glm-5.3', stream: true, messages: [{ role: 'user', content: '查一下' }],
730
+ tools: [{ type: 'function', function: { name: 'get_x', parameters: { type: 'object' } } }],
731
+ }),
732
+ })
733
+ const tcText = await tcRes.text()
734
+ const tcLines = tcText.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim())
735
+ let tcArgs = ''
736
+ let tcMeta = null
737
+ let tcFinish = null
738
+ for (const line of tcLines) {
739
+ if (line === '[DONE]') continue
740
+ const c = JSON.parse(line)
741
+ const d = c.choices?.[0]?.delta
742
+ if (d?.tool_calls?.[0]) {
743
+ if (d.tool_calls[0].id) tcMeta = d.tool_calls[0]
744
+ tcArgs += d.tool_calls[0].function?.arguments ?? ''
745
+ }
746
+ if (c.choices?.[0]?.finish_reason) tcFinish = c.choices[0].finish_reason
747
+ }
748
+ check('工具调用流式:增量片段拼回完整参数', tcArgs === '{"a":1}' && tcMeta?.id === 'get_x_0' && tcMeta.function?.name === 'get_x')
749
+ check('工具调用流式:finish_reason 映射为 tool_calls', tcFinish === 'tool_calls')
750
+ check('工具调用出站:parameters 已序列化为字符串', typeof toolMock.state.requests[0]?.body?.tools?.[0]?.function?.parameters === 'string')
751
+ const tcAggRes = await fetch(`http://127.0.0.1:${rt4.port}/v1/chat/completions`, {
752
+ method: 'POST',
753
+ headers: { 'content-type': 'application/json' },
754
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: '查一下' }] }),
755
+ })
756
+ const tcAgg = await tcAggRes.json()
757
+ check('工具调用非流式:message.tool_calls 聚合 + finish 映射',
758
+ tcAgg.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments === '{"a":1}'
759
+ && tcAgg.choices[0].finish_reason === 'tool_calls')
760
+ stop4()
761
+ toolMock.server.closeAllConnections?.()
762
+ toolMock.server.close()
763
+
764
+ check('计量:非流式请求也记录(真实模型口径)', meterCalls.filter((m) => m.usage?.total_tokens === 13).length >= 2)
765
+ stop()
766
+ chatMock.server.closeAllConnections?.()
767
+ chatMock.server.close()
768
+
769
+ // SSE 注释行清洗回归锁(审计 [20]):上游 provider_model_name 带 \r\n 时,
770
+ // reroute 注释行必须折叠为单行——不能在响应流里伪造 SSE 帧。
771
+ const injectMock = await mockTraeChat({ authedToken: 'tok-live', providerModel: 'evil\ndata: {"injected":true}\n\n' })
772
+ const rtInj = { running: false, port: null, lastError: null }
773
+ const gatewayInj = createTraeGateway({
774
+ settings: () => ({ ...settings, traeChatBaseURL: injectMock.base, maxConcurrentPerSession: 4 }),
775
+ withCredentials: async (attempt) => {
776
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
777
+ },
778
+ readAuthDevice: () => null,
779
+ readAuthMeta: () => ({ uid: 'u-001' }),
780
+ meter: { record: () => {} },
781
+ runtime: rtInj,
782
+ getCatalogIds: () => [],
783
+ })
784
+ const stopInj = gatewayInj.listen(0)
785
+ await sleep(80)
786
+ const injRes = await fetch(`http://127.0.0.1:${rtInj.port}/v1/chat/completions`, {
787
+ method: 'POST', headers: { 'content-type': 'application/json' },
788
+ body: JSON.stringify({ model: 'glm-5.3', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
789
+ })
790
+ const injText = await injRes.text()
791
+ check('SSE 清洗:reroute 注释行折叠换行(注入载荷不成帧)',
792
+ injRes.status === 200 && !injText.includes('{"injected":true}\n')
793
+ && injText.includes(': trae-reroute requested=glm-5.3 actual=evil data: {"injected":true} \n\n'),
794
+ injText.split('\n').find((l) => l.startsWith(': trae-reroute')) ?? '(no reroute line)')
795
+ check('SSE 清洗:流本体不受影响([DONE] 正常收尾)', injText.includes('data: [DONE]'))
796
+ stopInj()
797
+ injectMock.server.closeAllConnections?.()
798
+ injectMock.server.close()
799
+
800
+ // =========================================================================
801
+ console.log('== remote 传输(chat_sessions,mock remote 云端)==')
802
+ const remoteMock = await mockTraeRemote()
803
+ const rt5 = { running: false, port: null, lastError: null }
804
+ const meterRemote = []
805
+ const remotePrefs = {} // G3 档位 prefs 补默认(可变引用,逐臂调整)
806
+ const gateway5 = createTraeGateway({
807
+ settings: () => ({ ...settings, traeChatBaseURL: remoteMock.base, traeChatTransport: 'remote', maxConcurrentPerSession: 4 }),
808
+ withCredentials: async (attempt) => {
809
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
810
+ },
811
+ readAuthDevice: () => null,
812
+ readAuthMeta: () => ({ uid: 'u-001' }),
813
+ meter: { record: (r) => meterRemote.push(r) },
814
+ runtime: rt5,
815
+ getCatalogIds: () => ids,
816
+ getModelPrefs: () => remotePrefs,
817
+ })
818
+ const stop5 = gateway5.listen(0)
819
+ await sleep(80)
820
+
821
+ // 流式
822
+ const rStream = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
823
+ method: 'POST',
824
+ headers: { 'content-type': 'application/json' },
825
+ body: JSON.stringify({
826
+ model: 'glm-5.3', stream: true,
827
+ messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'hi' }, { role: 'assistant', content: 'prev' }, { role: 'user', content: 'again' }],
828
+ }),
829
+ })
830
+ const rStreamText = await rStream.text()
831
+ const rLines = rStreamText.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim())
832
+ let rAssembled = ''
833
+ let rReasoning = ''
834
+ let rUsage = null
835
+ let rFinish = null
836
+ for (const line of rLines) {
837
+ if (line === '[DONE]') continue
838
+ const c = JSON.parse(line)
839
+ if (c.choices?.[0]?.delta?.content) rAssembled += c.choices[0].delta.content
840
+ if (c.choices?.[0]?.delta?.reasoning_content) rReasoning += c.choices[0].delta.reasoning_content
841
+ if (c.usage) rUsage = c.usage
842
+ if (c.choices?.[0]?.finish_reason) rFinish = c.choices[0].finish_reason
843
+ }
844
+ check('remote 流式:SSE 200 + 文本差分拼接(无重复、summary 不追加——thought 已覆盖)',
845
+ rStream.status === 200 && rAssembled === '你好,世界')
846
+ check('remote 流式:reasoning_content 独立透传', rReasoning === '思考')
847
+ check('remote 流式:usage 进末块 + finish=stop', rUsage?.total_tokens === 109 && rFinish === 'stop')
848
+ const rCreate = remoteMock.state.creates[0]
849
+ check('remote 出站:chat_sessions 创建体(model_name 透传 + manual 策略 + query 扁平化角色标记)',
850
+ rCreate.body.initial_message.model_name === 'glm-5.3'
851
+ && rCreate.body.initial_message.model_selection_strategy === 'manual'
852
+ && rCreate.body.initial_message.agent_type === 'solo_agent_remote'
853
+ && rCreate.body.initial_message.content.length === 0
854
+ && rCreate.body.initial_message.query.includes('[System]\\nsys')
855
+ && rCreate.body.initial_message.query.includes('[Assistant]\\nprev')
856
+ && rCreate.body.mode === 'code' && rCreate.body.origin === 'web')
857
+ check('remote 出站:web 头组(Cloud-IDE-JWT + web client 指纹 + Origin)',
858
+ rCreate.headers['authorization'] === 'Cloud-IDE-JWT tok-live'
859
+ && rCreate.headers['x-trae-client-type'] === 'web'
860
+ && rCreate.headers['origin'] === 'https://solo.trae.cn')
861
+ check('remote 计量:记 model_config 的真实模型', meterRemote.some((m) => m.model === 'glm-5.3' && m.usage?.total_tokens === 109))
862
+ check('remote 出站:无档位时创建体不带 custom_model(既有路径零变化)', rCreate.body.initial_message.custom_model === undefined)
863
+
864
+ // G3 档位线缆(2026-10-04 官方 bundle 取证 + probe-trae-thinking-scene 证实
865
+ // 接受面):reasoning_effort → initial_message.custom_model.reasoning_effort;
866
+ // 客户端未带时文件层 prefs 补默认;客户端带值优先。
867
+ const rEffIdx = remoteMock.state.creates.length
868
+ const rEff = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
869
+ method: 'POST',
870
+ headers: { 'content-type': 'application/json' },
871
+ body: JSON.stringify({ model: 'glm-5.3', reasoning_effort: 'light', messages: [{ role: 'user', content: 'hi' }] }),
872
+ })
873
+ await rEff.text()
874
+ const rCreateEff = remoteMock.state.creates[rEffIdx]
875
+ check('remote 出站:客户端 reasoning_effort 注入 custom_model(官方线缆形态)',
876
+ rCreateEff.body.initial_message.custom_model?.reasoning_effort === 'light'
877
+ && rCreateEff.body.initial_message.custom_model?.config_name === 'glm-5.3'
878
+ && rCreateEff.body.initial_message.custom_model?.config_source === 1
879
+ && rCreateEff.body.initial_message.custom_model?.is_preset === true
880
+ && rCreateEff.body.initial_message.custom_model?.use_remote_service === true,
881
+ JSON.stringify(rCreateEff.body.initial_message.custom_model))
882
+ remotePrefs['glm-5.3'] = { effort: 'high' }
883
+ const rEffPrefIdx = remoteMock.state.creates.length
884
+ const rEffPref = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
885
+ method: 'POST',
886
+ headers: { 'content-type': 'application/json' },
887
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }] }),
888
+ })
889
+ await rEffPref.text()
890
+ check('remote 出站:客户端未带时 prefs 补默认', remoteMock.state.creates[rEffPrefIdx].body.initial_message.custom_model?.reasoning_effort === 'high')
891
+ const rEffWinIdx = remoteMock.state.creates.length
892
+ const rEffWin = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
893
+ method: 'POST',
894
+ headers: { 'content-type': 'application/json' },
895
+ body: JSON.stringify({ model: 'glm-5.3', reasoning_effort: 'light', messages: [{ role: 'user', content: 'hi' }] }),
896
+ })
897
+ await rEffWin.text()
898
+ check('remote 出站:客户端带值时 prefs 不覆盖', remoteMock.state.creates[rEffWinIdx].body.initial_message.custom_model?.reasoning_effort === 'light')
899
+ delete remotePrefs['glm-5.3']
900
+
901
+ // 非流式聚合
902
+ const rAgg = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
903
+ method: 'POST',
904
+ headers: { 'content-type': 'application/json' },
905
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }] }),
906
+ })
907
+ const rAggBody = await rAgg.json()
908
+ check('remote 非流式:聚合 chat.completion(content 来自 thought 差分)',
909
+ rAgg.status === 200 && rAggBody.choices?.[0]?.message?.content === '你好,世界' && rAggBody.choices[0].finish_reason === 'stop')
910
+
911
+ // tools 明确拒绝
912
+ const rTools = await fetch(`http://127.0.0.1:${rt5.port}/v1/chat/completions`, {
913
+ method: 'POST',
914
+ headers: { 'content-type': 'application/json' },
915
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }], tools: [{ type: 'function', function: { name: 'f' } }] }),
916
+ })
917
+ const rToolsBody = await rTools.json()
918
+ check('remote 带 tools → 400 remote-no-tools(不静默降级)', rTools.status === 400 && rToolsBody.error?.code === 'remote-no-tools')
919
+
920
+ await sleep(50) // stop_session 是 finally 里的 best-effort
921
+ check('remote 善后:stop_session 已调用', remoteMock.state.stops >= 2)
922
+ stop5()
923
+ remoteMock.server.closeAllConnections?.()
924
+ remoteMock.server.close()
925
+
926
+ // remote 单元:flattenQuery / buildRemoteCreateBody / 解析器边界
927
+ const fq = flattenQuery([
928
+ { role: 'system', content: 'S' },
929
+ { role: 'user', content: 'U1' },
930
+ { role: 'assistant', content: 'A1', tool_calls: [{ id: 'c1', function: { name: 'run', arguments: '{"x":1}' } }] },
931
+ { role: 'tool', tool_call_id: 'c1', name: 'run', content: 'R1' },
932
+ { role: 'assistant', content: [{ type: 'text', text: 'A2' }] },
933
+ ])
934
+ check('flattenQuery:角色标记 + 工具历史文本化',
935
+ fq.includes('[System]\\nS') && fq.includes('[Assistant]\\nA1') && fq.includes('[Client Tool Call: c1 run]')
936
+ && fq.includes('[Client Tool Result: c1 run]\\nR1') && fq.includes('[Assistant]\\nA2'))
937
+ const rbody = buildRemoteCreateBody('kimi-k3', [{ role: 'user', content: 'x' }])
938
+ check('buildRemoteCreateBody:信封形态', rbody.initial_message.model_name === 'kimi-k3'
939
+ && rbody.initial_message.model_selection_strategy === 'manual' && rbody.env === 'remote'
940
+ && typeof rbody.initial_message.common_params === 'string')
941
+ const rbodyEff = buildRemoteCreateBody('glm-5.3', [{ role: 'user', content: 'x' }], { reasoningEffort: 'light' })
942
+ check('buildRemoteCreateBody:effort → custom_model(preset 形态)',
943
+ rbodyEff.initial_message.custom_model?.reasoning_effort === 'light'
944
+ && rbodyEff.initial_message.custom_model?.model_name === 'glm-5.3'
945
+ && rbodyEff.initial_message.custom_model?.config_name === 'glm-5.3'
946
+ && rbodyEff.initial_message.custom_model?.config_source === 1
947
+ && rbodyEff.initial_message.custom_model?.is_preset === true)
948
+ check('buildRemoteCreateBody:空 effort 不出 custom_model',
949
+ buildRemoteCreateBody('glm-5.3', [{ role: 'user', content: 'x' }], { reasoningEffort: '' }).initial_message.custom_model === undefined)
950
+ const rp = createRemoteEventParser()
951
+ rp.handle('plan_item', { id: 'p1', thought: '', reasoning_content: 'r', tool_call_info: { name: '', params: null } })
952
+ rp.handle('plan_item', { id: 'p1', thought: '', reasoning_content: 'r', tool_call_info: { name: 'finish', params: { summary: '最终答复' } } })
953
+ const rpDone = rp.handle('done', { status: 'completed', user_message_context: { model_info: { config_name: 'kimi-k3' } } })
954
+ check('remote 解析器:thought 全空时 finish summary 兜底为正文', rpDone.text === '最终答复' && rpDone.finish === 'stop'
955
+ && rp.actualModel() === 'kimi-k3' && rp.finalText() === '最终答复')
956
+ const rp2 = createRemoteEventParser()
957
+ rp2.handle('plan_item', { id: 'p1', thought: '部分', reasoning_content: '', tool_call_info: null })
958
+ rp2.handle('plan_item', { id: 'p1', thought: '部分文本', reasoning_content: '', tool_call_info: { name: 'finish', params: { summary: '部分文本' } } })
959
+ const rp2Done = rp2.handle('done', { status: 'completed' })
960
+ check('remote 解析器:thought 已覆盖 summary 时不重复追加', rp2Done.text === undefined && rp2Done.finish === 'stop' && rp2.finalText() === '部分文本')
961
+ const rp3 = createRemoteEventParser()
962
+ const rp3a = rp3.handle('queuing', { position: 1 })
963
+ const rp3b = rp3.handle('notification', { content: 'x' })
964
+ check('remote 解析器:排队提示只报一次', rp3a.queue === true && Object.keys(rp3b).length === 0)
965
+ const rp4 = createRemoteEventParser()
966
+ const rp4e = rp4.handle('error', { code: 4011, message: 'rate limited' })
967
+ check('remote 解析器:error 事件终结', rp4e.error?.code === 4011 && rp4.isDone())
968
+
969
+ // =========================================================================
970
+ console.log('== 单元:信封与事件解析 ==')
971
+ const { body: envBody, requestId: envReqId } = buildChatRequest({
972
+ model: 'm1', max_tokens: 64, temperature: 0.5,
973
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'image_url' }] }],
974
+ }, 'conv-1')
975
+ check('buildChatRequest:content 块化 + function/stream + 生成参数透传',
976
+ envBody.messages[0].content?.length === 1 && envBody.messages[0].content[0].type === 'text' && envBody.messages[0].content[0].text === 'a'
977
+ && envBody.model === 'm1' && envBody.session_id === 'conv-1' && envBody.function === 'inline_chat' && envBody.stream === true
978
+ && envBody.max_tokens === 64 && envBody.temperature === 0.5 && typeof envBody.request_id === 'string' && envReqId === envBody.request_id)
979
+ check('buildChatRequest:默认模型 + tool 角色透传(孤儿 tool 先补 assistant 桩,踩坑 #39)',
980
+ buildChatRequest({ messages: [] }, 's').body.model === 'glm-5.3'
981
+ && buildChatRequest({ messages: [{ role: 'tool', tool_call_id: 'c1', content: 'out' }] }, 's').body.messages
982
+ .some((m) => m.tool_call_id === 'c1')
983
+ && buildChatRequest({ messages: [{ role: 'tool', tool_call_id: 'c1', content: 'out' }] }, 's').body.messages[0]?.tool_calls?.[0]?.id === 'c1')
984
+ const envTools = buildChatRequest({
985
+ messages: [{ role: 'user', content: 'x' }],
986
+ tools: [{ type: 'function', function: { name: 'f', description: 'd', parameters: { type: 'object', properties: {} } } }],
987
+ }, 's')
988
+ check('buildChatRequest:tools.function.parameters 序列化为字符串(Go string 型)',
989
+ typeof envTools.body.tools[0].function.parameters === 'string'
990
+ && JSON.parse(envTools.body.tools[0].function.parameters).type === 'object'
991
+ && envTools.body.tools[0].function.name === 'f')
992
+
993
+ // createTraeStreamParser:累计快照差分 / 思考 / 用量 / 排队 / 工具 / 终结
994
+ const p = createTraeStreamParser()
995
+ const pa = p.handle('output', { response: '你好', reasoning_content: '思' })
996
+ const pb = p.handle('output', { response: '你好,世界', reasoning_content: '思考' })
997
+ check('流解析:累计快照前缀差分', pa.text === '你好' && pb.text === ',世界' && pa.reasoning === '思' && pb.reasoning === '考')
998
+ p.handle('token_usage', { prompt_tokens: 16, completion_tokens: 19, total_tokens: 35 })
999
+ check('流解析:token_usage 顶层计数', p.usage()?.total_tokens === 35)
1000
+ const pq1 = p.handle('request_wait_in_queue', { position: 2 })
1001
+ const pq2 = p.handle('request_wait_in_queue', { position: 2 })
1002
+ check('流解析:排队位置变化才报', pq1.queue === 2 && Object.keys(pq2).length === 0)
1003
+ const pt = p.handle('output', { response: '你好,世界', tool_call_info: { id: 't1', name: 'fn', params: { a: 1 } } })
1004
+ check('流解析:tool_call_info 归一为 OpenAI 工具调用', pt.toolCalls?.[0]?.id === 't1' && pt.toolCalls[0].name === 'fn' && pt.toolCalls[0].argsDelta === '{"a":1}')
1005
+ const p2 = createTraeStreamParser()
1006
+ const frag1 = { index: 0, id: 'w_0', type: 'function', function_call: { name: 'get_current_weather', arguments: '{"city' } }
1007
+ const frag2 = { index: 0, id: '', type: '', function_call: { name: '', arguments: '": "北京"}' } }
1008
+ p2.handle('output', { tool_calls: [frag1] })
1009
+ const pc2 = p2.handle('output', { tool_calls: [frag2] })
1010
+ p2.handle('done', { finish_reason: 'stop' })
1011
+ check('流解析:tool_calls 增量片段按 index 拼接(续片空 id 不重复下发)',
1012
+ pc2.toolCalls?.[0]?.argsDelta === '": "北京"}' && pc2.toolCalls[0].id === undefined
1013
+ && p2.toolCalls()[0].id === 'w_0' && p2.toolCalls()[0].function.arguments === '{"city": "北京"}'
1014
+ && p2.finish() === 'tool_calls')
1015
+ // 单事件多 tool_calls:并行调用必须全部下发,绝不覆盖(旧实现只发最后一个)
1016
+ const pm = createTraeStreamParser()
1017
+ const pmOut = pm.handle('output', {
1018
+ tool_calls: [
1019
+ { index: 0, id: 'c0', function_call: { name: 'f0', arguments: '{}' } },
1020
+ { index: 1, id: 'c1', function_call: { name: 'f1', arguments: '{}' } },
1021
+ ],
1022
+ })
1023
+ check('流解析:单事件多 tool_calls 全量下发(并行调用不丢帧)',
1024
+ pmOut.toolCalls?.length === 2 && pmOut.toolCalls[0].id === 'c0' && pmOut.toolCalls[1].id === 'c1')
1025
+ const pd = createTraeStreamParser()
1026
+ const pdOut = pd.handle('done', { finish_reason: 'stop' })
1027
+ check('流解析:done 终结语义', pdOut.finish === 'stop' && pd.isDone() === true && pd.finish() === 'stop')
1028
+ const pe = createTraeStreamParser().handle('error', { code: 1001, message: 'auth' })
1029
+ check('流解析:error 事件归一', pe.error?.code === 1001)
1030
+ check('cumulativeDelta:前缀差分/回退/纯增量三态', cumulativeDelta('abc', 'abcdef') === 'def'
1031
+ && cumulativeDelta('abc', 'ab') === '' && cumulativeDelta('', 'x') === 'x')
1032
+ const nerr = normalizeTraeError(400, { ResponseMetadata: { Error: { Code: '10101', Message: 'Invalid client.' } } })
1033
+ check('normalizeTraeError:火山信封/裸码/非 JSON 三态', nerr.code === '10101' && normalizeTraeError(401, { code: 1001 }).code === 1001 && normalizeTraeError(500, null).message === 'HTTP 500')
1034
+ check('normalizeTraeError:空 message 按码表回填(remote 1005 套餐门)',
1035
+ normalizeTraeError(200, { code: 1005, message: '', data: { plan: 1 } }).message.includes('entitlement'))
1036
+
1037
+ // =========================================================================
1038
+ // 2026-08-24 "trae 3003 all models failed / PI_AI_ERROR" 故障定位的回归锁
1039
+ // (证据链与结论见 docs/diagnosis-trae-3003.md)
1040
+ console.log('== 错误语义与边缘韧性(3003 故障取证回归锁)==')
1041
+ check('formatTraeErrorMessage:已知码追加处置提示,未知码原样',
1042
+ formatTraeErrorMessage(3003, 'all models failed').includes('聊天传输')
1043
+ && formatTraeErrorMessage(9999, 'x') === 'trae 9999 x'
1044
+ && formatTraeErrorMessage(null, 'HTTP 403') === 'trae HTTP 403')
1045
+ check('formatTraeErrorMessage:3003 提示含 remote 工具环边界(2026-10-04 复诊补,防文案回退丢边界)',
1046
+ formatTraeErrorMessage(3003, 'all models failed').includes('不支持 dsh 工具环'))
1047
+
1048
+ const errMock = await mockTraeChat({ sseError: { code: 3003, message: 'all models failed', extra: null } })
1049
+ const rt6 = { running: false, port: null, lastError: null }
1050
+ const gateway6 = createTraeGateway({
1051
+ settings: () => ({ ...settings, traeChatBaseURL: errMock.base, maxConcurrentPerSession: 4 }),
1052
+ withCredentials: async (attempt) => {
1053
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1054
+ },
1055
+ readAuthDevice: () => null,
1056
+ readAuthMeta: () => ({ uid: 'u-001' }),
1057
+ meter: { record: () => {} },
1058
+ runtime: rt6,
1059
+ getCatalogIds: () => ids,
1060
+ })
1061
+ const stop6 = gateway6.listen(0)
1062
+ await sleep(80)
1063
+ const eRes = await fetch(`http://127.0.0.1:${rt6.port}/v1/chat/completions`, {
1064
+ method: 'POST', headers: { 'content-type': 'application/json' },
1065
+ body: JSON.stringify({ model: 'kimi-k2.6', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
1066
+ })
1067
+ const eText = await eRes.text()
1068
+ // 流式语义:SSE 头在消费上游前已发(恒 200),错误以流内 error 块 + [DONE] 终结
1069
+ const eLine = eText.split('\n').find((l) => l.startsWith('data:') && l.includes('3003'))
1070
+ check('inline SSE 3003:code 透传 + 处置提示进错误消息(用户可自助)',
1071
+ eRes.status === 200 && eText.includes('data: [DONE]')
1072
+ && !!eLine && JSON.parse(eLine.slice(5)).error?.code === 3003
1073
+ && String(JSON.parse(eLine.slice(5)).error?.message).includes('remote'))
1074
+ stop6()
1075
+ errMock.server.closeAllConnections?.()
1076
+ errMock.server.close()
1077
+
1078
+ const skewMock = await mockTraeRemote({ failFirstCreates: 1 })
1079
+ const rt7 = { running: false, port: null, lastError: null }
1080
+ const gateway7 = createTraeGateway({
1081
+ settings: () => ({ ...settings, traeChatBaseURL: skewMock.base, traeChatTransport: 'remote', maxConcurrentPerSession: 4 }),
1082
+ withCredentials: async (attempt) => {
1083
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1084
+ },
1085
+ readAuthDevice: () => null,
1086
+ readAuthMeta: () => ({ uid: 'u-001' }),
1087
+ meter: { record: () => {} },
1088
+ runtime: rt7,
1089
+ getCatalogIds: () => ids,
1090
+ })
1091
+ const stop7 = gateway7.listen(0)
1092
+ await sleep(80)
1093
+ const skRes = await fetch(`http://127.0.0.1:${rt7.port}/v1/chat/completions`, {
1094
+ method: 'POST', headers: { 'content-type': 'application/json' },
1095
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }] }),
1096
+ })
1097
+ const skBody = await skRes.json()
1098
+ check('remote create 首次裸 404(节点漂移):自动重试一次后成功',
1099
+ skRes.status === 200 && skBody.choices?.[0]?.message?.content === '你好,世界' && skewMock.state.creates.length === 2)
1100
+ stop7()
1101
+ skewMock.server.closeAllConnections?.()
1102
+ skewMock.server.close()
1103
+
1104
+ const deadMock = await mockTraeRemote({ failFirstCreates: 99 })
1105
+ let deadErr = null
1106
+ try { await createRemoteSession(deadMock.base, 'tok-live', 'glm-5.3', [{ role: 'user', content: 'x' }]) } catch (e) { deadErr = e }
1107
+ check('remote create 持续裸 404:两次尝试后失败且文案带自愈指引',
1108
+ deadMock.state.creates.length === 2 && deadErr?.status === 404 && String(deadErr?.message).includes('边缘节点'))
1109
+ deadMock.server.closeAllConnections?.()
1110
+ deadMock.server.close()
1111
+
1112
+ // 首字节/整体超时护栏(2026-08-24 本地代理死态实测,docs/diagnosis-trae-3003.md §8)
1113
+ const hangMock = await mockTraeChat({ hang: true })
1114
+ const rt8 = { running: false, port: null, lastError: null }
1115
+ const gateway8 = createTraeGateway({
1116
+ settings: () => ({ ...settings, traeChatBaseURL: hangMock.base, maxConcurrentPerSession: 4, upstreamFirstByteTimeoutMs: 400 }),
1117
+ withCredentials: async (attempt) => {
1118
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1119
+ },
1120
+ readAuthDevice: () => null,
1121
+ readAuthMeta: () => ({ uid: 'u-001' }),
1122
+ meter: { record: () => {} },
1123
+ runtime: rt8,
1124
+ getCatalogIds: () => ids,
1125
+ })
1126
+ const stop8 = gateway8.listen(0)
1127
+ await sleep(80)
1128
+ const h0 = Date.now()
1129
+ const hRes = await fetch(`http://127.0.0.1:${rt8.port}/v1/chat/completions`, {
1130
+ method: 'POST', headers: { 'content-type': 'application/json' },
1131
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }] }),
1132
+ })
1133
+ const hMs = Date.now() - h0
1134
+ const hBody = await hRes.json()
1135
+ check('inline 上游挂死:首字节护栏限时快速失败且文案带自助指引',
1136
+ hRes.status === 502 && hMs < 5000 && String(hBody.error?.message).includes('首字节超时') && hBody.error.message.includes('remote'))
1137
+ stop8()
1138
+ hangMock.server.closeAllConnections?.()
1139
+ hangMock.server.close()
1140
+
1141
+ const hangRemote = await mockTraeRemote({ hangCreate: true })
1142
+ let hErr = null
1143
+ const rh0 = Date.now()
1144
+ try { await createRemoteSession(hangRemote.base, 'tok-live', 'glm-5.3', [{ role: 'user', content: 'x' }], { timeoutMs: 300 }) } catch (e) { hErr = e }
1145
+ check('remote create 挂死:整体限时失败且文案带指引(不再无限挂起)',
1146
+ Date.now() - rh0 < 5000 && !!hErr && /超时/.test(String(hErr?.message)) && String(hErr.message).includes('inline'))
1147
+ hangRemote.server.closeAllConnections?.()
1148
+ hangRemote.server.close()
1149
+
1150
+ // 事故回退:inline_chat 3003 → 自动降级 chat_v3 出真实文本(诚实披露改派)
1151
+ const fbMock = await mockTraeChat({ fnError: 'inline_chat' })
1152
+ const rt9 = { running: false, port: null, lastError: null }
1153
+ const meterFb = []
1154
+ const gateway9 = createTraeGateway({
1155
+ settings: () => ({ ...settings, traeChatBaseURL: fbMock.base, maxConcurrentPerSession: 4 }),
1156
+ withCredentials: async (attempt) => {
1157
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1158
+ },
1159
+ readAuthDevice: () => null,
1160
+ readAuthMeta: () => ({ uid: 'u-001' }),
1161
+ meter: { record: (r) => meterFb.push(r) },
1162
+ runtime: rt9,
1163
+ getCatalogIds: () => ids,
1164
+ })
1165
+ const stop9 = gateway9.listen(0)
1166
+ await sleep(80)
1167
+ const fRes = await fetch(`http://127.0.0.1:${rt9.port}/v1/chat/completions`, {
1168
+ method: 'POST', headers: { 'content-type': 'application/json' },
1169
+ body: JSON.stringify({ model: 'glm-5.3', stream: false, messages: [{ role: 'user', content: 'hi' }] }),
1170
+ })
1171
+ const fBody = await fRes.json()
1172
+ check('inline 3003 事故回退:自动降级 chat_v3 拿到真实回答',
1173
+ fRes.status === 200 && fBody.choices?.[0]?.message?.content === '你好,世界')
1174
+ check('事故回退诚实披露:note 标注真实服务模型 + 计量记真实模型 + 两次上游调用',
1175
+ String(fBody.choices?.[0]?.message?.note ?? '').includes('served by kimi-k2.6')
1176
+ && String(fBody.choices?.[0]?.message?.note ?? '').includes('requested glm-5.3')
1177
+ && meterFb.some((m) => m.model === 'kimi-k2.6')
1178
+ && fbMock.state.requests.filter((r) => r.body?.function).length === 2)
1179
+ stop9()
1180
+ fbMock.server.closeAllConnections?.()
1181
+ fbMock.server.close()
1182
+
1183
+ // =========================================================================
1184
+ // [18b] agent 传输(function=solo_work_lite,2026-10-05 探针校准锁:
1185
+ // 证据 docs/probes/trae-agent-v3-*.jsonl)
1186
+ console.log('== agent 传输(solo_work_lite,探针形态锁)==')
1187
+ const agentMock = await mockTraeChat({ authedToken: 'tok-live', providerModel: 'glm-5.2' })
1188
+ const rtA = { running: false, port: null, lastError: null }
1189
+ const meterA = []
1190
+ const gatewayA = createTraeGateway({
1191
+ settings: () => ({ ...settings, traeChatBaseURL: agentMock.base, maxConcurrentPerSession: 4, traeChatTransport: 'agent' }),
1192
+ withCredentials: async (attempt) => {
1193
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1194
+ },
1195
+ readAuthDevice: () => null,
1196
+ readAuthMeta: () => ({ uid: 'u-001' }),
1197
+ meter: { record: (r) => meterA.push(r) },
1198
+ runtime: rtA,
1199
+ getCatalogIds: () => ids,
1200
+ })
1201
+ const stopA = gatewayA.listen(0)
1202
+ await sleep(80)
1203
+
1204
+ // [18b-1] 纯聊天:function=solo_work_lite 出站 + 钉死模型改派诚实披露
1205
+ const aChat = await fetch(`http://127.0.0.1:${rtA.port}/v1/chat/completions`, {
1206
+ method: 'POST', headers: { 'content-type': 'application/json' },
1207
+ body: JSON.stringify({ model: 'glm-5.3', stream: true, messages: [{ role: 'user', content: 'hi' }] }),
1208
+ })
1209
+ const aChatText = await aChat.text()
1210
+ const aOutReq = agentMock.state.requests.at(-1)
1211
+ check('agent:出站 function=solo_work_lite 且路径不变', aOutReq?.body?.function === 'solo_work_lite'
1212
+ && aOutReq?.url === '/api/agent/v3/llm_utils_chat')
1213
+ check('agent:模型改派诚实披露(钉死 glm-5.2,SSE 注释行)',
1214
+ aChatText.includes(': trae-reroute requested=glm-5.3 actual=glm-5.2')
1215
+ && meterA.some((m) => m.model === 'glm-5.2'))
1216
+
1217
+ // [18b-2] 带 tools:出站 tools 透传 + 历史 assistant.tool_calls 用 function_call 键
1218
+ const aToolRes = await fetch(`http://127.0.0.1:${rtA.port}/v1/chat/completions`, {
1219
+ method: 'POST', headers: { 'content-type': 'application/json' },
1220
+ body: JSON.stringify({
1221
+ model: 'glm-5.3', stream: true, tool_choice: 'auto',
1222
+ messages: [
1223
+ { role: 'user', content: '几点?' },
1224
+ { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'get_x', arguments: '{}' } }] },
1225
+ { role: 'tool', tool_call_id: 'c1', name: 'get_x', content: '14:30' },
1226
+ ],
1227
+ tools: [{ type: 'function', function: { name: 'get_x', parameters: { type: 'object' } } }],
1228
+ }),
1229
+ })
1230
+ await aToolRes.text()
1231
+ const aToolReq = agentMock.state.requests.at(-1)
1232
+ const aHistCall = aToolReq?.body?.messages?.find((m) => m.role === 'assistant')?.tool_calls?.[0]
1233
+ check('agent:历史 assistant.tool_calls 出站键=function_call(proto 层硬要求,A3 臂实测)',
1234
+ aHistCall && typeof aHistCall.function_call === 'object' && aHistCall.function_call.name === 'get_x'
1235
+ && aHistCall.function === undefined)
1236
+ check('agent:role:tool 回传块化透传(tool_call_id 保留)',
1237
+ aToolReq?.body?.messages?.find((m) => m.role === 'tool')?.tool_call_id === 'c1'
1238
+ && aToolReq.body.messages.find((m) => m.role === 'tool').content?.[0]?.text === '14:30')
1239
+ check('agent:tools 声明透传且 parameters 序列化字符串 + tool_choice 透传',
1240
+ aToolReq?.body?.tools?.[0]?.function?.name === 'get_x'
1241
+ && typeof aToolReq.body.tools[0].function.parameters === 'string'
1242
+ && aToolReq.body.tool_choice === 'auto')
1243
+
1244
+ // [18b-3] reasoning_effort 如实照发(A5 臂实测被忽略但不报错——不虚标能力,
1245
+ // 出站层不做拦截,事实表/docs 标注未生效)
1246
+ const aEff = await fetch(`http://127.0.0.1:${rtA.port}/v1/chat/completions`, {
1247
+ method: 'POST', headers: { 'content-type': 'application/json' },
1248
+ body: JSON.stringify({ model: 'glm-5.3', stream: true, reasoning_effort: 'high', messages: [{ role: 'user', content: 'hi' }] }),
1249
+ })
1250
+ await aEff.text()
1251
+ check('agent:reasoning_effort 照发(上游忽略不报错;不虚标生效)',
1252
+ agentMock.state.requests.at(-1)?.body?.reasoning_effort === 'high')
1253
+
1254
+ // [18b-4] agent 面 3003 不触发 chat_v3 回退(agent 本身就是出路)
1255
+ agentMock.state.requests.length = 0
1256
+ const agentErrMock = await mockTraeChat({ authedToken: 'tok-live', sseError: { code: 3003, message: 'all models failed', extra: null } })
1257
+ const rtAE = { running: false, port: null, lastError: null }
1258
+ const gatewayAE = createTraeGateway({
1259
+ settings: () => ({ ...settings, traeChatBaseURL: agentErrMock.base, maxConcurrentPerSession: 4, traeChatTransport: 'agent' }),
1260
+ withCredentials: async (attempt) => {
1261
+ try { return { cred: cred3, res: await attempt(cred3), err: null } } catch (err) { return { cred: cred3, res: null, err } }
1262
+ },
1263
+ readAuthDevice: () => null,
1264
+ readAuthMeta: () => ({ uid: 'u-001' }),
1265
+ meter: { record: () => {} },
1266
+ runtime: rtAE,
1267
+ getCatalogIds: () => [],
1268
+ })
1269
+ const stopAE = gatewayAE.listen(0)
1270
+ await sleep(80)
1271
+ const aeRes = await fetch(`http://127.0.0.1:${rtAE.port}/v1/chat/completions`, {
1272
+ method: 'POST', headers: { 'content-type': 'application/json' },
1273
+ body: JSON.stringify({ model: 'glm-5.3', messages: [{ role: 'user', content: 'hi' }] }),
1274
+ })
1275
+ const aeBody = await aeRes.json()
1276
+ check('agent:3003 不回退 chat_v3(终局错误透传,一次上游调用)',
1277
+ aeRes.status === 502 && aeBody.error?.code === 3003
1278
+ && agentErrMock.state.requests.length === 1)
1279
+ stopAE()
1280
+ agentErrMock.server.closeAllConnections?.()
1281
+ agentErrMock.server.close()
1282
+ stopA()
1283
+ agentMock.server.closeAllConnections?.()
1284
+ agentMock.server.close()
1285
+
1286
+ // 错误码表扩充锁(2026-10-05 探针实测:2001 function 未注册/proto 形态错、
1287
+ // 4023 chat 模型未知、9074 agent task 注册表空、4001/4011 raw 面)
1288
+ check('错误码表:探针实测新码入表(2001/4023/9074/4001/4011)',
1289
+ normalizeTraeError(200, { code: 2001, message: '' }).message.includes('function')
1290
+ && normalizeTraeError(200, { code: 4023, message: '' }).message.includes('model unknown')
1291
+ && normalizeTraeError(200, { code: 9074, message: '' }).message.includes('config item')
1292
+ && normalizeTraeError(200, { code: 4001, message: '' }).message.includes('参数绑定')
1293
+ && normalizeTraeError(200, { code: 4011, message: '' }).message.length > 0)
1294
+ check('错误提示:2001 带 function_call 键指引、4023 带模型路由指引',
1295
+ formatTraeErrorMessage(2001, 'x').includes('function_call')
1296
+ && formatTraeErrorMessage(4023, 'x').includes('同步目录'))
1297
+ check('错误提示:3003 指引含 agent 传输出路(2026-10-05 闭环)',
1298
+ formatTraeErrorMessage(3003, 'all models failed').includes('agent'))
1299
+
1300
+ // =========================================================================
1301
+ // [18] 组合根端到端(DSH_HOME 隔离 + USERPROFILE 重定向 fixture home):
1302
+ // apply() 起真 index.js —— 目录同步 → 镜像 compat/档位表 → traeModelSetPrefs
1303
+ // 全链路 → 失败路径 syncView.error。终审 Important:组合根新增路径(镜像
1304
+ // compat = #66 的哑火点)必须有离线锁,不能只靠活体对账。
1305
+ {
1306
+ const YAML = (await import('yaml')).default
1307
+ const { readJson, writeJson } = await import('../core/json-store.js')
1308
+ const rootDir = join(workDir, 'e2e-home')
1309
+ mkdirSync(join(rootDir, 'AppData', 'Roaming'), { recursive: true }) // win32 发现的空 home(启动自动同步恒落空 = 各平台同形)
1310
+ const prevDshHome = process.env.DSH_HOME
1311
+ const prevUserProfile = process.env.USERPROFILE
1312
+ process.env.DSH_HOME = rootDir
1313
+ process.env.USERPROFILE = rootDir
1314
+ try {
1315
+ // 预置已登录凭据(远有效期 → 不触发 refresh 网络调用;脱敏断言用)
1316
+ writeJson(join(rootDir, 'trae-plugin-auth.json'), {
1317
+ auth: { accessToken: 'tt-e2e-secret', refreshToken: 'rtt-e2e', expiresAt: Date.now() + 3_600_000 },
1318
+ device: { deviceId: 'd-e2e', machineId: 'm-e2e' },
1319
+ account: { uid: 'u-e2e', nickname: 'e2e' },
1320
+ })
1321
+ // codebuddy 侧启动同步指向 404 mock(快速失败,与本节断言无关)
1322
+ const deadEnd = createServer((req, res) => { res.writeHead(404, { 'content-type': 'application/json' }); res.end('{}') })
1323
+ await new Promise((r) => deadEnd.listen(0, '127.0.0.1', r))
1324
+ const deadOrigin = `http://127.0.0.1:${deadEnd.address().port}`
1325
+ // trae 网关空闲端口
1326
+ const portProbe = createServer()
1327
+ await new Promise((r) => portProbe.listen(0, '127.0.0.1', r))
1328
+ const gwPort = portProbe.address().port
1329
+ await new Promise((r) => portProbe.close(r))
1330
+ // 生产形态:启用开关与端口走文件层(镜像按 Config({...readFileLayer()}) 解析)
1331
+ writeJson(join(rootDir, 'codebuddy-plugin.json'), {
1332
+ traeEnabled: true,
1333
+ traeBridgePort: gwPort,
1334
+ traeChatBaseURL: deadOrigin,
1335
+ })
1336
+ const fixtureDb = join(rootDir, 'fixture.vscdb')
1337
+ buildCatalogFixture(fixtureDb) // glm-5.3(声明 light/high)+ kimi-k3(无声明)+ BYOK + dead
1338
+
1339
+ let routeHandler = null
1340
+ let disposeRoot = null
1341
+ const tap = await import('../index.js')
1342
+ tap.apply({
1343
+ inject: (services, cb) => {
1344
+ if (services.includes('webServer')) cb({ webServer: { register: (route) => { routeHandler = route.handler } } })
1345
+ },
1346
+ on: (event, cb) => { if (event === 'dispose') disposeRoot = cb },
1347
+ }, {
1348
+ bridgeEnabled: false,
1349
+ searchEnabled: false,
1350
+ imageGenEnabled: false,
1351
+ qoderEnabled: false,
1352
+ baseURL: deadOrigin,
1353
+ })
1354
+ check('[18] apply() 注册设置路由', typeof routeHandler === 'function')
1355
+ const ui = createServer((req, res) => routeHandler(req, res))
1356
+ await new Promise((r) => ui.listen(0, '127.0.0.1', r))
1357
+ const uiOrigin = `http://127.0.0.1:${ui.address().port}`
1358
+ const call = async (method, body) => {
1359
+ const res = await fetch(`${uiOrigin}/dsh-tap/settings`, {
1360
+ method,
1361
+ headers: { 'content-type': 'application/json', origin: uiOrigin },
1362
+ body: body === undefined ? undefined : JSON.stringify(body),
1363
+ })
1364
+ return { status: res.status, json: await res.json().catch(() => null) }
1365
+ }
1366
+ const SETTINGS_YAML = join(rootDir, 'settings.yaml')
1367
+ const FILE_LAYER = join(rootDir, 'codebuddy-plugin.json')
1368
+ const readMirrorBlock = () => {
1369
+ try { return YAML.parse(readFileSync(SETTINGS_YAML, 'utf8'))?.['llm-pi-ai']?.providers?.trae ?? null } catch { return null }
1370
+ }
1371
+
1372
+ // -- 目录同步(显式 dbPath = fixture;启动自动同步已被空 home 落空)--
1373
+ const s1 = await call('POST', { action: 'trae-model-sync', dbPath: fixtureDb })
1374
+ check('[18] trae-model-sync fixture → ok:true count=2', s1.status === 200 && s1.json?.ok === true && s1.json?.sync?.count === 2, JSON.stringify(s1.json?.sync))
1375
+ check('[18] 响应回带 trae.models.efforts(仅声明模型)', JSON.stringify(s1.json?.trae?.models?.efforts) === JSON.stringify({ 'glm-5.3': ['light', 'high'] }), JSON.stringify(s1.json?.trae?.models?.efforts))
1376
+ // 镜像写在路由 then 链里 fire-and-forget——轮询落盘
1377
+ let block = null
1378
+ for (let i = 0; i < 100; i++) {
1379
+ block = readMirrorBlock()
1380
+ if (block?.compat && Array.isArray(block.models) && block.models.length === 2) break
1381
+ await sleep(100)
1382
+ }
1383
+ check('[18] 镜像块 compat(pi-ai 出站写 reasoning_effort 的前提,#66 哑火点)',
1384
+ JSON.stringify(block?.compat) === JSON.stringify({ thinkingFormat: 'openai', supportsReasoningEffort: true }), JSON.stringify(block?.compat))
1385
+ const glmMirror = block?.models?.find((m) => m.id === 'glm-5.3')
1386
+ const kimiMirror = block?.models?.find((m) => m.id === 'kimi-k3')
1387
+ check('[18] 镜像条目档位表 = 宿主枚举键 → 声明拼写', JSON.stringify(glmMirror?.reasoningEfforts) === JSON.stringify({ low: 'light', high: 'high' }), JSON.stringify(glmMirror?.reasoningEfforts))
1388
+ check('[18] 无声明模型镜像不带 reasoningEfforts', kimiMirror && !('reasoningEfforts' in kimiMirror))
1389
+
1390
+ // -- GET 视图契约 --
1391
+ const g1 = (await call('GET')).json
1392
+ check('[18] GET trae.models.sync 成功态(count=2 无 error)', g1?.trae?.models?.sync?.count === 2 && g1.trae.models.sync.error === undefined, JSON.stringify(g1?.trae?.models?.sync))
1393
+ check('[18] GET trae.models.modelPrefs 初始空字典', JSON.stringify(g1?.trae?.models?.modelPrefs) === '{}')
1394
+ check('[18] GET 脱敏:access token 不进设置视图', !JSON.stringify(g1).includes('tt-e2e-secret'))
1395
+
1396
+ // -- traeModelSetPrefs 全链路 --
1397
+ let r = await call('POST', { patch: { traeModelSetPrefs: { id: 'glm-5.3', prefs: { effort: 'light' } } } })
1398
+ check('[18] prefs 设声明档位 → 200 ok', r.status === 200 && r.json?.ok === true, JSON.stringify(r.json).slice(0, 200))
1399
+ check('[18] 写文件层 traeModelPrefs', JSON.stringify(readJson(FILE_LAYER).traeModelPrefs) === JSON.stringify({ 'glm-5.3': { effort: 'light' } }))
1400
+ check('[18] POST 响应回带 modelPrefs 一致', r.json?.trae?.models?.modelPrefs?.['glm-5.3']?.effort === 'light')
1401
+ const yamlBefore = existsSync(SETTINGS_YAML) ? readFileSync(SETTINGS_YAML, 'utf8') : ''
1402
+ const layerBefore = readFileSync(FILE_LAYER, 'utf8')
1403
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'glm-5.3', prefs: { effort: 'light' } } } })
1404
+ check('[18] 重复提交同值幂等(settings.yaml 与文件层逐字节不变)',
1405
+ r.json?.ok === true && readFileSync(SETTINGS_YAML, 'utf8') === yamlBefore && readFileSync(FILE_LAYER, 'utf8') === layerBefore)
1406
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'glm-5.3', prefs: { effort: 'turbo' } } } })
1407
+ check('[18] 非法档位 → 400 且列出声明档位', r.status === 400 && /effort 必须是该模型目录声明的档位(light\/high)/.test(r.json?.error ?? ''), r.json?.error)
1408
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'kimi-k3', prefs: { effort: 'light' } } } })
1409
+ check('[18] 未声明档位模型 → 400 明确文案', r.status === 400 && /目录未声明思考档位/.test(r.json?.error ?? ''), r.json?.error)
1410
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'no-such', prefs: { effort: 'light' } } } })
1411
+ check('[18] 目录外 id → 400', r.status === 400 && /不在 Trae 目录里/.test(r.json?.error ?? ''))
1412
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'glm-5.3', prefs: { effort: 'light', bogus: 1 } } } })
1413
+ check('[18] 未知 prefs 键 → 400', r.status === 400 && /不支持的键/.test(r.json?.error ?? ''))
1414
+ r = await call('POST', { patch: { traeModelSetPrefs: { id: 'glm-5.3', prefs: {} } } })
1415
+ check('[18] 空 prefs = 删记录回默认', r.json?.ok === true && JSON.stringify(readJson(FILE_LAYER).traeModelPrefs ?? {}) === '{}')
1416
+
1417
+ // -- 失败路径:syncView.error 经真路由上浮(G2 组合根侧)--
1418
+ const f1 = await call('POST', { action: 'trae-model-sync', dbPath: join(rootDir, 'missing.vscdb') })
1419
+ check('[18] 同步失败 ok:false 且视图带 error+旧计数(kept)',
1420
+ f1.json?.ok === false && typeof f1.json?.trae?.models?.sync?.error === 'string'
1421
+ && f1.json.trae.models.sync.count === 2, JSON.stringify(f1.json?.trae?.models?.sync))
1422
+ const g2 = (await call('GET')).json
1423
+ check('[18] GET 视图同样带 sync.error(刷新后仍在)', typeof g2?.trae?.models?.sync?.error === 'string' && g2.trae.models.sync.count === 2)
1424
+ const s2 = await call('POST', { action: 'trae-model-sync', dbPath: fixtureDb })
1425
+ check('[18] 再次成功同步后 error 清空', s2.json?.ok === true && s2.json?.trae?.models?.sync?.error === undefined)
1426
+
1427
+ ui.close()
1428
+ deadEnd.close()
1429
+ if (typeof disposeRoot === 'function') disposeRoot()
1430
+ } finally {
1431
+ if (prevDshHome === undefined) delete process.env.DSH_HOME
1432
+ else process.env.DSH_HOME = prevDshHome
1433
+ if (prevUserProfile === undefined) delete process.env.USERPROFILE
1434
+ else process.env.USERPROFILE = prevUserProfile
1435
+ }
1436
+ }
1437
+
1438
+ } finally {
1439
+ rmSync(workDir, { recursive: true, force: true })
1440
+ }
1441
+
1442
+ function readFileOr(path, fallback) {
1443
+ try { return readFileSync(path, 'utf8') } catch { return fallback }
1444
+ }
1445
+
1446
+ console.log('')
1447
+ if (failures) {
1448
+ console.log(`verify:trae-provider FAILED — ${failures}/${checks} 项断言未通过`)
1449
+ process.exit(1)
1450
+ }
1451
+ console.log(`verify:trae-provider OK — ${checks} 项断言全部通过`)