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
@@ -0,0 +1,510 @@
1
+ package client
2
+
3
+ /* knowledge_test.go — 「发现 → 知识库」(OpenViking)的行为钉住:
4
+ * ① 清单只从服务端那张表里来,客户端传什么都在表里找;
5
+ * ② 安装动作只跑服务端构造好的 argv,**不接受客户端传命令**;
6
+ * ③ 状态如实:没装就说没装,版本读不到就留空(不编),查不到最新版本也不报错卡住;
7
+ * ④ 可升级的判定要真的比较版本号,且预发布版不提示升级。
8
+
9
+ * ⚠️ 这些用例**不打外网、不真装 openviking**:PyPI 查询与 python 探测都换成桩。 */
10
+
11
+ import (
12
+ "bufio"
13
+ "encoding/json"
14
+ "net/http"
15
+ "net/http/httptest"
16
+ "os"
17
+ "path/filepath"
18
+ "runtime"
19
+ "strings"
20
+ "testing"
21
+ )
22
+
23
+ // fakeKnowledgeEnv 造一个「家目录 + 干净的 PATH」的临时环境。
24
+ //
25
+ // ⚠️ PATH 必须**只留临时 binDir**、不能追加原 PATH:本机真的装了 OpenViking
26
+ // (`C:\Python313\Scripts\ov.exe` 在 PATH 上),追加原 PATH 会让「PATH 上没有 ov」
27
+ // 这条断言永远不成立 —— 而且那不是代码的问题,是本机的真实状态。
28
+ func fakeKnowledgeEnv(t *testing.T) (home string, binDir string) {
29
+ t.Helper()
30
+ root := t.TempDir()
31
+ home = filepath.Join(root, "home")
32
+ binDir = filepath.Join(root, "bin")
33
+ for _, d := range []string{home, binDir} {
34
+ if err := os.MkdirAll(d, 0o755); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ }
38
+ t.Setenv("PATH", binDir)
39
+ t.Setenv(ovEnvBin, "")
40
+ t.Setenv(ovEnvPython, "")
41
+
42
+ oldHome := ovHomeDir
43
+ oldProbe := ovPythonProbe
44
+ oldPyPI := pypiLatest
45
+ t.Cleanup(func() {
46
+ ovHomeDir = oldHome
47
+ ovPythonProbe = oldProbe
48
+ pypiLatest = oldPyPI
49
+ })
50
+ ovHomeDir = func() (string, error) { return home, nil }
51
+ // python 探测打桩:假装系统里有 3.13(够 ≥3.10 就行),但**不真的建 venv**。
52
+ ovPythonProbe = func() ([]string, string) { return []string{"python3"}, "3.13.3" }
53
+ pypiLatest = func(pkg string) (string, error) { return "", nil }
54
+ return home, binDir
55
+ }
56
+
57
+ // writeFakeOV 在 PATH 上放一个假 `ov`。
58
+ //
59
+ // ⚠️ Windows 上必须带 .exe 后缀:Go 的 LookPath 对无扩展名的文件会按 PATHEXT
60
+ // 逐个加后缀去找,不会直接用裸名 —— 裸名文件在 Windows 上永远找不到。
61
+ func writeFakeOV(t *testing.T, binDir, name, content string) string {
62
+ t.Helper()
63
+ if runtime.GOOS == "windows" && filepath.Ext(name) == "" {
64
+ name += ".exe"
65
+ }
66
+ p := filepath.Join(binDir, name)
67
+ if err := os.WriteFile(p, []byte(content), 0o755); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ return p
71
+ }
72
+
73
+ func knowledgeService() *Service { return &Service{opts: Options{}} }
74
+
75
+ /* ── 清单 ── */
76
+
77
+ func TestKnowledgeListsCatalog(t *testing.T) {
78
+ fakeKnowledgeEnv(t)
79
+ rec := httptest.NewRecorder()
80
+ knowledgeService().handleKnowledge(rec, httptest.NewRequest(http.MethodGet, "/desk/knowledge", nil))
81
+ if rec.Code != http.StatusOK {
82
+ t.Fatalf("状态码 = %d,期望 200", rec.Code)
83
+ }
84
+ var body struct {
85
+ Knowledge []knowledgeInfo `json:"knowledge"`
86
+ }
87
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
88
+ t.Fatalf("响应不是合法 JSON:%v(%s)", err, rec.Body.String())
89
+ }
90
+ if len(body.Knowledge) != 1 {
91
+ t.Fatalf("知识库数 = %d,期望 1(openviking)", len(body.Knowledge))
92
+ }
93
+ k := body.Knowledge[0]
94
+ if k.ID != "openviking" {
95
+ t.Fatalf("id = %q,期望 openviking", k.ID)
96
+ }
97
+ // PATH 上没有 → 必须如实说没装,而不是给一个看起来成功的空壳
98
+ if k.Installed {
99
+ t.Fatal("PATH 上没有 ov 时 Installed 应为 false")
100
+ }
101
+ if k.Tagline == "" || k.Description == "" || len(k.Examples) == 0 {
102
+ t.Fatal("介绍三件套(一句话简介 / 详细说明 / 示例)不能空 —— 发现页要展示它们")
103
+ }
104
+ if !strings.Contains(k.UsePrompt, "知识库") {
105
+ t.Fatal("让它干活的预填提示里要点明用途")
106
+ }
107
+ if k.Note == "" {
108
+ t.Fatal("必须有一句 note 讲清「为什么是现在这个状态」")
109
+ }
110
+ }
111
+
112
+ /* ── 探测:环境变量 / venv / PATH 三档 ── */
113
+
114
+ // 环境变量指定的命令优先级最高(用户显式指定)。
115
+ func TestKnowledgeDetectsEnvOverride(t *testing.T) {
116
+ home, binDir := fakeKnowledgeEnv(t)
117
+ custom := writeFakeOV(t, binDir, "my-ov", "#!/bin/sh\n")
118
+ t.Setenv(ovEnvBin, custom)
119
+
120
+ d := detectOpenViking()
121
+ if d.Bin != custom {
122
+ t.Fatalf("bin = %q,期望 %q", d.Bin, custom)
123
+ }
124
+ if d.Source != "env" {
125
+ t.Fatalf("source = %q,期望 env", d.Source)
126
+ }
127
+ _ = home
128
+ }
129
+
130
+ // 自建 venv 里的命令**不在 PATH 上** —— 一键安装装进 venv 后 `ov` 就不在 PATH,
131
+ // 所以 venv 这档必须排在 PATH 之前,否则「刚装好」会被误报成「未安装」。
132
+ func TestKnowledgeDetectsVenvBeforePath(t *testing.T) {
133
+ home, binDir := fakeKnowledgeEnv(t)
134
+ // PATH 上放一个同名命令:它应该被 venv 里那个**盖掉**(venv 优先)。
135
+ writeFakeOV(t, binDir, "ov", "#!/bin/sh\n")
136
+
137
+ venvBin := filepath.Join(ovVenvBinDir(filepath.Join(home, ovVenvName)), "ov")
138
+ if runtime.GOOS == "windows" {
139
+ venvBin += ".exe"
140
+ }
141
+ if err := os.MkdirAll(filepath.Dir(venvBin), 0o755); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ if err := os.WriteFile(venvBin, []byte("#!/bin/sh\n"), 0o755); err != nil {
145
+ t.Fatal(err)
146
+ }
147
+
148
+ d := detectOpenViking()
149
+ if d.Source != "venv" {
150
+ t.Fatalf("source = %q,期望 venv(venv 里的命令不在 PATH 上,必须先认它)", d.Source)
151
+ }
152
+ if d.Bin != venvBin {
153
+ t.Fatalf("bin = %q,期望 %q", d.Bin, venvBin)
154
+ }
155
+ }
156
+
157
+ // 两个命令名都认:0.5.2 的文档给 `ov`,0.4.15 的文档给 `openviking`。
158
+ // 认错的后果是「明明装了却说没装」。
159
+ func TestKnowledgeAcceptsBothCLINames(t *testing.T) {
160
+ _, binDir := fakeKnowledgeEnv(t)
161
+ for _, name := range ovCLINames {
162
+ t.Run(name, func(t *testing.T) {
163
+ _, binDir := fakeKnowledgeEnv(t)
164
+ p := writeFakeOV(t, binDir, name, "#!/bin/sh\n")
165
+ d := detectOpenViking()
166
+ if d.Bin != p {
167
+ t.Fatalf("bin = %q,期望 %q(命令名 %s 应当被认)", d.Bin, p, name)
168
+ }
169
+ })
170
+ }
171
+ _ = binDir
172
+ }
173
+
174
+ /* ── 版本号:分层兜底 ── */
175
+
176
+ // L1:CLI 自己说(`--version`)。
177
+ func TestKnowledgeVersionFromCLI(t *testing.T) {
178
+ if runtime.GOOS == "windows" {
179
+ t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
180
+ }
181
+ _, binDir := fakeKnowledgeEnv(t)
182
+ bin := writeFakeOV(t, binDir, "ov", "#!/bin/sh\necho 'ov 0.5.2'\n")
183
+ if got := versionFromCLI(bin); got != "0.5.2" {
184
+ t.Fatalf("版本 = %q,期望 0.5.2", got)
185
+ }
186
+ }
187
+
188
+ // L2:从 dist-info 目录名读(纯文件系统,不依赖解释器)。
189
+ // 这层是「CLI 没有 --version」时的主路径 —— `ov --version` 是否被支持并未确认。
190
+ func TestKnowledgeVersionFromDistInfo(t *testing.T) {
191
+ home, _ := fakeKnowledgeEnv(t)
192
+ venv := filepath.Join(home, ovVenvName)
193
+ site := filepath.Join(venv, "Lib", "site-packages")
194
+ if runtime.GOOS != "windows" {
195
+ site = filepath.Join(venv, "lib", "python3.12", "site-packages")
196
+ }
197
+ dist := filepath.Join(site, "openviking-0.4.15.dist-info")
198
+ if err := os.MkdirAll(dist, 0o755); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ if err := os.WriteFile(filepath.Join(dist, "METADATA"), []byte("Name: openviking\nVersion: 0.4.15\n"), 0o644); err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ // 放一个空的 bin(不传 bin 也行,但传了更贴近真实形态)
205
+ bin := filepath.Join(ovVenvBinDir(venv), "ov")
206
+ if runtime.GOOS == "windows" {
207
+ bin += ".exe"
208
+ }
209
+ if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
210
+ t.Fatal(err)
211
+ }
212
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+
216
+ got := versionFromDistInfo(bin, venv)
217
+ if got != "0.4.15" {
218
+ t.Fatalf("版本 = %q,期望 0.4.15", got)
219
+ }
220
+ }
221
+
222
+ // 读不到版本就留空 —— 绝不编一个看起来对的版本号。
223
+ func TestKnowledgeVersionEmptyWhenUnreadable(t *testing.T) {
224
+ home, _ := fakeKnowledgeEnv(t)
225
+ if got := readOpenVikingVersion("", home); got != "" {
226
+ t.Fatalf("没有命令时版本应为空串,实际 %q", got)
227
+ }
228
+ }
229
+
230
+ /* ── 版本比较 ── */
231
+
232
+ func TestKnowledgeVersionCompare(t *testing.T) {
233
+ cases := []struct {
234
+ cur, latest string
235
+ less bool
236
+ }{
237
+ {"0.4.15", "0.5.2", true},
238
+ {"0.5.2", "0.5.2", false},
239
+ {"0.6.0", "0.5.2", false},
240
+ {"0.5.2", "0.5.10", true},
241
+ {"1.0.0", "0.9.9", false},
242
+ {"", "0.5.2", false},
243
+ {"0.5.2", "", false},
244
+ }
245
+ for _, c := range cases {
246
+ if got := ovVersionLess(c.cur, c.latest); got != c.less {
247
+ t.Fatalf("ovVersionLess(%q, %q) = %v,期望 %v", c.cur, c.latest, got, c.less)
248
+ }
249
+ }
250
+ }
251
+
252
+ // 预发布版**不**提示升级 —— 那不是「有新版」,是「有测试版」。
253
+ //
254
+ // ⚠️ 判据不是「有没有 '-'」:PEP 440 的预发布形态**不一定用连字符分隔**
255
+ // (实测本机 `ov --version` 吐 `0.4.16.dev0`,PyPI 上还有 `0.6.0rc1` 这种连字符都没有的)。
256
+ func TestKnowledgePreReleaseNotOffered(t *testing.T) {
257
+ for _, v := range []string{
258
+ "0.6.0rc1", "0.6.0-rc1", "1.0.0a2", "1.0.0-alpha2", "0.7.0.dev3",
259
+ "0.4.16.dev0", "1.0.0b1", "1.0.0pre1",
260
+ } {
261
+ if !ovIsPreRelease(v) {
262
+ t.Fatalf("%q 应判为预发布", v)
263
+ }
264
+ }
265
+ for _, v := range []string{"0.5.2", "1.0.0", "0.4.16", "2.0.0+local"} {
266
+ if ovIsPreRelease(v) {
267
+ t.Fatalf("%q 不应判为预发布", v)
268
+ }
269
+ }
270
+ }
271
+
272
+ // 版本号提取要连后缀一起拿:`ov --version` 吐的是 `openviking 0.4.16.dev0`,
273
+ // 只取数字段会把这个 dev 版说成正式版 0.4.16(然后界面就误判「已是最新」)。
274
+ func TestKnowledgeVersionTokenKeepsSuffix(t *testing.T) {
275
+ cases := []struct{ in, want string }{
276
+ {"openviking 0.4.16.dev0", "0.4.16.dev0"},
277
+ {"OpenViking v0.5.2", "0.5.2"},
278
+ {"0.6.0rc1", "0.6.0rc1"},
279
+ {"0.5.2+local", "0.5.2+local"},
280
+ {"no version here", ""},
281
+ }
282
+ for _, c := range cases {
283
+ if got := ovVersionToken(c.in); got != c.want {
284
+ t.Fatalf("ovVersionToken(%q) = %q,期望 %q", c.in, got, c.want)
285
+ }
286
+ }
287
+ }
288
+
289
+ /* ── 最新版本:查不到不报错 ── */
290
+
291
+ func TestKnowledgeLatestVersionUnavailable(t *testing.T) {
292
+ fakeKnowledgeEnv(t)
293
+ pypiLatest = func(pkg string) (string, error) { return "", os.ErrNotExist }
294
+ s := knowledgeService()
295
+ v, ok := s.ovLatestVersion(ovPyPackage)
296
+ if ok {
297
+ t.Fatalf("查不到时 ok 应为 false,实际 %q", v)
298
+ }
299
+ if v != "" {
300
+ t.Fatalf("查不到时版本应为空串,实际 %q", v)
301
+ }
302
+ }
303
+
304
+ // 缓存:连续两次查询只打一次外网。
305
+ func TestKnowledgeLatestVersionCached(t *testing.T) {
306
+ fakeKnowledgeEnv(t)
307
+ calls := 0
308
+ pypiLatest = func(pkg string) (string, error) {
309
+ calls++
310
+ return "0.5.2", nil
311
+ }
312
+ s := knowledgeService()
313
+ for i := 0; i < 3; i++ {
314
+ v, ok := s.ovLatestVersion(ovPyPackage)
315
+ if !ok || v != "0.5.2" {
316
+ t.Fatalf("第 %d 次查询 = (%q, %v)", i+1, v, ok)
317
+ }
318
+ }
319
+ if calls != 1 {
320
+ t.Fatalf("外网查询次数 = %d,期望 1(要有缓存)", calls)
321
+ }
322
+ }
323
+
324
+ /* ── 可升级判定 ── */
325
+
326
+ func TestKnowledgeUpgradeAvailable(t *testing.T) {
327
+ home, binDir := fakeKnowledgeEnv(t)
328
+ // 造一个「装着 0.4.15」的 venv:dist-info 目录名就是版本来源
329
+ venv := filepath.Join(home, ovVenvName)
330
+ site := filepath.Join(venv, "Lib", "site-packages")
331
+ if runtime.GOOS != "windows" {
332
+ site = filepath.Join(venv, "lib", "python3.12", "site-packages")
333
+ }
334
+ if err := os.MkdirAll(filepath.Join(site, "openviking-0.4.15.dist-info"), 0o755); err != nil {
335
+ t.Fatal(err)
336
+ }
337
+ bin := filepath.Join(ovVenvBinDir(venv), "ov")
338
+ if runtime.GOOS == "windows" {
339
+ bin += ".exe"
340
+ }
341
+ if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
342
+ t.Fatal(err)
343
+ }
344
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
345
+ t.Fatal(err)
346
+ }
347
+ writeFakeOV(t, binDir, "other", "#!/bin/sh\n") // PATH 上有个无关命令,别被认成 ov
348
+
349
+ pypiLatest = func(pkg string) (string, error) { return "0.5.2", nil }
350
+ s := knowledgeService()
351
+ info := s.knowledgeInfoOf(knowledgeSpecs()[0])
352
+ if !info.Installed {
353
+ t.Fatal("venv 里有命令时 Installed 应为 true")
354
+ }
355
+ if info.Version != "0.4.15" {
356
+ t.Fatalf("版本 = %q,期望 0.4.15", info.Version)
357
+ }
358
+ if !info.UpgradeAvailable {
359
+ t.Fatal("本机 0.4.15 < 最新 0.5.2,UpgradeAvailable 应为 true")
360
+ }
361
+ if info.LatestVersion != "0.5.2" {
362
+ t.Fatalf("最新版本 = %q,期望 0.5.2", info.LatestVersion)
363
+ }
364
+ }
365
+
366
+ /* ── 安装 / 升级 ── */
367
+
368
+ // 安装动作:只跑服务端构造好的 argv,输出按行流回。
369
+ func TestKnowledgeInstallStreams(t *testing.T) {
370
+ if runtime.GOOS == "windows" {
371
+ t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
372
+ }
373
+ home, binDir := fakeKnowledgeEnv(t)
374
+ // 桩 python:建 venv 那步把「假 ov」放进 venv 的 bin 目录 ——
375
+ // 于是「跑完重新探测」这条链真的会翻成 installed=true,而不是靠测试自己断言。
376
+ venv := filepath.Join(home, ovVenvName)
377
+ py := filepath.Join(binDir, "python3")
378
+ script := "#!/bin/sh\necho \"python $*\"\n" +
379
+ "if [ \"$2\" = \"venv\" ]; then\n" +
380
+ " mkdir -p \"" + filepath.Join(venv, "bin") + "\"\n" +
381
+ " printf '#!/bin/sh\\necho \"ov 0.5.2\"\\n' > \"" + filepath.Join(venv, "bin", "ov") + "\"\n" +
382
+ " chmod +x \"" + filepath.Join(venv, "bin", "ov") + "\"\n" +
383
+ "fi\n" +
384
+ "exit 0\n"
385
+ if err := os.WriteFile(py, []byte(script), 0o755); err != nil {
386
+ t.Fatal(err)
387
+ }
388
+
389
+ rec := httptest.NewRecorder()
390
+ knowledgeService().handleKnowledgeAction(rec,
391
+ httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
392
+ if rec.Code != http.StatusOK {
393
+ t.Fatalf("状态码 = %d,期望 200(SSE):%s", rec.Code, rec.Body.String())
394
+ }
395
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
396
+ t.Fatalf("Content-Type = %q,期望 SSE", ct)
397
+ }
398
+
399
+ events := map[string][]string{}
400
+ var cur string
401
+ sc := bufio.NewScanner(strings.NewReader(rec.Body.String()))
402
+ for sc.Scan() {
403
+ line := sc.Text()
404
+ switch {
405
+ case strings.HasPrefix(line, "event: "):
406
+ cur = strings.TrimPrefix(line, "event: ")
407
+ case strings.HasPrefix(line, "data: "):
408
+ events[cur] = append(events[cur], strings.TrimPrefix(line, "data: "))
409
+ }
410
+ }
411
+ if len(events["open"]) != 1 {
412
+ t.Fatalf("应先发一条 open(告诉客户端要跑什么),实际 %v", events)
413
+ }
414
+ var open struct {
415
+ Commands []string `json:"commands"`
416
+ }
417
+ _ = json.Unmarshal([]byte(events["open"][0]), &open)
418
+ if len(open.Commands) != 3 {
419
+ t.Fatalf("open.commands = %v,期望三条(建 venv / pip install / pip show)", open.Commands)
420
+ }
421
+ if !strings.Contains(strings.Join(open.Commands, " "), "pip") {
422
+ t.Fatalf("命令里应有 pip:%v", open.Commands)
423
+ }
424
+ if !strings.Contains(strings.Join(events["line"], "\n"), "python") {
425
+ t.Fatalf("安装输出没有流回客户端:%v", events["line"])
426
+ }
427
+ if len(events["end"]) != 1 {
428
+ t.Fatalf("必须恰好一条 end 收尾,实际 %d", len(events["end"]))
429
+ }
430
+ var end struct {
431
+ OK bool `json:"ok"`
432
+ Knowledge knowledgeInfo `json:"knowledge"`
433
+ }
434
+ _ = json.Unmarshal([]byte(events["end"][0]), &end)
435
+ if !end.OK {
436
+ t.Fatalf("安装成功却回了 ok=false:%s", events["end"][0])
437
+ }
438
+ if !end.Knowledge.Installed {
439
+ t.Fatalf("跑完应重新探测到已安装(end.knowledge.installed 仍为 false):%s", events["end"][0])
440
+ }
441
+ }
442
+
443
+ // 未知 id / 未知动作一律 404,且**没有任何命令被执行** —— 这条接口不能变成任意命令执行器。
444
+ func TestKnowledgeActionRejectsUnknown(t *testing.T) {
445
+ fakeKnowledgeEnv(t)
446
+ for _, target := range []string{
447
+ "/desk/knowledge/rm-rf/install",
448
+ "/desk/knowledge/openviking/run",
449
+ "/desk/knowledge/openviking",
450
+ } {
451
+ rec := httptest.NewRecorder()
452
+ knowledgeService().handleKnowledgeAction(rec, httptest.NewRequest(http.MethodPost, target, nil))
453
+ if rec.Code != http.StatusNotFound {
454
+ t.Fatalf("%s 状态码 = %d,期望 404", target, rec.Code)
455
+ }
456
+ }
457
+ }
458
+
459
+ // 失败的安装:退出码 + 错误要回给客户端,end.ok=false(不能静默当成功)。
460
+ func TestKnowledgeInstallFailureReported(t *testing.T) {
461
+ if runtime.GOOS == "windows" {
462
+ t.Skip("本用例用 shell 脚本桩,Windows 上跑不了")
463
+ }
464
+ _, binDir := fakeKnowledgeEnv(t)
465
+ py := filepath.Join(binDir, "python3")
466
+ if err := os.WriteFile(py, []byte("#!/bin/sh\necho 'EACCES: permission denied' >&2\nexit 3\n"), 0o755); err != nil {
467
+ t.Fatal(err)
468
+ }
469
+ rec := httptest.NewRecorder()
470
+ knowledgeService().handleKnowledgeAction(rec,
471
+ httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
472
+ body := rec.Body.String()
473
+ if !strings.Contains(body, "EACCES: permission denied") {
474
+ t.Fatalf("stderr 没有流回客户端:%s", body)
475
+ }
476
+ if !strings.Contains(body, `"ok":false`) {
477
+ t.Fatalf("失败应以 ok=false 收尾:%s", body)
478
+ }
479
+ }
480
+
481
+ /* ── python 探测 ── */
482
+
483
+ // 找不到 ≥3.10 的 python → 就没有安装命令(界面要如实说「装不了」,而不是给一条跑不通的命令)。
484
+ func TestKnowledgeNoPythonNoSteps(t *testing.T) {
485
+ fakeKnowledgeEnv(t)
486
+ ovPythonProbe = func() ([]string, string) { return nil, "" }
487
+ isteps, usteps := ovSteps()
488
+ if len(isteps) != 0 || len(usteps) != 0 {
489
+ t.Fatalf("没有 python 时不该有步骤,实际 %v / %v", isteps, usteps)
490
+ }
491
+ rec := httptest.NewRecorder()
492
+ knowledgeService().handleKnowledgeAction(rec,
493
+ httptest.NewRequest(http.MethodPost, "/desk/knowledge/openviking/install", nil))
494
+ if rec.Code != http.StatusBadRequest {
495
+ t.Fatalf("状态码 = %d,期望 400(没有 python 就装不了)", rec.Code)
496
+ }
497
+ }
498
+
499
+ // 3.9 不满足 OpenViking 的要求(需要 ≥3.10)。
500
+ func TestKnowledgePythonVersionGate(t *testing.T) {
501
+ if ovPythonVersionOK("3.9.7") {
502
+ t.Fatal("3.9.7 不该通过(OpenViking 需要 ≥3.10)")
503
+ }
504
+ if !ovPythonVersionOK("3.10.0") {
505
+ t.Fatal("3.10.0 应通过")
506
+ }
507
+ if !ovPythonVersionOK("3.13.3") {
508
+ t.Fatal("3.13.3 应通过")
509
+ }
510
+ }