zen-gitsync 2.18.21 → 2.18.23
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 +2 -1
- package/src/cli/ai/agent.js +9 -2
- package/src/cli/ai/extensions.js +1 -1
- package/src/cli/ai/runtime.test.js +57 -1
- package/src/cli/ai/termui.js +11 -2
- package/src/cli/ai/tools.js +266 -5
- package/src/cli/ai/tools.test.js +81 -0
- package/src/cli/ai/transport.js +1 -1
- package/src/cli/ai/turn.js +59 -3
- package/src/config.js +60 -4
- package/src/mcp/install.js +548 -529
- package/src/ui/public/assets/{AgentView-zjXqXR6_.css → AgentView-Cb5CmQTA.css} +1 -1
- package/src/ui/public/assets/AgentView-DQOp7_6G.js +1 -0
- package/src/ui/public/assets/{AppVersionBadge-CAjnmlwK.js → AppVersionBadge-BBVS3HeA.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-Bx7ZWY7i.js → BranchSelector-BwmMgaSS.js} +1 -1
- package/src/ui/public/assets/{ChatSilenceBadge-BGRTUX_g.js → ChatSilenceBadge-BnaLbCwp.js} +6 -6
- package/src/ui/public/assets/{CommitForm-BArPY3IJ.js → CommitForm-BtRxJodG.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-C5Q6F8ql.js → CommonDialog-Qg_owAyQ.js} +1 -1
- package/src/ui/public/assets/{EditorView-x7dr5MPK.js → EditorView-DL3Lj4ho.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BHz9hKxX.js → FlowExecutionViewer-BsxAtCqd.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-F7YGzQV-.js → FlowOrchestrationWorkspace-lcqnsQE-.js} +1 -1
- package/src/ui/public/assets/{LogList-DBc9xebA.js → LogList-CW3-fShz.js} +1 -1
- package/src/ui/public/assets/{MindmapView-Dz-gyQCR.js → MindmapView-ymN6XI7n.js} +1 -1
- package/src/ui/public/assets/{MonitorView-CvPb2Dji.js → MonitorView-C4fFNPb2.js} +1 -1
- package/src/ui/public/assets/{ProjectManagerView-BiV62lNF.js → ProjectManagerView-ph_imDtI.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton--Q-S0DWw.js → ProjectStartupButton-C2dqnR-6.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-BGib3iis.js → RemoteManagerDialog-DFkRW2O4.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CzIWKirJ.js → RemoteRepoCard-BBnMjldy.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CuAuq9vv.js → SourceMapView-CQcK1acs.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-9MndOhWP.js → SvgIcon-XBkgNKCj.js} +1 -1
- package/src/ui/public/assets/{TaskExecutorPicker-BNLwo3aN.js → TaskExecutorPicker-B6p57RJY.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-B1wOq19j.js → UserInputNode-CkXLP8y4.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-MUfM9DER.css +1 -0
- package/src/ui/public/assets/WorkbenchView-cucUmAwl.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-B-yhZnp1.js → _plugin-vue_export-helper-B3dZvHQf.js} +3 -3
- package/src/ui/public/assets/configStore-HDlssep1.js +1 -0
- package/src/ui/public/assets/{dagre-BD_lsknB.js → dagre-JTV5zPzI.js} +3 -3
- package/src/ui/public/assets/{element-plus-axsKECfO.js → element-plus-CN9J6t-d.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-B4ponDC1.js → flow-mindmap-CyZrdnd4.js} +1 -1
- package/src/ui/public/assets/index-CAWXBaPa.js +59 -0
- package/src/ui/public/assets/index-CigPOqV6.css +1 -0
- package/src/ui/public/assets/{monaco-Bu9hROZs.js → monaco-irXl5Eu9.js} +1 -1
- package/src/ui/public/assets/{office-docx-Bb8autKU.js → office-docx-C48cuZOS.js} +1 -1
- package/src/ui/public/assets/{office-excel-G4ohUqnx.js → office-excel-sA-LZnfv.js} +1 -1
- package/src/ui/public/assets/{office-pptx-CLAxHUkH.js → office-pptx-BLPWYpxZ.js} +1 -1
- package/src/ui/public/assets/{relativeTime-DnD6Qv_n.js → relativeTime-Ce-gekf_.js} +1 -1
- package/src/ui/public/assets/{taskExecutionExport-D6VIBn4K.js → taskExecutionExport-Hfww5P9_.js} +3 -3
- package/src/ui/public/assets/toolsStore-oZEAoHnG.js +1 -0
- package/src/ui/public/assets/{useWorkbenchProjects-VSSECz1J.js → useWorkbenchProjects-BifctigH.js} +1 -1
- package/src/ui/public/assets/{vendor-DMJR8zxK.js → vendor-Bs5KA16r.js} +220 -220
- package/src/ui/public/assets/{vendor-B2ISauoC.css → vendor-CAhoE06w.css} +1 -1
- package/src/ui/public/assets/{vue-flow-BgHev8Va.js → vue-flow-NLvet82_.js} +1 -1
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/routes/config.js +13 -0
- package/src/ui/server/routes/fileOpen.js +1851 -1736
- package/src/ui/server/routes/fileOpen.test.js +39 -1
- package/src/ui/server/routes/workbench/agentChat.js +48 -8
- package/src/ui/server/routes/workbench/agentChatShared.test.js +584 -496
- package/src/ui/server/routes/workbench/agentEngines.js +7 -4
- package/src/ui/server/routes/workbench/executorModels.js +25 -4
- package/src/ui/server/routes/workbench/executorModels.test.js +557 -530
- package/src/ui/server/routes/workbench/jobActivity.js +2 -2
- package/src/ui/server/routes/workbench/taskRunner.codearts.test.js +308 -0
- package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +317 -317
- package/src/ui/server/routes/workbench/taskRunner.js +280 -7
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +130 -130
- package/src/ui/server/routes/workbench/taskRunner.pi.test.js +298 -298
- package/src/ui/public/assets/AgentView-BEtr0bKC.js +0 -1
- package/src/ui/public/assets/WorkbenchView-DGa_j7-z.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DnHUwx2h.js +0 -6
- package/src/ui/public/assets/configStore-D9y28eJA.js +0 -1
- package/src/ui/public/assets/index-ArXbQKZJ.js +0 -59
- package/src/ui/public/assets/index-iTgY1Kwp.css +0 -1
- package/src/ui/public/assets/toolsStore-BWcMcQnZ.js +0 -1
|
@@ -1,530 +1,557 @@
|
|
|
1
|
-
// 执行器模型探测的单测。
|
|
2
|
-
//
|
|
3
|
-
// 为什么这些用例值得写:这个模块的全部价值是"读五种格式的配置文件",
|
|
4
|
-
// 而三种格式的解析都是**手写的**(不引 TOML / JSONC 依赖,理由见模块注释)——
|
|
5
|
-
// 手写解析最容易在两个地方悄悄错:注释/空行的截断、以及多文件兜底被丢掉。
|
|
6
|
-
// 这两处都已经真出过 bug(见下面标 [回归] 的用例),所以它们必须钉住。
|
|
7
|
-
//
|
|
8
|
-
// 所有用例都在**临时沙箱 home** 里跑,绝不读真实 ~/ —— 否则用例会随开发机
|
|
9
|
-
// 装没装 claude、配没配模型而时红时绿。
|
|
10
|
-
import { test } from 'node:test'
|
|
11
|
-
import assert from 'node:assert/strict'
|
|
12
|
-
import fs from 'fs/promises'
|
|
13
|
-
import os from 'os'
|
|
14
|
-
import path from 'path'
|
|
15
|
-
import {
|
|
16
|
-
readTomlTopLevel,
|
|
17
|
-
stripJsonComments,
|
|
18
|
-
detectExecutorModels,
|
|
19
|
-
formatExecutorModel,
|
|
20
|
-
} from './executorModels.js'
|
|
21
|
-
|
|
22
|
-
const sandboxes = []
|
|
23
|
-
|
|
24
|
-
/** 造一个只属于本用例的 home 目录,按 {相对路径: 内容} 落文件 */
|
|
25
|
-
async function makeHome(files = {}) {
|
|
26
|
-
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-executor-models-'))
|
|
27
|
-
sandboxes.push(home)
|
|
28
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
29
|
-
const full = path.join(home, rel)
|
|
30
|
-
await fs.mkdir(path.dirname(full), { recursive: true })
|
|
31
|
-
await fs.writeFile(full, content, 'utf8')
|
|
32
|
-
}
|
|
33
|
-
return home
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test.after(async () => {
|
|
37
|
-
for (const home of sandboxes) await fs.rm(home, { recursive: true, force: true })
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
// ── readTomlTopLevel ─────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
test('readTomlTopLevel: 注释行与空行不截断顶层读取 [回归]', () => {
|
|
43
|
-
// 老实现把「空行 / 注释行 / 段头」三类一起 break,于是文件第一行是注释时
|
|
44
|
-
// 顶层 model 整个读空 —— 表现为界面上 codex 永远显示"未配置"。
|
|
45
|
-
const toml = [
|
|
46
|
-
'# codex 配置说明',
|
|
47
|
-
'# 第二行注释',
|
|
48
|
-
'model = "gpt-6-astra"',
|
|
49
|
-
'',
|
|
50
|
-
'model_provider = "custom"',
|
|
51
|
-
'',
|
|
52
|
-
'[model_providers.custom]',
|
|
53
|
-
'name = "kakouai"',
|
|
54
|
-
].join('\n')
|
|
55
|
-
assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
|
|
56
|
-
model: 'gpt-6-astra',
|
|
57
|
-
model_provider: 'custom',
|
|
58
|
-
})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('readTomlTopLevel: 段内的同名键不会被当成顶层值', () => {
|
|
62
|
-
const toml = 'model = "top"\n\n[projects."c:/x"]\nmodel = "inner"\n'
|
|
63
|
-
assert.equal(readTomlTopLevel(toml, ['model']).model, 'top')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
test('readTomlTopLevel: 只取白名单里的键(其余含密钥的键一律不取)', () => {
|
|
67
|
-
const toml = 'model = "m"\nexperimental_bearer_token = "tok"\napi_key = "k"\n'
|
|
68
|
-
assert.deepEqual(readTomlTopLevel(toml, ['model']), { model: 'm' })
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('readTomlTopLevel: 行尾注释不污染值;单引号值也认', () => {
|
|
72
|
-
const toml = "model = 'm-1' # 备注\nmodel_provider = \"custom\"#紧贴的注释\n"
|
|
73
|
-
assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
|
|
74
|
-
model: 'm-1',
|
|
75
|
-
model_provider: 'custom',
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('readTomlTopLevel: 段头里带方括号的路径不被误判成值', () => {
|
|
80
|
-
const toml = 'model = "m"\n\n[projects.\'c:\\weird[path]\']\nmodel = "inner"\n'
|
|
81
|
-
assert.equal(readTomlTopLevel(toml, ['model']).model, 'm')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// ── stripJsonComments ────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
test('stripJsonComments: 字符串里的 // 不被当注释(base_url 场景)', () => {
|
|
87
|
-
const jsonc = '{ "url": "https://api.example.com/v1", // 注释\n "n": 1 }'
|
|
88
|
-
assert.equal(JSON.parse(stripJsonComments(jsonc)).url, 'https://api.example.com/v1')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('stripJsonComments: 剥块注释与尾逗号后仍能 JSON.parse', () => {
|
|
92
|
-
const jsonc = '{\n /* 块\n 注释 */\n "model": "a/b",\n}'
|
|
93
|
-
assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { model: 'a/b' })
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
test('stripJsonComments: 转义引号不影响"在不在字符串里"的判断', () => {
|
|
97
|
-
const jsonc = '{ "s": "a\\"//b", "n": 1 } // 尾注释'
|
|
98
|
-
assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { s: 'a"//b', n: 1 })
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
// ── detectExecutorModels(沙箱 home)─────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
const CLAUDE_JSON = JSON.stringify({
|
|
104
|
-
env: {
|
|
105
|
-
ANTHROPIC_BASE_URL: 'http://127.0.0.1:15721',
|
|
106
|
-
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-5[1M]',
|
|
107
|
-
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'deepseek-v4.1-flash',
|
|
108
|
-
ANTHROPIC_API_KEY: 'sk-should-never-leak',
|
|
109
|
-
},
|
|
110
|
-
})
|
|
111
|
-
const CODEX_TOML = [
|
|
112
|
-
'# 顶层注释',
|
|
113
|
-
'model = "gpt-6-astra"',
|
|
114
|
-
'model_provider = "custom"',
|
|
115
|
-
'',
|
|
116
|
-
'[model_providers.custom]',
|
|
117
|
-
'name = "kakouai"',
|
|
118
|
-
'experimental_bearer_token = "tok-should-never-leak"',
|
|
119
|
-
'',
|
|
120
|
-
'[projects."c:/x"]',
|
|
121
|
-
'model = "inner-model"',
|
|
122
|
-
].join('\n')
|
|
123
|
-
const OPENCODE_JSONC = '{\n // 注释\n "model": "anthropic/claude-x",\n}'
|
|
124
|
-
const CODEBUDDY_JSON = JSON.stringify({ model: 'deepseek-v4-1-flash-260910' })
|
|
125
|
-
const PI_JSON = JSON.stringify({ defaultModel: 'deepseek/deepseek-v4-flash' })
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
'.
|
|
132
|
-
'.
|
|
133
|
-
'.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
'
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
assert.
|
|
417
|
-
})
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
'.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
'
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
assert.deepEqual(
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
)
|
|
512
|
-
})
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
assert.
|
|
529
|
-
|
|
530
|
-
}
|
|
1
|
+
// 执行器模型探测的单测。
|
|
2
|
+
//
|
|
3
|
+
// 为什么这些用例值得写:这个模块的全部价值是"读五种格式的配置文件",
|
|
4
|
+
// 而三种格式的解析都是**手写的**(不引 TOML / JSONC 依赖,理由见模块注释)——
|
|
5
|
+
// 手写解析最容易在两个地方悄悄错:注释/空行的截断、以及多文件兜底被丢掉。
|
|
6
|
+
// 这两处都已经真出过 bug(见下面标 [回归] 的用例),所以它们必须钉住。
|
|
7
|
+
//
|
|
8
|
+
// 所有用例都在**临时沙箱 home** 里跑,绝不读真实 ~/ —— 否则用例会随开发机
|
|
9
|
+
// 装没装 claude、配没配模型而时红时绿。
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import fs from 'fs/promises'
|
|
13
|
+
import os from 'os'
|
|
14
|
+
import path from 'path'
|
|
15
|
+
import {
|
|
16
|
+
readTomlTopLevel,
|
|
17
|
+
stripJsonComments,
|
|
18
|
+
detectExecutorModels,
|
|
19
|
+
formatExecutorModel,
|
|
20
|
+
} from './executorModels.js'
|
|
21
|
+
|
|
22
|
+
const sandboxes = []
|
|
23
|
+
|
|
24
|
+
/** 造一个只属于本用例的 home 目录,按 {相对路径: 内容} 落文件 */
|
|
25
|
+
async function makeHome(files = {}) {
|
|
26
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-executor-models-'))
|
|
27
|
+
sandboxes.push(home)
|
|
28
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
29
|
+
const full = path.join(home, rel)
|
|
30
|
+
await fs.mkdir(path.dirname(full), { recursive: true })
|
|
31
|
+
await fs.writeFile(full, content, 'utf8')
|
|
32
|
+
}
|
|
33
|
+
return home
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test.after(async () => {
|
|
37
|
+
for (const home of sandboxes) await fs.rm(home, { recursive: true, force: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// ── readTomlTopLevel ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
test('readTomlTopLevel: 注释行与空行不截断顶层读取 [回归]', () => {
|
|
43
|
+
// 老实现把「空行 / 注释行 / 段头」三类一起 break,于是文件第一行是注释时
|
|
44
|
+
// 顶层 model 整个读空 —— 表现为界面上 codex 永远显示"未配置"。
|
|
45
|
+
const toml = [
|
|
46
|
+
'# codex 配置说明',
|
|
47
|
+
'# 第二行注释',
|
|
48
|
+
'model = "gpt-6-astra"',
|
|
49
|
+
'',
|
|
50
|
+
'model_provider = "custom"',
|
|
51
|
+
'',
|
|
52
|
+
'[model_providers.custom]',
|
|
53
|
+
'name = "kakouai"',
|
|
54
|
+
].join('\n')
|
|
55
|
+
assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
|
|
56
|
+
model: 'gpt-6-astra',
|
|
57
|
+
model_provider: 'custom',
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('readTomlTopLevel: 段内的同名键不会被当成顶层值', () => {
|
|
62
|
+
const toml = 'model = "top"\n\n[projects."c:/x"]\nmodel = "inner"\n'
|
|
63
|
+
assert.equal(readTomlTopLevel(toml, ['model']).model, 'top')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('readTomlTopLevel: 只取白名单里的键(其余含密钥的键一律不取)', () => {
|
|
67
|
+
const toml = 'model = "m"\nexperimental_bearer_token = "tok"\napi_key = "k"\n'
|
|
68
|
+
assert.deepEqual(readTomlTopLevel(toml, ['model']), { model: 'm' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('readTomlTopLevel: 行尾注释不污染值;单引号值也认', () => {
|
|
72
|
+
const toml = "model = 'm-1' # 备注\nmodel_provider = \"custom\"#紧贴的注释\n"
|
|
73
|
+
assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
|
|
74
|
+
model: 'm-1',
|
|
75
|
+
model_provider: 'custom',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('readTomlTopLevel: 段头里带方括号的路径不被误判成值', () => {
|
|
80
|
+
const toml = 'model = "m"\n\n[projects.\'c:\\weird[path]\']\nmodel = "inner"\n'
|
|
81
|
+
assert.equal(readTomlTopLevel(toml, ['model']).model, 'm')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── stripJsonComments ────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
test('stripJsonComments: 字符串里的 // 不被当注释(base_url 场景)', () => {
|
|
87
|
+
const jsonc = '{ "url": "https://api.example.com/v1", // 注释\n "n": 1 }'
|
|
88
|
+
assert.equal(JSON.parse(stripJsonComments(jsonc)).url, 'https://api.example.com/v1')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('stripJsonComments: 剥块注释与尾逗号后仍能 JSON.parse', () => {
|
|
92
|
+
const jsonc = '{\n /* 块\n 注释 */\n "model": "a/b",\n}'
|
|
93
|
+
assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { model: 'a/b' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('stripJsonComments: 转义引号不影响"在不在字符串里"的判断', () => {
|
|
97
|
+
const jsonc = '{ "s": "a\\"//b", "n": 1 } // 尾注释'
|
|
98
|
+
assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { s: 'a"//b', n: 1 })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── detectExecutorModels(沙箱 home)─────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const CLAUDE_JSON = JSON.stringify({
|
|
104
|
+
env: {
|
|
105
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:15721',
|
|
106
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-5[1M]',
|
|
107
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'deepseek-v4.1-flash',
|
|
108
|
+
ANTHROPIC_API_KEY: 'sk-should-never-leak',
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
const CODEX_TOML = [
|
|
112
|
+
'# 顶层注释',
|
|
113
|
+
'model = "gpt-6-astra"',
|
|
114
|
+
'model_provider = "custom"',
|
|
115
|
+
'',
|
|
116
|
+
'[model_providers.custom]',
|
|
117
|
+
'name = "kakouai"',
|
|
118
|
+
'experimental_bearer_token = "tok-should-never-leak"',
|
|
119
|
+
'',
|
|
120
|
+
'[projects."c:/x"]',
|
|
121
|
+
'model = "inner-model"',
|
|
122
|
+
].join('\n')
|
|
123
|
+
const OPENCODE_JSONC = '{\n // 注释\n "model": "anthropic/claude-x",\n}'
|
|
124
|
+
const CODEBUDDY_JSON = JSON.stringify({ model: 'deepseek-v4-1-flash-260910' })
|
|
125
|
+
const PI_JSON = JSON.stringify({ defaultModel: 'deepseek/deepseek-v4-flash' })
|
|
126
|
+
// 码道的配置文件由它自己 shim 的 OPENCODE_CONFIG_FILE 指定:codearts_cli.json(json 优先)
|
|
127
|
+
const CODEARTS_JSON = JSON.stringify({ model: 'huaweicloud-maas/GLM-5.3' })
|
|
128
|
+
|
|
129
|
+
test('detectExecutorModels: 六家配置齐全时各自读出模型', async () => {
|
|
130
|
+
const home = await makeHome({
|
|
131
|
+
'.claude/settings.json': CLAUDE_JSON,
|
|
132
|
+
'.codex/config.toml': CODEX_TOML,
|
|
133
|
+
'.config/opencode/opencode.jsonc': OPENCODE_JSONC,
|
|
134
|
+
'.codebuddy/settings.json': CODEBUDDY_JSON,
|
|
135
|
+
'.pi/agent/settings.json': PI_JSON,
|
|
136
|
+
'.codeartsdoer/codearts_cli.json': CODEARTS_JSON,
|
|
137
|
+
})
|
|
138
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(out.claude, {
|
|
141
|
+
model: 'claude-sonnet-5[1M]',
|
|
142
|
+
display: 'deepseek-v4.1-flash',
|
|
143
|
+
provider: 'http://127.0.0.1:15721',
|
|
144
|
+
})
|
|
145
|
+
// 顶层注释不再截断 → model_provider 读得到 → 服务商名也能查出来
|
|
146
|
+
assert.deepEqual(out.codex, { model: 'gpt-6-astra', display: null, provider: 'kakouai' })
|
|
147
|
+
// 只有 .jsonc(没有 .json)时也要读到:早先那个 promise 被解构丢掉,这里是死代码
|
|
148
|
+
assert.deepEqual(out.opencode, {
|
|
149
|
+
model: 'anthropic/claude-x', display: null, provider: null, source: 'config',
|
|
150
|
+
})
|
|
151
|
+
// codebuddy:一个文件一处键,没有"第二来源"(TUI 选择会写回 settings.json)
|
|
152
|
+
assert.deepEqual(out.codebuddy, {
|
|
153
|
+
model: 'deepseek-v4-1-flash-260910', display: null, provider: null, source: 'config',
|
|
154
|
+
})
|
|
155
|
+
// pi:键名是 defaultModel(官方 settings 文档的 "Startup model ID"),不是 model
|
|
156
|
+
assert.deepEqual(out.pi, {
|
|
157
|
+
model: 'deepseek/deepseek-v4-flash', display: null, provider: null, source: 'config',
|
|
158
|
+
})
|
|
159
|
+
// codearts:它是 opencode 的 fork,配置 schema 与键名都同源(model 可以是
|
|
160
|
+
// "provider/model" 字符串);路径换成它自己的 ~/.codeartsdoer/codearts_cli.json
|
|
161
|
+
assert.deepEqual(out.codearts, {
|
|
162
|
+
model: 'huaweicloud-maas/GLM-5.3', display: null, provider: null, source: 'config',
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('detectExecutorModels: codearts 只有 .jsonc 时也读(OPENCODE_CONFIG_FILE 两个名都认)', async () => {
|
|
167
|
+
const home = await makeHome({
|
|
168
|
+
// JSONC:带注释(码道认 json 优先、jsonc 兜底,与 opencode 同一套读取规则)
|
|
169
|
+
'.codeartsdoer/codearts_cli.jsonc': '{\n // 注释\n "model": "huaweicloud-maas/GLM-5.2",\n}',
|
|
170
|
+
})
|
|
171
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
172
|
+
assert.equal(out.codearts?.model, 'huaweicloud-maas/GLM-5.2')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('detectExecutorModels: codearts 没配 model 时为 null(CLI 自己选默认,不猜)', async () => {
|
|
176
|
+
// 实测本机装完就是这样:codearts_cli.json 只有 $schema/logLevel/lsp
|
|
177
|
+
const home = await makeHome({
|
|
178
|
+
'.codeartsdoer/codearts_cli.json': JSON.stringify({
|
|
179
|
+
$schema: 'https://opencode.ai/config.json', logLevel: 'INFO', lsp: false,
|
|
180
|
+
}),
|
|
181
|
+
})
|
|
182
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).codearts, null)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('detectExecutorModels: codebuddy 没配 model 时为 null(CLI 内置默认不冒名)', async () => {
|
|
186
|
+
const home = await makeHome({ '.codebuddy/settings.json': '{"theme":"dark"}' })
|
|
187
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).codebuddy, null)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('detectExecutorModels: pi 没配 defaultModel 且没有会话记录时为 null(不猜)', async () => {
|
|
191
|
+
const home = await makeHome({ '.pi/agent/settings.json': '{"theme":"dark"}' })
|
|
192
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi, null)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('detectExecutorModels: pi 的 defaultModel 不是字符串时为 null', async () => {
|
|
196
|
+
const home = await makeHome({ '.pi/agent/settings.json': '{"defaultModel":42}' })
|
|
197
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi, null)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('detectExecutorModels: 只有 .jsonc 时 opencode 仍读得到 [回归]', async () => {
|
|
201
|
+
const home = await makeHome({ '.config/opencode/opencode.jsonc': OPENCODE_JSONC })
|
|
202
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
203
|
+
assert.equal(out.opencode?.model, 'anthropic/claude-x')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('detectExecutorModels: .json 与 .jsonc 同时存在时 .json 优先', async () => {
|
|
207
|
+
const home = await makeHome({
|
|
208
|
+
'.config/opencode/opencode.json': '{"model":"from-json"}',
|
|
209
|
+
'.config/opencode/opencode.jsonc': '{"model":"from-jsonc"}',
|
|
210
|
+
})
|
|
211
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
212
|
+
assert.equal(out.opencode?.model, 'from-json')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('detectExecutorModels: opencode 的 model 是对象时带上 variant', async () => {
|
|
216
|
+
const home = await makeHome({
|
|
217
|
+
'.config/opencode/opencode.json': '{"model":{"model":"openai/gpt-y","variant":"high"}}',
|
|
218
|
+
})
|
|
219
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
220
|
+
assert.equal(out.opencode?.model, 'openai/gpt-y (high)')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// ── opencode 的第二处来源:TUI 状态 ──────────────────────────────────────────
|
|
224
|
+
//
|
|
225
|
+
// opencode 的 TUI 允许直接选模型,而它把选择**存在自己的 state 里、不回写配置文件**。
|
|
226
|
+
// 只读配置文件的话,对一个明明在跑的模型显示"未在配置中指定" —— 用户会去翻配置,
|
|
227
|
+
// 配置里确实没有,白折腾一场。这组用例钉住"配置文件优先、没有则退回 state"。
|
|
228
|
+
|
|
229
|
+
const OPENCODE_STATE = JSON.stringify({
|
|
230
|
+
recent: [
|
|
231
|
+
{ providerID: 'opencode-go', modelID: 'space-bunny-free' },
|
|
232
|
+
{ providerID: 'opencode-go', modelID: 'deepseek-v4.1-flash' },
|
|
233
|
+
],
|
|
234
|
+
favorite: [],
|
|
235
|
+
variant: { 'opencode-go/space-bunny-free': 'max', 'opencode-go/deepseek-v4.1-flash': 'max' },
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('detectExecutorModels: 配置里没写 model 时,退回 TUI 状态里的「最近使用」', async () => {
|
|
239
|
+
const home = await makeHome({
|
|
240
|
+
// 本机真实情形:配置文件里只有一个 $schema
|
|
241
|
+
'.config/opencode/opencode.jsonc': '{\n "$schema": "https://opencode.ai/config.json"\n}',
|
|
242
|
+
'.local/state/opencode/model.json': OPENCODE_STATE,
|
|
243
|
+
})
|
|
244
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
245
|
+
assert.deepEqual(out.opencode, {
|
|
246
|
+
model: 'opencode-go/space-bunny-free (max)', // recent[0] + variant 表里的 max
|
|
247
|
+
display: null,
|
|
248
|
+
provider: null, // 模型名本身已是 provider/model,不再重复报一次
|
|
249
|
+
source: 'state',
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('detectExecutorModels: 配置文件里写了 model 就以它为准(state 只是兜底)', async () => {
|
|
254
|
+
const home = await makeHome({
|
|
255
|
+
'.config/opencode/opencode.json': '{"model":"anthropic/claude-x"}',
|
|
256
|
+
'.local/state/opencode/model.json': OPENCODE_STATE,
|
|
257
|
+
})
|
|
258
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
259
|
+
assert.deepEqual(out.opencode, {
|
|
260
|
+
model: 'anthropic/claude-x', display: null, provider: null, source: 'config',
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('detectExecutorModels: recent 项自带 variant 时优先于顶层 variant 表', async () => {
|
|
265
|
+
const home = await makeHome({
|
|
266
|
+
'.config/opencode/opencode.jsonc': '',
|
|
267
|
+
'.local/state/opencode/model.json': JSON.stringify({
|
|
268
|
+
recent: [{ providerID: 'p', modelID: 'm', variant: 'own' }],
|
|
269
|
+
variant: { 'p/m': 'mapped' },
|
|
270
|
+
}),
|
|
271
|
+
})
|
|
272
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
273
|
+
assert.equal(out.opencode?.model, 'p/m (own)')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('detectExecutorModels: state 里没有 recent / 缺字段时仍是 null(不猜)', async () => {
|
|
277
|
+
const empty = await makeHome({ '.local/state/opencode/model.json': '{"recent":[],"favorite":[]}' })
|
|
278
|
+
assert.equal((await detectExecutorModels({ homeDir: empty })).opencode, null)
|
|
279
|
+
|
|
280
|
+
const partial = await makeHome({
|
|
281
|
+
'.local/state/opencode/model.json': '{"recent":[{"providerID":"p"}]}',
|
|
282
|
+
})
|
|
283
|
+
assert.equal((await detectExecutorModels({ homeDir: partial })).opencode, null)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('detectExecutorModels: 两处都没有 model 才是真的"未在配置中指定"', async () => {
|
|
287
|
+
const home = await makeHome({ '.config/opencode/opencode.json': '{"model":""}' })
|
|
288
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).opencode, null)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// ── pi 的第二处来源:会话记录 ────────────────────────────────────────────────
|
|
292
|
+
//
|
|
293
|
+
// pi 的 ~/.pi/agent/settings.json 只在用户显式「设为默认」时才写 defaultModel
|
|
294
|
+
// (setModel 的 persist 分支),日常直接开 pi 的人那份文件是空的(本机就是:
|
|
295
|
+
// 里面只有 lastChangelogVersion)。此时 pi 走自己的回落链(CLI 参数 →
|
|
296
|
+
// --models 范围 → 会话恢复 → 配置默认 → **第一个有凭据的模型**),而"到底落了
|
|
297
|
+
// 哪个"只写在会话 jsonl 的 model_change 记录里。
|
|
298
|
+
// 只读配置文件的话,界面会对一个**正在跑的模型**说「未在配置中指定」——
|
|
299
|
+
// 用户会去翻配置,配置里确实没有,白折腾一场(2026-10-13 用户报的就是这个)。
|
|
300
|
+
// 这组用例钉住"配置文件优先、没有则退回会话记录",以及**取最新会话的最后一条**。
|
|
301
|
+
|
|
302
|
+
/** 一条 model_change 记录(形状来自真实 CLI 输出,见 taskRunner.pi.test.js) */
|
|
303
|
+
const piModelChange = (provider, modelId) => ({
|
|
304
|
+
type: 'model_change', id: 'x', parentId: null,
|
|
305
|
+
timestamp: '2026-10-13T09:36:32.779Z', provider, modelId,
|
|
306
|
+
})
|
|
307
|
+
/** 把若干行拼成 jsonl;字符串行原样放进去(用来造坏行) */
|
|
308
|
+
const jsonl = (lines) => lines.map(l => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n'
|
|
309
|
+
/** 会话文件路径:目录按 cwd 编码分,文件名是 <UTC ISO 时间戳>_<uuid>.jsonl */
|
|
310
|
+
const piSession = (stamp, id, lines) => ({
|
|
311
|
+
[`.pi/agent/sessions/--D--ws-demo--/${stamp}_${id}.jsonl`]: jsonl(lines),
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('detectExecutorModels: pi 配置里没写 defaultModel 时,退回会话记录里的模型', async () => {
|
|
315
|
+
const home = await makeHome({
|
|
316
|
+
// 本机真实情形:settings.json 里只有 lastChangelogVersion
|
|
317
|
+
'.pi/agent/settings.json': '{"lastChangelogVersion":"1.2.0"}',
|
|
318
|
+
...piSession('2026-10-13T09-36-32-719Z', 'aaa', [
|
|
319
|
+
{ type: 'session', id: 'aaa', cwd: 'd:\\ws\\demo' },
|
|
320
|
+
piModelChange('cc-switch-open-code-go', 'deepseek-v4.1-flash'),
|
|
321
|
+
{ type: 'message', message: { role: 'user', content: '你好' } },
|
|
322
|
+
]),
|
|
323
|
+
})
|
|
324
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
325
|
+
assert.deepEqual(out.pi, {
|
|
326
|
+
model: 'deepseek-v4.1-flash',
|
|
327
|
+
display: null,
|
|
328
|
+
provider: 'cc-switch-open-code-go',
|
|
329
|
+
source: 'state', // 与 opencode 同源:CLI 自己记的"最近使用",不是用户配的默认值
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('detectExecutorModels: pi 配置里写了 defaultModel 就以它为准(会话只是兜底)', async () => {
|
|
334
|
+
const home = await makeHome({
|
|
335
|
+
'.pi/agent/settings.json': JSON.stringify({
|
|
336
|
+
defaultProvider: 'anthropic',
|
|
337
|
+
defaultModel: 'claude-x',
|
|
338
|
+
}),
|
|
339
|
+
...piSession('2026-10-13T09-36-32-719Z', 'aaa', [
|
|
340
|
+
piModelChange('cc-switch-open-code-go', 'deepseek-v4.1-flash'),
|
|
341
|
+
]),
|
|
342
|
+
})
|
|
343
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
344
|
+
assert.deepEqual(out.pi, {
|
|
345
|
+
model: 'claude-x',
|
|
346
|
+
display: null,
|
|
347
|
+
provider: 'anthropic', // defaultProvider 是"这个模型打给谁",与 claude 的 base_url 同一个位置
|
|
348
|
+
source: 'config',
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('detectExecutorModels: pi 取**最新**那个会话文件,旧会话不算', async () => {
|
|
353
|
+
const home = await makeHome({
|
|
354
|
+
// 文件名前缀是 UTC ISO 时间戳 → 字典序即时间序(探测器就是按字符串比的)
|
|
355
|
+
...piSession('2026-10-13T09-33-31-743Z', 'old', [piModelChange('p-old', 'm-old')]),
|
|
356
|
+
...piSession('2026-10-13T09-36-32-719Z', 'new', [piModelChange('p-new', 'm-new')]),
|
|
357
|
+
})
|
|
358
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi?.model, 'm-new')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('detectExecutorModels: pi 的会话目录按 cwd 分,跨目录也挑最新的那个', async () => {
|
|
362
|
+
const home = await makeHome({
|
|
363
|
+
'.pi/agent/sessions/--D--ws-other--/2026-10-12T01-00-00-000Z_x.jsonl':
|
|
364
|
+
jsonl([piModelChange('p-other', 'm-other')]),
|
|
365
|
+
'.pi/agent/sessions/--D--ws-demo--/2026-10-13T09-36-32-719Z_y.jsonl':
|
|
366
|
+
jsonl([piModelChange('p-demo', 'm-demo')]),
|
|
367
|
+
})
|
|
368
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi?.model, 'm-demo')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('detectExecutorModels: pi 同一会话里 TUI 换过模型时,取**最后**一条 model_change', async () => {
|
|
372
|
+
const home = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'aaa', [
|
|
373
|
+
piModelChange('p', 'started-with'),
|
|
374
|
+
{ type: 'message', message: { role: 'user', content: '换个模型' } },
|
|
375
|
+
piModelChange('p', 'switched-to'),
|
|
376
|
+
{ type: 'message', message: { role: 'assistant', content: '好' } },
|
|
377
|
+
]))
|
|
378
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi?.model, 'switched-to')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test('detectExecutorModels: pi 会话文件很长时,头部与尾部两端都要扫(拼接顺序不能反)', async () => {
|
|
382
|
+
// 文件远大于扫描窗口(512KB):起步用的模型在头部、切过去的在尾部。
|
|
383
|
+
// 只扫尾部会漏掉"从没换过模型"的会话;只扫头部会把**开头**那个当成最近用的。
|
|
384
|
+
const filler = Array.from({ length: 9000 }, (_, i) => ({
|
|
385
|
+
type: 'message', id: `f${i}`, message: { role: 'assistant', content: 'x'.repeat(200) },
|
|
386
|
+
}))
|
|
387
|
+
const home = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'big', [
|
|
388
|
+
piModelChange('p', 'at-the-top'),
|
|
389
|
+
...filler,
|
|
390
|
+
piModelChange('p', 'at-the-bottom'),
|
|
391
|
+
]))
|
|
392
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi?.model, 'at-the-bottom')
|
|
393
|
+
|
|
394
|
+
// 对照:同一形状但尾部没有 model_change(从没换过)→ 回落到头部那条,而不是 null
|
|
395
|
+
const home2 = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'big', [
|
|
396
|
+
piModelChange('p', 'only-at-the-top'),
|
|
397
|
+
...filler,
|
|
398
|
+
]))
|
|
399
|
+
assert.equal((await detectExecutorModels({ homeDir: home2 })).pi?.model, 'only-at-the-top')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('detectExecutorModels: 会话大小在 1~2 个扫描窗口之间时,尾部那段也要读到', async () => {
|
|
403
|
+
// 回归:判"要不要读两段"曾写成 `tailStart < headLen`,于是 1~2 个窗口大的会话
|
|
404
|
+
// (头尾两段重叠)被误判成"一个窗口装得下",只返回头部 —— 模型恰好换在文件末尾
|
|
405
|
+
// 的会话就永远报开头那个。这里造一个 ~700KB 的会话(512KB < size < 1024KB)钉住它。
|
|
406
|
+
const filler = Array.from({ length: 660 }, (_, i) => ({
|
|
407
|
+
type: 'message', id: `f${i}`, message: { role: 'assistant', content: 'x'.repeat(1000) },
|
|
408
|
+
}))
|
|
409
|
+
const home = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'mid', [
|
|
410
|
+
piModelChange('p', 'at-the-top'),
|
|
411
|
+
...filler,
|
|
412
|
+
piModelChange('p', 'at-the-bottom'),
|
|
413
|
+
]))
|
|
414
|
+
const size = (await fs.stat(path.join(home, '.pi/agent/sessions/--D--ws-demo--',
|
|
415
|
+
'2026-10-13T09-36-32-719Z_mid.jsonl'))).size
|
|
416
|
+
assert.ok(size > 512 * 1024 && size < 1024 * 1024, `夹具大小应在 1~2 个窗口之间,实际 ${size}`)
|
|
417
|
+
assert.equal((await detectExecutorModels({ homeDir: home })).pi?.model, 'at-the-bottom')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('detectExecutorModels: pi 会话里没有可用的 model_change 时仍是 null(不猜)', async () => {
|
|
421
|
+
// 空目录 / 没有 .jsonl / 全是坏行 / modelId 非字符串 —— 一律当"没读到"
|
|
422
|
+
const empty = await makeHome({ '.pi/agent/sessions/--D--ws-demo--/.keep': '' })
|
|
423
|
+
assert.equal((await detectExecutorModels({ homeDir: empty })).pi, null)
|
|
424
|
+
|
|
425
|
+
const junk = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'aaa', [
|
|
426
|
+
'{ 这不是 JSON',
|
|
427
|
+
JSON.stringify({ type: 'model_change', provider: 'p' }), // 缺 modelId
|
|
428
|
+
JSON.stringify({ type: 'model_change', provider: 'p', modelId: ' ' }), // 全空白
|
|
429
|
+
JSON.stringify({ type: 'message', modelId: 'looks-like-one' }), // 不是 model_change
|
|
430
|
+
]))
|
|
431
|
+
assert.equal((await detectExecutorModels({ homeDir: junk })).pi, null)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('detectExecutorModels: pi 会话记录里的对话内容绝不进返回值', async () => {
|
|
435
|
+
const home = await makeHome(piSession('2026-10-13T09-36-32-719Z', 'aaa', [
|
|
436
|
+
piModelChange('p', 'm'),
|
|
437
|
+
{ type: 'message', message: { role: 'user', content: 'sk-should-never-leak' } },
|
|
438
|
+
{ type: 'session', apiKey: 'tok-should-never-leak' },
|
|
439
|
+
]))
|
|
440
|
+
const serialized = JSON.stringify(await detectExecutorModels({ homeDir: home }))
|
|
441
|
+
assert.equal(serialized.includes('sk-should-never-leak'), false)
|
|
442
|
+
assert.equal(serialized.includes('tok-should-never-leak'), false)
|
|
443
|
+
assert.equal(serialized.includes('should-never-leak'), false)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('detectExecutorModels: PI_CODING_AGENT_DIR 换掉配置目录时,settings 与会话都跟着走', async () => {
|
|
447
|
+
// 官方环境变量(pi 的 config.js getAgentDir)。设了却还读 ~/.pi/agent 的话,
|
|
448
|
+
// 会把"配了模型"报成"没配" —— 界面撒谎。
|
|
449
|
+
const home = await makeHome({
|
|
450
|
+
'.pi/agent/settings.json': '{"theme":"dark"}', // 旧目录:不该被读到
|
|
451
|
+
'custom-pi/settings.json': '{"defaultModel":"from-custom-dir"}',
|
|
452
|
+
'custom-pi/sessions/--D--ws-demo--/2026-10-13T09-36-32-719Z_a.jsonl':
|
|
453
|
+
jsonl([piModelChange('p', 'm-from-custom-dir')]),
|
|
454
|
+
})
|
|
455
|
+
const prev = process.env.PI_CODING_AGENT_DIR
|
|
456
|
+
process.env.PI_CODING_AGENT_DIR = path.join(home, 'custom-pi')
|
|
457
|
+
try {
|
|
458
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
459
|
+
assert.equal(out.pi?.model, 'from-custom-dir')
|
|
460
|
+
} finally {
|
|
461
|
+
if (prev === undefined) delete process.env.PI_CODING_AGENT_DIR
|
|
462
|
+
else process.env.PI_CODING_AGENT_DIR = prev
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test('detectExecutorModels: claude 显式 ANTHROPIC_MODEL 优先于档位别名', async () => {
|
|
467
|
+
const home = await makeHome({
|
|
468
|
+
'.claude/settings.json': JSON.stringify({
|
|
469
|
+
env: {
|
|
470
|
+
ANTHROPIC_MODEL: 'my-explicit-model',
|
|
471
|
+
ANTHROPIC_BASE_URL: 'http://proxy.local',
|
|
472
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-alias',
|
|
473
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'sonnet-real',
|
|
474
|
+
},
|
|
475
|
+
}),
|
|
476
|
+
})
|
|
477
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
478
|
+
// 显式指定时没有"别名背后"的第二层,但 base_url 仍要带出来
|
|
479
|
+
assert.deepEqual(out.claude, {
|
|
480
|
+
model: 'my-explicit-model',
|
|
481
|
+
display: null,
|
|
482
|
+
provider: 'http://proxy.local',
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('detectExecutorModels: 一个配置文件都没有时六项全 null 且不抛', async () => {
|
|
487
|
+
const home = await makeHome()
|
|
488
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
489
|
+
assert.deepEqual(out, { claude: null, codex: null, opencode: null, codebuddy: null, pi: null, codearts: null })
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('detectExecutorModels: 配置残缺/是目录/内容不是 JSON 时只让自己变 null', async () => {
|
|
493
|
+
const home = await makeHome({
|
|
494
|
+
'.claude/settings.json': '{ 这不是 JSON', // 解析失败 → null
|
|
495
|
+
'.codex/config.toml': '# 只有注释,没有 model\n', // 无 model → null
|
|
496
|
+
'.config/opencode/opencode.json': '{"model":""}', // 空串 → null
|
|
497
|
+
'.codebuddy/settings.json': '{"model":123}', // 非字符串 → null
|
|
498
|
+
'.pi/agent/settings.json': '{ 也不是 JSON', // 解析失败 → null
|
|
499
|
+
})
|
|
500
|
+
const out = await detectExecutorModels({ homeDir: home })
|
|
501
|
+
assert.deepEqual(out, { claude: null, codex: null, opencode: null, codebuddy: null, pi: null, codearts: null })
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('detectExecutorModels: 返回值里绝不出现任何密钥', async () => {
|
|
505
|
+
const home = await makeHome({
|
|
506
|
+
'.claude/settings.json': CLAUDE_JSON,
|
|
507
|
+
'.codex/config.toml': CODEX_TOML,
|
|
508
|
+
})
|
|
509
|
+
const serialized = JSON.stringify(await detectExecutorModels({ homeDir: home }))
|
|
510
|
+
assert.equal(serialized.includes('sk-should-never-leak'), false)
|
|
511
|
+
assert.equal(serialized.includes('tok-should-never-leak'), false)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
// ── formatExecutorModel ──────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
test('formatExecutorModel: display 优先,别名落到 detail', () => {
|
|
517
|
+
assert.deepEqual(
|
|
518
|
+
formatExecutorModel({
|
|
519
|
+
model: 'claude-sonnet-5[1M]',
|
|
520
|
+
display: 'deepseek-v4.1-flash',
|
|
521
|
+
provider: 'http://127.0.0.1:15721',
|
|
522
|
+
}),
|
|
523
|
+
{ name: 'deepseek-v4.1-flash', detail: 'claude-sonnet-5[1M]', provider: 'http://127.0.0.1:15721', source: null },
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('formatExecutorModel: 两个名字相同则不重复进 detail', () => {
|
|
528
|
+
assert.deepEqual(
|
|
529
|
+
formatExecutorModel({ model: 'same', display: 'same', provider: null }),
|
|
530
|
+
{ name: 'same', detail: null, provider: null, source: null },
|
|
531
|
+
)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('formatExecutorModel: 只有别名时就用别名,detail 为空', () => {
|
|
535
|
+
assert.deepEqual(
|
|
536
|
+
formatExecutorModel({ model: 'only-alias', display: null, provider: null }),
|
|
537
|
+
{ name: 'only-alias', detail: null, provider: null, source: null },
|
|
538
|
+
)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('formatExecutorModel: 来源(config / state)照实透传出去', () => {
|
|
542
|
+
assert.equal(
|
|
543
|
+
formatExecutorModel({ model: 'm', display: null, provider: null, source: 'state' }).source,
|
|
544
|
+
'state',
|
|
545
|
+
)
|
|
546
|
+
assert.equal(
|
|
547
|
+
formatExecutorModel({ model: 'm', display: null, provider: null, source: 'config' }).source,
|
|
548
|
+
'config',
|
|
549
|
+
)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
test('formatExecutorModel: null / 空对象一律 null(前端据此走"未在配置中指定")', () => {
|
|
553
|
+
assert.equal(formatExecutorModel(null), null)
|
|
554
|
+
assert.equal(formatExecutorModel(undefined), null)
|
|
555
|
+
assert.equal(formatExecutorModel({ model: null, display: null, provider: null }), null)
|
|
556
|
+
assert.equal(formatExecutorModel({ model: '', display: '', provider: null }), null)
|
|
557
|
+
})
|