magic-agent 2.6.0 → 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 (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -0,0 +1,431 @@
1
+ package client
2
+
3
+ // jobs_auto_test.go - 「自动」任务模式的判定闸门(2026-10-26)。
4
+ //
5
+ // 钉住的口径(用户定稿):
6
+ // · 先规则再走建议引擎:合同一顿快速通道不调模型;同会话 / 文件交集直接判串行;
7
+ // · 判定失败、超时、没配引擎 → **默认并行**(保护不是闸门,不能把任务卡死);
8
+ // · judging 不是调度候选 → 判定期间绝不能被抢跑;
9
+ // · 重启后不重判:judging 残留按并行放行(用户明确「不需要 60 秒后重新判定」);
10
+ // · 同空间连发两条:第二条必须看得见第一条(queued 也算候选),否则两条同时改同一个文件。
11
+
12
+ import (
13
+ "encoding/json"
14
+ "io"
15
+ "log"
16
+ "net/http/httptest"
17
+ "os"
18
+ "path/filepath"
19
+ "runtime"
20
+ "strings"
21
+ "testing"
22
+ "time"
23
+
24
+ "github.com/darren/magic-agent/internal/judge"
25
+ "github.com/darren/magic-agent/internal/session"
26
+ )
27
+
28
+ func autoTestQueue(t *testing.T, core string) *jobQueue {
29
+ t.Helper()
30
+ dir := t.TempDir()
31
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", filepath.Join(dir, "jobs"))
32
+ t.Setenv(session.EnvDir, filepath.Join(dir, "sessions"))
33
+ svc := &Service{
34
+ opts: Options{MagicAgent: core, Profile: DefaultProfile()},
35
+ log: log.New(io.Discard, "", 0),
36
+ runs: map[string]*askRun{},
37
+ }
38
+ q := newJobQueue(svc)
39
+ t.Cleanup(func() {
40
+ q.close()
41
+ time.Sleep(150 * time.Millisecond)
42
+ })
43
+ return q
44
+ }
45
+
46
+ // fakeJudgeCore 只会 --judge-conflict 的假 core(判定用,不跑任务)。
47
+ func fakeJudgeCore(t *testing.T, body string) string {
48
+ t.Helper()
49
+ if runtime.GOOS == "windows" {
50
+ t.Skip("假 core 用 sh 脚本;Windows 上的判定逻辑由纯函数测试覆盖")
51
+ }
52
+ dir := t.TempDir()
53
+ path := filepath.Join(dir, "magic-agent")
54
+ script := "#!/bin/sh\ncat >/dev/null\necho '" + body + "'\n"
55
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ return path
59
+ }
60
+
61
+ // writeRunningSession 直接在登记表里落一条「正在跑」的记录(PID 用本测试进程,探活会通过)。
62
+ func writeRunningSession(t *testing.T, ws, runID, sid, title string) {
63
+ t.Helper()
64
+ dir := session.Dir()
65
+ if err := os.MkdirAll(dir, 0o700); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ rec := session.Record{
69
+ RunID: runID, SessionID: sid, PID: os.Getpid(), Engine: "claude",
70
+ Workspace: ws, PromptHead: title,
71
+ State: session.StateRunning, StartedAt: time.Now(), UpdatedAt: time.Now(),
72
+ }
73
+ b, _ := json.Marshal(rec)
74
+ if err := os.WriteFile(filepath.Join(dir, runID+".json"), b, 0o600); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ }
78
+
79
+ func waitJobState(t *testing.T, q *jobQueue, id string, want func(*Job) bool) *Job {
80
+ t.Helper()
81
+ deadline := time.Now().Add(3 * time.Second)
82
+ var last *Job
83
+ for time.Now().Before(deadline) {
84
+ if last = q.get(id); last != nil && want(last) {
85
+ return last
86
+ }
87
+ time.Sleep(20 * time.Millisecond)
88
+ }
89
+ t.Fatalf("作业 %s 未达到期望状态,最后:%+v(auto=%+v)", id, last, last.GetAuto())
90
+ return last
91
+ }
92
+
93
+ // GetAuto 测试辅助:避免 nil 指针。
94
+ func (j *Job) GetAuto() *JobAuto {
95
+ if j == nil || j.Auto == nil {
96
+ return &JobAuto{}
97
+ }
98
+ return j.Auto
99
+ }
100
+
101
+ func TestAutoIdleSpaceRunsParallelWithoutModel(t *testing.T) {
102
+ ws := t.TempDir()
103
+ q := autoTestQueue(t, "")
104
+ j, created, err := q.submitAsk(askRequest{Prompt: "改一下底部栏", Mode: "auto", Workspace: ws}, "claude", "pc")
105
+ if err != nil || !created {
106
+ t.Fatalf("submitAsk: err=%v created=%v", err, created)
107
+ }
108
+ if j.State != JobQueued || j.Serial {
109
+ t.Fatalf("空间空闲应直接并行(queued + serial=false),实际 state=%s serial=%v", j.State, j.Serial)
110
+ }
111
+ if j.GetAuto().By != "rule" {
112
+ t.Errorf("空闲快速通道应标 by=rule(不调模型),实际 %q", j.GetAuto().By)
113
+ }
114
+ if j.Mode != "auto" {
115
+ t.Errorf("作业应记住 mode=auto,实际 %q", j.Mode)
116
+ }
117
+ }
118
+
119
+ func TestAutoSameSessionForcesSerial(t *testing.T) {
120
+ ws := t.TempDir()
121
+ q := autoTestQueue(t, "")
122
+ q.mu.Lock()
123
+ q.jobs["job-running"] = &Job{
124
+ ID: "job-running", Workspace: ws, Session: "sess-1", Engine: "claude",
125
+ Prompt: "上一轮", State: JobRunning, Serial: false,
126
+ }
127
+ q.mu.Unlock()
128
+
129
+ j, _, err := q.submitAsk(askRequest{Prompt: "追问一句", Mode: "auto", Session: "sess-1", Workspace: ws}, "claude", "pc")
130
+ if err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ if !j.Serial {
134
+ t.Fatalf("同一会话还有一轮没结束,必须串行,实际 serial=%v", j.Serial)
135
+ }
136
+ if j.GetAuto().By != "rule" {
137
+ t.Errorf("同会话是硬规则,应标 by=rule,实际 %q", j.GetAuto().By)
138
+ }
139
+ }
140
+
141
+ func TestAutoUndecidedFallsBackToParallel(t *testing.T) {
142
+ ws := t.TempDir()
143
+ // 没有可用 core → 语义判定必然失败 → 按并行放行,且如实标 undecided。
144
+ q := autoTestQueue(t, "")
145
+ writeRunningSession(t, ws, "run-busy", "sess-busy", "改底部栏")
146
+
147
+ j, _, err := q.submitAsk(askRequest{Prompt: "写个 README", Mode: "auto", Workspace: ws}, "claude", "pc")
148
+ if err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ if j.State != JobJudging {
152
+ t.Fatalf("有运行中候选时应先进 judging,实际 %s", j.State)
153
+ }
154
+ /* 判定完成后队列会立刻准入选(本测试没有可跑的 core,可能转 running/failed)——
155
+ 这里只验判定结论本身:不再 pending,且按并行放行。 */
156
+ got := waitJobState(t, q, j.ID, func(x *Job) bool { return x.GetAuto().By != "pending" && x.State != JobJudging })
157
+ if got.Serial {
158
+ t.Fatalf("判定失败必须按并行放行,实际 serial=true(auto=%+v)", got.Auto)
159
+ }
160
+ if got.GetAuto().By != "undecided" {
161
+ t.Errorf("应标 by=undecided,实际 %q", got.GetAuto().By)
162
+ }
163
+ if !strings.Contains(got.GetAuto().Reason, "并行") {
164
+ t.Errorf("理由要说明按并行放行,实际 %q", got.GetAuto().Reason)
165
+ }
166
+ }
167
+
168
+ func TestAutoModelConflictQueues(t *testing.T) {
169
+ ws := t.TempDir()
170
+ core := fakeJudgeCore(t, `{"conflict":true,"level":"hard","reason":"都要改 index.html","overlaps":["index.html"]}`)
171
+ q := autoTestQueue(t, core)
172
+ writeRunningSession(t, ws, "run-busy", "sess-busy", "改底部栏")
173
+
174
+ j, _, err := q.submitAsk(askRequest{Prompt: "改底部栏的配色", Mode: "auto", Workspace: ws}, "claude", "pc")
175
+ if err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ got := waitJobState(t, q, j.ID, func(x *Job) bool { return x.State == JobQueued })
179
+ if !got.Serial {
180
+ t.Fatalf("模型判冲突应转串行,实际 serial=false(auto=%+v)", got.Auto)
181
+ }
182
+ if got.GetAuto().By != "model" {
183
+ t.Errorf("应标 by=model,实际 %q", got.GetAuto().By)
184
+ }
185
+ if !strings.Contains(got.GetAuto().Reason, "index.html") {
186
+ t.Errorf("理由应带冲突点,实际 %q", got.GetAuto().Reason)
187
+ }
188
+ }
189
+
190
+ func TestAutoModelClearRunsParallel(t *testing.T) {
191
+ ws := t.TempDir()
192
+ core := fakeJudgeCore(t, `{"conflict":false,"level":"none","reason":"互不相干:一个改前端一个写文档"}`)
193
+ q := autoTestQueue(t, core)
194
+ writeRunningSession(t, ws, "run-busy", "sess-busy", "改底部栏")
195
+
196
+ j, _, err := q.submitAsk(askRequest{Prompt: "补一段说明文档", Mode: "auto", Workspace: ws}, "claude", "pc")
197
+ if err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ got := waitJobState(t, q, j.ID, func(x *Job) bool { return x.GetAuto().By != "pending" && x.State != JobJudging })
201
+ if got.Serial {
202
+ t.Fatalf("模型判不冲突应并行,实际 serial=true(auto=%+v)", got.Auto)
203
+ }
204
+ if got.GetAuto().By != "model" {
205
+ t.Errorf("应标 by=model,实际 %q", got.GetAuto().By)
206
+ }
207
+ }
208
+
209
+ /*
210
+ 连发两条:第一条判并行(空间空闲),第二条必须看得见它。
211
+
212
+ 不把 queued 算进候选时的现场是:两条都走「空间空闲」快速通道、都不看对方,
213
+ 最后两个进程同时改同一个文件 —— 正是自动模式要防的那件事。
214
+ */
215
+ func TestAutoSeesQueuedPeer(t *testing.T) {
216
+ ws := t.TempDir()
217
+ core := fakeJudgeCore(t, `{"conflict":true,"level":"hard","reason":"都要改同一个页面"}`)
218
+ q := autoTestQueue(t, core)
219
+
220
+ first, _, err := q.submitAsk(askRequest{Prompt: "改底部栏的配色", Mode: "auto", Workspace: ws}, "claude", "pc")
221
+ if err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ if first.State != JobQueued || first.Serial {
225
+ t.Fatalf("第一条在空闲空间应直接并行,实际 state=%s serial=%v", first.State, first.Serial)
226
+ }
227
+ // 第一条还没开跑(测试不发 dispatch),此时提交第二条。
228
+ second, _, err := q.submitAsk(askRequest{Prompt: "改底部栏的图标大小", Mode: "auto", Workspace: ws}, "claude", "pc")
229
+ if err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ if second.State != JobJudging {
233
+ t.Fatalf("同空间还有排队作业时应进判定,实际 %s", second.State)
234
+ }
235
+ got := waitJobState(t, q, second.ID, func(x *Job) bool { return x.GetAuto().By != "pending" })
236
+ if !got.Serial {
237
+ t.Fatalf("第二条与排队中的第一条冲突,应串行,实际 serial=false(auto=%+v)", got.Auto)
238
+ }
239
+ }
240
+
241
+ func TestAutoJudgingNotDispatchable(t *testing.T) {
242
+ ws := t.TempDir()
243
+ q := autoTestQueue(t, "")
244
+ q.mu.Lock()
245
+ q.jobs["job-judge"] = &Job{
246
+ ID: "job-judge", Workspace: ws, Engine: "claude", Prompt: "判定中",
247
+ State: JobJudging, Serial: false, CreatedAt: time.Now(), UpdatedAt: time.Now(),
248
+ }
249
+ q.mu.Unlock()
250
+
251
+ q.dispatch()
252
+ if got := q.get("job-judge"); got.State != JobJudging {
253
+ t.Fatalf("judging 不能被 dispatch 抢跑,实际 %s", got.State)
254
+ }
255
+ if q.runOf("job-judge") != nil {
256
+ t.Fatal("judging 期间不应有正在跑的轮次")
257
+ }
258
+ }
259
+
260
+ func TestAutoJudgingRecoversAsParallelAfterRestart(t *testing.T) {
261
+ dir := t.TempDir()
262
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", dir)
263
+ t.Setenv(session.EnvDir, filepath.Join(dir, "sessions"))
264
+ j := Job{
265
+ ID: "job-judging", RequestID: "req-judging", Workspace: "/tmp/ws", Engine: "claude",
266
+ Prompt: "判定中被重启", Mode: "auto", State: JobJudging, Serial: false,
267
+ Auto: &JobAuto{By: "pending", Reason: "正在判断"},
268
+ CreatedAt: time.Now(), UpdatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour),
269
+ }
270
+ b, _ := json.Marshal(j)
271
+ if err := os.WriteFile(filepath.Join(dir, j.ID+".json"), b, 0o600); err != nil {
272
+ t.Fatal(err)
273
+ }
274
+ svc := &Service{opts: Options{Profile: DefaultProfile()}, log: log.New(io.Discard, "", 0), runs: map[string]*askRun{}}
275
+ q := newJobQueue(svc)
276
+ defer q.close()
277
+
278
+ got := q.get("job-judging")
279
+ if got == nil || got.State != JobQueued {
280
+ t.Fatalf("judging 残留重启后应转 queued,实际 %+v", got)
281
+ }
282
+ if got.Serial {
283
+ t.Error("重启不重判 → 必须按并行放行")
284
+ }
285
+ if got.GetAuto().By != "undecided" {
286
+ t.Errorf("应标 undecided,实际 %q", got.GetAuto().By)
287
+ }
288
+ }
289
+
290
+ func TestSpaceKeyNormalizesTeamRunDir(t *testing.T) {
291
+ ws := filepath.Join(string(filepath.Separator), "tmp", "proj")
292
+ run := filepath.Join(ws, ".magic", "team", "run-abc", "t1")
293
+ if got, want := spaceKeyOf(run), spaceKeyOf(ws); got != want {
294
+ t.Fatalf("团队运行目录应归到父空间:%q != %q", got, want)
295
+ }
296
+ // 尾斜杠 / 相对路径写法要归到同一个键(core 的锁键同口径)。
297
+ if !sameSpace(spaceKeyOf(ws+string(filepath.Separator)), spaceKeyOf(ws)) {
298
+ t.Error("尾斜杠应归一到同一个空间")
299
+ }
300
+ }
301
+
302
+ func TestPromptFileOverlap(t *testing.T) {
303
+ cands := []judge.Candidate{
304
+ {Files: []string{"client-ui-mobile/index.html", "docs/a.md"}},
305
+ }
306
+ // 完整路径
307
+ if _, ov := promptFileOverlap("改 client-ui-mobile/index.html 的底部栏", cands); len(ov) == 0 {
308
+ t.Error("提示词里写出完整路径应判冲突")
309
+ }
310
+ // 文件名(带扩展名)
311
+ if _, ov := promptFileOverlap("把 index.html 的底部栏调窄", cands); len(ov) == 0 {
312
+ t.Error("提示词里写出文件名应判冲突")
313
+ }
314
+ // 不相干
315
+ if _, ov := promptFileOverlap("写一篇公众号文章", cands); len(ov) != 0 {
316
+ t.Errorf("不相干的提示词不该判冲突,实际 %v", ov)
317
+ }
318
+ }
319
+
320
+ func TestPathsInToolInput(t *testing.T) {
321
+ cases := map[string]string{
322
+ `{"file_path":"/a/b.go"}`: "/a/b.go",
323
+ `{"path":"src/x.ts"}`: "src/x.ts",
324
+ `{"notebook_path":"n.ipynb"}`: "n.ipynb",
325
+ `{"patch":"*** Update File: /a/b.md"}`: "", // 认不出就老实返回空,别瞎猜
326
+ }
327
+ for in, want := range cases {
328
+ got := pathsInToolInput(in)
329
+ if want == "" {
330
+ if len(got) != 0 {
331
+ t.Errorf("输入 %s 不该抽出路径,实际 %v", in, got)
332
+ }
333
+ continue
334
+ }
335
+ found := false
336
+ for _, g := range got {
337
+ if g == want {
338
+ found = true
339
+ }
340
+ }
341
+ if !found {
342
+ t.Errorf("输入 %s 应抽出 %q,实际 %v", in, want, got)
343
+ }
344
+ }
345
+ }
346
+
347
+ // autoHTTPCore 既是任务 core(NDJSON + 等 stop)又是判定 core(--judge-conflict 回 JSON)。
348
+ // 直接以 jobCore 为底:它已经处理了 --contract / --engines / --sessions 探测分支。
349
+ func autoHTTPCore(t *testing.T, verdict string) string {
350
+ t.Helper()
351
+ base := jobCore(t)
352
+ b, err := os.ReadFile(base)
353
+ if err != nil {
354
+ t.Fatal(err)
355
+ }
356
+ branch := "case \"$*\" in\n *--judge-conflict*)\n cat >/dev/null\n echo '" + verdict + "'\n exit 0\n ;;\n"
357
+ out := strings.Replace(string(b), "case \"$*\" in\n", branch, 1)
358
+ if out == string(b) {
359
+ t.Fatal("没找到 jobCore 的 case 分支,判定 core 打桩失败")
360
+ }
361
+ path := filepath.Join(t.TempDir(), "magic-agent")
362
+ if err := os.WriteFile(path, []byte(out), 0o755); err != nil {
363
+ t.Fatal(err)
364
+ }
365
+ return path
366
+ }
367
+
368
+ // 端到端(HTTP 层):mode=auto 的请求要原样落进作业记录,判定结论要能被客户端读到。
369
+ func TestAutoModeOverHTTP(t *testing.T) {
370
+ ws := t.TempDir()
371
+ core := autoHTTPCore(t, `{"conflict":true,"level":"hard","reason":"都要改 index.html","overlaps":["index.html"]}`)
372
+ svc, _ := jobTestService(t, core)
373
+ base := httptest.NewServer(svc.Handler())
374
+ defer base.Close()
375
+ writeRunningSession(t, ws, "run-busy", "sess-busy", "改底部栏")
376
+
377
+ job := postJob(t, base.URL, map[string]any{
378
+ "engine": "claude", "prompt": "改底部栏配色", "workspace": ws, "mode": "auto",
379
+ })
380
+ if job.Mode != "auto" {
381
+ t.Fatalf("作业应记住 mode=auto,实际 %q", job.Mode)
382
+ }
383
+
384
+ got := waitJob(t, base.URL, job.ID, func(j *Job) bool {
385
+ return j.Auto != nil && j.Auto.By == "model"
386
+ }, 5*time.Second)
387
+ if !got.Serial {
388
+ t.Fatalf("模型判冲突应串行,实际 serial=false(auto=%+v)", got.Auto)
389
+ }
390
+ if !strings.Contains(got.Auto.Reason, "index.html") {
391
+ t.Errorf("理由应带冲突点,实际 %q", got.Auto.Reason)
392
+ }
393
+ if got.Mode != "auto" {
394
+ t.Errorf("GET 也要带 mode,实际 %q", got.Mode)
395
+ }
396
+ }
397
+
398
+ // 显式模式必须**说了算**(2026-10-26 用户:「模式判断要拦截每个会话,如果不是
399
+ // 自动就按选择模式处理」):只发 mode、不发 serial 时不能被缺省串行吃掉。
400
+ func TestExplicitModeWinsOverSerialFlag(t *testing.T) {
401
+ cases := []struct {
402
+ name string
403
+ mode string
404
+ serial *bool
405
+ want bool
406
+ }{
407
+ {"parallel 不管 serial=true", "parallel", boolPtr(true), false},
408
+ {"parallel 不带 serial", "parallel", nil, false},
409
+ {"serial 不管 serial=false", "serial", boolPtr(false), true},
410
+ {"serial 不带 serial", "serial", nil, true},
411
+ {"auto 不消费 serial(走判定)", "auto", boolPtr(true), false},
412
+ }
413
+ for _, c := range cases {
414
+ t.Run(c.name, func(t *testing.T) {
415
+ ws := t.TempDir()
416
+ q := autoTestQueue(t, ws)
417
+ j, _, err := q.submitAsk(askRequest{
418
+ Prompt: "跑一下", Workspace: ws, Mode: c.mode, Serial: c.serial,
419
+ }, "claude", "pc")
420
+ if err != nil {
421
+ t.Fatalf("submitAsk: %v", err)
422
+ }
423
+ if j.Serial != c.want {
424
+ t.Fatalf("mode=%q serial=%v → Serial=%v,期望 %v", c.mode, c.serial, j.Serial, c.want)
425
+ }
426
+ if c.mode != "auto" && j.Mode != c.mode {
427
+ t.Fatalf("mode=%q 没记到作业上,实际 %q", c.mode, j.Mode)
428
+ }
429
+ })
430
+ }
431
+ }
@@ -31,7 +31,8 @@ import (
31
31
  type pluginStep struct {
32
32
  Args []string
33
33
  Optional bool
34
- SkipNote string // Optional 步骤跳过时展示的原因
34
+ SkipNote string // Optional 步骤跳过时展示的原因
35
+ Env []string // 非空 = 用它替代继承的父进程环境(见 npmEnv)
35
36
  }
36
37
 
37
38
  func (p pluginStep) Command() string { return strings.Join(p.Args, " ") }
@@ -342,6 +343,9 @@ func runPluginStep(ctx context.Context, st pluginStep, onLine func(string)) (int
342
343
  return 0, errors.New("空命令")
343
344
  }
344
345
  cmd := exec.CommandContext(ctx, st.Args[0], st.Args[1:]...)
346
+ if len(st.Env) > 0 {
347
+ cmd.Env = st.Env
348
+ }
345
349
  pr, pw, err := os.Pipe()
346
350
  if err != nil {
347
351
  return 1, err
@@ -6,6 +6,7 @@ import (
6
6
  "net/http"
7
7
  "os"
8
8
  "path/filepath"
9
+ "strings"
9
10
  )
10
11
 
11
12
  /* 移动端「环境配置」的远端存储(2026-10-22 用户:
@@ -97,3 +98,69 @@ func (s *Service) handlePrefs(w http.ResponseWriter, r *http.Request) {
97
98
  writeErr(w, http.StatusMethodNotAllowed, "只支持 GET / PUT")
98
99
  }
99
100
  }
101
+
102
+ // defaultEngineModel 读「我的 → 默认引擎/模型」(2026-10-26 用户:
103
+ // 「手动自动都要默认设置的引擎」)。
104
+ //
105
+ // 手动「整理」是从请求体拿的(移动端每次点都带 engine/model);后台自动归纳没有
106
+ // 请求体,只能自己去读 —— 与移动端同一份权威存储:/desk/prefs 落的
107
+ // ~/.magic-agent/mobile-prefs.json(键名由移动端定义,这里只按约定取两个)。
108
+ //
109
+ // 不做任何猜测或回退:配置里没设引擎就是空串(core 走自己的默认 llm),
110
+ // **不**用「备用引擎」替补 —— 备用是主引擎失败时才切的东西,不是默认值。
111
+ // 模型只在引擎非空时才返回(core 侧 model 不能脱离 engine 单独指定)。
112
+ func (s *Service) defaultEngineModel() (string, string) {
113
+ p := mobilePrefsPath()
114
+ if p == "" {
115
+ return "", ""
116
+ }
117
+ b, err := os.ReadFile(p)
118
+ if err != nil || len(b) == 0 {
119
+ return "", ""
120
+ }
121
+ var v map[string]any
122
+ if err := json.Unmarshal(b, &v); err != nil {
123
+ return "", ""
124
+ }
125
+ engine := strings.TrimSpace(prefString(v[prefKeyEngine]))
126
+ if engine == "" {
127
+ return "", ""
128
+ }
129
+ // 白名单同样要过:白名单外的引擎 core 会拒绝,自动归纳每轮都失败等于没跑。
130
+ if !s.opts.Profile.AllowsEngine(engine) {
131
+ s.logf("需求自动归纳: 默认引擎 %q 不在 profile 白名单里,回落 core 默认", engine)
132
+ return "", ""
133
+ }
134
+ return engine, strings.TrimSpace(prefString(v[prefKeyModel]))
135
+ }
136
+
137
+ // 移动端 /desk/prefs 里的键名(客户端定义、服务端不解释;这里只读这两个)。
138
+ const (
139
+ prefKeyEngine = "magic.engine"
140
+ prefKeyModel = "magic.model"
141
+ )
142
+
143
+ // prefString 把配置值规整成字符串(值理论上都是字符串,但坏文件兜一下)。
144
+ func prefString(v any) string {
145
+ if s, ok := v.(string); ok {
146
+ return s
147
+ }
148
+ return ""
149
+ }
150
+
151
+ // logf 防御性写日志:Options.Logger 可空(单测 / 内嵌调用),nil 时**不能 panic** ——
152
+ // 自动归纳是后台弱路径,日志丢了可以接受,把客户端带崩不行。
153
+ func (s *Service) logf(format string, args ...any) {
154
+ if s.log == nil {
155
+ return
156
+ }
157
+ s.log.Printf(format, args...)
158
+ }
159
+
160
+ // modelSuffix 日志里拼「 · 模型」;没配模型时只留引擎名。
161
+ func modelSuffix(model string) string {
162
+ if model == "" {
163
+ return ""
164
+ }
165
+ return " · " + model
166
+ }
@@ -0,0 +1,59 @@
1
+ package client
2
+
3
+ import (
4
+ "os"
5
+ "strconv"
6
+ "strings"
7
+ "time"
8
+ )
9
+
10
+ // init 在 Linux 上把 procStart 换成精确的版本:/proc/self/stat 的字段 22(starttime,单位
11
+ // 是开机后的时钟滴答)+ btime(/proc/stat),算出本进程真正被拉起的那一刻。
12
+ // 只关系到「盘上的 magic-agent 是不是本进程启动后才被 npm 换掉的」(见 replacedAfterStart),
13
+ // 读不到就保留 mtime 兜底,不影响任何正常路径。
14
+ func init() {
15
+ start, ok := linuxProcessStart()
16
+ if !ok {
17
+ return
18
+ }
19
+ procStart = func() (time.Time, bool) { return start, true }
20
+ }
21
+
22
+ func linuxProcessStart() (time.Time, bool) {
23
+ b, err := os.ReadFile("/proc/self/stat")
24
+ if err != nil {
25
+ return time.Time{}, false
26
+ }
27
+ // comm 可能带空格和括号:从最后一个 ')' 之后才开始数字段。之后第一个字段是 3(state),
28
+ // starttime 是第 22 个 ⇒ 下标 22-3 = 19。
29
+ s := string(b)
30
+ i := strings.LastIndexByte(s, ')')
31
+ if i < 0 {
32
+ return time.Time{}, false
33
+ }
34
+ fields := strings.Fields(s[i+1:])
35
+ if len(fields) < 20 {
36
+ return time.Time{}, false
37
+ }
38
+ ticks, err := strconv.ParseUint(fields[19], 10, 64)
39
+ if err != nil {
40
+ return time.Time{}, false
41
+ }
42
+ sb, err := os.ReadFile("/proc/stat")
43
+ if err != nil {
44
+ return time.Time{}, false
45
+ }
46
+ var btime uint64
47
+ for _, ln := range strings.Split(string(sb), "\n") {
48
+ if strings.HasPrefix(ln, "btime ") {
49
+ btime, _ = strconv.ParseUint(strings.TrimSpace(ln[len("btime "):]), 10, 64)
50
+ break
51
+ }
52
+ }
53
+ if btime == 0 {
54
+ return time.Time{}, false
55
+ }
56
+ // USER_HZ 在 Linux 上恒为 100(内核导出给用户态的时钟刻度,不随 CONFIG_HZ 变)。
57
+ sec := btime + ticks/100
58
+ return time.Unix(int64(sec), int64(ticks%100)*10_000_000), true
59
+ }
@@ -0,0 +1,82 @@
1
+ package client
2
+
3
+ /* publicurl.go - 公网入口(cloudflared quick tunnel 域名)的**落盘**。
4
+ *
5
+ * 为什么(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
6
+ * 隧道是壳(掌天瓶)起的,quick tunnel 的域名只在**隧道重起**时才会变;magic-client
7
+ * 重启根本不碰 cloudflared,域名本该原样不变。但域名探测结果以前只存在内存里 ——
8
+ * 客户端一重启就丢,`/desk/health` 头几次请求报 `publicUrl: ""`,手机于是退回局域网
9
+ * 地址:用户看到的就是「重启后地址变了」。
10
+ *
11
+ * 所以把探测到的域名落盘(profile 数据目录的 `public-url`),启动即读回当「上次已知」:
12
+ * · 隧道没重起 → 域名正确,手机一条连接都不用改;
13
+ * · 隧道真重起了 → 后台探测在 publicTTL 内换成新域名;
14
+ * · cloudflared 比客户端起得慢 → 宽限期(publicGrace)内不采纳「探不到」,
15
+ * 已知域名先顶着,别在这一小段里把用户推到局域网地址上。
16
+ */
17
+
18
+ import (
19
+ "os"
20
+ "path/filepath"
21
+ "strings"
22
+ "time"
23
+ )
24
+
25
+ // publicGrace 重启后的宽限期:这段时间里探测不到隧道也**不撤掉**已落盘的域名。
26
+ //
27
+ // 顺带解决的是「壳重启」这个场景:cloudflared 要几百毫秒到几秒才把 metrics 端口开起来,
28
+ // 客户端如果抢在前面探空并清掉已知域名,手机就会在这段空窗里换地址。
29
+ const publicGrace = 90 * time.Second
30
+
31
+ // publicURLFile 落盘文件(与 instance-id 同目录:同一份 profile = 同一台桌面)。
32
+ func publicURLFile(prof Profile) string {
33
+ dir := strings.TrimSpace(prof.DataDir())
34
+ if dir == "" {
35
+ return ""
36
+ }
37
+ return filepath.Join(dir, "public-url")
38
+ }
39
+
40
+ // loadSavedPublicURL 读上次落盘的域名;没有/读不出 → 空串。
41
+ func (s *Service) loadSavedPublicURL() string {
42
+ p := publicURLFile(s.opts.Profile)
43
+ if p == "" {
44
+ return ""
45
+ }
46
+ b, err := os.ReadFile(p)
47
+ if err != nil {
48
+ return ""
49
+ }
50
+ u := strings.TrimSpace(string(b))
51
+ if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
52
+ return "" // 半截写入 / 手改坏了:当作没有,别把垃圾地址发给手机
53
+ }
54
+ return strings.TrimRight(u, "/")
55
+ }
56
+
57
+ // savePublicURL 落盘当前域名(0600)。写不下不是致命错:本次仍能用,只是下次重启会重新探。
58
+ func (s *Service) savePublicURL(u string) {
59
+ p := publicURLFile(s.opts.Profile)
60
+ if p == "" || strings.TrimSpace(u) == "" {
61
+ return
62
+ }
63
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
64
+ return
65
+ }
66
+ _ = os.WriteFile(p, []byte(strings.TrimSpace(u)+"\n"), 0o600)
67
+ }
68
+
69
+ // clearSavedPublicURL 删掉落盘的域名(连续探测确认隧道真的没了)。
70
+ func (s *Service) clearSavedPublicURL() {
71
+ p := publicURLFile(s.opts.Profile)
72
+ if p == "" {
73
+ return
74
+ }
75
+ _ = os.Remove(p)
76
+ }
77
+
78
+ // publicURLOrEmpty 供交接用:拿当前已知的公网域名(不触发等待)。
79
+ func (s *Service) publicURLOrEmpty() string {
80
+ u := s.publicURL()
81
+ return strings.TrimRight(strings.TrimSpace(u), "/")
82
+ }
@@ -482,9 +482,17 @@ func (s *Service) runAutoConsolidate(ws string) {
482
482
  s.reqAuto.running[ws] = true
483
483
  s.reqAuto.mu.Unlock()
484
484
 
485
+ /* 引擎/模型:**与手动「整理」同一口径** —— 「整理的引擎就是配置的默认引擎」。
486
+ 后台没有请求体可带,于是从唯一的权威配置源(/desk/prefs 落的那份 JSON)读;
487
+ 读不到(还没配过 / 文件坏了)= 传空,core 仍按自己的默认(llm)跑,与旧行为一致。 */
488
+ engine, model := s.defaultEngineModel()
489
+ if engine != "" {
490
+ s.logf("需求自动归纳 %s: 引擎 %s%s", ws, engine, modelSuffix(model))
491
+ }
492
+
485
493
  // 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
486
494
  ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
487
- out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
495
+ out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, engine, model)...)
488
496
  cancel()
489
497
  if err != nil {
490
498
  s.log.Printf("需求自动归纳失败 %s: %v", ws, err)