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.
- package/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- 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 -
|
|
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
|
-
{"
|
|
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、-
|
|
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
|
|
490
|
+
func TestTraeComplete(t *testing.T) {
|
|
492
491
|
dir := t.TempDir()
|
|
493
492
|
log := filepath.Join(dir, "args.log")
|
|
494
|
-
cli := filepath.Join(dir, "
|
|
495
|
-
script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\
|
|
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
|
-
//
|
|
517
|
-
if strings.Contains(args, "
|
|
518
|
-
t.Errorf("
|
|
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
|
-
|
|
521
|
-
|
|
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, "
|
|
538
|
-
script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\
|
|
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, "-
|
|
556
|
-
t.Errorf("-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
// ──
|
|
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
|
|
701
|
+
func TestTraeResumeSession(t *testing.T) {
|
|
741
702
|
dir := t.TempDir()
|
|
742
|
-
w, log := argsCaptureCLI(t, dir, "trae-cli",
|
|
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
|
-
//
|
|
712
|
+
// 2.0 的 thread.started 会给出真实会话 id。
|
|
752
713
|
if resp.SessionID != "tr-9" {
|
|
753
|
-
t.Errorf("Response.SessionID = %q want tr-9
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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",
|
|
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
|
-
|
|
774
|
-
|
|
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), "
|
|
779
|
-
t.Errorf("默认新会话不应带
|
|
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
|
|
826
|
-
|
|
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",
|
|
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, "--
|
|
840
|
-
t.Errorf("
|
|
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("
|
|
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", "
|
|
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",
|
|
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
|
+
}
|