magic-agent 1.0.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,877 @@
1
+ package agent
2
+
3
+ // models_test.go - 模型清单动态探测的测试。
4
+ //
5
+ // 覆盖:
6
+ // - 三个纯文本/配置解析器(codebuddy --help / llm models / claude settings.json)
7
+ // - JSON 提取(顶层数组 vs {"models":[...]},容忍前置日志噪声)
8
+ // - 各引擎 ListModels 的**命令构造 + 解析**(假 CLI,不碰真实 CLI)
9
+ // - 无动态来源的引擎(arkclaw)返回 ErrNoModelSource
10
+ //
11
+ // 真实 CLI 的真实输出另有真机验收(README「已验证」段),此处只保证契约。
12
+
13
+ import (
14
+ "context"
15
+ "encoding/json"
16
+ "errors"
17
+ "os"
18
+ "path/filepath"
19
+ "strings"
20
+ "testing"
21
+ "time"
22
+ )
23
+
24
+ // ── 纯函数解析器 ─────────────────────────────────────────────
25
+
26
+ func TestParseCodebuddyHelpModels(t *testing.T) {
27
+ // 真实 --help 的排版(--model 描述内含括号清单)。
28
+ help := `Options:
29
+ --model <model> Model for the current session. Please provide the model ID. Currently supported: (auto, hy4-preview, hy3, glm-5.3, custom-local:MiniMax-M3, custom-local:gpt-6-astra)
30
+ --text-to-image-model <model> Model for text-to-image generation
31
+ `
32
+ want := []string{"auto", "hy4-preview", "hy3", "glm-5.3", "custom-local:MiniMax-M3", "custom-local:gpt-6-astra"}
33
+ if got := parseCodebuddyHelpModels(help); !equalStrings(got, want) {
34
+ t.Errorf("got %v want %v", got, want)
35
+ }
36
+
37
+ // 清单跨行(终端宽度换行)也要能截到配对右括号。
38
+ wrapped := "Currently supported: (auto, hy3,\n glm-5.3)\nNext: x\n"
39
+ if got := parseCodebuddyHelpModels(wrapped); !equalStrings(got, []string{"auto", "hy3", "glm-5.3"}) {
40
+ t.Errorf("wrapped list: got %v", got)
41
+ }
42
+
43
+ // 没有标记 → nil(不能让调用方误以为"零个模型")。
44
+ if got := parseCodebuddyHelpModels("no such marker"); got != nil {
45
+ t.Errorf("no marker: got %v want nil", got)
46
+ }
47
+
48
+ // 去重 + 去空。
49
+ dup := "Currently supported: (auto, hy3, hy3, , auto)"
50
+ if got := parseCodebuddyHelpModels(dup); !equalStrings(got, []string{"auto", "hy3"}) {
51
+ t.Errorf("dedupe: got %v", got)
52
+ }
53
+ }
54
+
55
+ func TestParseLLMModelsText(t *testing.T) {
56
+ out := `OpenAI Chat: gpt-4o (aliases: 4o)
57
+ OpenAI Chat: gpt-4o-mini (aliases: 4o-mini)
58
+ OpenRouter: x-ai/grok-4
59
+
60
+ gpt-4o
61
+ `
62
+ want := []string{"gpt-4o", "gpt-4o-mini", "x-ai/grok-4"}
63
+ if got := parseLLMModelsText(out); !equalStrings(got, want) {
64
+ t.Errorf("got %v want %v", got, want)
65
+ }
66
+ }
67
+
68
+ func TestClaudeModelsFromSettings(t *testing.T) {
69
+ // 真实 settings.json 的形态:每个档位一组**四个**变量,其中只有 `*_MODEL`(id 格)
70
+ // 是模型;`*_MODEL_NAME` 是显示名。id 格分布在两个命名空间:
71
+ // ANTHROPIC_DEFAULT_*_MODEL / ANTHROPIC_MODEL 与 CLAUDE_CODE_SUBAGENT_MODEL。
72
+ // 回归保护(2026-09-28 用户报「claude 返回的模型列表不对」→「setting 中不是 5 个模型吗」):
73
+ // ① 显示名(本机那份是 GLM-5.2 / kimi-k2.6 / deepseek-v4-pro)**不得**进清单;
74
+ // ② CLAUDE_CODE_SUBAGENT_MODEL 这个 id 格**不能漏**。
75
+ // env 里还混有非字符串值(真实文件就有 1 这种整型),必须跳过而不是解析失败。
76
+ data := []byte(`{
77
+ "model": "claude-sonnet-4-6",
78
+ "env": {
79
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5[1M]",
80
+ "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "kimi-k2.6",
81
+ "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION": "Kimi K2.6",
82
+ "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "1m,vision",
83
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-5[1M]",
84
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "GLM-5.2",
85
+ "ANTHROPIC_MODEL": "claude-fable-5[1M]",
86
+ "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-flash",
87
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
88
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1
89
+ }
90
+ }`)
91
+ // 顶层 model 先,其后 env 键按字母序 —— 共 5 个 id 格。
92
+ want := []string{
93
+ "claude-sonnet-4-6",
94
+ "claude-opus-5[1M]",
95
+ "claude-sonnet-5[1M]",
96
+ "claude-fable-5[1M]",
97
+ "deepseek-v4-flash",
98
+ }
99
+
100
+ got, err := claudeModelsFromSettings(data)
101
+ if err != nil {
102
+ t.Fatalf("parse: %v", err)
103
+ }
104
+ if len(got) != len(want) {
105
+ t.Fatalf("got %v want %v(集合应完全一致)", got, want)
106
+ }
107
+ for _, m := range want {
108
+ if !containsStr(got, m) {
109
+ t.Errorf("missing %q in %v", m, got)
110
+ }
111
+ }
112
+
113
+ // 显示名 / 描述 / 能力位 / 非模型 env 一律不得混入
114
+ for _, bad := range []string{"kimi-k2.6", "GLM-5.2", "Kimi K2.6", "1m,vision", "http://127.0.0.1:15721", "1"} {
115
+ if containsStr(got, bad) {
116
+ t.Errorf("非模型 id 的值 %q 混进了清单: %v", bad, got)
117
+ }
118
+ }
119
+
120
+ // 语法错 → 报错(不是静默空清单)。
121
+ if _, err := claudeModelsFromSettings([]byte("{oops")); err == nil {
122
+ t.Error("invalid JSON should error")
123
+ }
124
+ }
125
+
126
+ func TestJSONModelsArray(t *testing.T) {
127
+ // 顶层数组(trae)。
128
+ arr := `[
129
+ {"name":"Doubao-Seed-Evolving","real_name":"Doubao-Seed-Evolving"},
130
+ {"name":"My-MiniMax-M3","real_name":"MiniMax-M3"}
131
+ ]`
132
+ objs, err := jsonModelsArray(arr)
133
+ if err != nil {
134
+ t.Fatalf("array: %v", err)
135
+ }
136
+ if got := modelNamesFromObjects(objs, "name", "real_name"); !equalStrings(got, []string{"Doubao-Seed-Evolving", "My-MiniMax-M3"}) {
137
+ t.Errorf("trae names: %v", got)
138
+ }
139
+
140
+ // 包裹对象(codex / openclaw)+ 前置日志噪声。
141
+ wrapped := "[plugin] noise line\n" + `{"count":1,"models":[{"key":"minimax/MiniMax-M3","name":"MiniMax-M3"}]}`
142
+ objs, err = jsonModelsArray(wrapped)
143
+ if err != nil {
144
+ t.Fatalf("wrapped: %v", err)
145
+ }
146
+ if got := modelNamesFromObjects(objs, "key", "name"); !equalStrings(got, []string{"minimax/MiniMax-M3"}) {
147
+ t.Errorf("openclaw keys: %v", got)
148
+ }
149
+
150
+ if _, err := jsonModelsArray("no json at all"); err == nil {
151
+ t.Error("no JSON should error")
152
+ }
153
+ }
154
+
155
+ // ── 各引擎 ListModels(假 CLI)────────────────────────────────
156
+
157
+ func TestEngineListModelsWithFakeCLI(t *testing.T) {
158
+ // 隔离 HOME:codebuddy-ai 的扩展来源会扫 ~/.codebuddy-ai 与 ~/.codebuddy 的
159
+ // local_storage 缓存,不隔离会吃到真机缓存导致断言失败。
160
+ t.Setenv("HOME", t.TempDir())
161
+ cases := []struct {
162
+ name string
163
+ cliName string
164
+ canned string
165
+ wantArg string // 记进 args.log 的参数(顺序敏感)
166
+ want []string
167
+ build func(bin string) ModelLister
168
+ }{
169
+ {
170
+ name: "trae",
171
+ cliName: "trae-cli",
172
+ canned: `echo '[{"name":"My-MiniMax-M3","real_name":"MiniMax-M3"},{"name":"GLM-5.3"}]'`,
173
+ wantArg: "models --json",
174
+ want: []string{"My-MiniMax-M3", "GLM-5.3"},
175
+ build: func(bin string) ModelLister { return &TraeEngine{BinPath: bin} },
176
+ },
177
+ {
178
+ name: "codex",
179
+ cliName: "codex",
180
+ canned: `echo '{"models":[{"slug":"MiniMax-M3","display_name":"MiniMax-M3"}]}'`,
181
+ wantArg: "debug models",
182
+ want: []string{"MiniMax-M3"},
183
+ build: func(bin string) ModelLister { return &CodexEngine{BinPath: bin} },
184
+ },
185
+ {
186
+ name: "openclaw",
187
+ cliName: "openclaw",
188
+ canned: `echo '{"count":2,"models":[{"key":"minimax/MiniMax-M3"},{"key":"papergames/deepseek-v4-pro"}]}'`,
189
+ wantArg: "models list --json",
190
+ want: []string{"minimax/MiniMax-M3", "papergames/deepseek-v4-pro"},
191
+ build: func(bin string) ModelLister { return &OpenClawEngine{BinPath: bin} },
192
+ },
193
+ {
194
+ name: "llm",
195
+ cliName: "llm",
196
+ canned: `printf '%s\n' 'OpenAI Chat: gpt-4o (aliases: 4o)' 'OpenRouter: x-ai/grok-4'`,
197
+ wantArg: "models",
198
+ want: []string{"gpt-4o", "x-ai/grok-4"},
199
+ build: func(bin string) ModelLister { return &LLMEngine{BinPath: bin} },
200
+ },
201
+ {
202
+ name: "codebuddy",
203
+ cliName: "codebuddy",
204
+ canned: `printf '%s\n' 'Options:' ' --model <model> Model ID. Currently supported: (auto, hy3, custom-local:MiniMax-M3)'`,
205
+ wantArg: "--help",
206
+ want: []string{"auto", "hy3", "custom-local:MiniMax-M3"},
207
+ build: func(bin string) ModelLister { return &CodeBuddyEngine{BinPath: bin} },
208
+ },
209
+ {
210
+ // AI 后端的注册表是分层别名(国外模型网关侧映射),与 WorkBuddy 端完全不同;
211
+ // listModels 必须经 extraEnv 隔离读到自己的清单(这里用假 CLI 验证解析面)。
212
+ name: "codebuddy-ai",
213
+ cliName: "codebuddy-ai",
214
+ canned: `printf '%s\n' 'Options:' ' --model <model> Model ID. Currently supported: (fast-model, balanced-model, primary-model, deep-model)'`,
215
+ wantArg: "--help",
216
+ want: []string{"fast-model", "balanced-model", "primary-model", "deep-model"},
217
+ build: func(bin string) ModelLister { return &CodeBuddyAIEngine{BinPath: bin} },
218
+ },
219
+ }
220
+ for _, tc := range cases {
221
+ t.Run(tc.name, func(t *testing.T) {
222
+ w, log := argsCaptureCLI(t, t.TempDir(), tc.cliName, tc.canned)
223
+ got, err := tc.build(w.bin).ListModels(context.Background())
224
+ if err != nil {
225
+ t.Fatalf("ListModels: %v", err)
226
+ }
227
+ if !equalStrings(got, tc.want) {
228
+ t.Errorf("models = %v want %v", got, tc.want)
229
+ }
230
+ data, _ := os.ReadFile(log)
231
+ if args := strings.TrimSpace(string(data)); args != tc.wantArg {
232
+ t.Errorf("args = %q want %q", args, tc.wantArg)
233
+ }
234
+ })
235
+ }
236
+ }
237
+
238
+ // 探测失败(CLI 报错 / 输出里没有清单)必须报错而非返回空清单。
239
+ func TestEngineListModelsFailures(t *testing.T) {
240
+ // 隔离 HOME 与配置目录:codebuddy 的清单现在先读扩展来源(acc 缓存 /
241
+ // 远程配置缓存),不隔离会吃到真机的 ~/.workbuddy 缓存而"意外成功"。
242
+ t.Setenv("HOME", t.TempDir())
243
+ t.Setenv("CODEBUDDY_CONFIG_DIR", filepath.Join(t.TempDir(), "cfg"))
244
+
245
+ // 三级来源全空、--help 里也没有 "Currently supported" → ErrNoModelSource 语义的错误。
246
+ w, _ := argsCaptureCLI(t, t.TempDir(), "codebuddy", `echo 'no model list'`)
247
+ if _, err := (&CodeBuddyEngine{BinPath: w.bin}).ListModels(context.Background()); err == nil {
248
+ t.Error("codebuddy without list should error")
249
+ }
250
+
251
+ // CLI 非零退出 → 报错(stderr 摘要并入)。
252
+ dir := t.TempDir()
253
+ bin := filepath.Join(dir, "trae-cli")
254
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\necho boom >&2\nexit 3\n"), 0o755); err != nil {
255
+ t.Fatal(err)
256
+ }
257
+ if _, err := (&TraeEngine{BinPath: bin}).ListModels(context.Background()); err == nil {
258
+ t.Error("failing CLI should error")
259
+ }
260
+
261
+ // 找不到 CLI → 报错(不 panic)。
262
+ if _, err := (&LLMEngine{BinPath: filepath.Join(dir, "nope")}).ListModels(context.Background()); err == nil {
263
+ t.Error("missing CLI should error")
264
+ }
265
+ }
266
+
267
+ // ── product.json 模型清单(codebuddy-ai 专用来源)────────────────
268
+
269
+ func TestParseCreditMultiplier(t *testing.T) {
270
+ cases := []struct{ in, want string }{
271
+ {"x2.20 credits", "2.20"},
272
+ {"x0.77", "0.77"},
273
+ {"x0.00", "0.00"},
274
+ {" X1.5 CREDITS ", "1.5"},
275
+ {"", ""}, // 空
276
+ {"credits", ""}, // 缺倍率
277
+ {"abc", ""}, // 非数字
278
+ {"x", ""}, // 只有前缀
279
+ }
280
+ for _, tc := range cases {
281
+ if got := parseCreditMultiplier(tc.in); got != tc.want {
282
+ t.Errorf("parseCreditMultiplier(%q) = %q want %q", tc.in, got, tc.want)
283
+ }
284
+ }
285
+ }
286
+
287
+ func TestParseProductJSONModels(t *testing.T) {
288
+ // 真实 AI 端 product.json 的裁剪形态:条目带 name / credits 等无关字段。
289
+ data := []byte(`{
290
+ "$schema": "x",
291
+ "models": [
292
+ {"id":"default-model","name":"Default","credits":"x2.20 credits"},
293
+ {"id":"gpt-5.5","name":"GPT-5.5","credits":"x3.31"},
294
+ {"id":"gemini-3.1-pro","name":"Gemini-3.1-Pro"},
295
+ {"id":"fast-model","name":"Fast","credits":"x0.34 credits"}
296
+ ]
297
+ }`)
298
+ want := []string{"default-model", "gpt-5.5", "gemini-3.1-pro", "fast-model"}
299
+ if got := parseProductJSONModels(data); !equalStrings(got, want) {
300
+ t.Errorf("got %v want %v", got, want)
301
+ }
302
+ // 积分倍率:规范化数字;无 credits 的模型不进表。
303
+ wantCredits := map[string]string{"default-model": "2.20", "gpt-5.5": "3.31", "fast-model": "0.34"}
304
+ if _, gotCredits := parseProductJSONCatalog(data); !equalMaps(gotCredits, wantCredits) {
305
+ t.Errorf("credits = %v want %v", gotCredits, wantCredits)
306
+ }
307
+
308
+ // 缺 id / 空 id 的条目跳过;重复项去重。
309
+ partial := []byte(`{"models":[{"id":"a"},{"name":"no-id"},{"id":" "},{"id":"b"},{"id":"a"}]}`)
310
+ if got := parseProductJSONModels(partial); !equalStrings(got, []string{"a", "b"}) {
311
+ t.Errorf("partial: got %v", got)
312
+ }
313
+
314
+ // 坏 JSON / models 类型不对 / 无 models / 空清单 → nil(调用方据此回退 --help)。
315
+ for _, bad := range []string{`not json`, `{"models":"nope"}`, `{}`, `{"models":[]}`} {
316
+ if got := parseProductJSONModels([]byte(bad)); got != nil {
317
+ t.Errorf("bad %q: got %v want nil", bad, got)
318
+ }
319
+ }
320
+ }
321
+
322
+ func TestProductJSONPath(t *testing.T) {
323
+ cases := []struct{ bin, want string }{
324
+ {
325
+ // 独立安装(npm)布局:product.json 在包根(bin/ 的上一级)
326
+ bin: "/opt/npm-global/lib/node_modules/@tencent-ai/codebuddy-code/bin/codebuddy",
327
+ want: "/opt/npm-global/lib/node_modules/@tencent-ai/codebuddy-code/product.json",
328
+ },
329
+ {bin: "/usr/local/bin/codebuddy", want: "/usr/local/product.json"},
330
+ {bin: "", want: ""},
331
+ }
332
+ for _, tc := range cases {
333
+ if got := productJSONPath(tc.bin); got != tc.want {
334
+ t.Errorf("productJSONPath(%q) = %q want %q", tc.bin, got, tc.want)
335
+ }
336
+ }
337
+ }
338
+
339
+ // ── 远程配置缓存(codebuddy-ai 扩展来源之一)────────────────────
340
+
341
+ func TestReadRemoteConfigCacheModels(t *testing.T) {
342
+ dir := filepath.Join(t.TempDir(), "local_storage")
343
+ if err := os.MkdirAll(dir, 0o755); err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ write := func(name, body string) {
347
+ t.Helper()
348
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
349
+ t.Fatal(err)
350
+ }
351
+ }
352
+ // 正常条目:两个账号的清单取并集、去重、保序。
353
+ write("entry_a.info", `[
354
+ {"userId":"u1","data":{"models":[{"id":"auto"},{"id":"deepseek-v4.1-flash"}]}},
355
+ {"userId":"u2","data":{"models":[{"id":"deepseek-v4.1-flash"},{"id":"hy3"}]}}
356
+ ]`)
357
+ // 其他形态:对象 / 字符串数组(base64+gzip 大对象)/ 坏 JSON → 都跳过。
358
+ write("entry_flags.info", `{"productFeatures":{"Billing":true}}`)
359
+ write("entry_blob.info", `["H4sIAAAAAAAAE+y9e28kWXYf"]`)
360
+ write("entry_broken.info", `not json`)
361
+ write("ignore.txt", `[{"data":{"models":[{"id":"nope"}]}}]`)
362
+
363
+ want := []string{"auto", "deepseek-v4.1-flash", "hy3"}
364
+ if got := readRemoteConfigCacheModels(dir); !equalStrings(got, want) {
365
+ t.Errorf("got %v want %v", got, want)
366
+ }
367
+
368
+ // 目录缺失 / 空目录 / 空参 → nil。
369
+ if got := readRemoteConfigCacheModels(filepath.Join(t.TempDir(), "nope")); got != nil {
370
+ t.Errorf("missing dir: got %v want nil", got)
371
+ }
372
+ empty := filepath.Join(t.TempDir(), "local_storage")
373
+ if err := os.MkdirAll(empty, 0o755); err != nil {
374
+ t.Fatal(err)
375
+ }
376
+ if got := readRemoteConfigCacheModels(empty); got != nil {
377
+ t.Errorf("empty dir: got %v want nil", got)
378
+ }
379
+ if got := readRemoteConfigCacheModels(""); got != nil {
380
+ t.Errorf("empty arg: got %v want nil", got)
381
+ }
382
+ }
383
+
384
+ // ── 客户端合并配置缓存(codebuddy-ai 首选来源)──────────────────
385
+
386
+ // codebuddy-ai 首选 ~/.workbuddy-ai/cache/acc-product-config-v*.json(客户端模型
387
+ // 选择器同源);多版本取 mtime 最新;缺失时回退「远程配置缓存 ∪ product.json」。
388
+ func TestCodeBuddyListModelsAccConfig(t *testing.T) {
389
+ t.Setenv("HOME", t.TempDir())
390
+ cacheDir := filepath.Join(os.Getenv("HOME"), ".workbuddy-ai", "cache")
391
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
392
+ t.Fatal(err)
393
+ }
394
+ writeAcc := func(name, body string, modTime time.Time) {
395
+ t.Helper()
396
+ p := filepath.Join(cacheDir, name)
397
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
398
+ t.Fatal(err)
399
+ }
400
+ if err := os.Chtimes(p, modTime, modTime); err != nil {
401
+ t.Fatal(err)
402
+ }
403
+ }
404
+
405
+ accV3 := `{"endpoint":"https://www.workbuddy.ai","models":[
406
+ {"id":"fast-model","credits":"x0.34 credits"},{"id":"deepseek-v4.1-flash","credits":"x0.00"},
407
+ {"id":"gpt-5.5","credits":"x3.31"},{"id":"gpt-6-astra","credits":"x6.67"},{"id":"custom-local:MiniMax-M3"}
408
+ ]}`
409
+ // 旧版本的清单不同,用于验证「取 mtime 最新」。
410
+ accV2 := `{"models":[{"id":"stale-model","credits":"x1.11"}]}`
411
+
412
+ // 客户端选择器清单(agents[].models):acc 清单按它过滤,只留客户端展示的
413
+ // 具名模型 + custom-local:*。stale-model 也列进来,供 B) 用。
414
+ writeSelectorList(t, ".codebuddy-ai", "fast-model", "deepseek-v4.1-flash", "gpt-5.5", "gpt-6-astra", "stale-model")
415
+
416
+ // A) acc 缓存命中 → 精确返回客户端清单(哪怕远程配置缓存 / product.json
417
+ // 里有别的模型也不并入);ModelCredits 给出规范化倍率,custom-local 不进表。
418
+ writeAcc("acc-product-config-v2.json", accV2, time.Now().Add(-time.Hour))
419
+ writeAcc("acc-product-config-v3.json", accV3, time.Now())
420
+ bin, argsLog := appPkgCLI(t, "", `{"models":[{"id":"gpt-5.5"}]}`)
421
+ ai := &CodeBuddyAIEngine{BinPath: bin}
422
+ got, err := ai.ListModels(context.Background())
423
+ if err != nil {
424
+ t.Fatalf("ListModels: %v", err)
425
+ }
426
+ want := []string{"fast-model", "deepseek-v4.1-flash", "gpt-5.5", "gpt-6-astra", "custom-local:MiniMax-M3"}
427
+ if !equalStrings(got, want) {
428
+ t.Errorf("acc got %v want %v", got, want)
429
+ }
430
+ wantCredits := map[string]string{
431
+ "fast-model": "0.34", "deepseek-v4.1-flash": "0.00", "gpt-5.5": "3.31", "gpt-6-astra": "6.67",
432
+ }
433
+ if gotC := ai.ModelCredits(context.Background()); !equalMaps(gotC, wantCredits) {
434
+ t.Errorf("ModelCredits = %v want %v", gotC, wantCredits)
435
+ }
436
+ if _, err := os.Stat(argsLog); err == nil {
437
+ t.Error("acc 缓存命中时不应启动 CLI 取 --help")
438
+ }
439
+
440
+ // B) 只有旧版本 → 一样生效(glob 不写死版本号)。
441
+ if err := os.Remove(filepath.Join(cacheDir, "acc-product-config-v3.json")); err != nil {
442
+ t.Fatal(err)
443
+ }
444
+ got, err = ai.ListModels(context.Background())
445
+ if err != nil {
446
+ t.Fatalf("ListModels(v2): %v", err)
447
+ }
448
+ if !equalStrings(got, []string{"stale-model"}) {
449
+ t.Errorf("v2-only got %v", got)
450
+ }
451
+
452
+ // C) acc 缓存按**客户端选择器清单**过滤:清单外的条目(补全 codewise-* /
453
+ // 图像 hunyuan-image-* / 历史别名 default-1.* …)不进清单 —— 全量注册表
454
+ // 铺给 -m 会比客户端多太多。过滤后一条不剩按「无来源」处理,回退下一级
455
+ // 来源(这里是 product.json)。
456
+ accNoise := `{"models":[{"id":"codewise-completions"},{"id":"hunyuan-image-alpha"},
457
+ {"id":"default-1.1"},{"id":"kimi-k2-instruct-taiji"}]}`
458
+ writeAcc("acc-product-config-v3.json", accNoise, time.Now())
459
+ got, err = ai.ListModels(context.Background())
460
+ if err != nil {
461
+ t.Fatalf("ListModels(noise): %v", err)
462
+ }
463
+ if !equalStrings(got, []string{"gpt-5.5"}) {
464
+ t.Errorf("清单外的补全/图像/别名应被剔除,实际 got %v", got)
465
+ }
466
+
467
+ // D) 混合:选择器内的具名模型 + custom-local 保留,清单外的剔除,倍率表同步收窄。
468
+ writeSelectorList(t, ".codebuddy-ai", "hy3", "glm-5.3")
469
+ accMixed := `{"models":[{"id":"hy3","credits":"x0.00"},{"id":"codewise-jump"},
470
+ {"id":"custom-local:MiniMax-M3"},{"id":"glm-5.3","credits":"x0.79"}]}`
471
+ writeAcc("acc-product-config-v3.json", accMixed, time.Now())
472
+ got, err = ai.ListModels(context.Background())
473
+ if err != nil {
474
+ t.Fatalf("ListModels(mixed): %v", err)
475
+ }
476
+ if !equalStrings(got, []string{"hy3", "custom-local:MiniMax-M3", "glm-5.3"}) {
477
+ t.Errorf("mixed got %v", got)
478
+ }
479
+ if gotC := ai.ModelCredits(context.Background()); !equalMaps(gotC, map[string]string{"hy3": "0.00", "glm-5.3": "0.79"}) {
480
+ t.Errorf("mixed ModelCredits = %v", gotC)
481
+ }
482
+ }
483
+
484
+ // codebuddy 两端的清单链:acc 缓存 → 远程配置缓存 ∪ product.json → 回退 --help。
485
+ // 2026-09-28 起 codebuddy(WorkBuddy 端)也启用扩展链,故 D 段与 AI 端同构。
486
+ func TestCodeBuddyListModelsProductJSON(t *testing.T) {
487
+ // 隔离 HOME:扩展来源扫 ~/.codebuddy-ai 与 ~/.codebuddy 的缓存,不隔离会吃到真机缓存。
488
+ t.Setenv("HOME", t.TempDir())
489
+
490
+ const aiHelp = `Options:
491
+ --model <model> Model ID. Currently supported: (fast-model, balanced-model, primary-model, deep-model)
492
+ `
493
+ const aiProduct = `{"models":[{"id":"gpt-5.5","credits":"x3.31 credits"},{"id":"gemini-3.1-pro"},{"id":"deepseek-v3-2-volc"},{"id":"fast-model"}]}`
494
+ cacheBody := `[
495
+ {"userId":"u1","data":{"models":[{"id":"deepseek-v4.1-flash","credits":"x0.03"},{"id":"auto"},{"id":"hy3"}]}},
496
+ {"userId":"u2","data":{"models":[{"id":"deepseek-v4.1-flash"},{"id":"fast-model","credits":"x0.34 credits"}]}}
497
+ ]`
498
+ sharedCache := filepath.Join(os.Getenv("HOME"), ".codebuddy", "local_storage")
499
+ writeCache := func(dir, body string) {
500
+ t.Helper()
501
+ if err := os.MkdirAll(dir, 0o755); err != nil {
502
+ t.Fatal(err)
503
+ }
504
+ if err := os.WriteFile(filepath.Join(dir, "entry_cache.info"), []byte(body), 0o644); err != nil {
505
+ t.Fatal(err)
506
+ }
507
+ }
508
+
509
+ // A) 缓存 + product.json → 并集(缓存序在前、去重),且**不启动 CLI**。
510
+ // 倍率同链合并:缓存优先(deepseek-v4.1-flash 取 u1 的 x0.03),product.json 补 gpt-5.5。
511
+ writeCache(sharedCache, cacheBody)
512
+ bin, argsLog := appPkgCLI(t, aiHelp, aiProduct)
513
+ ai := &CodeBuddyAIEngine{BinPath: bin}
514
+ got, err := ai.ListModels(context.Background())
515
+ if err != nil {
516
+ t.Fatalf("ListModels: %v", err)
517
+ }
518
+ want := []string{"deepseek-v4.1-flash", "auto", "hy3", "fast-model", "gpt-5.5", "gemini-3.1-pro", "deepseek-v3-2-volc"}
519
+ if !equalStrings(got, want) {
520
+ t.Errorf("ai got %v want %v", got, want)
521
+ }
522
+ wantCredits := map[string]string{"deepseek-v4.1-flash": "0.03", "fast-model": "0.34", "gpt-5.5": "3.31"}
523
+ if gotC := ai.ModelCredits(context.Background()); !equalMaps(gotC, wantCredits) {
524
+ t.Errorf("ModelCredits = %v want %v", gotC, wantCredits)
525
+ }
526
+ if _, err := os.Stat(argsLog); err == nil {
527
+ t.Error("扩展来源命中时不应启动 CLI 取 --help")
528
+ }
529
+
530
+ // B) 无缓存 + product.json → 仅 product.json,仍不启动 CLI。
531
+ if err := os.RemoveAll(sharedCache); err != nil {
532
+ t.Fatal(err)
533
+ }
534
+ got, err = ai.ListModels(context.Background())
535
+ if err != nil {
536
+ t.Fatalf("ListModels(product): %v", err)
537
+ }
538
+ if !equalStrings(got, []string{"gpt-5.5", "gemini-3.1-pro", "deepseek-v3-2-volc", "fast-model"}) {
539
+ t.Errorf("product-only got %v", got)
540
+ }
541
+ if gotC := ai.ModelCredits(context.Background()); !equalMaps(gotC, map[string]string{"gpt-5.5": "3.31"}) {
542
+ t.Errorf("product-only ModelCredits = %v", gotC)
543
+ }
544
+
545
+ // C) 两者皆无 → 回退 --help 的四个分层别名。
546
+ bin3, _ := appPkgCLI(t, aiHelp, "")
547
+ got, err = (&CodeBuddyAIEngine{BinPath: bin3}).ListModels(context.Background())
548
+ if err != nil {
549
+ t.Fatalf("ListModels(fallback): %v", err)
550
+ }
551
+ if !equalStrings(got, []string{"fast-model", "balanced-model", "primary-model", "deep-model"}) {
552
+ t.Errorf("fallback got %v", got)
553
+ }
554
+
555
+ // D) WorkBuddy 端(2026-09-28 起与 AI 端同链):先 acc 缓存(客户端同源),
556
+ // 按选择器清单过滤,命中即独占、不并入 ②、也不启动 CLI —— 官方预制模型
557
+ // (hy3 / deepseek-v4.1-flash)只能从这里来,--help 的清单里并没有。
558
+ wbAccBody := `{"models":[
559
+ {"id":"hy3","credits":"x0.00"},{"id":"hy4-preview","credits":"x0.29"},
560
+ {"id":"deepseek-v4.1-flash","credits":"x0.11"},{"id":"codewise-jump"},
561
+ {"id":"custom-local:MiniMax-M3.1-Flash-Preview"}
562
+ ]}`
563
+ wbAccDir := filepath.Join(os.Getenv("HOME"), ".workbuddy", "cache")
564
+ if err := os.MkdirAll(wbAccDir, 0o755); err != nil {
565
+ t.Fatal(err)
566
+ }
567
+ if err := os.WriteFile(filepath.Join(wbAccDir, "acc-product-config-v3.json"), []byte(wbAccBody), 0o644); err != nil {
568
+ t.Fatal(err)
569
+ }
570
+ // 选择器清单里没有 codewise-jump(行内补全)→ 应被剔除。
571
+ writeSelectorList(t, ".workbuddy", "hy3", "hy4-preview", "deepseek-v4.1-flash")
572
+ const wbHelp = `Options:
573
+ --model <model> Model ID. Currently supported: (auto, hy3, glm-5.3)
574
+ `
575
+ binWB, wbArgsLog := appPkgCLI(t, wbHelp, aiProduct)
576
+ cbWB := &CodeBuddyEngine{BinPath: binWB}
577
+ got, err = cbWB.ListModels(context.Background())
578
+ if err != nil {
579
+ t.Fatalf("ListModels(workbuddy acc): %v", err)
580
+ }
581
+ wantWB := []string{"hy3", "hy4-preview", "deepseek-v4.1-flash", "custom-local:MiniMax-M3.1-Flash-Preview"}
582
+ if !equalStrings(got, wantWB) {
583
+ t.Errorf("workbuddy acc got %v want %v", got, wantWB)
584
+ }
585
+ // 倍率与清单同源同链:acc 里的 custom-local 无倍率则不进表,官方四条都在。
586
+ if gotC := cbWB.ModelCredits(context.Background()); !equalMaps(gotC, map[string]string{
587
+ "hy3": "0.00", "hy4-preview": "0.29", "deepseek-v4.1-flash": "0.11",
588
+ }) {
589
+ t.Errorf("workbuddy ModelCredits = %v", gotC)
590
+ }
591
+ if _, err := os.Stat(wbArgsLog); err == nil {
592
+ t.Error("workbuddy 命中 acc 缓存时不应启动 CLI 取 --help")
593
+ }
594
+
595
+ // D2) workbuddy 的 acc 缺席 → 回退 ② 远程配置缓存 ∪ product.json(仍不启 CLI)。
596
+ // sharedCache 在 B) 已被删掉,这里先写回来(② 的远程配置缓存那一路)。
597
+ if err := os.RemoveAll(wbAccDir); err != nil {
598
+ t.Fatal(err)
599
+ }
600
+ writeCache(sharedCache, cacheBody)
601
+ got, err = cbWB.ListModels(context.Background())
602
+ if err != nil {
603
+ t.Fatalf("ListModels(workbuddy ②): %v", err)
604
+ }
605
+ if !equalStrings(got, []string{"deepseek-v4.1-flash", "auto", "hy3", "fast-model", "gpt-5.5", "gemini-3.1-pro", "deepseek-v3-2-volc"}) {
606
+ t.Errorf("workbuddy ② got %v", got)
607
+ }
608
+
609
+ // D3) acc 与 ② 都没有 → 末级兜底 --help(此时才启动 CLI)。
610
+ binWB3, _ := appPkgCLI(t, wbHelp, "")
611
+ if err := os.RemoveAll(sharedCache); err != nil {
612
+ t.Fatal(err)
613
+ }
614
+ got, err = (&CodeBuddyEngine{BinPath: binWB3}).ListModels(context.Background())
615
+ if err != nil {
616
+ t.Fatalf("ListModels(workbuddy --help): %v", err)
617
+ }
618
+ if !equalStrings(got, []string{"auto", "hy3", "glm-5.3"}) {
619
+ t.Errorf("workbuddy --help got %v", got)
620
+ }
621
+
622
+ // E) 用户显式设置 CODEBUDDY_CONFIG_DIR:只信该目录,共享缓存不兜底。
623
+ t.Setenv("CODEBUDDY_CONFIG_DIR", t.TempDir())
624
+ bin5, _ := appPkgCLI(t, aiHelp, "")
625
+ got, err = (&CodeBuddyAIEngine{BinPath: bin5}).ListModels(context.Background())
626
+ if err != nil {
627
+ t.Fatalf("ListModels(custom dir): %v", err)
628
+ }
629
+ if !equalStrings(got, []string{"fast-model", "balanced-model", "primary-model", "deep-model"}) {
630
+ t.Errorf("custom-dir got %v", got)
631
+ }
632
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
633
+ }
634
+
635
+ // codebuddy(非 ai)的清单 / 倍率链:acc 缓存(~/.workbuddy)→ 远程配置缓存 ∪
636
+ // product.json → --help。**两端同链**(2026-09-28 起 codebuddy 也启用扩展清单链:
637
+ // 此前它只按 --help 出清单、倍率却按 acc 缓存取,同一引擎两个字段读自两个文件)。
638
+ // 同时验证清单与倍率全程不启动 CLI(acc 命中时只读缓存文件)。
639
+ func TestCodeBuddyModelCredits(t *testing.T) {
640
+ t.Setenv("HOME", t.TempDir())
641
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
642
+
643
+ const help = `Options:
644
+ --model <model> Model ID. Currently supported: (hy3, fast-model, deep-model)
645
+ `
646
+ // product.json 给 hy3/fast-model 两条倍率:acc 命中时不得混入(7.77/1.23);
647
+ // 兜底链里它们分别被缓存值顶掉 / 并入。
648
+ product := `{"models":[{"id":"hy3","credits":"x7.77"},{"id":"fast-model","credits":"x1.23"}]}`
649
+ accBody := `{"models":[
650
+ {"id":"hy3","credits":"x0.00"},{"id":"fast-model","credits":"x0.34"},
651
+ {"id":"balanced-model","credits":"x0.65"},{"id":"only-in-acc","credits":"x1.00"}
652
+ ]}`
653
+ cacheBody := `[{"userId":"u1","data":{"models":[{"id":"hy3","credits":"x9.99"}]}}]`
654
+ accDir := filepath.Join(os.Getenv("HOME"), ".workbuddy", "cache")
655
+ sharedCache := filepath.Join(os.Getenv("HOME"), ".codebuddy", "local_storage")
656
+ writeFile := func(p, body string) {
657
+ t.Helper()
658
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
659
+ t.Fatal(err)
660
+ }
661
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
662
+ t.Fatal(err)
663
+ }
664
+ }
665
+
666
+ // A) acc 缓存命中 → 清单与倍率**同源**(2026-09-28 起两端一致,都按 acc):
667
+ // 倍率只来自 acc(hy3=0.00,而非缓存 9.99 / product 7.77);acc 独占、
668
+ // 不并入 ②,且不启动 CLI。清单按选择器清单过滤:only-in-acc 不在客户端
669
+ // 选择器里 → 剔除(这正是「比客户端多太多」的根因)。
670
+ writeFile(filepath.Join(accDir, "acc-product-config-v3.json"), accBody)
671
+ writeFile(filepath.Join(sharedCache, "entry_cache.info"), cacheBody)
672
+ writeSelectorList(t, ".workbuddy", "hy3", "fast-model", "balanced-model")
673
+ bin, argsLog := appPkgCLI(t, help, product)
674
+ cb := &CodeBuddyEngine{BinPath: bin}
675
+ got, err := cb.ListModels(context.Background())
676
+ if err != nil {
677
+ t.Fatalf("ListModels: %v", err)
678
+ }
679
+ if !equalStrings(got, []string{"hy3", "fast-model", "balanced-model"}) {
680
+ t.Errorf("清单应按 acc 缓存 ∩ 选择器清单,got %v", got)
681
+ }
682
+ wantA := map[string]string{"hy3": "0.00", "fast-model": "0.34", "balanced-model": "0.65"}
683
+ if gotC := cb.ModelCredits(context.Background()); !equalMaps(gotC, wantA) {
684
+ t.Errorf("ModelCredits = %v want %v", gotC, wantA)
685
+ }
686
+ // 清单与倍率都不应启动 CLI(acc 命中,压根用不到 --help)。
687
+ if _, err := os.Stat(argsLog); err == nil {
688
+ t.Error("acc 命中时不应启动 CLI")
689
+ }
690
+
691
+ // B) acc 缺席 → 远程配置缓存 ∪ product.json 合并(缓存优先:hy3 取 9.99)。
692
+ // 选择器目录也删掉:避免 A 段的选择器清单继续命中(acc 已删,readAccCatalog
693
+ // 应直接回退 ②,否则选择器变成 ①→② 之间的伪来源)。
694
+ if err := os.RemoveAll(accDir); err != nil {
695
+ t.Fatal(err)
696
+ }
697
+ selectorDir := filepath.Join(os.Getenv("HOME"), ".workbuddy", "local_storage")
698
+ if err := os.RemoveAll(selectorDir); err != nil {
699
+ t.Fatal(err)
700
+ }
701
+ wantB := map[string]string{"hy3": "9.99", "fast-model": "1.23"}
702
+ if gotC := cb.ModelCredits(context.Background()); !equalMaps(gotC, wantB) {
703
+ t.Errorf("ModelCredits(fallback) = %v want %v", gotC, wantB)
704
+ }
705
+
706
+ // C) 缓存也没有 → 仅 product.json。
707
+ if err := os.RemoveAll(sharedCache); err != nil {
708
+ t.Fatal(err)
709
+ }
710
+ wantC := map[string]string{"hy3": "7.77", "fast-model": "1.23"}
711
+ if gotC := cb.ModelCredits(context.Background()); !equalMaps(gotC, wantC) {
712
+ t.Errorf("ModelCredits(product) = %v want %v", gotC, wantC)
713
+ }
714
+
715
+ // D) 什么都没有 → nil(CLI 端省略 model_credits)。
716
+ bin2, _ := appPkgCLI(t, help, "")
717
+ if gotC := (&CodeBuddyEngine{BinPath: bin2}).ModelCredits(context.Background()); gotC != nil {
718
+ t.Errorf("ModelCredits(empty) = %v want nil", gotC)
719
+ }
720
+ }
721
+
722
+ // writeSelectorList 在 <home>/<appHome>/local_storage/entry_selector.info 写下客户端
723
+ // 模型选择器清单(agents[].models,acc 清单按它过滤 —— 见 agentSelectorModels)。
724
+ // appHome 传 ".workbuddy" 或 ".workbuddy-ai"。
725
+ func writeSelectorList(t *testing.T, appHome string, models ...string) {
726
+ t.Helper()
727
+ dir := filepath.Join(os.Getenv("HOME"), appHome, "local_storage")
728
+ if err := os.MkdirAll(dir, 0o755); err != nil {
729
+ t.Fatal(err)
730
+ }
731
+ ids, err := json.Marshal(models)
732
+ if err != nil {
733
+ t.Fatal(err)
734
+ }
735
+ body := `[{"userId":"u1","data":{"agents":[{"modelTags":["craft"],"models":` + string(ids) + `}]}}]`
736
+ if err := os.WriteFile(filepath.Join(dir, "entry_selector.info"), []byte(body), 0o644); err != nil {
737
+ t.Fatal(err)
738
+ }
739
+ }
740
+
741
+ // appPkgCLI 搭出 App 包目录结构(<tmp>/cli/bin/<name> + <tmp>/cli/product.json),
742
+ // 用于验证 productJSONPath 的上溯推导。productJSON 为空则不放 product.json。
743
+ func appPkgCLI(t *testing.T, helpOut, productJSON string) (bin, argsLog string) {
744
+ t.Helper()
745
+ cliDir := filepath.Join(t.TempDir(), "cli")
746
+ binDir := filepath.Join(cliDir, "bin")
747
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
748
+ t.Fatal(err)
749
+ }
750
+ argsLog = filepath.Join(cliDir, "args.log")
751
+ bin = filepath.Join(binDir, "codebuddy")
752
+ // --help 文案是多行文本,用 heredoc 原样输出(直接拼接会被 shell 当命令执行)。
753
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + argsLog + "\n" +
754
+ "cat <<'HELPEOF'\n" + strings.TrimRight(helpOut, "\n") + "\nHELPEOF\n"
755
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
756
+ t.Fatal(err)
757
+ }
758
+ if productJSON != "" {
759
+ if err := os.WriteFile(filepath.Join(cliDir, "product.json"), []byte(productJSON), 0o644); err != nil {
760
+ t.Fatal(err)
761
+ }
762
+ }
763
+ return bin, argsLog
764
+ }
765
+
766
+ // claude 的清单来自 settings.json:HOME 下 .claude/settings.json,
767
+ // CLAUDE_CONFIG_DIR 优先;缺失时是 ErrNoModelSource(不是硬编码兜底)。
768
+ func TestClaudeListModelsFromConfig(t *testing.T) {
769
+ home := t.TempDir()
770
+ t.Setenv("HOME", home)
771
+ t.Setenv("CLAUDE_CONFIG_DIR", "")
772
+
773
+ dir := filepath.Join(home, ".claude")
774
+ if err := os.MkdirAll(dir, 0o755); err != nil {
775
+ t.Fatal(err)
776
+ }
777
+ settings := `{"model":"","env":{"ANTHROPIC_DEFAULT_SONNET_MODEL":"claude-sonnet-5[1M]"}}`
778
+ if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o644); err != nil {
779
+ t.Fatal(err)
780
+ }
781
+ got, err := (&ClaudeEngine{}).ListModels(context.Background())
782
+ if err != nil {
783
+ t.Fatalf("ListModels: %v", err)
784
+ }
785
+ if !equalStrings(got, []string{"claude-sonnet-5[1M]"}) {
786
+ t.Errorf("got %v", got)
787
+ }
788
+
789
+ // CLAUDE_CONFIG_DIR 覆盖目录。
790
+ alt := t.TempDir()
791
+ if err := os.WriteFile(filepath.Join(alt, "settings.json"),
792
+ []byte(`{"model":"claude-opus-5[1M]"}`), 0o644); err != nil {
793
+ t.Fatal(err)
794
+ }
795
+ t.Setenv("CLAUDE_CONFIG_DIR", alt)
796
+ got, err = (&ClaudeEngine{}).ListModels(context.Background())
797
+ if err != nil {
798
+ t.Fatalf("ListModels(alt): %v", err)
799
+ }
800
+ if !equalStrings(got, []string{"claude-opus-5[1M]"}) {
801
+ t.Errorf("alt got %v", got)
802
+ }
803
+
804
+ // 文件不存在 → ErrNoModelSource。
805
+ t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
806
+ if _, err := (&ClaudeEngine{}).ListModels(context.Background()); !errors.Is(err, ErrNoModelSource) {
807
+ t.Errorf("missing settings: want ErrNoModelSource, got %v", err)
808
+ }
809
+ }
810
+
811
+ // 无动态来源 / 清单为空的引擎:返回 ErrNoModelSource 包裹的错误(附原因)。
812
+ func TestArkClawListModelsNoSource(t *testing.T) {
813
+ _, err := (&ArkClawEngine{}).ListModels(context.Background())
814
+ if !errors.Is(err, ErrNoModelSource) {
815
+ t.Fatalf("want ErrNoModelSource, got %v", err)
816
+ }
817
+ if !strings.Contains(err.Error(), "claw_id") {
818
+ t.Errorf("reason should mention claw_id: %v", err)
819
+ }
820
+ }
821
+
822
+ // 全部内置引擎都实现了 ModelLister(否则 --engines 会缺 models 字段);
823
+ // ModelListerOf 对未实现者返回 nil。
824
+ func TestAllEnginesImplementModelLister(t *testing.T) {
825
+ for _, e := range Engines() {
826
+ if ModelListerOf(e) == nil {
827
+ t.Errorf("engine %q does not implement ModelLister", e.Name())
828
+ }
829
+ }
830
+ if ModelListerOf(&stringEngineStub{}) != nil {
831
+ t.Error("ModelListerOf should return nil for non-lister engines")
832
+ }
833
+ }
834
+
835
+ // stringEngineStub 未实现 ModelLister 的最小引擎。
836
+ type stringEngineStub struct{}
837
+
838
+ func (s *stringEngineStub) Name() string { return "stub" }
839
+ func (s *stringEngineStub) Detect() (bool, string) { return true, "" }
840
+ func (s *stringEngineStub) Complete(context.Context, Request) (Response, error) {
841
+ return Response{}, nil
842
+ }
843
+
844
+ // ── 辅助 ────────────────────────────────────────────────────
845
+
846
+ func equalStrings(a, b []string) bool {
847
+ if len(a) != len(b) {
848
+ return false
849
+ }
850
+ for i := range a {
851
+ if a[i] != b[i] {
852
+ return false
853
+ }
854
+ }
855
+ return true
856
+ }
857
+
858
+ func equalMaps(a, b map[string]string) bool {
859
+ if len(a) != len(b) {
860
+ return false
861
+ }
862
+ for k, v := range a {
863
+ if b[k] != v {
864
+ return false
865
+ }
866
+ }
867
+ return true
868
+ }
869
+
870
+ func containsStr(list []string, want string) bool {
871
+ for _, v := range list {
872
+ if v == want {
873
+ return true
874
+ }
875
+ }
876
+ return false
877
+ }