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
@@ -0,0 +1,287 @@
1
+ package client
2
+
3
+ // ab_test.go - agent-browser 封装与 shot-remote 端点的测试。
4
+ //
5
+ // 不连真 daemon(那要真 Chromium):用 go build 现场编一个**假 agent-browser exe**
6
+ // 验证探测链、--json 解析与端点行为。go 编译器不在 PATH 时相关用例跳过
7
+ // (本机开发环境一定有;CI 沙箱也总有)。
8
+
9
+ import (
10
+ "context"
11
+ "encoding/json"
12
+ "io"
13
+ "log"
14
+ "net/http"
15
+ "net/http/httptest"
16
+ "net/url"
17
+ "os"
18
+ "os/exec"
19
+ "path/filepath"
20
+ "runtime"
21
+ "strings"
22
+ "testing"
23
+ )
24
+
25
+ // abNativeExeName 当前平台 agent-browser 原生 exe 的文件名(与 ab.go 同规则)。
26
+ func abNativeExeName() string {
27
+ name := "agent-browser-" + runtime.GOOS + "-" + runtime.GOARCH
28
+ if runtime.GOOS == "windows" {
29
+ name += ".exe"
30
+ }
31
+ return name
32
+ }
33
+
34
+ // fakeABBin 现场编一个假 agent-browser exe,返回其路径。
35
+ //
36
+ // 行为由环境变量 AB_FAKE_MODE 控制(abRun 会把调用方环境透传给子进程):
37
+ //
38
+ // ok → 按子命令吐 {"success":true,"data":…};screenshot 会真写出文件
39
+ // err → 吐 {"success":false,"error":"fake boom"}
40
+ // garbage → 吐非 JSON 乱码
41
+ // missing → 无输出直接退出 1
42
+ func fakeABBin(t *testing.T) string {
43
+ t.Helper()
44
+ src := `package main
45
+
46
+ import (
47
+ "fmt"
48
+ "os"
49
+ "strings"
50
+ )
51
+
52
+ func main() {
53
+ mode := os.Getenv("AB_FAKE_MODE")
54
+ switch mode {
55
+ case "err":
56
+ fmt.Println(` + "`" + `{"success":false,"error":"fake boom"}` + "`" + `)
57
+ os.Exit(1)
58
+ case "garbage":
59
+ fmt.Println("garbage not json at all")
60
+ os.Exit(0)
61
+ case "missing":
62
+ os.Exit(1)
63
+ }
64
+ // 按子命令给一点可断言的 data(跳过全局 flag)。
65
+ sub := ""
66
+ for i, a := range os.Args[1:] {
67
+ if strings.HasPrefix(a, "-") || a == "magic-client" {
68
+ continue
69
+ }
70
+ switch a {
71
+ case "get", "open", "eval", "pdf", "snapshot":
72
+ continue
73
+ case "screenshot":
74
+ // 真的把文件写出来:screenshot 的**最后一个非 flag 参数**是路径
75
+ // (与真 agent-browser 一致 —— flag 必须放在路径前面)。
76
+ out := ""
77
+ for _, b := range os.Args[i+1:] {
78
+ if !strings.HasPrefix(b, "-") {
79
+ out = b
80
+ }
81
+ }
82
+ if out != "" {
83
+ _ = os.WriteFile(out, []byte("png"), 0o600)
84
+ }
85
+ fmt.Println(` + "`" + `{"success":true,"data":{"path":"x"}}` + "`" + `)
86
+ return
87
+ case "url":
88
+ fmt.Println(` + "`" + `{"success":true,"data":"https://example.com/page"}` + "`" + `)
89
+ return
90
+ case "title":
91
+ fmt.Println(` + "`" + `{"success":true,"data":"Example"}` + "`" + `)
92
+ return
93
+ }
94
+ if sub == "" {
95
+ sub = a
96
+ }
97
+ }
98
+ fmt.Println(` + "`" + `{"success":true,"data":{"ok":true}}` + "`" + `)
99
+ }
100
+ `
101
+ dir := t.TempDir()
102
+ srcFile := filepath.Join(dir, "main.go")
103
+ if err := os.WriteFile(srcFile, []byte(src), 0o600); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ exe := filepath.Join(dir, abNativeExeName())
107
+ cmd := exec.Command("go", "build", "-o", exe, srcFile)
108
+ if out, err := cmd.CombinedOutput(); err != nil {
109
+ t.Skipf("go build 假 exe 失败(跳过,不影响其它用例): %v: %s", err, out)
110
+ }
111
+ return exe
112
+ }
113
+
114
+ // TestABBin探测链 环境变量 > npm 前缀 > 都没有时空。
115
+ func TestABBin探测链(t *testing.T) {
116
+ exe := fakeABBin(t)
117
+ // 1) 显式环境变量最优先。
118
+ t.Setenv(EnvAgentBrowserBin, exe)
119
+ if got := abBin(); got != exe {
120
+ t.Fatalf("环境变量应最优先,实际 %q", got)
121
+ }
122
+ // 2) npm 全局前缀(<prefix>/node_modules/agent-browser/bin/<平台名>)。
123
+ t.Setenv(EnvAgentBrowserBin, "")
124
+ prefix := t.TempDir()
125
+ binDir := filepath.Join(prefix, "node_modules", "agent-browser", "bin")
126
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ b, err := os.ReadFile(exe)
130
+ if err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ if err := os.WriteFile(filepath.Join(binDir, abNativeExeName()), b, 0o755); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ t.Setenv("NPM_CONFIG_PREFIX", prefix)
137
+ got := abBin()
138
+ if !strings.Contains(filepath.ToSlash(got), "node_modules/agent-browser/bin/"+abNativeExeName()) {
139
+ t.Fatalf("应从 npm 前缀探测到,实际 %q", got)
140
+ }
141
+ }
142
+
143
+ // TestABRunJSON 解析:--json 成功 / 失败 / 乱码 / 无输出四种形状。
144
+ func TestABRunJSON(t *testing.T) {
145
+ exe := fakeABBin(t)
146
+ t.Setenv(EnvAgentBrowserBin, exe)
147
+
148
+ t.Setenv("AB_FAKE_MODE", "ok")
149
+ data, err := abRun(context.Background(), "get", "url")
150
+ if err != nil {
151
+ t.Fatalf("ok 模式失败: %v", err)
152
+ }
153
+ if string(data) != `"https://example.com/page"` {
154
+ t.Fatalf("data 不对: %s", data)
155
+ }
156
+
157
+ t.Setenv("AB_FAKE_MODE", "err")
158
+ if _, err := abRun(context.Background(), "get", "url"); err == nil || !strings.Contains(err.Error(), "fake boom") {
159
+ t.Fatalf("err 模式应报 fake boom: %v", err)
160
+ }
161
+
162
+ t.Setenv("AB_FAKE_MODE", "garbage")
163
+ if _, err := abRun(context.Background(), "get", "url"); err == nil {
164
+ t.Fatal("乱码输出应报错")
165
+ }
166
+
167
+ t.Setenv("AB_FAKE_MODE", "missing")
168
+ if _, err := abRun(context.Background(), "get", "url"); err == nil {
169
+ t.Fatal("无输出退出应报错")
170
+ }
171
+ }
172
+
173
+ // TestABShot 落盘:假 exe 真写出文件,abShot 返回 nil 且文件在。
174
+ func TestABShot(t *testing.T) {
175
+ exe := fakeABBin(t)
176
+ t.Setenv(EnvAgentBrowserBin, exe)
177
+ t.Setenv("AB_FAKE_MODE", "ok")
178
+ out := filepath.Join(t.TempDir(), "s.png")
179
+ if err := abShot(context.Background(), out, true); err != nil {
180
+ t.Fatalf("截图失败: %v", err)
181
+ }
182
+ if b, err := os.ReadFile(out); err != nil || string(b) != "png" {
183
+ t.Fatalf("截图文件没落盘: %v", err)
184
+ }
185
+ }
186
+
187
+ // abHideAll 把 abBin 的全部探测路径指到空目录(测「没装 agent-browser」的分支)。
188
+ // t.Setenv 自动在测试结束时还原。PATH 上可能有真安装(npm 全局 bin),一并清掉 ——
189
+ // 清 PATH 后 go build 假 exe 的用例会受影响,但 hide 只在 501 分支用,不冲突。
190
+ func abHideAll(t *testing.T) {
191
+ t.Helper()
192
+ t.Setenv(EnvAgentBrowserBin, "")
193
+ t.Setenv("NPM_CONFIG_PREFIX", "")
194
+ t.Setenv("npm_config_prefix", "")
195
+ t.Setenv("APPDATA", t.TempDir()) // Windows 默认前缀指空
196
+ t.Setenv("PATH", t.TempDir()) // LookPath 兜底也指空
197
+ }
198
+
199
+ // TestShotRemoteEndpoint shot-remote 端点:能力位 gate / 参数校验 / 正常路径。
200
+ func TestShotRemoteEndpoint(t *testing.T) {
201
+ exe := fakeABBin(t)
202
+ s := &Service{log: log.New(io.Discard, "", 0), contract: Contract{Features: []string{FeatureRequirementShots}}}
203
+ mux := http.NewServeMux()
204
+ mux.HandleFunc("/desk/requirements/shot-remote", s.handleRequirementShotRemote)
205
+ srv := httptest.NewServer(mux)
206
+ defer srv.Close()
207
+
208
+ // 没配 agent-browser → 501 + 安装提示。
209
+ // ⚠️ 本机可能真装了 agent-browser(npm 默认前缀 / PATH 都能命中):
210
+ // 「没装」分支要把**所有**探测路径都指到空目录,否则测的是真安装。
211
+ abHideAll(t)
212
+ resp, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
213
+ strings.NewReader(`{"url":"https://example.com"}`))
214
+ if err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ body := readAllBody(resp)
218
+ if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(body, "agent-browser") {
219
+ t.Fatalf("没装 agent-browser 应 501 + 提示,实际 %d %s", resp.StatusCode, body)
220
+ }
221
+
222
+ // 配上假 exe → 正常路径回 data(截图落 uploads 目录并真实存在)。
223
+ t.Setenv(EnvAgentBrowserBin, exe)
224
+ t.Setenv("AB_FAKE_MODE", "ok")
225
+ t.Setenv("TMP", t.TempDir()) // uploads 目录走 os.TempDir()
226
+ t.Setenv("TEMP", os.Getenv("TMP"))
227
+ resp2, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
228
+ strings.NewReader(`{"url":"https://example.com"}`))
229
+ if err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ body2 := readAllBody(resp2)
233
+ if resp2.StatusCode != http.StatusOK {
234
+ t.Fatalf("正常路径应 200,实际 %d %s", resp2.StatusCode, body2)
235
+ }
236
+ var out struct {
237
+ OK bool `json:"ok"`
238
+ Data string `json:"data"`
239
+ }
240
+ if err := json.Unmarshal([]byte(body2), &out); err != nil || !out.OK || !strings.HasPrefix(out.Data, "/desk/uploads/") {
241
+ t.Fatalf("响应形状不对: %s", body2)
242
+ }
243
+ // data 指向的文件真实存在。
244
+ name, err := url.PathUnescape(strings.TrimPrefix(out.Data, "/desk/uploads/"))
245
+ if err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if _, err := os.Stat(filepath.Join(os.TempDir(), "magic-client-uploads", name)); err != nil {
249
+ t.Fatalf("截图文件不在 uploads: %v", err)
250
+ }
251
+
252
+ // 非法 url → 400。
253
+ resp3, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
254
+ strings.NewReader(`{"url":"javascript:alert(1)"}`))
255
+ if err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ resp3.Body.Close()
259
+ if resp3.StatusCode != http.StatusBadRequest {
260
+ t.Fatalf("非法 url 应 400,实际 %d", resp3.StatusCode)
261
+ }
262
+ }
263
+
264
+ // TestShotRemote能力位 缺 feature.requirement.shots 必须 501(与 /shots 端点同一杆秤)。
265
+ func TestShotRemote能力位(t *testing.T) {
266
+ s := &Service{log: log.New(io.Discard, "", 0), contract: Contract{Features: []string{}}}
267
+ mux := http.NewServeMux()
268
+ mux.HandleFunc("/desk/requirements/shot-remote", s.handleRequirementShotRemote)
269
+ srv := httptest.NewServer(mux)
270
+ defer srv.Close()
271
+ resp, err := http.Post(srv.URL+"/desk/requirements/shot-remote", "application/json",
272
+ strings.NewReader(`{"url":"https://example.com"}`))
273
+ if err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ defer resp.Body.Close()
277
+ if resp.StatusCode != http.StatusNotImplemented {
278
+ t.Fatalf("缺能力位应 501,实际 %d", resp.StatusCode)
279
+ }
280
+ }
281
+
282
+ // readAllBody 把响应体读完转成字符串(测试用,错误一律 fatal)。
283
+ func readAllBody(resp *http.Response) string {
284
+ b, _ := io.ReadAll(resp.Body)
285
+ resp.Body.Close()
286
+ return string(b)
287
+ }
@@ -105,6 +105,11 @@ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
105
105
  Version string `json:"version"`
106
106
  Size int64 `json:"size"`
107
107
  URL string `json:"url"`
108
+ /* Upgrade(2026-10-26 只增字段):这个客户端**自己能不能**做桌面端 npm 一键升级
109
+ (npm 在 PATH 上、找得到目标二进制)。为 true 时界面的「检测更新」会顺手问
110
+ /desk/core,一次把「App 有没有新版」「桌面 CLI 有没有新版」都报出来。
111
+ 旧插件没有这个字段 → 界面只查 APK,不假装能升桌面端。 */
112
+ Upgrade bool `json:"upgrade"`
108
113
  }{}
109
114
  if s.apkAvailable() {
110
115
  if st, err := os.Stat(s.apkPath); err == nil {
@@ -113,6 +118,9 @@ func (s *Service) handleAppInfo(w http.ResponseWriter, r *http.Request) {
113
118
  out.URL = "/desk/app.apk"
114
119
  }
115
120
  }
121
+ if s.upgradableCore() {
122
+ out.Upgrade = true
123
+ }
116
124
  writeJSON(w, http.StatusOK, out)
117
125
  }
118
126
 
@@ -41,6 +41,7 @@ func TestAppInfoAndAPK(t *testing.T) {
41
41
  Version string `json:"version"`
42
42
  Size int64 `json:"size"`
43
43
  URL string `json:"url"`
44
+ Upgrade bool `json:"upgrade"`
44
45
  }
45
46
  if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
46
47
  t.Fatal(err)
@@ -48,6 +49,12 @@ func TestAppInfoAndAPK(t *testing.T) {
48
49
  if got.Version != "2.1.2" || got.URL != "/desk/app.apk" || got.Size != int64(len("fake-apk-2.1.2")) {
49
50
  t.Fatalf("app info = %+v", got)
50
51
  }
52
+ // 2026-10-26:/desk/app 要顺手告诉界面「这份客户端能不能一键升桌面端 npm」,
53
+ // 界面据此一次检测 APK + npm。值与本机真实探测一致(测试机上有没有 npm 不定),
54
+ // 不写死。
55
+ if got.Upgrade != s.upgradableCore() {
56
+ t.Fatalf("/desk/app.upgrade 与本机探测不一致:%+v", got)
57
+ }
51
58
 
52
59
  rec = httptest.NewRecorder()
53
60
  s.handleAppAPK(rec, httptest.NewRequest(http.MethodGet, "/desk/app.apk", nil))
@@ -87,6 +94,58 @@ func TestAppInfoWithoutAPK(t *testing.T) {
87
94
  }
88
95
  }
89
96
 
97
+ // 2026-10-26 用户:「版本检测需要同时考虑 apk 和 npm,都要支持升级」。
98
+ // 升级链路(coreupgrade.go)早就写好、单测也一直在跑,但正式路由从没挂上 ——
99
+ // 手机问 /desk/core 拿到的是 404 页而不是版本信息。这条钉住「真的挂上去了」。
100
+ func TestVersionRoutesRegistered(t *testing.T) {
101
+ s, _ := newUpgradeService(t)
102
+ h := s.Handler()
103
+ hdr := map[string]string{"X-Magic-Token": s.opts.Token}
104
+ for _, path := range []string{"/desk/core", "/desk/core/upgrade"} {
105
+ req := httptest.NewRequest(http.MethodGet, path, nil)
106
+ for k, v := range hdr {
107
+ req.Header.Set(k, v)
108
+ }
109
+ rec := httptest.NewRecorder()
110
+ h.ServeHTTP(rec, req)
111
+ if rec.Code == http.StatusNotFound {
112
+ t.Fatalf("%s 没挂到正式路由上(404)", path)
113
+ }
114
+ }
115
+ // GET /desk/core 的响应形状:界面按这些字段决定「能不能一键升」。
116
+ req := httptest.NewRequest(http.MethodGet, "/desk/core", nil)
117
+ rec := httptest.NewRecorder()
118
+ h.ServeHTTP(rec, req)
119
+ if rec.Code != http.StatusOK {
120
+ t.Fatalf("/desk/core code = %d: %s", rec.Code, rec.Body.String())
121
+ }
122
+ var info coreInfo
123
+ if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ if !info.CanUpgrade || !info.Upgradable || info.Latest != "2.4.2" {
127
+ t.Fatalf("/desk/core = %+v", info)
128
+ }
129
+ }
130
+
131
+ // 找得到 npm + 目标二进制时,/desk/app 必须明说「能升桌面端」,界面才会去问 /desk/core。
132
+ func TestAppInfoReportsNpmUpgradeAvailability(t *testing.T) {
133
+ s, _ := newUpgradeService(t)
134
+ apk := writeTestAPK(t, t.TempDir(), "2.4.1")
135
+ s.apkPath = apk
136
+ rec := httptest.NewRecorder()
137
+ s.handleAppInfo(rec, httptest.NewRequest(http.MethodGet, "/desk/app", nil))
138
+ var got struct {
139
+ Upgrade bool `json:"upgrade"`
140
+ }
141
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ if !got.Upgrade {
145
+ t.Fatalf("/desk/app.upgrade = false,但假 npm 与目标二进制都在:%s", rec.Body.String())
146
+ }
147
+ }
148
+
90
149
  func TestAPKVersionFallsBackToFilename(t *testing.T) {
91
150
  dir := t.TempDir()
92
151
  apk := filepath.Join(dir, "magic-agent-mobile-3.2.1-release.apk")