magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  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 +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -15,11 +15,13 @@ package client
15
15
  import (
16
16
  "bytes"
17
17
  "context"
18
+ "crypto/sha256"
19
+ "encoding/hex"
18
20
  "encoding/json"
19
21
  "io"
20
22
  "net/http"
21
- "os/exec"
22
23
  "strings"
24
+ "sync"
23
25
  "time"
24
26
  "unicode/utf8"
25
27
 
@@ -41,6 +43,77 @@ const (
41
43
  // 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
42
44
  var suggestSlots = make(chan struct{}, 2)
43
45
 
46
+ // 建议缓存:同一轮问答(user+reply+引擎/模型都相同)的建议只跑一次模型调用。
47
+ // 界面在会话切换 / 重渲染时会重复请求同一轮的建议,没缓存就每次都起一个旁路
48
+ // 引擎进程白烧一次调用(用户 2026-10-24:「会话的建议要有缓存 不能每次都重复跑」)。
49
+ // 只缓存**成功**响应(含空数组——空也是合法结果);失败不缓存,下次重试。
50
+ const (
51
+ suggestCacheTTL = 30 * time.Minute
52
+ suggestCacheMaxSize = 128
53
+ )
54
+
55
+ type suggestCacheEntry struct {
56
+ payload []byte // 成功响应的 JSON(已保证 Suggestions 非 nil)
57
+ expireAt time.Time
58
+ usedAt time.Time // LRU 淘汰用
59
+ }
60
+
61
+ var suggestCache = struct {
62
+ sync.Mutex
63
+ m map[string]suggestCacheEntry
64
+ }{m: make(map[string]suggestCacheEntry)}
65
+
66
+ // suggestCacheKey 按完整请求内容算 key:user/reply 一样但换了引擎/模型就分开算。
67
+ func suggestCacheKey(body struct {
68
+ User string `json:"user"`
69
+ Reply string `json:"reply"`
70
+ Engine string `json:"engine"`
71
+ Model string `json:"model"`
72
+ FallbackEngine string `json:"fallbackEngine"`
73
+ FallbackModel string `json:"fallbackModel"`
74
+ }) string {
75
+ h := sha256.Sum256([]byte(strings.Join([]string{
76
+ body.User, body.Reply, body.Engine, body.Model, body.FallbackEngine, body.FallbackModel,
77
+ }, "\x00")))
78
+ return hex.EncodeToString(h[:])
79
+ }
80
+
81
+ // suggestCacheGet 命中返回响应 JSON;顺带做惰性过期与 LRU 容量控制。
82
+ func suggestCacheGet(key string) ([]byte, bool) {
83
+ suggestCache.Lock()
84
+ defer suggestCache.Unlock()
85
+ e, ok := suggestCache.m[key]
86
+ if !ok {
87
+ return nil, false
88
+ }
89
+ if time.Now().After(e.expireAt) {
90
+ delete(suggestCache.m, key)
91
+ return nil, false
92
+ }
93
+ e.usedAt = time.Now()
94
+ suggestCache.m[key] = e
95
+ return e.payload, true
96
+ }
97
+
98
+ func suggestCachePut(key string, payload []byte) {
99
+ suggestCache.Lock()
100
+ defer suggestCache.Unlock()
101
+ if len(suggestCache.m) >= suggestCacheMaxSize {
102
+ // 淘汰最久未用的一条(缓存最多 128 条,线性找足够快)。
103
+ var oldestKey string
104
+ var oldest time.Time
105
+ first := true
106
+ for k, v := range suggestCache.m {
107
+ if first || v.usedAt.Before(oldest) {
108
+ oldestKey, oldest, first = k, v.usedAt, false
109
+ }
110
+ }
111
+ delete(suggestCache.m, oldestKey)
112
+ }
113
+ now := time.Now()
114
+ suggestCache.m[key] = suggestCacheEntry{payload: payload, expireAt: now.Add(suggestCacheTTL), usedAt: now}
115
+ }
116
+
44
117
  // handleSuggest POST /desk/suggest。
45
118
  //
46
119
  // 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
@@ -55,6 +128,10 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
55
128
  Reply string `json:"reply"`
56
129
  Engine string `json:"engine"`
57
130
  Model string `json:"model"`
131
+ // 建议引擎的主备(2026-10-24,只增字段):主建议引擎失败时先试它,
132
+ // 再落到 core 内置的 llm 兜底。空 = 老行为。
133
+ FallbackEngine string `json:"fallbackEngine"`
134
+ FallbackModel string `json:"fallbackModel"`
58
135
  }
59
136
  if r.Body != nil {
60
137
  if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
@@ -70,6 +147,14 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
70
147
  body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
71
148
  body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
72
149
 
150
+ // 同一轮问答的重复请求直接回缓存(切换会话/重渲染会反复要同一轮的建议)。
151
+ cacheKey := suggestCacheKey(body)
152
+ if payload, ok := suggestCacheGet(cacheKey); ok {
153
+ w.Header().Set("X-Suggest-Cache", "hit")
154
+ writeJSONBytes(w, http.StatusOK, payload)
155
+ return
156
+ }
157
+
73
158
  select {
74
159
  case suggestSlots <- struct{}{}:
75
160
  defer func() { <-suggestSlots }()
@@ -90,7 +175,13 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
90
175
  if m := strings.TrimSpace(body.Model); m != "" {
91
176
  args = append(args, "-m", m)
92
177
  }
93
- cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
178
+ if fb := strings.TrimSpace(body.FallbackEngine); fb != "" {
179
+ args = append(args, "--suggest-fallback-engine", fb)
180
+ if m := strings.TrimSpace(body.FallbackModel); m != "" {
181
+ args = append(args, "--suggest-fallback-model", m)
182
+ }
183
+ }
184
+ cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
94
185
  cmd.Stdin = bytes.NewReader(payload)
95
186
  var stdout, stderr bytes.Buffer
96
187
  cmd.Stdout = &stdout
@@ -119,9 +210,21 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
119
210
  if res.Suggestions == nil {
120
211
  res.Suggestions = []string{}
121
212
  }
213
+ if payload, err := json.Marshal(res); err == nil {
214
+ suggestCachePut(cacheKey, payload)
215
+ writeJSONBytes(w, http.StatusOK, payload)
216
+ return
217
+ }
122
218
  writeJSON(w, http.StatusOK, res)
123
219
  }
124
220
 
221
+ // writeJSONBytes 按预序列化的字节写响应(缓存命中与缓存写入共用同一份形状)。
222
+ func writeJSONBytes(w http.ResponseWriter, code int, payload []byte) {
223
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
224
+ w.WriteHeader(code)
225
+ _, _ = w.Write(payload)
226
+ }
227
+
125
228
  // clipRunes 按 rune 截断(中文不能按字节切)。
126
229
  func clipRunes(s string, max int) string {
127
230
  if max <= 0 || utf8.RuneCountInString(s) <= max {
@@ -6,15 +6,31 @@ import (
6
6
  "net/http/httptest"
7
7
  "os"
8
8
  "path/filepath"
9
+ "runtime"
9
10
  "strings"
10
11
  "testing"
11
12
  )
12
13
 
13
14
  // fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
15
+ //
16
+ // Windows 上 exec 不了 sh 脚本(既有基线),改写等价的 .cmd 批处理 —— scriptBody
17
+ // 仍收 sh 语法,由映射表翻成 cmd 等价物;没翻译的写法直接 Fatal,防止静默跑错。
14
18
  func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
15
19
  t.Helper()
16
20
  argLog := filepath.Join(dir, "args.txt")
17
21
  p := filepath.Join(dir, "magic-agent")
22
+ if runtime.GOOS == "windows" {
23
+ p += ".cmd"
24
+ win, ok := shToCmd[scriptBody]
25
+ if !ok {
26
+ t.Fatalf("没给该 sh 脚本体准备 Windows 等价物:%q", scriptBody)
27
+ }
28
+ body := "@echo off\r\necho %*>\"" + argLog + "\"\r\nfind /v \"\" >NUL\r\n" + win + "\r\n"
29
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ return p, argLog
33
+ }
18
34
  body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
19
35
  if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
20
36
  t.Fatal(err)
@@ -22,6 +38,15 @@ func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
22
38
  return p, argLog
23
39
  }
24
40
 
41
+ // shToCmd 测试里用到的假 core 脚本体的 cmd 等价写法。
42
+ var shToCmd = map[string]string{
43
+ `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`: `echo {"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}`,
44
+ `printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`: `echo {"suggestions":["继续"],"engine":"fake","elapsedMs":9}`,
45
+ `printf '{"suggestions":[]}\n'`: `echo {"suggestions":[]}`,
46
+ "echo 'llm CLI not found' >&2\nexit 3": `echo llm CLI not found 1>&2&exit /b 3`,
47
+ `printf 'not json\n'`: `echo not json`,
48
+ }
49
+
25
50
  func TestSuggestEndpoint(t *testing.T) {
26
51
  dir := t.TempDir()
27
52
  core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
@@ -53,6 +78,67 @@ func TestSuggestEndpoint(t *testing.T) {
53
78
  }
54
79
  }
55
80
 
81
+ func TestSuggestEndpointCacheHit(t *testing.T) {
82
+ dir := t.TempDir()
83
+ core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`)
84
+ s := &Service{opts: Options{MagicAgent: core}}
85
+ body := `{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine"}`
86
+
87
+ call := func() *httptest.ResponseRecorder {
88
+ rec := httptest.NewRecorder()
89
+ s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(body)))
90
+ return rec
91
+ }
92
+
93
+ first := call()
94
+ if first.Code != http.StatusOK {
95
+ t.Fatalf("首次 code = %d,body=%s", first.Code, first.Body.String())
96
+ }
97
+ if first.Header().Get("X-Suggest-Cache") == "hit" {
98
+ t.Fatalf("首次请求不应命中缓存")
99
+ }
100
+ second := call()
101
+ if second.Code != http.StatusOK {
102
+ t.Fatalf("二次 code = %d,body=%s", second.Code, second.Body.String())
103
+ }
104
+ if second.Header().Get("X-Suggest-Cache") != "hit" {
105
+ t.Fatalf("同内容的第二次请求应命中缓存")
106
+ }
107
+ if second.Body.String() != first.Body.String() {
108
+ t.Fatalf("缓存响应与首次不一致:%s vs %s", second.Body.String(), first.Body.String())
109
+ }
110
+
111
+ // 换引擎 = 换 key,不能复用旧缓存。
112
+ third := httptest.NewRecorder()
113
+ s.handleSuggest(third, httptest.NewRequest(http.MethodPost, "/desk/suggest",
114
+ strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"other-engine"}`)))
115
+ if third.Header().Get("X-Suggest-Cache") == "hit" {
116
+ t.Fatalf("换了引擎应重新生成,不该命中缓存")
117
+ }
118
+
119
+ // 失败不缓存:core 挂了下次要重试。
120
+ failing := &Service{opts: Options{MagicAgent: fakeSuggestCoreFailing(t, dir)}}
121
+ f1 := httptest.NewRecorder()
122
+ failing.handleSuggest(f1, httptest.NewRequest(http.MethodPost, "/desk/suggest",
123
+ strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
124
+ if f1.Code != http.StatusBadGateway {
125
+ t.Fatalf("失败轮 code = %d,期望 502", f1.Code)
126
+ }
127
+ f2 := httptest.NewRecorder()
128
+ failing.handleSuggest(f2, httptest.NewRequest(http.MethodPost, "/desk/suggest",
129
+ strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
130
+ if f2.Code != http.StatusBadGateway {
131
+ t.Fatalf("失败后重试仍应走 core(code = %d,期望 502)", f2.Code)
132
+ }
133
+ }
134
+
135
+ // fakeSuggestCoreFailing 恒定失败的假 core(复用 fakeSuggestCore 的平台适配)。
136
+ func fakeSuggestCoreFailing(t *testing.T, dir string) string {
137
+ t.Helper()
138
+ p, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
139
+ return p
140
+ }
141
+
56
142
  func TestSuggestEndpointBadRequests(t *testing.T) {
57
143
  dir := t.TempDir()
58
144
  core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
@@ -92,21 +92,35 @@ func teamFlags(t *teamRequest, profile Profile, roles []string) ([]string, error
92
92
  r := t.Routes[rawKind]
93
93
  engine := strings.TrimSpace(r.Engine)
94
94
  model := strings.TrimSpace(r.Model)
95
- if engine == "" {
95
+ fbEngine := strings.TrimSpace(r.FallbackEngine)
96
+ fbModel := strings.TrimSpace(r.FallbackModel)
97
+ /* 备用可以单独配(主引擎留空 = 跟随主引擎 + 这一路的备):所以这里不能用
98
+ 「engine 为空就 continue」把整条略掉 —— 有备用就得下发。 */
99
+ if engine != "" {
100
+ if !profile.AllowsEngine(engine) {
101
+ return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
102
+ }
103
+ spec := kind + "=" + engine
96
104
  if model != "" {
97
- // 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
98
- return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
105
+ spec += "/" + model
99
106
  }
100
- continue // 整条空 = 该类跟随主引擎
101
- }
102
- if !profile.AllowsEngine(engine) {
103
- return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
107
+ args = append(args, "--team-route", spec)
108
+ } else if model != "" {
109
+ // 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
110
+ return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
104
111
  }
105
- spec := kind + "=" + engine
106
- if model != "" {
107
- spec += "/" + model
112
+ if fbEngine != "" {
113
+ if !profile.AllowsEngine(fbEngine) {
114
+ return nil, fmt.Errorf("team.routes.%s 的备用引擎 %q 不在 profile 白名单内", kind, fbEngine)
115
+ }
116
+ spec := kind + "=" + fbEngine
117
+ if fbModel != "" {
118
+ spec += "/" + fbModel
119
+ }
120
+ args = append(args, "--team-fallback", spec)
121
+ } else if fbModel != "" {
122
+ return nil, fmt.Errorf("team.routes.%s 给了备用 model 但没给备用 engine", kind)
108
123
  }
109
- args = append(args, "--team-route", spec)
110
124
  }
111
125
  return args, nil
112
126
  }
@@ -146,6 +146,45 @@ func TestTeamFlagsRejectsModelWithoutEngine(t *testing.T) {
146
146
  }
147
147
  }
148
148
 
149
+ func TestTeamFlagsFallbackRoute(t *testing.T) {
150
+ // 主备(2026-10-24):主 + 备都要翻成 flag;只配备用也要下发(主引擎跟随主引擎)。
151
+ args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
152
+ "image": {Engine: "codebuddy", Model: "hy3", FallbackEngine: "dsh", FallbackModel: "v3"},
153
+ "text": {FallbackEngine: "claude"},
154
+ }}, Profile{}, nil)
155
+ if err != nil {
156
+ t.Fatalf("unexpected err: %v", err)
157
+ }
158
+ joined := strings.Join(args, " ")
159
+ if !strings.Contains(joined, "--team-route image=codebuddy/hy3") {
160
+ t.Fatalf("主引擎没下发:%v", args)
161
+ }
162
+ if !strings.Contains(joined, "--team-fallback image=dsh/v3") {
163
+ t.Fatalf("备用引擎没下发:%v", args)
164
+ }
165
+ if !strings.Contains(joined, "--team-fallback text=claude") {
166
+ t.Fatalf("只配备用的路由没下发:%v", args)
167
+ }
168
+ if strings.Contains(joined, "--team-route text=") {
169
+ t.Fatalf("只配备用不该伪造主引擎:%v", args)
170
+ }
171
+ }
172
+
173
+ func TestTeamFlagsRejectsBadFallback(t *testing.T) {
174
+ // 备用引擎同样要过白名单。
175
+ if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
176
+ "image": {Engine: "llm", FallbackEngine: "claude"},
177
+ }}, Profile{Engines: []string{"llm"}}, nil); err == nil {
178
+ t.Fatal("备用引擎不在白名单时应报错")
179
+ }
180
+ // 备用 model 没有备用 engine:语义不明,不猜。
181
+ if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
182
+ "image": {FallbackModel: "sonnet"},
183
+ }}, Profile{}, nil); err == nil || !strings.Contains(err.Error(), "备用") {
184
+ t.Fatalf("err = %v,应提到备用", err)
185
+ }
186
+ }
187
+
149
188
  func TestTeamFlagsAcceptsPlanRoute(t *testing.T) {
150
189
  // plan = 计划阶段(谁来拆任务),与五类子任务一样可以单独指定引擎。
151
190
  args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"plan": {Engine: "claude"}}}, Profile{}, nil)
@@ -0,0 +1,88 @@
1
+ package client
2
+
3
+ import (
4
+ "fmt"
5
+ "log"
6
+ "net"
7
+ "net/http"
8
+ "os"
9
+ "os/signal"
10
+ "path/filepath"
11
+ "strings"
12
+ "syscall"
13
+ "testing"
14
+ )
15
+
16
+ /* 测试用的「假 magic-client serve」:TestRestartDaemonReal 里把测试二进制软链成
17
+ * magic-client 再以 RUN_AS_SERVE=1 起子进程 —— 并且让这个子进程本身也是 "magic-client" 路径
18
+ * (restartDaemon 按「同一个可执行文件」认领实例)。
19
+ *
20
+ * 只实现升级链路用到的三件事:/desk/health、/desk/core/upgrade、/desk/version,外加
21
+ * 升级成功后 restartDaemon 用同一套参数再拉起自己。端口取自 MAGIC_TEST_STATE 旁的
22
+ * port 文件(第一次由父进程预先挑好),从而能验证「重启后端口不变」。
23
+ */
24
+ func TestMain(m *testing.M) {
25
+ if os.Getenv("RUN_AS_SERVE") == "1" {
26
+ runFakeServe()
27
+ return
28
+ }
29
+ os.Exit(m.Run())
30
+ }
31
+
32
+ const fakeServeToken = "test-token-0123456789abcdef"
33
+
34
+ func runFakeServe() {
35
+ statePath := strings.TrimSpace(os.Getenv("MAGIC_TEST_STATE"))
36
+ portFile := statePath + ".port"
37
+ core := os.Getenv("MAGIC_AGENT_BIN")
38
+
39
+ svc := &Service{opts: Options{MagicAgent: core, StatePath: statePath}, log: log.New(os.Stderr, "fake-serve: ", 0)}
40
+ svc.contract = Contract{ContractVersion: RequiredContractVersion, Version: coreBinaryVersion(core), Features: requiredFeatures}
41
+
42
+ mux := http.NewServeMux()
43
+ mux.HandleFunc("/desk/health", func(w http.ResponseWriter, r *http.Request) {
44
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": svc.contract.Version})
45
+ })
46
+ mux.HandleFunc("/desk/version", func(w http.ResponseWriter, r *http.Request) {
47
+ writeJSON(w, http.StatusOK, map[string]any{"version": coreBinaryVersion(core), "pid": os.Getpid()})
48
+ })
49
+ mux.HandleFunc("/desk/core/upgrade", svc.handleCoreUpgrade)
50
+ if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
51
+ panic(err)
52
+ }
53
+
54
+ /* 端口:父进程预挑了一个(写进 .port),子进程优先用它。 */
55
+ addr := "127.0.0.1:0"
56
+ if b, err := os.ReadFile(portFile); err == nil {
57
+ addr = "127.0.0.1:" + strings.TrimSpace(string(b))
58
+ }
59
+ ln, err := net.Listen("tcp", addr)
60
+ if err != nil { // 端口被占:由系统分配
61
+ if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
62
+ panic(err)
63
+ }
64
+ }
65
+ port := ln.Addr().(*net.TCPAddr).Port
66
+ _ = os.WriteFile(portFile, []byte(fmt.Sprint(port)), 0o644)
67
+
68
+ /* 监听起来之后才记「由 start 拉起」的样子:Exe 是本进程路径、Addr 是**真实**的监听端口。
69
+ (restartDaemon 会原样复用这个 Addr —— 端口不变,前端才能按原地址重连。) */
70
+ self, _ := os.Executable()
71
+ st := &DaemonState{PID: os.Getpid(), Port: port, Lan: false, Exe: self, Token: fakeServeToken,
72
+ Addr: ln.Addr().String(), StatePath: statePath}
73
+ if err := writeDaemonState(statePath, st); err != nil {
74
+ panic(err)
75
+ }
76
+
77
+ // restartDaemon 拉起的新实例要用同一个 Addr:旧实例收到 SIGTERM 立刻让出端口。
78
+ srv := &http.Server{Handler: mux}
79
+ go func() {
80
+ ch := make(chan os.Signal, 1)
81
+ signal.Notify(ch, syscall.SIGTERM)
82
+ <-ch
83
+ _ = srv.Close()
84
+ }()
85
+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
86
+ fmt.Fprintln(os.Stderr, "fake-serve:", err)
87
+ }
88
+ }
@@ -0,0 +1,158 @@
1
+ package client
2
+
3
+ // workspace_default_test.go - 默认工作区不能退化成文件系统根(2026-10-26 实测事故)。
4
+ //
5
+ // 事故现场:插件被 `cd /` 起来 → /desk/health 的 defaultWorkspace 报 `/` →
6
+ // 移动端新建会话的工作区就是根目录。后果不只是「文件写在 /」:
7
+ // · 作业文件写 "workspace": "/",而 core 自己登记的是真实 cwd,两边对不上;
8
+ // · mode=auto 的同空间冲突判定、core 的工作空间锁都按这个 key 算 ——
9
+ // `/` 变成「全机所有会话同一个空间」的万能桶,串行/并行全判错。
10
+ //
11
+ // 所以这里钉住三件事:根目录不当默认、有 cwd 就用 cwd、cwd 是根时退回上次用过的工作区。
12
+
13
+ import (
14
+ "encoding/json"
15
+ "os"
16
+ "path/filepath"
17
+ "testing"
18
+ )
19
+
20
+ // writePrefs 往 prefs 文件写一份配置(只写关心的字段)。
21
+ func writePrefs(t *testing.T, v map[string]any) string {
22
+ t.Helper()
23
+ p := mobilePrefsPath()
24
+ if p == "" {
25
+ t.Fatal("mobilePrefsPath() 为空")
26
+ }
27
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ b, err := json.Marshal(v)
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ if err := os.WriteFile(p, b, 0o600); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ return p
38
+ }
39
+
40
+ // withTempHome 把 HOME 指向临时目录(prefs 落在 $HOME/.magic-agent 下)。
41
+ func withTempHome(t *testing.T) string {
42
+ t.Helper()
43
+ home := t.TempDir()
44
+ t.Setenv("HOME", home)
45
+ return home
46
+ }
47
+
48
+ // TestIsRootDir 根目录的唯一判据:自己和自己的父目录相同。
49
+ func TestIsRootDir(t *testing.T) {
50
+ cases := []struct {
51
+ path string
52
+ want bool
53
+ }{
54
+ {"/", true},
55
+ {"/tmp", false},
56
+ {"/Users/x/project", false},
57
+ {"", false},
58
+ }
59
+ for _, c := range cases {
60
+ if got := isRootDir(c.path); got != c.want {
61
+ t.Errorf("isRootDir(%q) = %v, want %v", c.path, got, c.want)
62
+ }
63
+ }
64
+ }
65
+
66
+ // TestDefaultWorkspaceUsesCwd cwd 有意义时就用它,不去翻 prefs。
67
+ func TestDefaultWorkspaceUsesCwd(t *testing.T) {
68
+ withTempHome(t)
69
+ proj := t.TempDir()
70
+ if err := os.Chdir(proj); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ t.Cleanup(func() { _ = os.Chdir("/Users") })
74
+
75
+ // prefs 里放一个别的目录,确认 cwd 优先。
76
+ if err := os.WriteFile(mobilePrefsPath(), []byte(`{"lastWorkspace":"/tmp"}`), 0o600); err != nil {
77
+ // 目录可能不存在,先建
78
+ _ = os.MkdirAll(filepath.Dir(mobilePrefsPath()), 0o700)
79
+ _ = os.WriteFile(mobilePrefsPath(), []byte(`{"lastWorkspace":"/tmp"}`), 0o600)
80
+ }
81
+
82
+ // 注意:真实路径可能带 /private 前缀(macOS 的 /tmp 是软链),所以比较 EvalSymlinks。
83
+ want, _ := filepath.EvalSymlinks(proj)
84
+ got, _ := filepath.EvalSymlinks(defaultWorkspace())
85
+ if got != want {
86
+ t.Errorf("defaultWorkspace() = %q, want cwd %q", got, want)
87
+ }
88
+ }
89
+
90
+ // TestDefaultWorkspaceFallsBackWhenCwdIsRoot cwd 是 `/` 时退回上次用过的工作区。
91
+ //
92
+ // 这条是本次事故的直接回归测试。
93
+ func TestDefaultWorkspaceFallsBackWhenCwdIsRoot(t *testing.T) {
94
+ withTempHome(t)
95
+ last := t.TempDir()
96
+ writePrefs(t, map[string]any{"lastWorkspace": last})
97
+
98
+ if err := os.Chdir("/"); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ t.Cleanup(func() { _ = os.Chdir("/Users") })
102
+
103
+ want, _ := filepath.EvalSymlinks(last)
104
+ got, _ := filepath.EvalSymlinks(defaultWorkspace())
105
+ if got != want {
106
+ t.Errorf("cwd 为 / 时 defaultWorkspace() = %q, want 上次工作区 %q", got, want)
107
+ }
108
+ if isRootDir(got) {
109
+ t.Errorf("默认工作区仍然退化成了根目录 %q", got)
110
+ }
111
+ }
112
+
113
+ // TestDefaultWorkspaceIgnoresBadPrefs prefs 里的值不可用时不能当默认。
114
+ //
115
+ // 三种坏值:不是绝对路径、目录已不存在、字段缺失。任一条被当成默认工作区,
116
+ // 都会让会话归到一个不该归的地方(相对路径还会按插件 cwd 解释,等于又回到根目录)。
117
+ func TestDefaultWorkspaceIgnoresBadPrefs(t *testing.T) {
118
+ withTempHome(t)
119
+ if err := os.Chdir("/"); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ t.Cleanup(func() { _ = os.Chdir("/Users") })
123
+
124
+ bad := []struct {
125
+ name string
126
+ v map[string]any
127
+ }{
128
+ {"相对路径", map[string]any{"lastWorkspace": "relative/dir"}},
129
+ {"目录不存在", map[string]any{"lastWorkspace": "/definitely/not/here/xyz"}},
130
+ {"字段缺失", map[string]any{"engine": "dsh"}},
131
+ {"空串", map[string]any{"lastWorkspace": ""}},
132
+ }
133
+ for _, c := range bad {
134
+ t.Run(c.name, func(t *testing.T) {
135
+ writePrefs(t, c.v)
136
+ got := defaultWorkspace()
137
+ // 没有可用兜底时只能回到 cwd(/),但绝不能是 prefs 里的坏值。
138
+ if c.v["lastWorkspace"] != "" && c.v["lastWorkspace"] != nil {
139
+ if got == c.v["lastWorkspace"] {
140
+ t.Errorf("采纳了不可用的 lastWorkspace %v", c.v["lastWorkspace"])
141
+ }
142
+ }
143
+ if got != "/" && !filepath.IsAbs(got) {
144
+ t.Errorf("返回值不是绝对路径:%q", got)
145
+ }
146
+ })
147
+ }
148
+ }
149
+
150
+ // TestLastWorkspacePrefRejectsMissingDir 已删除的目录不算「上次用过的工作区」。
151
+ func TestLastWorkspacePrefRejectsMissingDir(t *testing.T) {
152
+ withTempHome(t)
153
+ gone := filepath.Join(t.TempDir(), "deleted")
154
+ writePrefs(t, map[string]any{"lastWorkspace": gone})
155
+ if got := lastWorkspacePref(); got != "" {
156
+ t.Errorf("目录已不存在却仍返回 %q", got)
157
+ }
158
+ }
@@ -0,0 +1,54 @@
1
+ package client
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/darren/magic-agent/internal/session"
8
+ )
9
+
10
+ /*
11
+ 工作空间锁开关的下发口径(2026-10-26)。
12
+
13
+ 为什么单独立一个文件、而不塞进 startAsk 的用例里:这条判据翻转的代价极隐蔽。
14
+ core 的锁从「选入式」改成「默认生效」之后,**不传变量**的含义从「不排」变成了
15
+ 「排」。于是原先「串行才传 =1」的写法会在无人察觉时把并行模式串起来 ——
16
+ 界面上的表现是「切了并行,任务还是一个一个跑」,没人会先怀疑环境变量。
17
+
18
+ 锁的两端(core / 插件)分属两个包,任何一端单独改都会被另一端静默抵消,
19
+ 所以这条不变量必须在这里被钉死。
20
+ */
21
+ func TestWorkspaceLockEnvForSerial(t *testing.T) {
22
+ got := workspaceLockEnv(nil)
23
+ if len(got) != 0 {
24
+ t.Fatalf("serial 未指定应按串行处理、什么都不传,实际 %v", got)
25
+ }
26
+ }
27
+
28
+ func TestWorkspaceLockEnvSerialLeavesDefault(t *testing.T) {
29
+ serial := true
30
+ got := workspaceLockEnv(&serial)
31
+ /* 不写 `=1` 是刻意的:core 的默认值就是开,显式写一遍只是多一个会和
32
+ workspaceLockEnabled 的解析口径分叉的地方。 */
33
+ if len(got) != 0 {
34
+ t.Fatalf("串行作业不该追加任何变量(core 默认即开),实际 %v", got)
35
+ }
36
+ }
37
+
38
+ func TestWorkspaceLockEnvParallelDisables(t *testing.T) {
39
+ serial := false
40
+ got := workspaceLockEnv(&serial)
41
+ if len(got) != 1 {
42
+ t.Fatalf("并行作业必须且只能追加一条变量,实际 %v", got)
43
+ }
44
+ want := session.EnvWorkspaceLock + "=0"
45
+ if got[0] != want {
46
+ t.Fatalf("并行作业应下发 %q,实际 %q", want, got[0])
47
+ }
48
+ /* 值必须是 core 认的「关」:workspaceLockEnabled 只把 "0"/"false"/"off"/"no"
49
+ 当关闭。写成 "false" 也行,但拼错成 "no " / "OFF " 之类就静默失效了 ——
50
+ 这里断言到字面量,改 core 的解析口径时会立刻被这条测试拽住。 */
51
+ if !strings.HasSuffix(got[0], "=0") {
52
+ t.Fatalf("关闭值必须是 core 认得的写法,实际 %q", got[0])
53
+ }
54
+ }