magic-agent 2.3.2 → 2.3.9

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 (33) hide show
  1. package/CLIENT-CONTRACT.md +30 -4
  2. package/README.md +1 -1
  3. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  4. package/client-ui/shared/conversation.js +39 -0
  5. package/client-ui/shared/transport-http.js +10 -0
  6. package/client-ui-mobile/index.html +368 -44
  7. package/internal/agent/engine.go +2 -2
  8. package/internal/agent/llmengine.go +0 -8
  9. package/internal/agent/llmengine_test.go +0 -17
  10. package/internal/agent/llmmodels.go +0 -35
  11. package/internal/client/cors_test.go +32 -0
  12. package/internal/client/jobs.go +1160 -0
  13. package/internal/client/jobs_test.go +369 -0
  14. package/internal/client/service.go +235 -140
  15. package/internal/client/service_test.go +43 -0
  16. package/internal/session/session.go +15 -1
  17. package/internal/session/workspace_lock.go +165 -0
  18. package/internal/session/workspace_lock_test.go +90 -0
  19. package/internal/suggest/suggest.go +8 -25
  20. package/internal/suggest/suggest_test.go +0 -6
  21. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  22. package/npm/apk/version.json +3 -3
  23. package/npm/dist/darwin-arm64/magic-agent +0 -0
  24. package/npm/dist/darwin-arm64/magic-client +0 -0
  25. package/npm/dist/darwin-x64/magic-agent +0 -0
  26. package/npm/dist/darwin-x64/magic-client +0 -0
  27. package/npm/dist/linux-arm64/magic-agent +0 -0
  28. package/npm/dist/linux-arm64/magic-client +0 -0
  29. package/npm/dist/linux-x64/magic-agent +0 -0
  30. package/npm/dist/linux-x64/magic-client +0 -0
  31. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  32. package/npm/dist/win32-x64/magic-client.exe +0 -0
  33. package/package.json +1 -1
@@ -0,0 +1,369 @@
1
+ package client
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "log"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+ )
17
+
18
+ /* 服务端作业队列的回归闸门(2026-10-23):
19
+ · 同一 workspace 串行、不同 workspace 并行;
20
+ · request_id 幂等(重复提交不多跑一遍);
21
+ · 排队中可取消,取消后不会偷偷开跑;
22
+ · 落盘 + 重启恢复(queued 留着、running 标失败)。 */
23
+
24
+ // jobCore 假 core:记下每一次「真正开跑」的参数,然后停在 stdin 上等 stop。
25
+ func jobCore(t *testing.T) string {
26
+ t.Helper()
27
+ dir := t.TempDir()
28
+ path := filepath.Join(dir, "magic-agent")
29
+ script := `#!/bin/sh
30
+ case "$*" in
31
+ *--contract*)
32
+ echo '{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}'
33
+ exit 0
34
+ ;;
35
+ *--engines*)
36
+ echo '[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]'
37
+ exit 0
38
+ ;;
39
+ *--sessions*)
40
+ echo '[]'
41
+ exit 0
42
+ ;;
43
+ esac
44
+ if [ -n "$JOB_TEST_LOG" ]; then
45
+ printf '%s\n' "$*" >> "$JOB_TEST_LOG"
46
+ fi
47
+ echo '{"v":1,"seq":1,"type":"ready","engine":"claude"}'
48
+ echo '{"v":1,"seq":2,"type":"text","text":"ok"}'
49
+ while IFS= read -r line; do
50
+ case "$line" in
51
+ *'"stop"'*) echo '{"v":1,"seq":3,"type":"result"}'; exit 0 ;;
52
+ esac
53
+ done
54
+ `
55
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ return path
59
+ }
60
+
61
+ func jobTestService(t *testing.T, core string) (*Service, string) {
62
+ t.Helper()
63
+ dir := t.TempDir()
64
+ logPath := filepath.Join(dir, "runs.log")
65
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", filepath.Join(dir, "jobs"))
66
+ t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "8")
67
+ t.Setenv("JOB_TEST_LOG", logPath)
68
+ svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
69
+ if err != nil {
70
+ t.Fatalf("New: %v", err)
71
+ }
72
+ /* 收尾:先停调度循环并给假 core 发 stop,等后台把最后一笔状态写完,
73
+ 再让 t.TempDir 去删目录(否则会和作业落盘抢文件)。 */
74
+ t.Cleanup(func() {
75
+ svc.jobs.close()
76
+ time.Sleep(300 * time.Millisecond)
77
+ })
78
+ return svc, logPath
79
+ }
80
+
81
+ func postJob(t *testing.T, base string, req map[string]any) *Job {
82
+ t.Helper()
83
+ b, _ := json.Marshal(req)
84
+ r, _ := http.NewRequest(http.MethodPost, base+"/desk/jobs", bytes.NewReader(b))
85
+ r.Header.Set("X-Magic-Token", "tok")
86
+ resp, err := http.DefaultClient.Do(r)
87
+ if err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ defer resp.Body.Close()
91
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
92
+ msg, _ := io.ReadAll(resp.Body)
93
+ t.Fatalf("POST /desk/jobs 应 2xx,实际 %d(%s)", resp.StatusCode, msg)
94
+ }
95
+ var out struct {
96
+ Job *Job `json:"job"`
97
+ }
98
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Job == nil {
99
+ t.Fatalf("作业响应不合法:%v", err)
100
+ }
101
+ return out.Job
102
+ }
103
+
104
+ func getJob(t *testing.T, base, id string) *Job {
105
+ t.Helper()
106
+ r, _ := http.NewRequest(http.MethodGet, base+"/desk/jobs/"+id, nil)
107
+ r.Header.Set("X-Magic-Token", "tok")
108
+ resp, err := http.DefaultClient.Do(r)
109
+ if err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ defer resp.Body.Close()
113
+ var j Job
114
+ if err := json.NewDecoder(resp.Body).Decode(&j); err != nil {
115
+ t.Fatalf("GET /desk/jobs/{id} 解析失败:%v", err)
116
+ }
117
+ return &j
118
+ }
119
+
120
+ func waitJob(t *testing.T, base, id string, want func(*Job) bool, timeout time.Duration) *Job {
121
+ t.Helper()
122
+ deadline := time.Now().Add(timeout)
123
+ var last *Job
124
+ for time.Now().Before(deadline) {
125
+ last = getJob(t, base, id)
126
+ if want(last) {
127
+ return last
128
+ }
129
+ time.Sleep(50 * time.Millisecond)
130
+ }
131
+ t.Fatalf("作业 %s 未在 %s 内达到期望状态,最后:%+v", id, timeout, last)
132
+ return nil
133
+ }
134
+
135
+ func readRuns(t *testing.T, path string) []string {
136
+ t.Helper()
137
+ b, err := os.ReadFile(path)
138
+ if err != nil {
139
+ return nil
140
+ }
141
+ var out []string
142
+ for _, ln := range strings.Split(strings.TrimSpace(string(b)), "\n") {
143
+ if strings.TrimSpace(ln) != "" {
144
+ out = append(out, ln)
145
+ }
146
+ }
147
+ return out
148
+ }
149
+
150
+ func TestJobsSerialPerWorkspace(t *testing.T) {
151
+ svc, runs := jobTestService(t, jobCore(t))
152
+ srv := httptest.NewServer(svc.Handler())
153
+ defer srv.Close()
154
+ ws := t.TempDir()
155
+
156
+ j1 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "一", "workspace": ws, "serial": true, "request_id": "r1"})
157
+ if j1.State != JobQueued {
158
+ t.Errorf("刚提交应是 queued,实际 %s", j1.State)
159
+ }
160
+ waitJob(t, srv.URL, j1.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
161
+
162
+ j2 := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "二", "workspace": ws, "serial": true, "request_id": "r2"})
163
+ time.Sleep(600 * time.Millisecond) // 给调度器足够时间:它**不该**开第二条
164
+ j2 = getJob(t, srv.URL, j2.ID)
165
+ if j2.State != JobQueued {
166
+ t.Fatalf("同 workspace 第二条必须排队,实际 %s", j2.State)
167
+ }
168
+ if got := len(readRuns(t, runs)); got != 1 {
169
+ t.Fatalf("同 workspace 只应有 1 次真正开跑,实际 %d:%v", got, readRuns(t, runs))
170
+ }
171
+
172
+ // 取消排队的第二条:它绝不能之后偷偷开跑。
173
+ cr, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j2.ID+"/cancel", nil)
174
+ cr.Header.Set("X-Magic-Token", "tok")
175
+ resp, err := http.DefaultClient.Do(cr)
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ resp.Body.Close()
180
+ j2 = waitJob(t, srv.URL, j2.ID, func(j *Job) bool { return j.State == JobCancelled }, 3*time.Second)
181
+ if j2.State != JobCancelled {
182
+ t.Fatalf("取消失败:%+v", j2)
183
+ }
184
+
185
+ // 停掉第一条,槽位释放。
186
+ cr2, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j1.ID+"/cancel", nil)
187
+ cr2.Header.Set("X-Magic-Token", "tok")
188
+ if resp, err := http.DefaultClient.Do(cr2); err == nil {
189
+ resp.Body.Close()
190
+ }
191
+ waitJob(t, srv.URL, j1.ID, func(j *Job) bool { return j.terminal() }, 5*time.Second)
192
+ time.Sleep(300 * time.Millisecond)
193
+ if got := len(readRuns(t, runs)); got != 1 {
194
+ t.Fatalf("被取消的排队条目不应开跑,实际开跑 %d 次:%v", got, readRuns(t, runs))
195
+ }
196
+ }
197
+
198
+ func TestJobsParallelAcrossWorkspaces(t *testing.T) {
199
+ svc, runs := jobTestService(t, jobCore(t))
200
+ srv := httptest.NewServer(svc.Handler())
201
+ defer srv.Close()
202
+
203
+ wsA, wsB := t.TempDir(), t.TempDir()
204
+ a := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "a", "workspace": wsA, "serial": true, "request_id": "pa"})
205
+ b := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "b", "workspace": wsB, "serial": true, "request_id": "pb"})
206
+ waitJob(t, srv.URL, a.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
207
+ waitJob(t, srv.URL, b.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
208
+ if got := len(readRuns(t, runs)); got != 2 {
209
+ t.Fatalf("不同 workspace 应并行开跑(2 次),实际 %d:%v", got, readRuns(t, runs))
210
+ }
211
+ for _, id := range []string{a.ID, b.ID} {
212
+ r, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+id+"/cancel", nil)
213
+ r.Header.Set("X-Magic-Token", "tok")
214
+ if resp, err := http.DefaultClient.Do(r); err == nil {
215
+ resp.Body.Close()
216
+ }
217
+ }
218
+ }
219
+
220
+ func TestJobsIdempotentRequestID(t *testing.T) {
221
+ svc, runs := jobTestService(t, jobCore(t))
222
+ srv := httptest.NewServer(svc.Handler())
223
+ defer srv.Close()
224
+ ws := t.TempDir()
225
+ body := map[string]any{"engine": "claude", "prompt": "只跑一次", "workspace": ws, "serial": false, "request_id": "same-id"}
226
+
227
+ j1 := postJob(t, srv.URL, body)
228
+ j2 := postJob(t, srv.URL, body)
229
+ if j1.ID != j2.ID {
230
+ t.Fatalf("同一 request_id 应返回同一个作业:%s vs %s", j1.ID, j2.ID)
231
+ }
232
+ waitJob(t, srv.URL, j1.ID, func(j *Job) bool { return j.State == JobRunning }, 5*time.Second)
233
+ time.Sleep(400 * time.Millisecond)
234
+ if got := len(readRuns(t, runs)); got != 1 {
235
+ t.Fatalf("幂等失败:同一 request_id 开跑了 %d 次", got)
236
+ }
237
+ r, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j1.ID+"/cancel", nil)
238
+ r.Header.Set("X-Magic-Token", "tok")
239
+ if resp, err := http.DefaultClient.Do(r); err == nil {
240
+ resp.Body.Close()
241
+ }
242
+ }
243
+
244
+ func TestJobQueueRecoversAfterRestart(t *testing.T) {
245
+ dir := t.TempDir()
246
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", dir)
247
+ now := time.Now()
248
+ running := Job{
249
+ ID: "job-running", Workspace: "/tmp/ws", Engine: "claude", Prompt: "旧的",
250
+ State: JobRunning, Serial: true, CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute),
251
+ }
252
+ queued := Job{
253
+ ID: "job-queued", RequestID: "req-old", Workspace: "/tmp/ws", Engine: "claude", Prompt: "排队的",
254
+ State: JobQueued, Serial: true, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(time.Hour),
255
+ }
256
+ for _, j := range []Job{running, queued} {
257
+ b, _ := json.Marshal(j)
258
+ if err := os.WriteFile(filepath.Join(dir, j.ID+".json"), b, 0o600); err != nil {
259
+ t.Fatal(err)
260
+ }
261
+ }
262
+ svc := &Service{opts: Options{Profile: DefaultProfile()}, log: log.New(io.Discard, "", 0), runs: map[string]*askRun{}}
263
+ q := newJobQueue(svc)
264
+ defer q.close()
265
+ gotRun := q.get("job-running")
266
+ if gotRun == nil || gotRun.State != JobFailed {
267
+ t.Fatalf("running 残留应标失败,实际 %+v", gotRun)
268
+ }
269
+ if !strings.Contains(gotRun.Error, "重启") {
270
+ t.Errorf("失败原因应说明重启,实际 %q", gotRun.Error)
271
+ }
272
+ gotQueued := q.get("job-queued")
273
+ if gotQueued == nil || gotQueued.State != JobQueued {
274
+ t.Fatalf("带 request_id 的 queued 应保留,实际 %+v", gotQueued)
275
+ }
276
+ }
277
+
278
+ func TestAskIdempotentRequestID(t *testing.T) {
279
+ svc, runs := jobTestService(t, jobCore(t))
280
+ srv := httptest.NewServer(svc.Handler())
281
+ defer srv.Close()
282
+
283
+ req := askRequest{Engine: "claude", Prompt: "重复提交", RequestID: "dup-ask"}
284
+ s1 := openSSE(t, srv.URL, "tok", req)
285
+ defer s1.close()
286
+ ev1 := s1.next(t)
287
+ id1, _ := ev1["id"].(string)
288
+
289
+ /* 第二次提交(模拟断线后重连 / 另一个标签页):应接回同一轮,而不是再起一个 core。 */
290
+ s2 := openSSE(t, srv.URL, "tok", req)
291
+ defer s2.close()
292
+ ev2 := s2.next(t)
293
+ id2, _ := ev2["id"].(string)
294
+
295
+ if id1 == "" || id1 != id2 {
296
+ t.Fatalf("同一 request_id 的两条 /desk/ask 应接回同一轮:%q vs %q", id1, id2)
297
+ }
298
+ time.Sleep(400 * time.Millisecond)
299
+ if got := len(readRuns(t, runs)); got != 1 {
300
+ t.Fatalf("同一 request_id 只应开跑一次,实际 %d", got)
301
+ }
302
+ }
303
+
304
+ func TestJobsListAndSnapshot(t *testing.T) {
305
+ svc, _ := jobTestService(t, jobCore(t))
306
+ srv := httptest.NewServer(svc.Handler())
307
+ defer srv.Close()
308
+ ws := t.TempDir()
309
+ j := postJob(t, srv.URL, map[string]any{"engine": "claude", "prompt": "列表", "workspace": ws, "serial": false, "request_id": "list-1"})
310
+
311
+ // 列表里能看到它(带位置字段)。
312
+ r, _ := http.NewRequest(http.MethodGet, srv.URL+"/desk/jobs?workspace="+ws, nil)
313
+ r.Header.Set("X-Magic-Token", "tok")
314
+ resp, err := http.DefaultClient.Do(r)
315
+ if err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ defer resp.Body.Close()
319
+ var out struct {
320
+ Jobs []*Job `json:"jobs"`
321
+ }
322
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
323
+ t.Fatal(err)
324
+ }
325
+ found := false
326
+ for _, it := range out.Jobs {
327
+ if it.ID == j.ID {
328
+ found = true
329
+ }
330
+ }
331
+ if !found {
332
+ t.Fatalf("列表里应能看到刚提交的作业:%+v", out.Jobs)
333
+ }
334
+
335
+ // 事件快照:开跑后应能拿到 ready/text。
336
+ waitJob(t, srv.URL, j.ID, func(x *Job) bool { return x.State == JobRunning }, 5*time.Second)
337
+ deadline := time.Now().Add(3 * time.Second)
338
+ ok := false
339
+ for time.Now().Before(deadline) {
340
+ er, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/desk/jobs/%s/events?after=0", srv.URL, j.ID), nil)
341
+ er.Header.Set("X-Magic-Token", "tok")
342
+ eresp, err := http.DefaultClient.Do(er)
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ var snap struct {
347
+ Events []map[string]any `json:"events"`
348
+ }
349
+ _ = json.NewDecoder(eresp.Body).Decode(&snap)
350
+ eresp.Body.Close()
351
+ for _, ev := range snap.Events {
352
+ if ev["type"] == "ready" {
353
+ ok = true
354
+ }
355
+ }
356
+ if ok {
357
+ break
358
+ }
359
+ time.Sleep(100 * time.Millisecond)
360
+ }
361
+ if !ok {
362
+ t.Fatal("作业事件快照里应能看到 core 的 ready")
363
+ }
364
+ r2, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/jobs/"+j.ID+"/cancel", nil)
365
+ r2.Header.Set("X-Magic-Token", "tok")
366
+ if resp, err := http.DefaultClient.Do(r2); err == nil {
367
+ resp.Body.Close()
368
+ }
369
+ }