magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -5,13 +5,20 @@ import (
5
5
  "bytes"
6
6
  "encoding/json"
7
7
  "fmt"
8
+ "image"
9
+ "image/png"
8
10
  "io"
11
+ "mime/multipart"
9
12
  "net/http"
10
13
  "net/http/httptest"
14
+ "net/url"
11
15
  "os"
12
16
  "path/filepath"
13
17
  "strings"
14
18
  "testing"
19
+ "time"
20
+
21
+ "github.com/darren/magic-agent/internal/session"
15
22
  )
16
23
 
17
24
  // fakeCore 写一个假的 magic-agent 脚本:既能回答 --contract / --engines,
@@ -32,12 +39,16 @@ case "$*" in
32
39
  exit 0
33
40
  ;;
34
41
  *--sessions*)
35
- echo '[{"run_id":"run-1","session_id":"sess-1","engine":"claude","state":"done"}]'
42
+ echo '[{"run_id":"run-1","session_id":"sess-1","engine":"claude","state":"done","source":"pc","client":"magic-client"},
43
+ {"run_id":"run-2","session_id":"sess-2","engine":"claude","state":"done"},
44
+ {"run_id":"run-3","session_id":"sess-3","engine":"claude","state":"done","client":"other-app"},
45
+ {"run_id":"run-4","session_id":"sess-4","engine":"claude","state":"done","source":"pc","client":"magic-test"}]'
36
46
  exit 0
37
47
  ;;
38
48
  esac
39
- # 把收到的参数也回显成一行事件:让「插件有没有把 --session 透传下去」变成可断言的事实。
40
- printf '{"v":1,"seq":0,"type":"args","raw":"%s"}\n' "$*"
49
+ # 把收到的参数与来源客户端环境变量一起回显成一行事件:让「插件有没有把 --session 透传下去」
50
+ # 与「有没有注入 MAGIC_AGENT_CLIENT」都变成可断言的事实。
51
+ printf '{"v":1,"seq":0,"type":"args","raw":"%s","client":"%s"}\n' "$*" "$MAGIC_AGENT_CLIENT"
41
52
  echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
42
53
  echo '{"v":1,"seq":2,"type":"text","text":"hi"}'
43
54
  while IFS= read -r line; do
@@ -110,46 +121,21 @@ func TestAuthRequired(t *testing.T) {
110
121
  }
111
122
  defer resp.Body.Close()
112
123
  if resp.StatusCode != http.StatusUnauthorized {
113
- t.Errorf("无令牌访问 /desk/health 应 401,实际 %d", resp.StatusCode)
114
- }
115
-
116
- req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/health", nil)
117
- req.Header.Set("X-Magic-Token", "tok")
118
- resp2, err := http.DefaultClient.Do(req)
119
- if err != nil {
120
- t.Fatal(err)
121
- }
122
- defer resp2.Body.Close()
123
- if resp2.StatusCode != http.StatusOK {
124
- t.Errorf("带令牌应 200,实际 %d", resp2.StatusCode)
125
- }
126
- var health struct {
127
- OK bool `json:"ok"`
128
- ContractVersion int `json:"contractVersion"`
129
- Features []string `json:"features"`
130
- }
131
- if err := json.NewDecoder(resp2.Body).Decode(&health); err != nil {
132
- t.Fatal(err)
133
- }
134
- if !health.OK || health.ContractVersion != 1 || len(health.Features) != 2 {
135
- t.Errorf("health 内容不对: %+v", health)
124
+ t.Errorf("/desk/health 无令牌应 401,实际 %d", resp.StatusCode)
136
125
  }
137
126
  }
138
127
 
139
- // TestProfileGateEngine profile 白名单之外的引擎必须被拦住(403),
140
- // 否则「按需个性化配置」就是一句空话。
141
128
  func TestProfileGateEngine(t *testing.T) {
142
- core := fakeCore(t, goodContract)
143
129
  prof := DefaultProfile()
144
130
  prof.Engines = []string{"claude"}
145
- svc, err := New(Options{MagicAgent: core, Profile: prof, Token: "tok"})
131
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
146
132
  if err != nil {
147
133
  t.Fatal(err)
148
134
  }
149
135
  srv := httptest.NewServer(svc.Handler())
150
136
  defer srv.Close()
151
137
 
152
- body, _ := json.Marshal(askRequest{Engine: "codex", Prompt: "x"})
138
+ body, _ := json.Marshal(askRequest{Engine: "codex", Prompt: "hi"})
153
139
  req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/ask", bytes.NewReader(body))
154
140
  req.Header.Set("X-Magic-Token", "tok")
155
141
  resp, err := http.DefaultClient.Do(req)
@@ -158,11 +144,10 @@ func TestProfileGateEngine(t *testing.T) {
158
144
  }
159
145
  defer resp.Body.Close()
160
146
  if resp.StatusCode != http.StatusForbidden {
161
- t.Errorf("白名单外的引擎应 403,实际 %d", resp.StatusCode)
147
+ t.Errorf("白名单外引擎应 403,实际 %d", resp.StatusCode)
162
148
  }
163
149
  }
164
150
 
165
- // sseReader 逐行读 SSE(data: 后面的 JSON)。
166
151
  type sseReader struct {
167
152
  sc *bufio.Scanner
168
153
  resp *http.Response
@@ -208,6 +193,61 @@ func (r *sseReader) next(t *testing.T) map[string]any {
208
193
 
209
194
  func (r *sseReader) close() { _ = r.resp.Body.Close() }
210
195
 
196
+ // fakeSilentCore 写一个「首段输出前长时间静默」的假 core —— 复现 2026-10-19 手机端
197
+ // 17.7s「连接中断」的真实场景:核心在冷启动/长思考时几十秒不吐一行,期间服务端
198
+ // 一个字节都不写,移动网络或 WebView 就会把这条 SSE 当成死连接掐掉。
199
+ func fakeSilentCore(t *testing.T, contract string) string {
200
+ t.Helper()
201
+ dir := t.TempDir()
202
+ path := filepath.Join(dir, "magic-agent")
203
+ script := `#!/bin/sh
204
+ case "$*" in
205
+ *--contract*)
206
+ echo '` + contract + `'
207
+ exit 0
208
+ ;;
209
+ *--engines*)
210
+ echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
211
+ exit 0
212
+ ;;
213
+ esac
214
+ sleep 12
215
+ echo '{"v":1,"seq":1,"type":"result","text":"hi"}'
216
+ `
217
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ return path
221
+ }
222
+
223
+ // TestAskSendsHeartbeatDuringSilence core 长时间不输出时,SSE 必须持续有心跳帧 ——
224
+ // 否则手机端会在首段回复到达前就把连接掐掉(用户看到「没有收到回复 · 连接中断」,
225
+ // 而服务端任务其实完整跑完了)。心跳用 SSE 注释帧(`: ping`),解析端会忽略。
226
+ func TestAskSendsHeartbeatDuringSilence(t *testing.T) {
227
+ svc := newTestService(t, fakeSilentCore(t, goodContract))
228
+ srv := httptest.NewServer(svc.Handler())
229
+ defer srv.Close()
230
+
231
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "长时间思考"})
232
+ defer sse.close()
233
+ _ = sse.next(t) // open
234
+
235
+ ping := make(chan struct{})
236
+ go func() {
237
+ for sse.sc.Scan() {
238
+ if strings.HasPrefix(strings.TrimSpace(sse.sc.Text()), ": ping") {
239
+ close(ping)
240
+ return
241
+ }
242
+ }
243
+ }()
244
+ select {
245
+ case <-ping:
246
+ case <-time.After(12 * time.Second):
247
+ t.Fatal("静默超过 10s 仍未收到 SSE 心跳(': ping')")
248
+ }
249
+ }
250
+
211
251
  // TestAskRelaysAndControl 中继与控制通道的端到端:
212
252
  // 1. core 的每一行原样到达客户端(顺序与内容都不改);
213
253
  // 2. `POST /desk/ask/{id}/control` 写进 core 的 stdin(假 core 会回显,构成可断言证据)。
@@ -280,7 +320,13 @@ func TestAskRelaysAndControl(t *testing.T) {
280
320
  }
281
321
  }
282
322
 
283
- // TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions`)。
323
+ // TestSessionsProxy 列表页要的是 core 的**持久会话登记表**(`--sessions`),
324
+ // 且只回「本客户端 + 同族 magic-* 来源」那些(原口径见用户 2026-10-12
325
+ // 「不是 magic-agent 客户端调用的任务不显示」;2026-10-15 放宽到名单,见 extraSessionClients)。
326
+ //
327
+ // 登记表目录是全机共享的,假 core 故意返回四种来源:本客户端的、没有 client 的
328
+ // (= 直接跑 CLI / 旧记录)、别的应用的、掌天瓶(magic-test)的 —— 匿名那条与别家应用仍被滤掉,
329
+ // 留下本客户端的 sess-1 与掌天瓶的 sess-4。
284
330
  func TestSessionsProxy(t *testing.T) {
285
331
  svc := newTestService(t, fakeCore(t, goodContract))
286
332
  srv := httptest.NewServer(svc.Handler())
@@ -299,33 +345,83 @@ func TestSessionsProxy(t *testing.T) {
299
345
  var rows []struct {
300
346
  RunID string `json:"run_id"`
301
347
  SessionID string `json:"session_id"`
348
+ Client string `json:"client"`
349
+ Source string `json:"source"`
302
350
  }
303
351
  if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
304
352
  t.Fatal(err)
305
353
  }
306
- if len(rows) != 1 || rows[0].SessionID != "sess-1" {
307
- t.Errorf("应原样转发 core 的会话表,实际 %+v", rows)
354
+ if len(rows) != 2 {
355
+ t.Fatalf("应只剩本客户端与 magic-test 的会话(2 条),实际 %d: %+v", len(rows), rows)
356
+ }
357
+ if rows[0].RunID != "run-1" || rows[1].RunID != "run-4" {
358
+ t.Errorf("过滤结果不对:%+v", rows)
308
359
  }
309
360
  }
310
361
 
311
- // TestAskPassesSession 续接参数必须透传到 core 的 `--session` ——
312
- // 否则「点历史会话继续」会静默变成新会话(用户看到的是「它不记得上一轮」)。
313
- func TestAskPassesSession(t *testing.T) {
362
+ func TestAskPassesSaveHistory(t *testing.T) {
314
363
  svc := newTestService(t, fakeCore(t, goodContract))
315
364
  srv := httptest.NewServer(svc.Handler())
316
365
  defer srv.Close()
317
366
 
318
- sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "接着上一轮", Session: "sess-1"})
367
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi"})
319
368
  defer sse.close()
320
- _ = sse.next(t) // open
369
+ _ = sse.next(t)
370
+ raw, _ := sse.next(t)["raw"].(string)
371
+ if !strings.Contains(raw, "--save-registry") || !strings.Contains(raw, "--save-history") {
372
+ t.Errorf("core 应收到两个存档开关,实际 %q", raw)
373
+ }
374
+ }
375
+
376
+ func TestAskInjectsClientEnv(t *testing.T) {
377
+ svc := newTestService(t, fakeCore(t, goodContract))
378
+ srv := httptest.NewServer(svc.Handler())
379
+ defer srv.Close()
380
+
381
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi", Client: "mobile"})
382
+ defer sse.close()
383
+ _ = sse.next(t)
384
+ var ev map[string]any
385
+ for ev == nil || ev["type"] != "args" {
386
+ ev = sse.next(t)
387
+ }
388
+ if got, _ := ev["client"].(string); got != "" && !strings.Contains(got, "magic") {
389
+ t.Errorf("MAGIC_AGENT_CLIENT 应为本插件 AppID,实际 %q", got)
390
+ }
391
+ }
392
+
393
+ func TestAskInjectsProfileAppID(t *testing.T) {
394
+ prof := DefaultProfile()
395
+ prof.App.BundleID = "magic-test"
396
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
397
+ if err != nil {
398
+ t.Fatal(err)
399
+ }
400
+ srv := httptest.NewServer(svc.Handler())
401
+ defer srv.Close()
321
402
 
322
- args := sse.next(t)
323
- raw, _ := args["raw"].(string)
324
- if !strings.Contains(raw, "--session sess-1") {
325
- t.Errorf("core 收到的参数里应有 --session sess-1,实际 %q", raw)
403
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "hi"})
404
+ defer sse.close()
405
+ _ = sse.next(t)
406
+ var ev map[string]any
407
+ for ev == nil || ev["type"] != "args" {
408
+ ev = sse.next(t)
409
+ }
410
+ if got, _ := ev["client"].(string); got != "magic-test" {
411
+ t.Errorf("MAGIC_AGENT_CLIENT 应为 profile AppID,实际 %q", got)
326
412
  }
327
- if !strings.Contains(raw, "--events") || !strings.Contains(raw, "--control") {
328
- t.Errorf("core 收到的参数里应有 --events 与 --control,实际 %q", raw)
413
+ }
414
+
415
+ func TestAskPassesSession(t *testing.T) {
416
+ svc := newTestService(t, fakeCore(t, goodContract))
417
+ srv := httptest.NewServer(svc.Handler())
418
+ defer srv.Close()
419
+
420
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Session: "sess-1"})
421
+ defer sse.close()
422
+ _ = sse.next(t)
423
+ if raw, _ := sse.next(t)["raw"].(string); !strings.Contains(raw, "--session sess-1") {
424
+ t.Errorf("应透传 --session sess-1,实际 %q", raw)
329
425
  }
330
426
  }
331
427
 
@@ -369,14 +465,12 @@ func TestAskPassesPermission(t *testing.T) {
369
465
  }
370
466
  }
371
467
 
372
- // TestProjectAPINotConfigured 没配项目后端时 `/api/*` 回 501 并**点名 profile 字段**,
373
- // 不静默 404(静默 404 会让人以为是模块前端写错了)。
374
468
  func TestProjectAPINotConfigured(t *testing.T) {
375
469
  svc := newTestService(t, fakeCore(t, goodContract))
376
470
  srv := httptest.NewServer(svc.Handler())
377
471
  defer srv.Close()
378
472
 
379
- req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/docs", nil)
473
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/x", nil)
380
474
  req.Header.Set("X-Magic-Token", "tok")
381
475
  resp, err := http.DefaultClient.Do(req)
382
476
  if err != nil {
@@ -384,34 +478,26 @@ func TestProjectAPINotConfigured(t *testing.T) {
384
478
  }
385
479
  defer resp.Body.Close()
386
480
  if resp.StatusCode != http.StatusNotImplemented {
387
- t.Fatalf("/api/* 未配后端应 501,实际 %d", resp.StatusCode)
388
- }
389
- var m map[string]string
390
- if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
391
- t.Fatal(err)
392
- }
393
- if !strings.Contains(m["error"], "backend") {
394
- t.Errorf("错误信息应点名 profile 的 backend 字段,实际 %q", m["error"])
481
+ t.Errorf("未配置 backend 时 /api 应 501,实际 %d", resp.StatusCode)
395
482
  }
396
483
  }
397
484
 
398
- // TestProjectAPIForwards `/api/*` 原样转发;凭据由服务端注入,插件自己的令牌不带去后端。
399
- //
400
- // 这三条一起构成「业务视图能搬但业务逻辑不用重写」的前提:路径 / 方法 / 查询不变,
401
- // 凭据不泄漏到浏览器,两套令牌不互相污染。
402
485
  func TestProjectAPIForwards(t *testing.T) {
403
- type seen struct {
404
- path, query, auth, magic string
405
- }
406
- var got seen
407
486
  backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
408
- got = seen{path: r.URL.Path, query: r.URL.RawQuery, auth: r.Header.Get("Authorization"), magic: r.Header.Get("X-Magic-Token")}
409
- writeJSON(w, http.StatusOK, map[string]any{"表格": "docs", "行数": 3})
487
+ if r.Header.Get("Authorization") != "Bearer srv-token" {
488
+ t.Errorf("后端应收服务端注入的凭据,实际 %q", r.Header.Get("Authorization"))
489
+ }
490
+ if r.Header.Get("X-Magic-Token") != "" {
491
+ t.Error("插件的令牌不应带给后端")
492
+ }
493
+ w.Header().Set("Content-Type", "application/json")
494
+ _, _ = w.Write([]byte(`{"ok":true}`))
410
495
  }))
411
496
  defer backend.Close()
412
497
 
413
498
  prof := DefaultProfile()
414
- prof.Backend = BackendConfig{URL: backend.URL, Token: "backend-secret"}
499
+ prof.Backend.URL = backend.URL
500
+ prof.Backend.Token = "srv-token"
415
501
  svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
416
502
  if err != nil {
417
503
  t.Fatal(err)
@@ -419,7 +505,7 @@ func TestProjectAPIForwards(t *testing.T) {
419
505
  srv := httptest.NewServer(svc.Handler())
420
506
  defer srv.Close()
421
507
 
422
- req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/docs?page=2", nil)
508
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/x", nil)
423
509
  req.Header.Set("X-Magic-Token", "tok")
424
510
  resp, err := http.DefaultClient.Do(req)
425
511
  if err != nil {
@@ -427,47 +513,31 @@ func TestProjectAPIForwards(t *testing.T) {
427
513
  }
428
514
  defer resp.Body.Close()
429
515
  if resp.StatusCode != http.StatusOK {
430
- t.Fatalf("/api/docs 应 200,实际 %d", resp.StatusCode)
431
- }
432
-
433
- if got.path != "/api/docs" {
434
- t.Errorf("后端收到的路径应为 /api/docs,实际 %q", got.path)
435
- }
436
- if got.query != "page=2" {
437
- t.Errorf("查询串应原样转发,实际 %q", got.query)
438
- }
439
- if got.auth != "Bearer backend-secret" {
440
- t.Errorf("应注入后端凭据,实际 %q", got.auth)
516
+ t.Fatalf("/api 转发应 200,实际 %d", resp.StatusCode)
441
517
  }
442
- if got.magic != "" {
443
- t.Errorf("插件的令牌不该带去后端,实际 %q", got.magic)
444
- }
445
- var body map[string]any
446
- if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
447
- t.Fatal(err)
448
- }
449
- if body["表格"] != "docs" {
450
- t.Errorf("后端响应应原样回来,实际 %v", body)
518
+ b, _ := io.ReadAll(resp.Body)
519
+ if string(b) != `{"ok":true}` {
520
+ t.Errorf("转发响应不对:%s", b)
451
521
  }
452
522
  }
453
523
 
454
- // TestProjectAPIRejectsBadURL 地址写错时启动即失败(比运行时 502 好查)。
455
524
  func TestProjectAPIRejectsBadURL(t *testing.T) {
456
525
  prof := DefaultProfile()
457
- prof.Backend = BackendConfig{URL: "不是地址"}
458
- if _, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof}); err == nil {
459
- t.Fatal("非法 backend.url 应当启动即失败")
526
+ prof.Backend.URL = "not a url"
527
+ _, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof})
528
+ if err == nil || !strings.Contains(err.Error(), "backend.url") {
529
+ t.Fatalf("非法 backend.url 应启动即报错,实际 %v", err)
460
530
  }
461
531
  }
462
532
 
463
- // TestModulesEndpoint 模块清单从 profile 派生:只回答「有哪些模块、入口在哪、声明了哪些表」。
464
533
  func TestModulesEndpoint(t *testing.T) {
465
- prof := DefaultProfile()
466
- prof.Modules = []Module{
467
- {ID: "cases", Entry: "cases.js"},
468
- {ID: "reqboard", Entry: "reqboard.js", DataTables: []string{"workspaces", "requirements"}},
534
+ dir := t.TempDir()
535
+ if err := os.MkdirAll(filepath.Join(dir, "m1"), 0o755); err != nil {
536
+ t.Fatal(err)
469
537
  }
470
- svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok", UIRoot: t.TempDir()})
538
+ prof := DefaultProfile()
539
+ prof.Modules = []Module{{ID: "m1", Entry: "m1/index.html"}}
540
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok"})
471
541
  if err != nil {
472
542
  t.Fatal(err)
473
543
  }
@@ -483,172 +553,545 @@ func TestModulesEndpoint(t *testing.T) {
483
553
  defer resp.Body.Close()
484
554
  var out struct {
485
555
  Modules []struct {
486
- ID string `json:"id"`
487
- URL string `json:"url"`
488
- DataTables []string `json:"dataTables"`
489
- Loadable bool `json:"loadable"`
556
+ ID string `json:"id"`
490
557
  } `json:"modules"`
491
- Backend string `json:"backend"`
492
558
  }
493
559
  if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
494
560
  t.Fatal(err)
495
561
  }
496
- if len(out.Modules) != 2 {
497
- t.Fatalf("应有 2 个模块,实际 %d", len(out.Modules))
562
+ if len(out.Modules) != 1 || out.Modules[0].ID != "m1" {
563
+ t.Errorf("模块列表不对:%+v", out.Modules)
564
+ }
565
+ }
566
+
567
+ func TestModulesStatic(t *testing.T) {
568
+ uiDir := t.TempDir()
569
+ if err := os.MkdirAll(filepath.Join(uiDir, "shared"), 0o755); err != nil {
570
+ t.Fatal(err)
571
+ }
572
+ if err := os.WriteFile(filepath.Join(uiDir, "shared", "a.js"), []byte("var a=1;"), 0o644); err != nil {
573
+ t.Fatal(err)
498
574
  }
499
- // URL 必须带上挂载点前缀 `/modules/`(漏了就是「清单里有点开 404」)。
500
- if out.Modules[0].ID != "cases" || out.Modules[0].URL != "/modules/cases.js" || !out.Modules[0].Loadable {
501
- t.Errorf("第一个模块不对: %+v", out.Modules[0])
575
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), UIRoot: uiDir, Token: "tok"})
576
+ if err != nil {
577
+ t.Fatal(err)
578
+ }
579
+ srv := httptest.NewServer(svc.Handler())
580
+ defer srv.Close()
581
+
582
+ resp, err := http.Get(srv.URL + "/m/shared/a.js")
583
+ if err != nil {
584
+ t.Fatal(err)
502
585
  }
503
- if len(out.Modules[1].DataTables) != 2 {
504
- t.Errorf("模块声明的表应原样回,实际 %+v", out.Modules[1])
586
+ defer resp.Body.Close()
587
+ if resp.StatusCode != http.StatusOK {
588
+ t.Fatalf("共享静态资源应 200,实际 %d", resp.StatusCode)
505
589
  }
506
- if out.Backend != "" {
507
- t.Errorf("未配后端时 backend 应为空串,实际 %q", out.Backend)
590
+ b, _ := io.ReadAll(resp.Body)
591
+ if string(b) != "var a=1;" {
592
+ t.Errorf("内容不对:%s", b)
508
593
  }
509
594
  }
510
595
 
511
- // TestModulesStatic 模块静态资源的两个根与优先级:
512
- // 先项目自己的 `ui.modulesDir`,再退回插件的 `--ui-dir`;越界读不到根外的东西。
513
- //
514
- // 为什么要有独立的模块目录:模块文件属于项目(跟项目仓库一起改),基础版 UI 属于插件。
515
- // 混在一个目录里,插件升级与模块改动会互相踩。
516
- func TestModulesStatic(t *testing.T) {
517
- uiDir := t.TempDir()
518
- modDir := t.TempDir()
519
- if err := os.WriteFile(filepath.Join(uiDir, "index.html"), []byte("from-ui"), 0o644); err != nil {
596
+ func TestUploadReadBackAndTraversalGuard(t *testing.T) {
597
+ svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: DefaultProfile(), Token: "tok"})
598
+ if err != nil {
599
+ t.Fatal(err)
600
+ }
601
+ srv := httptest.NewServer(svc.Handler())
602
+ defer srv.Close()
603
+
604
+ var buf bytes.Buffer
605
+ w := multipartWrite(&buf, "file", "a.txt", []byte("hello"))
606
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/upload", &buf)
607
+ req.Header.Set("X-Magic-Token", "tok")
608
+ req.Header.Set("Content-Type", w)
609
+ resp, err := http.DefaultClient.Do(req)
610
+ if err != nil {
520
611
  t.Fatal(err)
521
612
  }
522
- if err := os.WriteFile(filepath.Join(uiDir, "shared.js"), []byte("from-ui"), 0o644); err != nil {
613
+ defer resp.Body.Close()
614
+ if resp.StatusCode != http.StatusOK {
615
+ msg, _ := io.ReadAll(resp.Body)
616
+ t.Fatalf("上传应 200,实际 %d(%s)", resp.StatusCode, msg)
617
+ }
618
+ var out struct {
619
+ Path string `json:"path"`
620
+ URL string `json:"url"`
621
+ }
622
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
523
623
  t.Fatal(err)
524
624
  }
525
- if err := os.WriteFile(filepath.Join(modDir, "shared.js"), []byte("from-project"), 0o644); err != nil {
625
+
626
+ // 回读:<img> 只能走 ?token=。
627
+ read, err := http.Get(srv.URL + out.URL + "?token=tok")
628
+ if err != nil {
526
629
  t.Fatal(err)
527
630
  }
528
- if err := os.WriteFile(filepath.Join(modDir, "cases.js"), []byte("cases-module"), 0o644); err != nil {
631
+ defer read.Body.Close()
632
+ if read.StatusCode != http.StatusOK {
633
+ t.Fatalf("回读应 200,实际 %d", read.StatusCode)
634
+ }
635
+
636
+ // 目录穿越:编码后的 ../ 不能读到上传目录以外。
637
+ bad, err := http.Get(srv.URL + "/desk/uploads/..%2F..%2Fetc%2Fpasswd?token=tok")
638
+ if err != nil {
529
639
  t.Fatal(err)
530
640
  }
531
- secret := filepath.Join(filepath.Dir(modDir), "secret-"+filepath.Base(modDir)+".txt")
532
- if err := os.WriteFile(secret, []byte("TOP-SECRET"), 0o644); err != nil {
641
+ defer bad.Body.Close()
642
+ if bad.StatusCode == http.StatusOK {
643
+ t.Error("目录穿越请求不应成功")
644
+ }
645
+ }
646
+
647
+ func TestMediaFileServesImageByPath(t *testing.T) {
648
+ dir := t.TempDir()
649
+ img := filepath.Join(dir, "x.png")
650
+ if err := os.WriteFile(img, []byte{0x89, 'P', 'N', 'G'}, 0o644); err != nil {
533
651
  t.Fatal(err)
534
652
  }
653
+ svc := newTestService(t, fakeCore(t, goodContract))
654
+ srv := httptest.NewServer(svc.Handler())
655
+ defer srv.Close()
535
656
 
536
- prof := DefaultProfile()
537
- prof.UI.ModulesDir = modDir
538
- svc, err := New(Options{MagicAgent: fakeCore(t, goodContract), Profile: prof, Token: "tok", UIRoot: uiDir})
657
+ resp, err := http.Get(srv.URL + "/desk/media?path=" + url.QueryEscape(img) + "&token=tok")
539
658
  if err != nil {
540
659
  t.Fatal(err)
541
660
  }
661
+ defer resp.Body.Close()
662
+ if resp.StatusCode != http.StatusOK {
663
+ t.Fatalf("按路径回读图片应 200,实际 %d", resp.StatusCode)
664
+ }
665
+ }
666
+
667
+ func TestPathReadTextAndGuards(t *testing.T) {
668
+ dir := t.TempDir()
669
+ md := filepath.Join(dir, "a.md")
670
+ if err := os.WriteFile(md, []byte("# 标题\n\n正文"), 0o644); err != nil {
671
+ t.Fatal(err)
672
+ }
673
+ bin := filepath.Join(dir, "b.bin")
674
+ if err := os.WriteFile(bin, []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
675
+ t.Fatal(err)
676
+ }
677
+ big := filepath.Join(dir, "c.txt")
678
+ if err := os.WriteFile(big, bytes.Repeat([]byte("a"), maxReadBytes+1), 0o644); err != nil {
679
+ t.Fatal(err)
680
+ }
681
+ svc := newTestService(t, fakeCore(t, goodContract))
542
682
  srv := httptest.NewServer(svc.Handler())
543
683
  defer srv.Close()
544
684
 
545
- get := func(p string) (int, string) {
546
- req, _ := http.NewRequest(http.MethodGet, srv.URL+p, nil)
685
+ read := func(path, base string) map[string]any {
686
+ body, _ := json.Marshal(map[string]string{"path": path, "base": base})
687
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/path-read", bytes.NewReader(body))
547
688
  req.Header.Set("X-Magic-Token", "tok")
689
+ req.Header.Set("Content-Type", "application/json")
548
690
  resp, err := http.DefaultClient.Do(req)
549
691
  if err != nil {
550
- t.Fatalf("GET %s: %v", p, err)
692
+ t.Fatal(err)
551
693
  }
552
694
  defer resp.Body.Close()
553
- b, _ := io.ReadAll(resp.Body)
554
- return resp.StatusCode, string(b)
695
+ if resp.StatusCode != http.StatusOK {
696
+ t.Fatalf("path-read 应 200,实际 %d", resp.StatusCode)
697
+ }
698
+ var out map[string]any
699
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
700
+ t.Fatal(err)
701
+ }
702
+ return out
555
703
  }
556
704
 
557
- // 同名文件:项目目录优先
558
- if code, body := get("/modules/shared.js"); code != 200 || body != "from-project" {
559
- t.Errorf("/modules/shared.js 应取项目目录的,实际 %d %q", code, body)
705
+ // 绝对路径读文本。
706
+ if got := read(md, ""); got["text"] != "# 标题\n\n正文" {
707
+ t.Fatalf("文本应原样返回,实际 %v", got)
560
708
  }
561
- // 项目目录里没有的,退回 UI 目录
562
- if code, body := get("/modules/index.html"); code != 200 || body != "from-ui" {
563
- t.Errorf("/modules/index.html 应退回 UI 目录,实际 %d %q", code, body)
709
+ // 相对路径按 base 解析。
710
+ if got := read("a.md", dir); got["text"] != "# 标题\n\n正文" {
711
+ t.Fatalf("相对路径应按 base 解析,实际 %v", got)
564
712
  }
565
- // 项目模块能读到(这是「业务模块留在项目仓库里」的关键)
566
- if code, body := get("/modules/cases.js"); code != 200 || body != "cases-module" {
567
- t.Errorf("/modules/cases.js 应读到项目模块,实际 %d %q", code, body)
713
+ // 不存在。
714
+ if got := read(filepath.Join(dir, "nope.md"), ""); got["ok"] != false {
715
+ t.Fatalf("不存在的文件应 ok=false,实际 %v", got)
568
716
  }
569
- // 都不存在 → 404
570
- if code, _ := get("/modules/nope.js"); code != http.StatusNotFound {
571
- t.Errorf("不存在的模块应 404,实际 %d", code)
717
+ // 二进制。
718
+ if got := read(bin, ""); got["binary"] != true {
719
+ t.Fatalf("含 NUL 应判二进制,实际 %v", got)
572
720
  }
573
- // 越界:Clean 把 `..` 收进根内,因此只能命中根内文件或 404,绝不可能读到根外
574
- if _, body := get("/modules/../" + filepath.Base(secret)); strings.Contains(body, "TOP-SECRET") {
575
- t.Error("越界路径读到了根外的文件")
721
+ // 超限。
722
+ if got := read(big, ""); got["tooBig"] != true {
723
+ t.Fatalf("超 512KB 应判 tooBig,实际 %v", got)
576
724
  }
577
725
  }
578
726
 
579
- // TestLoadProfileDefaultsAndOverrides profile 只描述差异:省略的字段要有稳定默认值。
580
- func TestLoadProfileDefaultsAndOverrides(t *testing.T) {
581
- t.Run("空路径给默认", func(t *testing.T) {
582
- p, err := LoadProfile("")
583
- if err != nil {
584
- t.Fatal(err)
585
- }
586
- if p.UI.Entry != "index.html" || p.AppID() != "magic-client" {
587
- t.Errorf("默认 profile 不对: entry=%q appID=%q", p.UI.Entry, p.AppID())
588
- }
589
- if len(p.Modules) != 0 {
590
- t.Errorf("默认 profile 不该有业务模块,实际 %v", p.Modules)
727
+ func TestMediaFileScalesWhenWidthGiven(t *testing.T) {
728
+ dir := t.TempDir()
729
+ img := filepath.Join(dir, "wide.png")
730
+ src := image.NewNRGBA(image.Rect(0, 0, 2000, 1000))
731
+ for y := 0; y < 1000; y++ {
732
+ for x := 0; x < 2000; x++ {
733
+ i := src.PixOffset(x, y)
734
+ src.Pix[i] = uint8(x*7 + y*13)
735
+ src.Pix[i+1] = uint8(x*3 + y*29)
736
+ src.Pix[i+2] = uint8((x * y) >> 3)
737
+ src.Pix[i+3] = 0xff
591
738
  }
592
- })
739
+ }
740
+ f, err := os.Create(img)
741
+ if err != nil {
742
+ t.Fatal(err)
743
+ }
744
+ if err := png.Encode(f, src); err != nil {
745
+ t.Fatal(err)
746
+ }
747
+ f.Close()
593
748
 
594
- t.Run("读文件并补默认", func(t *testing.T) {
595
- dir := t.TempDir()
596
- path := filepath.Join(dir, "掌天瓶.json")
597
- content := `{"app":{"name":"掌天瓶","bundleId":"com.magic.test"},"modules":[{"id":"cases","entry":"modules/cases.js"}]}`
598
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
599
- t.Fatal(err)
600
- }
601
- p, err := LoadProfile(path)
602
- if err != nil {
603
- t.Fatal(err)
604
- }
605
- if p.App.Name != "掌天瓶" || p.AppID() != "com.magic.test" {
606
- t.Errorf("app 字段没读到: %+v", p.App)
607
- }
608
- if p.UI.Entry != "index.html" {
609
- t.Errorf("省略的 ui.entry 应补默认,实际 %q", p.UI.Entry)
610
- }
611
- if len(p.Modules) != 1 || p.Modules[0].ID != "cases" {
612
- t.Errorf("业务模块清单没读到: %+v", p.Modules)
613
- }
614
- if !strings.HasSuffix(p.DataDir(), filepath.Join(".magic-client", "com.magic.test")) {
615
- t.Errorf("数据目录应按 appID 派生,实际 %q", p.DataDir())
616
- }
617
- })
749
+ svc := newTestService(t, fakeCore(t, goodContract))
750
+ srv := httptest.NewServer(svc.Handler())
751
+ defer srv.Close()
618
752
 
619
- t.Run("坏文件报错", func(t *testing.T) {
620
- dir := t.TempDir()
621
- path := filepath.Join(dir, "bad.json")
622
- if err := os.WriteFile(path, []byte("{不是 JSON"), 0o644); err != nil {
623
- t.Fatal(err)
624
- }
625
- if _, err := LoadProfile(path); err == nil {
626
- t.Fatal("坏 profile 应当报错")
627
- }
628
- })
753
+ resp, err := http.Get(srv.URL + "/desk/media?path=" + url.QueryEscape(img) + "&w=720&token=tok")
754
+ if err != nil {
755
+ t.Fatal(err)
756
+ }
757
+ defer resp.Body.Close()
758
+ if resp.StatusCode != http.StatusOK {
759
+ t.Fatalf("缩放回读应 200,实际 %d", resp.StatusCode)
760
+ }
761
+ got, _, err := image.Decode(resp.Body)
762
+ if err != nil {
763
+ t.Fatalf("缩放结果应是可解码图片:%v", err)
764
+ }
765
+ if w := got.Bounds().Dx(); w != 720 {
766
+ t.Fatalf("应按目标宽度 720 缩放,实际 %d", w)
767
+ }
768
+ if h := got.Bounds().Dy(); h != 360 {
769
+ t.Fatalf("应等比缩放到 360 高,实际 %d", h)
770
+ }
771
+ }
772
+
773
+ func TestLoadProfileDefaultsAndOverrides(t *testing.T) {
774
+ dir := t.TempDir()
775
+ path := filepath.Join(dir, "p.json")
776
+ if err := os.WriteFile(path, []byte(`{"app":{"bundleId":"app-x"},"engines":["claude"],"backend":{"url":"http://127.0.0.1:9"}}`), 0o600); err != nil {
777
+ t.Fatal(err)
778
+ }
779
+ prof, err := LoadProfile(path)
780
+ if err != nil {
781
+ t.Fatal(err)
782
+ }
783
+ if prof.AppID() != "app-x" {
784
+ t.Errorf("app.bundleId 未生效:%q", prof.AppID())
785
+ }
786
+ if len(prof.Engines) != 1 || prof.Engines[0] != "claude" {
787
+ t.Errorf("engines 未生效:%+v", prof.Engines)
788
+ }
629
789
  }
630
790
 
631
- // TestWriteErrShape 错误一律以 {"error": "..."} 回,客户端只认这一种形状。
632
791
  func TestWriteErrShape(t *testing.T) {
792
+ rec := httptest.NewRecorder()
793
+ writeErr(rec, http.StatusBadRequest, "坏请求")
794
+ if rec.Code != http.StatusBadRequest {
795
+ t.Errorf("状态码 = %d", rec.Code)
796
+ }
797
+ var out struct {
798
+ Error string `json:"error"`
799
+ }
800
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
801
+ t.Fatal(err)
802
+ }
803
+ if out.Error != "坏请求" {
804
+ t.Errorf("error = %q", out.Error)
805
+ }
806
+ }
807
+
808
+ func TestSessionDeleteRemovesRunNamedLog(t *testing.T) {
809
+ home := t.TempDir()
810
+ t.Setenv(session.EnvDir, home)
811
+ t.Setenv("HOME", home)
812
+ logDir := filepath.Join(home, ".magic-agent", "sessions")
813
+ if err := os.MkdirAll(logDir, 0o700); err != nil {
814
+ t.Fatal(err)
815
+ }
816
+
817
+ now := time.Now()
818
+ rec := session.Record{
819
+ RunID: "run-keep", SessionID: "sess-keep", Engine: "codex",
820
+ State: session.StateDone, StartedAt: now, UpdatedAt: now,
821
+ }
822
+ body, _ := json.Marshal(rec)
823
+ if err := os.WriteFile(filepath.Join(home, "run-keep.json"), body, 0o600); err != nil {
824
+ t.Fatal(err)
825
+ }
826
+ // 日志只落在 run_id 名下(引擎整轮没吐 id → 没改名)。
827
+ logPath := filepath.Join(logDir, "run-keep.jsonl")
828
+ if err := os.WriteFile(logPath, []byte(`{"seq":1,"kind":"user","text":"hi"}`+"\n"), 0o600); err != nil {
829
+ t.Fatal(err)
830
+ }
831
+
633
832
  svc := newTestService(t, fakeCore(t, goodContract))
634
833
  srv := httptest.NewServer(svc.Handler())
635
834
  defer srv.Close()
636
835
 
637
- req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/ask", strings.NewReader(`{"engine":"claude"}`))
836
+ req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/desk/session/sess-keep", nil)
837
+ req.Header.Set("X-Magic-Token", "tok")
838
+ resp, err := http.DefaultClient.Do(req)
839
+ if err != nil {
840
+ t.Fatal(err)
841
+ }
842
+ defer resp.Body.Close()
843
+ if resp.StatusCode != http.StatusOK {
844
+ msg, _ := io.ReadAll(resp.Body)
845
+ t.Fatalf("删会话应 200,实际 %d(%s)", resp.StatusCode, msg)
846
+ }
847
+ var out struct {
848
+ RecordsRemoved int `json:"recordsRemoved"`
849
+ }
850
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
851
+ t.Fatal(err)
852
+ }
853
+ if out.RecordsRemoved != 1 {
854
+ t.Errorf("应删掉 1 条登记记录,实际 %d", out.RecordsRemoved)
855
+ }
856
+
857
+ // 按 session_id 删,run_id 名下的登记记录与日志都必须一起消失。
858
+ if _, err := os.Stat(filepath.Join(home, "run-keep.json")); !os.IsNotExist(err) {
859
+ t.Error("登记记录 run-keep.json 应已删除")
860
+ }
861
+ if _, err := os.Stat(logPath); !os.IsNotExist(err) {
862
+ t.Error("日志文件 run-keep.jsonl 应已删除(只删 <session_id>.jsonl 会留下孤儿)")
863
+ }
864
+ }
865
+
866
+ // fakeResumeCore 发两段之间停一下、随后自然退出的假 core:用来模拟
867
+ // 「客户端在流中途断线,但服务端把 core 跑完」的断线续传场景。
868
+ func fakeResumeCore(t *testing.T, contract string) string {
869
+ t.Helper()
870
+ dir := t.TempDir()
871
+ path := filepath.Join(dir, "magic-agent")
872
+ script := `#!/bin/sh
873
+ case "$*" in
874
+ *--contract*)
875
+ echo '` + contract + `'
876
+ exit 0
877
+ ;;
878
+ *--engines*)
879
+ echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
880
+ exit 0
881
+ ;;
882
+ esac
883
+ echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
884
+ echo '{"v":1,"seq":2,"type":"text","text":"前半"}'
885
+ sleep 2
886
+ echo '{"v":1,"seq":3,"type":"text","text":"后半"}'
887
+ echo '{"v":1,"seq":4,"type":"result","text":"前半后半"}'
888
+ `
889
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
890
+ t.Fatal(err)
891
+ }
892
+ return path
893
+ }
894
+
895
+ // askEvents 拉一次 /desk/ask/{id}/events?after= 并解出响应。
896
+ type askEventsResp struct {
897
+ ID string `json:"id"`
898
+ Events []map[string]any `json:"events"`
899
+ Count int `json:"count"`
900
+ After uint64 `json:"after"`
901
+ LastSeq uint64 `json:"lastSeq"`
902
+ Done bool `json:"done"`
903
+ Code *int `json:"code"`
904
+ SnapshotRequired bool `json:"snapshotRequired"`
905
+ }
906
+
907
+ func getAskEvents(t *testing.T, base, token, id string, after uint64) askEventsResp {
908
+ t.Helper()
909
+ req, _ := http.NewRequest(http.MethodGet,
910
+ fmt.Sprintf("%s/desk/ask/%s/events?after=%d", base, url.PathEscape(id), after), nil)
911
+ req.Header.Set("X-Magic-Token", token)
912
+ resp, err := http.DefaultClient.Do(req)
913
+ if err != nil {
914
+ t.Fatal(err)
915
+ }
916
+ defer resp.Body.Close()
917
+ if resp.StatusCode != http.StatusOK {
918
+ msg, _ := io.ReadAll(resp.Body)
919
+ t.Fatalf("/desk/ask/{id}/events 应 200,实际 %d(%s)", resp.StatusCode, msg)
920
+ }
921
+ var out askEventsResp
922
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
923
+ t.Fatal(err)
924
+ }
925
+ return out
926
+ }
927
+
928
+ // TestAskEventsResumeAfterCursor 断线续传的服务端半场:
929
+ // 1. 连接还在时,游标能拿到「之后」的事件,且不会重发已看过的;
930
+ // 2. 客户端断连后 core 继续跑完,事件仍然进回放缓冲;
931
+ // 3. core 结束后 done/code 可补拉,运行中列表不再包含它。
932
+ func TestAskEventsResumeAfterCursor(t *testing.T) {
933
+ svc := newTestService(t, fakeResumeCore(t, goodContract))
934
+ srv := httptest.NewServer(svc.Handler())
935
+ defer srv.Close()
936
+
937
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "讲两句"})
938
+ opened := sse.next(t)
939
+ askID, _ := opened["id"].(string)
940
+ if askID == "" {
941
+ t.Fatalf("open 事件应带 ask id,实际 %v", opened)
942
+ }
943
+ ready := sse.next(t)
944
+ if ready["type"] != "ready" {
945
+ t.Fatalf("应先收到 ready,实际 %v", ready)
946
+ }
947
+ first := sse.next(t)
948
+ if first["type"] != "text" || first["seq"] != float64(2) {
949
+ t.Fatalf("应收到 seq=2 的 text,实际 %v", first)
950
+ }
951
+
952
+ // 游标 = 2:此刻还没有新事件,接口应返回空集且未结束。
953
+ cur := getAskEvents(t, srv.URL, "tok", askID, 2)
954
+ if cur.Count != 0 || cur.Done {
955
+ t.Fatalf("after=2 时不该有新事件且未结束,实际 count=%d done=%v", cur.Count, cur.Done)
956
+ }
957
+ if cur.LastSeq != 2 {
958
+ t.Errorf("lastSeq = %d want 2", cur.LastSeq)
959
+ }
960
+
961
+ // 断开 SSE(模拟手机锁屏 / 切后台),core 不受影响,继续跑完后半段。
962
+ sse.close()
963
+
964
+ deadline := time.Now().Add(10 * time.Second)
965
+ var last askEventsResp
966
+ for time.Now().Before(deadline) {
967
+ last = getAskEvents(t, srv.URL, "tok", askID, 2)
968
+ if last.Done {
969
+ break
970
+ }
971
+ time.Sleep(200 * time.Millisecond)
972
+ }
973
+ if !last.Done {
974
+ t.Fatalf("core 结束后应能补拉到 done,实际 %+v", last)
975
+ }
976
+ if last.Code == nil || *last.Code != 0 {
977
+ t.Errorf("补拉终态应带 code=0,实际 %v", last.Code)
978
+ }
979
+ if last.LastSeq != 4 {
980
+ t.Errorf("lastSeq = %d want 4", last.LastSeq)
981
+ }
982
+ var types []string
983
+ for _, ev := range last.Events {
984
+ s, _ := ev["type"].(string)
985
+ types = append(types, s)
986
+ }
987
+ if strings.Join(types, ",") != "text,result" {
988
+ t.Errorf("断点之后应只补回 text(seq3) 与 result(seq4),实际 %v", types)
989
+ }
990
+ if txt, _ := last.Events[0]["text"].(string); txt != "后半" {
991
+ t.Errorf("补回的第二段正文应为「后半」,实际 %q", txt)
992
+ }
993
+
994
+ // 已结束的 ask 不该再出现在「正在跑」列表里(它只在内存里留一段补拉窗口)。
995
+ reqList, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/asks", nil)
996
+ reqList.Header.Set("X-Magic-Token", "tok")
997
+ respList, err := http.DefaultClient.Do(reqList)
998
+ if err != nil {
999
+ t.Fatal(err)
1000
+ }
1001
+ defer respList.Body.Close()
1002
+ var asks []map[string]any
1003
+ if err := json.NewDecoder(respList.Body).Decode(&asks); err != nil {
1004
+ t.Fatal(err)
1005
+ }
1006
+ if len(asks) != 0 {
1007
+ t.Errorf("结束的 ask 不应出现在运行中列表,实际 %+v", asks)
1008
+ }
1009
+
1010
+ // 未知 id 诚实报 404(不是空列表,免得客户端以为「还在跑」)。
1011
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/ask/ask-nope/events?after=0", nil)
638
1012
  req.Header.Set("X-Magic-Token", "tok")
639
1013
  resp, err := http.DefaultClient.Do(req)
640
1014
  if err != nil {
641
1015
  t.Fatal(err)
642
1016
  }
643
1017
  defer resp.Body.Close()
644
- if resp.StatusCode != http.StatusBadRequest {
645
- t.Fatalf("空 prompt 应 400,实际 %d", resp.StatusCode)
1018
+ if resp.StatusCode != http.StatusNotFound {
1019
+ t.Errorf("未知 ask 应 404,实际 %d", resp.StatusCode)
646
1020
  }
647
- var m map[string]string
648
- if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
1021
+ }
1022
+
1023
+ // TestAskEventsIncludesSyntheticPrompt 断点续传要把「用户问的那句」补回来:
1024
+ // core 的 stdout 事件流里只有 ready/text/result,从不发 user(user 只进会话日志),
1025
+ // 所以网关在 /desk/ask 启动前把 prompt 合成一条 type:"user" 塞进回放缓冲(appendPrompt)。
1026
+ // 三条约束逐条钉住:
1027
+ // 1. after=0 的首条就是它:text/attachments 与请求一致,带 prompt_id,**不带 seq**;
1028
+ // 2. 它**不进** SSE 直播流(第一帧仍是 core 的 ready),否则实时端会画出两个用户气泡;
1029
+ // 3. after>0 的增量补拉不再给(否则每补一次就重复一个气泡)。
1030
+ func TestAskEventsIncludesSyntheticPrompt(t *testing.T) {
1031
+ svc := newTestService(t, fakeResumeCore(t, goodContract))
1032
+ srv := httptest.NewServer(svc.Handler())
1033
+ defer srv.Close()
1034
+
1035
+ att := filepath.Join(t.TempDir(), "note.png")
1036
+ if err := os.WriteFile(att, []byte("png"), 0o600); err != nil {
649
1037
  t.Fatal(err)
650
1038
  }
651
- if !strings.Contains(m["error"], "prompt") {
652
- t.Errorf("错误信息应提到 prompt,实际 %q", m["error"])
1039
+ sse := openSSE(t, srv.URL, "tok", askRequest{
1040
+ Engine: "claude", Prompt: "讲两句", Attachments: []string{att},
1041
+ })
1042
+ defer sse.close()
1043
+ opened := sse.next(t)
1044
+ askID, _ := opened["id"].(string)
1045
+ if askID == "" {
1046
+ t.Fatalf("open 事件应带 ask id,实际 %v", opened)
1047
+ }
1048
+
1049
+ // 约束 2:直播流第一帧必须还是 core 的 ready。
1050
+ ready := sse.next(t)
1051
+ if ready["type"] != "ready" {
1052
+ t.Fatalf("直播流首帧应仍是 ready(合成 user 不该进直播流),实际 %v", ready)
1053
+ }
1054
+
1055
+ // 约束 1:从头补(after=0)时先给合成的提问。
1056
+ cur := getAskEvents(t, srv.URL, "tok", askID, 0)
1057
+ if cur.Count == 0 {
1058
+ t.Fatal("after=0 应至少补回合成的 user 事件")
1059
+ }
1060
+ user := cur.Events[0]
1061
+ if user["type"] != "user" {
1062
+ t.Fatalf("补回的首条应是 user,实际 %v", user)
1063
+ }
1064
+ if txt, _ := user["text"].(string); txt != "讲两句" {
1065
+ t.Errorf("合成 user 的 text = %q want 讲两句", txt)
1066
+ }
1067
+ if pid, _ := user["prompt_id"].(string); pid == "" {
1068
+ t.Error("合成 user 应带 prompt_id(客户端去重的键)")
1069
+ }
1070
+ if _, has := user["seq"]; has {
1071
+ t.Errorf("合成 user 不该带 seq(不能推进客户端游标),实际 %v", user)
1072
+ }
1073
+ atts, _ := user["attachments"].([]any)
1074
+ if len(atts) != 1 {
1075
+ t.Fatalf("合成 user 应带 1 个附件,实际 %v", user["attachments"])
1076
+ }
1077
+ if m, _ := atts[0].(map[string]any); m["path"] != att {
1078
+ t.Errorf("附件 path = %v want %s", atts[0], att)
653
1079
  }
1080
+
1081
+ // 约束 3:带游标的增量补拉不再重复给。
1082
+ inc := getAskEvents(t, srv.URL, "tok", askID, 1)
1083
+ for _, ev := range inc.Events {
1084
+ if ev["type"] == "user" {
1085
+ t.Errorf("after>0 的增量补拉不该再给合成 user,实际 %v", ev)
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ // multipartWrite 把 content 写成一个 multipart/form-data 请求体,返回 Content-Type。
1091
+ func multipartWrite(buf *bytes.Buffer, field, name string, content []byte) string {
1092
+ w := multipart.NewWriter(buf)
1093
+ fw, _ := w.CreateFormFile(field, name)
1094
+ _, _ = fw.Write(content)
1095
+ _ = w.Close()
1096
+ return w.FormDataContentType()
654
1097
  }