magic-agent 2.4.3 → 2.6.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 (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -10,12 +10,15 @@ package agent
10
10
  // codebuddyBase 注释。
11
11
  //
12
12
  // 账号怎么分开(一个 CLI 挂两个账号):
13
- // CLI 的票据路径是 sharedDataPath/auth/<authentication.id>.info,而 authentication.id
14
- // 的取值链里 **ACC_PRODUCT_CONFIG_V3(环境变量)优先于 CLI 包内的 product.json**。
15
- // 给两个子进程注入不同的 id,就等于让它们各用各的账号、互不顶号(2026-09-28 实测)。
13
+ // CLI 的票据路径是 <os.homedir()>\AppData\Local\CodeBuddyExtension\Data\Public\
14
+ // auth\<authentication.id>.info。key 由 authentication.id 决定,而它的取值链里
15
+ // **ACC_PRODUCT_CONFIG_V3(环境变量)优先于 CLI 包内的 product.json**。
16
+ // ⚠️ CLI 2.174.0 起该优先级不再可靠(getAuthSavePath 先读合并后的产品配置),
17
+ // 故 codebuddy-ai 额外用独立 homedir 物理隔离 auth 目录(见 codebuddyAIHomeEnv);
18
+ // codebuddy 继续用真实 homedir + ~/.codebuddy。
16
19
  //
17
- // codebuddy 账号 id codebuddy → auth/codebuddy.info
18
- // codebuddy-ai 账号 id codebuddy-ai → auth/codebuddy-ai.info
20
+ // codebuddy 账号 id codebuddy → auth/codebuddy.info(真实 homedir)
21
+ // codebuddy-ai 账号 id codebuddy-ai → ~/.codebuddy-ai/AppData/.../auth/*.info
19
22
  //
20
23
  // 非交互协议与 claude 同源(CodeBuddy Code v2.x)、flag 面一致;模型清单与积分倍率
21
24
  // 走同一条扩展链(见 listModels)。
@@ -58,9 +61,9 @@ import (
58
61
  "context"
59
62
  "encoding/json"
60
63
  "fmt"
61
- "io"
62
64
  "os"
63
65
  "path/filepath"
66
+ "runtime"
64
67
  "strings"
65
68
  "time"
66
69
  )
@@ -94,6 +97,12 @@ type codebuddyCore struct {
94
97
  // extendedModelSources=true 时是扩展清单链 ①;一律用于 modelCredits 的首选 ①;
95
98
  // 以及**自定义模型端点同步**的来源(见 custom_models.go)。
96
99
  accConfigPath string
100
+ // accountCatalogDir 非空时,清单/倍率的 ① 改用**本账号 CLI 自己下发**的远程
101
+ // 配置缓存(<config_dir>/local_storage),并用同目录的选择器清单过滤;不再读桌面
102
+ // App 的 acc 缓存。codebuddy-ai 用:账号已与 App 物理隔离(独立 homedir),App 的
103
+ // acc 缓存属于另一个账号,只有 5 条模型,会把清单钉死在 2 个 custom-local。
104
+ // 空 = 维持「acc 缓存 → 远程缓存 ∪ product.json」原链(codebuddy 主引擎)。
105
+ accountCatalogDir string
97
106
  // configDir 该引擎自己的配置目录(codebuddyDir / codebuddyAIDir),
98
107
  // 即 CLI 读 models.json 的地方 —— 自定义模型端点同步的落点。
99
108
  // 用户显式设了 CODEBUDDY_CONFIG_DIR 时以那个为准(见 effectiveConfigDir)。
@@ -137,7 +146,16 @@ func (c codebuddyCore) selectorModels() []string {
137
146
  // ② 远程配置缓存 ∪ App 包 product.json(超集近似,含国内后端条目)
138
147
  //
139
148
  // 命中 ① 时 ② 不再叠加 —— ① 就是客户端所见,别无二义。倍率表与清单同源同链。
149
+ //
150
+ // ① 的来源按引擎区分:accountCatalogDir 非空(codebuddy-ai)时用**本账号 CLI 自己
151
+ // 下发**的远程配置缓存 + 同目录选择器清单(见 remoteCacheCatalogWithSelector),
152
+ // 不再读桌面 App 的 acc 缓存 —— 账号隔离后那份属于另一个账号。
140
153
  func (c codebuddyCore) extendedModelCatalog(bin string) ([]string, map[string]string, bool) {
154
+ if c.accountCatalogDir != "" {
155
+ if ids, credits := remoteCacheCatalogWithSelector(c.accountCatalogDir); len(ids) > 0 {
156
+ return ids, credits, true
157
+ }
158
+ }
141
159
  if ids, credits := readAccCatalog(c.accConfigPath, c.selectorModels()); len(ids) > 0 {
142
160
  return ids, credits, true
143
161
  }
@@ -172,6 +190,15 @@ func (c codebuddyCore) extendedModelCatalog(bin string) ([]string, map[string]st
172
190
  // 不改变模型清单来源**:两个引擎的清单都走同一条扩展链(见 listModels),
173
191
  // 倍率表与清单同源同链、按 model id 对上。
174
192
  func (c codebuddyCore) modelCredits(bin string) map[string]string {
193
+ // ① 同 extendedModelCatalog:codebuddy-ai 用本账号 CLI 下发的配置(有模型即钉死)
194
+ if c.accountCatalogDir != "" {
195
+ if ids, credits := remoteCacheCatalogWithSelector(c.accountCatalogDir); len(ids) > 0 {
196
+ if len(credits) == 0 {
197
+ return nil
198
+ }
199
+ return credits
200
+ }
201
+ }
175
202
  // ① 客户端合并配置缓存(与 extendedModelCatalog 同源同语义:**有模型即钉死**,
176
203
  // 哪怕一条倍率都没有也不穿透 ②③ 去混别处的倍率 —— 客户端不给就是不显示)
177
204
  if ids, credits := readAccCatalog(c.accConfigPath, c.selectorModels()); len(ids) > 0 {
@@ -238,7 +265,7 @@ func (c codebuddyCore) listModels(ctx context.Context) ([]string, error) {
238
265
  // ① 客户端合并配置缓存(客户端模型选择器同源)→ ② 远程配置缓存 ∪
239
266
  // App 包 product.json(超集近似)→ 都没有时落 ③ --help(见函数头)。
240
267
  if ids, _, ok := c.extendedModelCatalog(bin); ok {
241
- return ids, nil
268
+ return c.withCLICustomModels(ids), nil
242
269
  }
243
270
  }
244
271
  stdout, stderr, err := runCLIEnvIn(ctx, "", c.extraEnv, bin, "--help")
@@ -249,7 +276,13 @@ func (c codebuddyCore) listModels(ctx context.Context) ([]string, error) {
249
276
  if len(models) == 0 {
250
277
  return nil, fmt.Errorf("%w: --help has no \"Currently supported\" list", ErrNoModelSource)
251
278
  }
252
- return models, nil
279
+ return c.withCLICustomModels(models), nil
280
+ }
281
+
282
+ // withCLICustomModels 把 CLI 自己 models.json 里的本地自定义模型并入清单
283
+ // (并剔除已从 models.json 删除的旧条目,见 MergeCLICustomModels)。
284
+ func (c codebuddyCore) withCLICustomModels(ids []string) []string {
285
+ return MergeCLICustomModels(c.effectiveConfigDir(), ids)
253
286
  }
254
287
 
255
288
  // syncCustomModelEndpoints 顺带把 App 里配好的自定义模型端点补进 CLI 的 models.json。
@@ -407,32 +440,34 @@ func (c codebuddyCore) stream(ctx context.Context, req Request, onEvent func(Str
407
440
  // 与 claude 完全同构、同理由(2026-10-01 改,Windows 实测):
408
441
  // 位置参数在 Windows 上会被 .cmd 批处理拆坏,含换行时整段丢失。
409
442
  // 常驻会话(Request.Append 非 nil)的区别只在 stdin 是个**流**。
443
+ // Reply 非 nil 时同样保持打开 —— 计划审批等 control_request 要写回 control_response
444
+ //(2026-10-25 实测:不回就整轮挂住,见 Request.Reply)。
410
445
  prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
411
446
  if prompt == "" {
412
447
  return StreamResult{}, fmt.Errorf("%s: empty prompt", c.name)
413
448
  }
414
- var stdin io.Reader
415
- if req.Append != nil {
416
- pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
417
- if perr != nil {
418
- return StreamResult{}, perr
419
- }
420
- stdin = pipe
421
- } else {
422
- line, serr := streamJSONUserLine(prompt, req.Attachments)
423
- if serr != nil {
424
- return StreamResult{}, serr
425
- }
426
- stdin = strings.NewReader(line)
449
+ stdin, closeStdin, serr := streamJSONStdinFor(req, prompt)
450
+ if serr != nil {
451
+ return StreamResult{}, serr
427
452
  }
453
+ defer closeStdin()
428
454
  args := streamJSONArgs(c.buildArgsBase(req), prompt, true)
429
455
  acc := &streamAccumulator{
430
456
  Engine: c.name,
431
- OnEvent: onEvent,
432
457
  OnSessionID: req.OnSessionID,
433
458
  AggregateOnly: true,
434
459
  SanitizeTurnText: stripThinkBlock,
435
460
  }
461
+ if onEvent != nil || req.Reply != nil {
462
+ acc.OnEvent = func(ev StreamEvent) {
463
+ if streamTerminalKind(ev.Kind) {
464
+ closeStdin()
465
+ }
466
+ if onEvent != nil {
467
+ onEvent(ev)
468
+ }
469
+ }
470
+ }
436
471
  var fin streamJSONResult
437
472
  // 同上:stderr 尾巴是「为什么没走完协议」的唯一线索。
438
473
  var cliStderr streamStderrBuf
@@ -536,6 +571,19 @@ func (e *CodeBuddyEngine) ModelCredits(ctx context.Context) map[string]string {
536
571
  return e.core().modelCredits(bin)
537
572
  }
538
573
 
574
+ // ModelVendors 实现 ModelVendorLister:各模型的供应商展示名(选择器第二行用)。
575
+ // 只取 CLI 自己 models.json 里 `vendor` 有意义的条目(App 写的 "Custom" 会被过滤),
576
+ // 拿不到返回 nil。
577
+ func (e *CodeBuddyEngine) ModelVendors(ctx context.Context) map[string]string {
578
+ return CLIModelVendors(e.core().effectiveConfigDir())
579
+ }
580
+
581
+ // ModelQuota 实现 ModelQuotaLister:自定义 MiniMax 端点的 token plan 额度
582
+ // (与 llm 下 MiniMax 模型同一口径,按 key 查;官方 minimax-* 没有用户 key,不显示)。
583
+ func (e *CodeBuddyEngine) ModelQuota(ctx context.Context) map[string]QuotaWindows {
584
+ return CLIModelQuotas(ctx, e.core().effectiveConfigDir())
585
+ }
586
+
539
587
  // Complete 实现 Engine:单次调用 codebuddy CLI。
540
588
  func (e *CodeBuddyEngine) Complete(ctx context.Context, req Request) (Response, error) {
541
589
  return e.core().complete(ctx, req)
@@ -583,6 +631,7 @@ func (e *CodeBuddyAIEngine) core() codebuddyCore {
583
631
  extendedModelSources: true,
584
632
  modelCacheDirs: codebuddyAIModelCacheDirs(),
585
633
  accConfigPath: codebuddyAIAccConfigPath(),
634
+ accountCatalogDir: filepath.Join(codebuddyAIDir(), "local_storage"),
586
635
  configDir: codebuddyAIDir(),
587
636
  }
588
637
  }
@@ -684,12 +733,13 @@ func codebuddyModelCacheDirs() []string {
684
733
  return []string{own}
685
734
  }
686
735
 
687
- // codebuddyAIDir codebuddy-ai 引擎的专用配置目录(~/.codebuddy-ai)。
736
+ // codebuddyAIDir codebuddy-ai 引擎的专用目录(~/.codebuddy-ai)。
688
737
  //
689
- // 注意:**账号**的隔离靠 authentication.id(见 codebuddyAuthID / codebuddyAIAuthID),
690
- // 与配置目录无关 —— 票据文件是共享的 sharedDataPath/auth/<id>.info。
691
- // 这里分目录是为了让两个引擎的 settings / keyblob / 会话 / 插件缓存各归各的,
692
- // 避免互相覆盖(历史上共享目录时出现过登录态被顶)。
738
+ // 这个目录同时承担三件事:CLI 配置目录(settings / 会话 / 插件 / models.json)、
739
+ // 以及 codebuddy-ai 的 **user home**(票据落在它下面的 AppData/Local/
740
+ // CodeBuddyExtension/Data/Public/auth/,见 codebuddyAIHomeEnv)。
741
+ // 账号(authentication.id)与目录各管一层:id 决定票据文件名,homedir 决定
742
+ // 票据落在哪个 auth 目录 —— 只有两层都分开,两个引擎才不会共用登录态。
693
743
  func codebuddyAIDir() string {
694
744
  home, err := os.UserHomeDir()
695
745
  if err != nil || home == "" {
@@ -700,17 +750,21 @@ func codebuddyAIDir() string {
700
750
 
701
751
  // ── 账号隔离:一个 CLI,两个账号 ─────────────────────────────
702
752
  //
703
- // CLI 的票据路径由 authentication.id 决定:
753
+ // CLI 的票据路径由 homedir + authentication.id 共同决定:
704
754
  //
705
- // sharedDataPath/auth/<authentication.id>.info (sharedDataPath 与配置目录无关)
755
+ // <os.homedir()>/AppData/Local/CodeBuddyExtension/Data/Public/auth/<id>.info
706
756
  //
707
- // 而 authentication.id 的取值链是:
757
+ // 而 id 的取值链是:
708
758
  //
709
759
  // ① 合并后的产品配置 → ② ACC_PRODUCT_CONFIG_PATH 指向的文件
710
760
  // → ③ ACC_PRODUCT_CONFIG_V3 / _V2(环境变量里的 JSON)→ ④ CLI 包内 product.json
711
761
  //
712
- // 实测(2026-09-28):独立运行时 ①② 为空,③ 生效 —— 给两个子进程注入不同的 id,
713
- // CLI 就各读各的票据、各用各的账号,互不顶号(改 id 后立刻改读另一份 .info 并能正常出正文)。
762
+ // 2026-09-28 实测:独立运行时 ①② 为空,③ 生效 —— 注入不同 id 就能各读各的票据。
763
+ // 但 CLI 2.174.0 起这条不再可靠:getAuthSavePath() 先读合并后的产品配置,
764
+ // 还会经 resolveCustomAuthId() 给文件名加 `-custom-<host>` 后缀,登录/刷新时
765
+ // 可能落回 codebuddy.info / Tencent-Cloud.coding-copilot.info(两个引擎同一账号)。
766
+ // 因此 2026-10-24 起 codebuddy-ai 再叠一层 **独立 homedir**(见 codebuddyAIHomeEnv),
767
+ // 把 auth 目录物理隔开;codebuddy 引擎维持原样(真 homedir + ~/.codebuddy)。
714
768
  //
715
769
  // ⚠️ ACC_PRODUCT_CONFIG_PATH 必须**清空**:父会话(WorkBuddy App / 内嵌会话)会把它
716
770
  // 指到自己的 acc-product-config,而它优先级**高于** ③,会把账号选回 App 的默认账号。
@@ -761,9 +815,36 @@ func codebuddyDir() string {
761
815
  }
762
816
 
763
817
  // codebuddyAIExtraEnv 返回 codebuddy-ai 子进程的账号环境变量。
764
- // 与 codebuddy 的差异只在账号标识与配置目录,二进制是同一个。
818
+ // 与 codebuddy 的差异在账号标识、配置目录,以及**独立的 user home**
819
+ // (见 codebuddyAIHomeEnv:票据按 homedir 落盘,必须物理隔开)。
765
820
  func codebuddyAIExtraEnv() []string {
766
- return codebuddyAccountEnv(codebuddyAIDir(), codebuddyAIAuthID, codebuddyAIAuthIDEnv)
821
+ env := codebuddyAccountEnv(codebuddyAIDir(), codebuddyAIAuthID, codebuddyAIAuthIDEnv)
822
+ return append(env, codebuddyAIHomeEnv()...)
823
+ }
824
+
825
+ // codebuddyAIHomeEnv 把 codebuddy-ai 的 sharedData(票据目录)关进它自己的目录。
826
+ //
827
+ // 为什么光靠 ACC_PRODUCT_CONFIG_V3 不够(CLI 2.174.0 实测):
828
+ // CLI 的票据路径是
829
+ //
830
+ // <os.homedir()>\AppData\Local\CodeBuddyExtension\Data\Public\auth\<id>.info
831
+ //
832
+ // 而 id 由 getAuthSavePath() 决定,顺序是「合并后的产品配置 → spilled 配置 →
833
+ // 环境变量 V3 → 包内 product.json」,且还会经 resolveCustomAuthId() 追加
834
+ // `-custom-<host>` 后缀。env 只是在某些时机生效,登录/刷新时可能落回
835
+ // `codebuddy.info` 或 `Tencent-Cloud.coding-copilot.info`,于是两个引擎共用
836
+ // 同一份票据(2026-10-24 实测:auth 目录里根本没有 codebuddy-ai.info)。
837
+ // 把 homedir 指到 ~/.codebuddy-ai 后,两边的 auth/ 目录物理隔离,id 怎么解析
838
+ // 都不会撞;codebuddy 引擎保持现状(真 homedir + ~/.codebuddy,票据不动)。
839
+ //
840
+ // Windows 上 Node 的 os.homedir() 读 USERPROFILE;HOME 在本机为空,git 仍按
841
+ // HOMEDRIVE/HOMEPATH 找到真实用户配置,不破坏工具链。其它平台读 HOME。
842
+ func codebuddyAIHomeEnv() []string {
843
+ home := codebuddyAIDir()
844
+ if runtime.GOOS == "windows" {
845
+ return []string{"USERPROFILE=" + home}
846
+ }
847
+ return []string{"HOME=" + home}
767
848
  }
768
849
 
769
850
  // bin 探测 codebuddy-ai CLI 路径。
@@ -799,6 +880,16 @@ func (e *CodeBuddyAIEngine) ModelCredits(ctx context.Context) map[string]string
799
880
  return e.core().modelCredits(bin)
800
881
  }
801
882
 
883
+ // ModelVendors 实现 ModelVendorLister(同 CodeBuddyEngine,见上)。
884
+ func (e *CodeBuddyAIEngine) ModelVendors(ctx context.Context) map[string]string {
885
+ return CLIModelVendors(e.core().effectiveConfigDir())
886
+ }
887
+
888
+ // ModelQuota 实现 ModelQuotaLister(同 CodeBuddyEngine,见上)。
889
+ func (e *CodeBuddyAIEngine) ModelQuota(ctx context.Context) map[string]QuotaWindows {
890
+ return CLIModelQuotas(ctx, e.core().effectiveConfigDir())
891
+ }
892
+
802
893
  // Complete 实现 Engine:单次调用 codebuddy-ai CLI。
803
894
  func (e *CodeBuddyAIEngine) Complete(ctx context.Context, req Request) (Response, error) {
804
895
  return e.core().complete(ctx, req)
@@ -1,6 +1,9 @@
1
1
  package agent
2
2
 
3
3
  import (
4
+ "os"
5
+ "path/filepath"
6
+ "runtime"
4
7
  "strings"
5
8
  "testing"
6
9
  )
@@ -126,3 +129,54 @@ func TestCodeBuddyLoginCommandMissingBin(t *testing.T) {
126
129
  t.Fatal("CLI 不存在时应返回错误")
127
130
  }
128
131
  }
132
+
133
+ // codebuddy-ai 必须连 homedir 一起隔离:CLI 2.174.0 的票据目录由 os.homedir()
134
+ // 决定,只靠 authentication.id 已不能保证两个引擎各用各的票据(见 codebuddyAIHomeEnv)。
135
+ // codebuddy 主引擎保持原样 —— 不注入 HOME/USERPROFILE,仍用真实 home 和现有票据。
136
+ func TestCodeBuddyAIHomeIsolation(t *testing.T) {
137
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
138
+ t.Setenv(codebuddyAIAuthIDEnv, "")
139
+
140
+ want := codebuddyAIDir()
141
+ ai := envMap(codebuddyAIExtraEnv())
142
+ if runtime.GOOS == "windows" {
143
+ if ai["USERPROFILE"] != want {
144
+ t.Errorf("USERPROFILE = %q, want %q", ai["USERPROFILE"], want)
145
+ }
146
+ if _, ok := ai["HOME"]; ok {
147
+ t.Errorf("Windows 上不应改写 HOME(git 依赖真实用户配置),实际 %q", ai["HOME"])
148
+ }
149
+ } else if ai["HOME"] != want {
150
+ t.Errorf("HOME = %q, want %q", ai["HOME"], want)
151
+ }
152
+
153
+ main := envMap(codebuddyAccountEnv(codebuddyDir(), codebuddyAuthID, codebuddyAuthIDEnv))
154
+ for _, k := range []string{"HOME", "USERPROFILE"} {
155
+ if v, ok := main[k]; ok {
156
+ t.Errorf("codebuddy 引擎不应改写 %s(实际 %q),否则会动到现有票据", k, v)
157
+ }
158
+ }
159
+ }
160
+
161
+ // codebuddy-ai 隔离后,清单来源改用本账号 CLI 自己下发的远程配置缓存:
162
+ // 按同目录选择器清单过滤具名模型、custom-local 一律保留(与 readAccCatalog 同语义)。
163
+ // 回归目标:桌面 App 的 acc 缓存(另一账号、只有 5 条)不再把清单钉死在 2 个 custom。
164
+ func TestRemoteCacheCatalogWithSelector(t *testing.T) {
165
+ dir := t.TempDir()
166
+ entry := `[{"userId":"u1","data":{` +
167
+ `"models":[{"id":"hy3","credits":"x0.00"},{"id":"hy3-b","credits":"x0.10"},` +
168
+ `{"id":"custom-local:MiniMax-M3"}],` +
169
+ `"agents":[{"name":"cli","models":["hy3"]}]}}]`
170
+ if err := os.WriteFile(filepath.Join(dir, "entry_a.info"), []byte(entry), 0o600); err != nil {
171
+ t.Fatal(err)
172
+ }
173
+
174
+ ids, credits := remoteCacheCatalogWithSelector(dir)
175
+ want := "hy3,custom-local:MiniMax-M3"
176
+ if got := strings.Join(ids, ","); got != want {
177
+ t.Errorf("清单 = %q, want %q(选择器外的具名模型应被过滤)", got, want)
178
+ }
179
+ if credits["hy3"] != "0.00" {
180
+ t.Errorf("倍率 hy3 = %q, want 0.00", credits["hy3"])
181
+ }
182
+ }
@@ -16,6 +16,7 @@ package agent
16
16
  // 两个开关都是"多发/少发"级别的错,所以必须有断言钉住。
17
17
 
18
18
  import (
19
+ "strconv"
19
20
  "strings"
20
21
  "testing"
21
22
  )
@@ -260,3 +261,105 @@ func TestStripThinkBlock(t *testing.T) {
260
261
  })
261
262
  }
262
263
  }
264
+
265
+ // TestStripToolMissingLines 「工具不存在」错误行的摘除边界。
266
+ func TestStripToolMissingLines(t *testing.T) {
267
+ const line = `Tool "AskUserQuestion" does not exist in the current tool set.`
268
+ cases := []struct {
269
+ name string
270
+ in string
271
+ want string
272
+ wantTools []string
273
+ }{
274
+ {"单独一行", line, "", []string{"AskUserQuestion"}},
275
+ {"夹在正文中间", "前半\n" + line + "\n后半", "前半\n后半", []string{"AskUserQuestion"}},
276
+ {"两次调用", line + line, "", []string{"AskUserQuestion", "AskUserQuestion"}},
277
+ {"别的工具报错不动", `Tool "Bash" is not available`, `Tool "Bash" is not available`, nil},
278
+ {"只有开头相同不动", `提及 Tool "x" 但没说不在工具集`, `提及 Tool "x" 但没说不在工具集`, nil},
279
+ {"没有关键字", "普通正文", "普通正文", nil},
280
+ }
281
+ for _, c := range cases {
282
+ t.Run(c.name, func(t *testing.T) {
283
+ got, tools := stripToolMissingLines(c.in)
284
+ if got != c.want {
285
+ t.Errorf("stripToolMissingLines(%q) = %q,want %q", c.in, got, c.want)
286
+ }
287
+ if strings.Join(tools, ",") != strings.Join(c.wantTools, ",") {
288
+ t.Errorf("工具名 = %v,want %v", tools, c.wantTools)
289
+ }
290
+ })
291
+ }
292
+ }
293
+
294
+ // TestToolMissingLineBecomesNotice 回归(2026-10-24 真机,codebuddy + hy3):
295
+ // 模型调 AskUserQuestion,CLI 把它当成**正文**推回来 ——
296
+ //
297
+ // Tool "AskUserQuestion" does not exist in the current tool set.
298
+ //
299
+ // 这句英文错误不能进正文(UI 里会夹在回答中间,回放同样脏),要改成一条
300
+ // KindNotice 过程提示;正文只留模型真正说的话。
301
+ func TestToolMissingLineBecomesNotice(t *testing.T) {
302
+ const errLine = `Tool "AskUserQuestion" does not exist in the current tool set.`
303
+ lines := []string{
304
+ `{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}`,
305
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"我需要先和你对齐。"}}}`,
306
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":` + strconv.Quote(errLine) + `}}}`,
307
+ // 新版 CLI 的流尾聚合行:同一份全文(含错误行)再给一次。
308
+ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":` +
309
+ strconv.Quote("我需要先和你对齐。"+errLine) + `}]}}`,
310
+ }
311
+ acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
312
+ evs := feedNDJSON(t, acc, lines)
313
+
314
+ var joined string
315
+ notices := 0
316
+ for _, ev := range evs {
317
+ switch ev.Kind {
318
+ case KindText:
319
+ joined += ev.Text
320
+ if strings.Contains(ev.Text, "does not exist") {
321
+ t.Fatalf("错误行进正文了:%q", ev.Text)
322
+ }
323
+ case KindNotice:
324
+ notices++
325
+ if !strings.Contains(ev.Text, "AskUserQuestion") {
326
+ t.Errorf("提示文案没提工具名:%q", ev.Text)
327
+ }
328
+ }
329
+ }
330
+ if joined != "我需要先和你对齐。" {
331
+ t.Fatalf("正文 = %q,want 只留模型说的话", joined)
332
+ }
333
+ if acc.Text.String() != joined {
334
+ t.Fatalf("acc.Text = %q,want 与正文一致", acc.Text.String())
335
+ }
336
+ // 增量与聚合行各出现一次,提示只该有一条(toolMissingSeen 去重)。
337
+ if notices != 1 {
338
+ t.Fatalf("KindNotice 条数 = %d,want 1", notices)
339
+ }
340
+ }
341
+
342
+ // TestTurnEndStripsToolMissingLine result 行带同一句错误时也不能漏进 turn_end。
343
+ func TestTurnEndStripsToolMissingLine(t *testing.T) {
344
+ const errLine = `Tool "AskUserQuestion" does not exist in the current tool set.`
345
+ acc := &streamAccumulator{Engine: "codebuddy"}
346
+ var kinds []StreamEventKind
347
+ got := ""
348
+ acc.OnEvent = func(ev StreamEvent) {
349
+ kinds = append(kinds, ev.Kind)
350
+ if ev.Kind == KindTurnEnd {
351
+ got = ev.Text
352
+ }
353
+ }
354
+ acc.emitTurnEnd("好,"+errLine+"\n继续", "s-1")
355
+ if strings.Contains(got, "does not exist") {
356
+ t.Fatalf("turn_end 仍带错误行:%q", got)
357
+ }
358
+ if got != "好,\n继续" {
359
+ t.Fatalf("turn_end = %q", got)
360
+ }
361
+ // 提示要在 turn_end 之前发出去(turn_end 是轮次边界,之后不该再有事件)。
362
+ if len(kinds) < 2 || kinds[len(kinds)-1] != KindTurnEnd || kinds[0] != KindNotice {
363
+ t.Fatalf("事件顺序 = %v,want [notice … turn_end]", kinds)
364
+ }
365
+ }