magic-agent 2.4.3 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,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
+ }