magic-agent 2.4.2 → 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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -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 +7 -10
  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 +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -199,7 +199,7 @@ func TestInstallCommandOf(t *testing.T) {
199
199
  "codex": "npm install -g @openai/codex",
200
200
  "openclaw": "npm install -g openclaw@latest",
201
201
  "dsh": "npm i -g @deepseek-ai/dsh",
202
- "trae": `sh -c "$(curl -L https://trae.cn/trae-cli/install.sh)"`,
202
+ "trae": `sh -c "$(curl -fsSL https://trae.cn/trae-cli/install_v2.sh)"`,
203
203
  "llm": "python3 -m venv ~/.llm-venv && ~/.llm-venv/bin/pip install llm",
204
204
  }
205
205
  for engine, want := range cases {
@@ -288,7 +288,7 @@ func TestFirstSemver(t *testing.T) {
288
288
  {"codex-cli 0.154.0", "0.154.0"},
289
289
  {"OpenClaw 2026.6.11 (e085fa1)", "2026.6.11"},
290
290
  {"0.1.5-rc.2", "0.1.5-rc.2"},
291
- {"trae-cli 0.120.52\n", "0.120.52"},
291
+ {"traecli 0.211.1\n", "0.211.1"},
292
292
  {"1.2.3+build.7", "1.2.3+build.7"},
293
293
  // 认不出来就空串(**不编**)
294
294
  {"no version here", ""},
@@ -4,10 +4,9 @@ package agent
4
4
  //
5
5
  // 用 fake CLI 脚本(写入临时目录的 shell 脚本)模拟三家 CLI 的行为,
6
6
  // 验证:
7
- // - 参数构造(--model、--tools ""、--output-format、-c model.name、
8
- // --query-timeout 映射)
7
+ // - 参数构造(--model、--tools ""、--output-format、-m model、exec resume)
9
8
  // - 输出解析(claude JSON envelope、codebuddy 三种 envelope、
10
- // user_query 回显剥离、trae 纯文本)
9
+ // user_query 回显剥离、trae 2.0 的 exec --json 事件流)
11
10
  // - FlattenPrompt 扁平化逻辑
12
11
  // - 引擎注册表 / Lookup
13
12
 
@@ -209,7 +208,7 @@ func TestReasoningEffortArgs(t *testing.T) {
209
208
  }
210
209
  // dsh 的 launcher/headless 没有 reasoning 开关:NoThinking 对它保持现状,
211
210
  // 不能塞 --effort(实测启动器报 "--profile <name> is required")。
212
- dshNo := dshHeadlessArgs("headless", "", "hi", false)
211
+ dshNo := dshHeadlessArgs("headless", "", "", "hi", false)
213
212
  if len(dshNo) < 3 || dshNo[0] != "--profile" || dshNo[1] != "headless" {
214
213
  t.Errorf("dsh 参数不应被 NoThinking 改坏: %v", dshNo)
215
214
  }
@@ -488,11 +487,11 @@ func TestCodeBuddyDefaultModel(t *testing.T) {
488
487
 
489
488
  // ── Trae 引擎 ─────────────────────────────────────────────────
490
489
 
491
- func TestTraeCompletePlainText(t *testing.T) {
490
+ func TestTraeComplete(t *testing.T) {
492
491
  dir := t.TempDir()
493
492
  log := filepath.Join(dir, "args.log")
494
- cli := filepath.Join(dir, "trae-cli")
495
- script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'trae 的回答'\n"
493
+ cli := filepath.Join(dir, "traex")
494
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\n" + traeV2Stub("trae 的回答", "th-1")
496
495
  if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
497
496
  t.Fatal(err)
498
497
  }
@@ -510,16 +509,18 @@ func TestTraeCompletePlainText(t *testing.T) {
510
509
  if resp.Text != "trae 的回答" {
511
510
  t.Errorf("Text = %q", resp.Text)
512
511
  }
512
+ if resp.SessionID != "th-1" {
513
+ t.Errorf("SessionID = %q want th-1", resp.SessionID)
514
+ }
513
515
 
514
516
  data, _ := os.ReadFile(log)
515
517
  args := string(data)
516
- // 默认模型:不传 -c model.name
517
- if strings.Contains(args, "model.name") {
518
- t.Errorf("default model should not pass -c model.name: %q", args)
518
+ // 2.0:exec --json,不再有 -p / --query-timeout / model.name
519
+ if !strings.Contains(args, "exec") || !strings.Contains(args, "--json") {
520
+ t.Errorf("2.0 应走 exec --json: %q", args)
519
521
  }
520
- // 超时映射到 --query-timeout
521
- if !strings.Contains(args, "--query-timeout 1m30s") {
522
- t.Errorf("--query-timeout not mapped: %q", args)
522
+ if strings.Contains(args, "--query-timeout") || strings.Contains(args, "model.name") {
523
+ t.Errorf("不应出现 1.x 参数: %q", args)
523
524
  }
524
525
  // 工具禁用
525
526
  if !strings.Contains(args, "--disallowed-tool Bash") {
@@ -534,8 +535,8 @@ func TestTraeCompletePlainText(t *testing.T) {
534
535
  func TestTraeExplicitModel(t *testing.T) {
535
536
  dir := t.TempDir()
536
537
  log := filepath.Join(dir, "args.log")
537
- cli := filepath.Join(dir, "trae-cli")
538
- script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
538
+ cli := filepath.Join(dir, "traex")
539
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\n" + traeV2Stub("ok", "th-1")
539
540
  if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
540
541
  t.Fatal(err)
541
542
  }
@@ -552,59 +553,19 @@ func TestTraeExplicitModel(t *testing.T) {
552
553
  }
553
554
  data, _ := os.ReadFile(log)
554
555
  args := string(data)
555
- if !strings.Contains(args, "-c model.name=My-MiniMax-M3") {
556
- t.Errorf("-c model.name not passed: %q", args)
556
+ if !strings.Contains(args, "-m My-MiniMax-M3") {
557
+ t.Errorf("-m model not passed: %q", args)
557
558
  }
558
559
  }
559
560
 
560
- func TestTraeTimeoutCappedAt600s(t *testing.T) {
561
- dir := t.TempDir()
562
- log := filepath.Join(dir, "args.log")
563
- cli := filepath.Join(dir, "trae-cli")
564
- script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
565
- if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
566
- t.Fatal(err)
567
- }
568
- e := &TraeEngine{BinPath: cli}
569
- _, err := e.Complete(context.Background(), Request{
570
- Messages: []Message{{Role: "user", Content: "hi"}},
571
- Timeout: 30 * time.Minute,
572
- })
573
- if err != nil {
574
- t.Fatalf("Complete: %v", err)
575
- }
576
- data, _ := os.ReadFile(log)
577
- if !strings.Contains(string(data), "--query-timeout 10m0s") {
578
- t.Errorf("query-timeout should cap at 600s: %q", string(data))
579
- }
561
+ // traeV2Stub 造 trae 2.0 的 JSONL 事件流(thread.started + 正文 + turn.completed)。
562
+ func traeV2Stub(text, sessionID string) string {
563
+ return "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"" + sessionID + "\"}'\n" +
564
+ "printf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"id\":\"i\",\"type\":\"agent_message\",\"text\":\"" + text + "\"}}'\n" +
565
+ "printf '%s\\n' '{\"type\":\"turn.completed\"}'\n"
580
566
  }
581
567
 
582
- // ── TraeDefaultModel 配置探测 ─────────────────────────────────
583
-
584
- func TestTraeDefaultModel(t *testing.T) {
585
- home := t.TempDir()
586
- t.Setenv("HOME", home)
587
-
588
- // 无配置文件 → 空串
589
- if got := TraeDefaultModel(); got != "" {
590
- t.Errorf("no config: got %q want empty", got)
591
- }
592
-
593
- // 有配置 → 读 model.name
594
- cfgDir := filepath.Join(home, ".trae")
595
- if err := os.MkdirAll(cfgDir, 0o755); err != nil {
596
- t.Fatal(err)
597
- }
598
- yaml := "model:\n name: My-MiniMax-M3\nmodels:\n- name: \"My-MiniMax-M3\"\n"
599
- if err := os.WriteFile(filepath.Join(cfgDir, "trae_cli.yaml"), []byte(yaml), 0o644); err != nil {
600
- t.Fatal(err)
601
- }
602
- if got := TraeDefaultModel(); got != "My-MiniMax-M3" {
603
- t.Errorf("got %q want My-MiniMax-M3", got)
604
- }
605
- }
606
-
607
- // ── 会话续接(SessionID → --resume 映射)──────────────────────
568
+ // ── 会话续接(SessionID → exec resume 映射)────────────────────
608
569
 
609
570
  // argsCaptureCLI 写一个把 "$*" 逐行记入 args.log 的假 CLI,返回 (引擎, log 路径)。
610
571
  func argsCaptureCLI(t *testing.T, dir, name, cannedOutput string) (*engineWithLog, string) {
@@ -737,9 +698,9 @@ echo '{"type":"result","result":"好"}'
737
698
  }
738
699
  }
739
700
 
740
- func TestTraeResumeSessionEqualsForm(t *testing.T) {
701
+ func TestTraeResumeSession(t *testing.T) {
741
702
  dir := t.TempDir()
742
- w, log := argsCaptureCLI(t, dir, "trae-cli", `echo '续上了'`)
703
+ w, log := argsCaptureCLI(t, dir, "trae-cli", traeV2Stub("续上了", "tr-9"))
743
704
  e := w.trae()
744
705
  resp, err := e.Complete(context.Background(), Request{
745
706
  Messages: []Message{{Role: "user", Content: "hi"}},
@@ -748,21 +709,23 @@ func TestTraeResumeSessionEqualsForm(t *testing.T) {
748
709
  if err != nil {
749
710
  t.Fatalf("Complete: %v", err)
750
711
  }
751
- // trae 纯文本模式 CLI 不回 session_id → 回显请求里的 id,保证逐轮透传。
712
+ // 2.0 的 thread.started 会给出真实会话 id。
752
713
  if resp.SessionID != "tr-9" {
753
- t.Errorf("Response.SessionID = %q want tr-9(请求回显)", resp.SessionID)
714
+ t.Errorf("Response.SessionID = %q want tr-9", resp.SessionID)
754
715
  }
755
716
  data, _ := os.ReadFile(log)
756
717
  args := string(data)
757
- // pflag 可选值风格:必须等号形式,空格形式下裸 id 会被吞成 AUTO。
758
- if !strings.Contains(args, "--resume=tr-9") {
759
- t.Errorf("--resume=tr-9 (equals form) not passed: %q", args)
718
+ if !strings.Contains(args, "exec resume tr-9") {
719
+ t.Errorf("exec resume tr-9 not passed: %q", args)
720
+ }
721
+ if strings.Contains(args, "--resume") {
722
+ t.Errorf("2.0 不应再出现 --resume: %q", args)
760
723
  }
761
724
  }
762
725
 
763
726
  func TestTraeNewSessionDefault(t *testing.T) {
764
727
  dir := t.TempDir()
765
- w, log := argsCaptureCLI(t, dir, "trae-cli", `echo 'ok'`)
728
+ w, log := argsCaptureCLI(t, dir, "trae-cli", traeV2Stub("ok", "th-new"))
766
729
  e := w.trae()
767
730
  resp, err := e.Complete(context.Background(), Request{
768
731
  Messages: []Message{{Role: "user", Content: "hi"}},
@@ -770,13 +733,12 @@ func TestTraeNewSessionDefault(t *testing.T) {
770
733
  if err != nil {
771
734
  t.Fatalf("Complete: %v", err)
772
735
  }
773
- // 新会话:请求里没有 id,回显也是空。
774
- if resp.SessionID != "" {
775
- t.Errorf("Response.SessionID = %q want empty", resp.SessionID)
736
+ if resp.SessionID != "th-new" {
737
+ t.Errorf("Response.SessionID = %q want th-new", resp.SessionID)
776
738
  }
777
739
  data, _ := os.ReadFile(log)
778
- if strings.Contains(string(data), "--resume") {
779
- t.Errorf("默认新会话不应带 --resume: %q", string(data))
740
+ if strings.Contains(string(data), "resume") {
741
+ t.Errorf("默认新会话不应带 resume: %q", string(data))
780
742
  }
781
743
  }
782
744
 
@@ -822,11 +784,10 @@ func TestCodeBuddyContinueSession(t *testing.T) {
822
784
  }
823
785
  }
824
786
 
825
- // trae 无 --continue:裸 --resume 触发 AUTO(续接最近一次会话),
826
- // 且不能是等号形式(等号形式是显式 id 续接)。
827
- func TestTraeContinueBareResume(t *testing.T) {
787
+ // trae 2.0:Continue → `exec resume --last`(不再有裸 --resume)。
788
+ func TestTraeContinueResumeLast(t *testing.T) {
828
789
  dir := t.TempDir()
829
- w, log := argsCaptureCLI(t, dir, "trae-cli", `echo 'ok'`)
790
+ w, log := argsCaptureCLI(t, dir, "trae-cli", traeV2Stub("ok", "th-last"))
830
791
  e := w.trae()
831
792
  if _, err := e.Complete(context.Background(), Request{
832
793
  Messages: []Message{{Role: "user", Content: "hi"}},
@@ -836,11 +797,11 @@ func TestTraeContinueBareResume(t *testing.T) {
836
797
  }
837
798
  data, _ := os.ReadFile(log)
838
799
  args := string(data)
839
- if !strings.Contains(args, "--resume") {
840
- t.Errorf("bare --resume not passed: %q", args)
800
+ if !strings.Contains(args, "exec resume --last") {
801
+ t.Errorf("exec resume --last not passed: %q", args)
841
802
  }
842
- if strings.Contains(args, "--resume=") {
843
- t.Errorf("Continue 应为裸 --resume,不带 =id: %q", args)
803
+ if strings.Contains(args, "--resume") {
804
+ t.Errorf("2.0 不应再出现 --resume: %q", args)
844
805
  }
845
806
  }
846
807
 
@@ -857,7 +818,7 @@ func TestSessionPriorityOverContinue(t *testing.T) {
857
818
  }{
858
819
  {"claude", "s-1", "--resume s-1", "--continue", "claude", `echo '{"type":"result","result":"ok"}'`},
859
820
  {"codebuddy", "cb-1", "--resume cb-1", "--continue", "codebuddy", `echo '{"type":"result","result":"ok"}'`},
860
- {"trae", "tr-1", "--resume=tr-1", "--continue", "trae-cli", `echo 'ok'`},
821
+ {"trae", "tr-1", "exec resume tr-1", "--continue", "trae-cli", traeV2Stub("ok", "tr-1")},
861
822
  }
862
823
  for _, tc := range cases {
863
824
  t.Run(tc.name, func(t *testing.T) {
@@ -999,7 +960,7 @@ func TestTemperatureIgnoredByCLIs(t *testing.T) {
999
960
  _, err := w.codebuddy().Complete(context.Background(), req)
1000
961
  return err
1001
962
  }},
1002
- {"trae", "trae-cli", `echo 'ok'`,
963
+ {"trae", "trae-cli", traeV2Stub("ok", "th-1"),
1003
964
  func(w *engineWithLog, req Request) error {
1004
965
  _, err := w.trae().Complete(context.Background(), req)
1005
966
  return err
@@ -0,0 +1,313 @@
1
+ package agent
2
+
3
+ // fallback.go - 主引擎失败时降级到备用引擎(2026-10-24;2026-10-26 按真机报障修口径)。
4
+ //
5
+ // 为什么需要它:额度用尽(如 dsh 的 402 Token Plan 上限)、临时不可用、单模型故障
6
+ // 都不该让一整轮直接失败 —— 用户在一次设置里就能指定「主 + 备」,主的那条不行时
7
+ // 自动换备用再跑一次。
8
+ //
9
+ // 2026-10-26 用户报「主备模型还没真的发挥作用」,真机作业记录给出了三个原因,逐个钉住:
10
+ //
11
+ // 1. **只当引擎变了才降级**:`fallback_engine == 主引擎` 直接被判「没配备用」。
12
+ // 手机上最常见的配法恰恰是同引擎换模型(dsh 的 modelverse 主、volcengine 备;
13
+ // codebuddy 一个账号换另一个模型)—— 以前这一路**永远不会降级**。
14
+ // 2. **备用模型被带到了备用引擎上**:清单是各引擎自己的,把 codebuddy 的模型喂给 dsh
15
+ // 只会得到「未知模型」。以前又恰好因为第 1 条,这条错路没被走到。
16
+ // 现在:同引擎沿用备用模型;换引擎时备用模型不往下传(FallbackDown),引擎用自己的默认。
17
+ // 3. **主引擎吐过思考 / 计划 / 工具调用就不降级**:额度耗尽往往在**第一段输出之前**
18
+ // 就会报出来,但有的引擎先吐空 delta 或只吐思考;只要不是正文,用户就没看到答案,
19
+ // 更不该让这一轮直接死掉。
20
+ //
21
+ // 四条纪律(写死,别放开):
22
+ //
23
+ // 1. **只有正文才算产出**:主引擎已经吐过正文 / 图片就不再换(工具结果也算 —— 说明
24
+ // 它已经被喂过输出、可能动过手了);思考、空正文、计划 / 待授权不拦降级 —— 换引擎
25
+ // 重跑时这些都只是过程,用户不会看到两套答案。
26
+ // 2. **降级清空会话锚点**:备用引擎没有主引擎那条会话(SessionID / Continue 都是
27
+ // 别人的),带过去只会 resume 失败。降级一定是「新开一轮」。
28
+ // 3. **只降一级**:备用失败就报「主备都失败」,不再链式找第三个 —— 配置里只有两档,
29
+ // 行为要可预期。ctx 已取消 / 超时同样不降级(不是引擎的错,重试也没用)。
30
+ // 4. **决定的理由进返回**:FallbackInfo 告诉调用方「降了没有、为什么没降」,
31
+ // 由 CLI 落到事件流的 `fallback` 帧 —— 用户能一眼确认主备确实接上了(问题 1 的现场
32
+ // 就是「配了、跑完了、没有任何痕迹」,没法判断到底生效没有)。
33
+ //
34
+ // 降级发生时先发一条 `fallback` 帧(结构化,客户端据此把这一轮标成「由备用引擎作答」),
35
+ // 再发一条 KindNotice(人话一行,老客户端照旧看到「已降级到备用引擎」)。
36
+
37
+ import (
38
+ "context"
39
+ "fmt"
40
+ "strings"
41
+ "time"
42
+ )
43
+
44
+ // RunOptions 一次非流式调用的编排参数(与 Runner 字段同义,供 RunWithFallback 复用)。
45
+ type RunOptions struct {
46
+ Timeout time.Duration
47
+ Retries int
48
+ Backoff time.Duration
49
+ Verbose bool
50
+ }
51
+
52
+ // FallbackInfo 这一轮的主备结论(无论降没降都有,供 CLI 落 fallback 帧 / 打日志)。
53
+ type FallbackInfo struct {
54
+ // PrimaryEngine / PrimaryModel 这一轮主引擎实际跑的引擎与模型。
55
+ PrimaryEngine string
56
+ PrimaryModel string
57
+ // ConfiguredEngine / ConfiguredModel 用户配的备用(原样)。
58
+ ConfiguredEngine string
59
+ ConfiguredModel string
60
+ // Used 是否真的降级并由备用引擎成功作答。
61
+ Used bool
62
+ // Engine / Model 备用引擎实际用的引擎与模型(Used 时有意义)。
63
+ Engine string
64
+ Model string
65
+ // SwitchedEngine 备用是另一个引擎(而不是同引擎换模型)。
66
+ SwitchedEngine bool
67
+ // Reason 没降级的原因(人话,中文);降级时为空。客户端直接显示。
68
+ Reason string
69
+ // Err 主引擎的原始错误(没降级且主引擎失败时非空)。
70
+ Err error
71
+ }
72
+
73
+ // FallbackEvent 降级那一刻发的事件正文(StreamEvent.Fallback)。
74
+ type FallbackEvent struct {
75
+ FromEngine string `json:"from_engine"`
76
+ FromModel string `json:"from_model,omitempty"`
77
+ ToEngine string `json:"to_engine"`
78
+ ToModel string `json:"to_model,omitempty"`
79
+ Switched bool `json:"switched_engine"`
80
+ Reason string `json:"reason,omitempty"`
81
+ }
82
+
83
+ // event 把 FallbackEvent 转成一条 StreamEvent(Kind=fallback)。
84
+ func (f FallbackEvent) event() StreamEvent {
85
+ return StreamEvent{Kind: KindFallback, Text: FallbackNoticeText(f), Fallback: &f}
86
+ }
87
+
88
+ // fallbackTarget 解析降级目标:返回备用引擎对象与改写后的请求。
89
+ //
90
+ // 没配 / 配的就是主引擎且没换模型 / 引擎不存在 / 引擎没有所需能力(流式)时 ok=false,
91
+ // 原因在 reason(人话;没配备用时为空)。调用方保留原始错误。
92
+ func fallbackTarget(primary Engine, req Request, needStream bool) (Engine, Request, string, bool) {
93
+ name := strings.TrimSpace(req.FallbackEngine)
94
+ if name == "" {
95
+ return nil, req, "", false
96
+ }
97
+ if strings.EqualFold(name, primary.Name()) && strings.TrimSpace(req.FallbackModel) == "" {
98
+ return nil, req, "备用就是主引擎(引擎与模型都相同),没有可降级的目标", false
99
+ }
100
+ eng := Lookup(name)
101
+ if eng == nil {
102
+ return nil, req, fmt.Sprintf("备用引擎「%s」不存在,没有降级", name), false
103
+ }
104
+ if needStream && AsStreamer(eng) == nil {
105
+ return nil, req, fmt.Sprintf("备用引擎「%s」不支持流式调用,没有降级", name), false
106
+ }
107
+ fb := req
108
+ fb.Engine = eng.Name()
109
+ // 会话锚点是主引擎的,备用引擎接不上(见文件头第 2 条)。
110
+ fb.SessionID = ""
111
+ fb.Continue = false
112
+ // 只降一级。
113
+ fb.FallbackEngine = ""
114
+ fb.FallbackModel = ""
115
+ fb.FallbackDown = true
116
+ if strings.EqualFold(eng.Name(), primary.Name()) {
117
+ // 同引擎换模型:备用模型就是这一路的意义所在,原样带上。
118
+ fb.Model = strings.TrimSpace(req.FallbackModel)
119
+ } else {
120
+ /* 换引擎:备用模型属于主引擎的清单(codebuddy 的 hy3 喂给 dsh = 未知模型)。
121
+ 清空 + FallbackDown,让备用引擎用自己的默认模型跑 —— 有答案胜过必然失败。 */
122
+ fb.Model = ""
123
+ }
124
+ return eng, fb, "", true
125
+ }
126
+
127
+ // configured 用户是否配了备用(引擎非空且与主引擎不同,或同引擎换了模型)。
128
+ func configured(primary Engine, req Request) bool {
129
+ fb := strings.TrimSpace(req.FallbackEngine)
130
+ if fb == "" {
131
+ return false
132
+ }
133
+ if !strings.EqualFold(fb, primary.Name()) {
134
+ return true
135
+ }
136
+ return strings.TrimSpace(req.FallbackModel) != ""
137
+ }
138
+
139
+ // FallbackNoticeText 降级提示的文案(主会话里的一行,不是错误)。
140
+ //
141
+ // 同引擎换模型时说「已切到备用模型」—— 用户配的正是模型,提示里得看到它。
142
+ func FallbackNoticeText(f FallbackEvent) string {
143
+ reason := strings.TrimSpace(f.Reason)
144
+ if reason == "" {
145
+ reason = "主引擎失败"
146
+ }
147
+ to := fmt.Sprintf("「%s」", f.ToEngine)
148
+ if f.ToModel != "" {
149
+ to = fmt.Sprintf("「%s」的 %s", f.ToEngine, f.ToModel)
150
+ }
151
+ if !f.Switched && f.FromEngine == f.ToEngine {
152
+ return fmt.Sprintf("「%s」执行失败(%s),已切到备用模型%s重试", f.FromEngine, reason, to)
153
+ }
154
+ return fmt.Sprintf("「%s」执行失败(%s),已降级到备用引擎%s重试", f.FromEngine, reason, to)
155
+ }
156
+
157
+ // eventProduced 这条事件算不算「已经有产出了」(见文件头纪律第 1 条)。
158
+ //
159
+ // 思考**不算**:它只是过程;用户没看到任何答案,换引擎重跑不会让他看到两套正文。
160
+ // 空白正文也不算(有些引擎会发空 delta)。工具结果算 —— 主引擎已经动过手。
161
+ func eventProduced(ev StreamEvent) bool {
162
+ switch ev.Kind {
163
+ case KindText, KindImage:
164
+ return strings.TrimSpace(ev.Text) != ""
165
+ case KindToolResult:
166
+ return strings.TrimSpace(ev.Text) != ""
167
+ }
168
+ return false
169
+ }
170
+
171
+ // stripNonResetKinds 去掉「结束这一轮」语义的过程帧:降级后备用引擎紧接着还要跑,
172
+ // 调用方若把主引擎残留的 turn_end / turn_failed 转出去,这一轮会在备用引擎开口之前
173
+ // 就被画成「已结束」(或直接关机),备用答案再无去处。
174
+ //
175
+ // 留下来的其它事件(思考、工具卡、计划 / 待授权、notice)照常转发:它们已落盘,是真实过程。
176
+ func stripNonResetKinds(ev StreamEvent) (StreamEvent, bool) {
177
+ switch ev.Kind {
178
+ case KindTurnEnd, KindTurnFailed:
179
+ return StreamEvent{}, false
180
+ }
181
+ return ev, true
182
+ }
183
+
184
+ // StreamWithFallback 流式执行:主引擎失败或只有过程、没有正文时,自动换备用引擎重跑一次。
185
+ //
186
+ // primary 必须是 Streamer(团队子任务与 ask 的流式路径都满足)。
187
+ // onEvent 收到的 fallback / notice 需要调用方照常转发(它们属于「过程」,不是错误)。
188
+ // 返回的 info 说明这一轮的结论(没降级时的原因由调用方落 fallback 帧)。
189
+ func StreamWithFallback(ctx context.Context, primary Engine, req Request, onEvent func(StreamEvent)) (StreamResult, FallbackInfo, error) {
190
+ info := FallbackInfo{
191
+ PrimaryEngine: primary.Name(),
192
+ PrimaryModel: req.Model,
193
+ ConfiguredEngine: strings.TrimSpace(req.FallbackEngine),
194
+ ConfiguredModel: strings.TrimSpace(req.FallbackModel),
195
+ }
196
+ if st := AsStreamer(primary); st == nil {
197
+ return StreamResult{}, info, fmt.Errorf("%s 不支持流式调用", primary.Name())
198
+ }
199
+ if onEvent == nil {
200
+ onEvent = func(StreamEvent) {}
201
+ }
202
+ produced := false
203
+ watch := func(ev StreamEvent) {
204
+ if eventProduced(ev) {
205
+ produced = true
206
+ }
207
+ onEvent(ev)
208
+ }
209
+ res, err := AsStreamer(primary).Stream(ctx, req, watch)
210
+ if err == nil || produced || ctx.Err() != nil {
211
+ if produced && configured(primary, req) {
212
+ info.Reason = "主引擎已经输出正文,本轮不降级(避免两段互相矛盾的答案)"
213
+ }
214
+ return res, info, err
215
+ }
216
+ eng, fbReq, blocked, ok := fallbackTarget(primary, req, true)
217
+ if !ok {
218
+ info.Reason = blocked
219
+ return res, info, err
220
+ }
221
+ fe := FallbackEvent{
222
+ FromEngine: primary.Name(),
223
+ FromModel: strings.TrimSpace(req.Model),
224
+ ToEngine: eng.Name(),
225
+ ToModel: strings.TrimSpace(fbReq.Model),
226
+ Switched: !strings.EqualFold(eng.Name(), primary.Name()),
227
+ Reason: shortReason(err),
228
+ }
229
+ onEvent(fe.event())
230
+ res2, err2 := AsStreamer(eng).Stream(ctx, fbReq, func(ev StreamEvent) {
231
+ if kept, ok := stripNonResetKinds(ev); ok {
232
+ onEvent(kept)
233
+ }
234
+ })
235
+ if err2 == nil {
236
+ info.Used = true
237
+ info.Engine = eng.Name()
238
+ info.Model = effectiveModel(strings.TrimSpace(res2.Model), fe.ToModel)
239
+ info.SwitchedEngine = fe.Switched
240
+ return res2, info, nil
241
+ }
242
+ return res2, info, fmt.Errorf("「%s」失败(%s);备用引擎「%s」也失败:%w",
243
+ primary.Name(), shortReason(err), eng.Name(), err2)
244
+ }
245
+
246
+ // RunWithFallback 非流式执行(含超时与重试):主引擎跑完仍失败且没拿到正文时,
247
+ // 换备用引擎按同样的超时 / 重试策略再跑一次。
248
+ //
249
+ // onFallback 可为 nil;非空时在降级前调用一次(CLI 用它往 stderr / 事件流写提示)。
250
+ func RunWithFallback(ctx context.Context, primary Engine, req Request, ro RunOptions, onFallback func(FallbackEvent)) (Response, FallbackInfo, error) {
251
+ info := FallbackInfo{
252
+ PrimaryEngine: primary.Name(),
253
+ PrimaryModel: req.Model,
254
+ ConfiguredEngine: strings.TrimSpace(req.FallbackEngine),
255
+ ConfiguredModel: strings.TrimSpace(req.FallbackModel),
256
+ }
257
+ run := func(e Engine, r Request) (Response, error) {
258
+ return (&Runner{Engine: e, Timeout: ro.Timeout, Retries: ro.Retries, Backoff: ro.Backoff, Verbose: ro.Verbose}).Run(ctx, r)
259
+ }
260
+ resp, err := run(primary, req)
261
+ if err == nil || strings.TrimSpace(resp.Text) != "" || ctx.Err() != nil {
262
+ return resp, info, err
263
+ }
264
+ eng, fbReq, blocked, ok := fallbackTarget(primary, req, false)
265
+ if !ok {
266
+ info.Reason = blocked
267
+ return resp, info, err
268
+ }
269
+ fe := FallbackEvent{
270
+ FromEngine: primary.Name(),
271
+ FromModel: strings.TrimSpace(req.Model),
272
+ ToEngine: eng.Name(),
273
+ ToModel: strings.TrimSpace(fbReq.Model),
274
+ Switched: !strings.EqualFold(eng.Name(), primary.Name()),
275
+ Reason: shortReason(err),
276
+ }
277
+ if onFallback != nil {
278
+ onFallback(fe)
279
+ }
280
+ resp2, err2 := run(eng, fbReq)
281
+ if err2 != nil {
282
+ // 备用也没成:Engine 留空 —— 它是「谁给出了答案」的标签,没答案就不记。
283
+ return resp2, info, fmt.Errorf("「%s」失败(%s);备用引擎「%s」也失败:%w",
284
+ primary.Name(), shortReason(err), eng.Name(), err2)
285
+ }
286
+ info.Used = true
287
+ info.Engine = eng.Name()
288
+ info.SwitchedEngine = fe.Switched
289
+ info.Model = effectiveModel(strings.TrimSpace(resp2.Model), fe.ToModel)
290
+ return resp2, info, nil
291
+ }
292
+
293
+ // effectiveModel 备用引擎实际用的模型标签:引擎没回填时用降级目标上配的那个,
294
+ // 两者都空 = 引擎默认模型(标签留空,不编)。
295
+ func effectiveModel(reported, target string) string {
296
+ if reported != "" {
297
+ return reported
298
+ }
299
+ return target
300
+ }
301
+
302
+ // shortReason 降级提示里用的短原因:去掉重复的外层包装,压成一行。
303
+ func shortReason(err error) string {
304
+ s := strings.TrimSpace(ReasonOf(err))
305
+ if s == "" {
306
+ s = strings.TrimSpace(err.Error())
307
+ }
308
+ s = strings.ReplaceAll(s, "\n", " ")
309
+ if n := len([]rune(s)); n > 160 {
310
+ s = string([]rune(s)[:160]) + "…"
311
+ }
312
+ return s
313
+ }