magic-agent 2.3.0 → 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 (59) hide show
  1. package/CLIENT-CONTRACT.md +78 -13
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  7. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  8. package/client-ui/shared/conv/conv.css +17 -0
  9. package/client-ui/shared/conversation.js +209 -39
  10. package/client-ui/shared/transport-http.js +193 -38
  11. package/client-ui-mobile/index.html +733 -107
  12. package/client-ui-mobile/reqs.js +42 -29
  13. package/internal/agent/capability.go +8 -1
  14. package/internal/agent/claude.go +5 -3
  15. package/internal/agent/codebuddy.go +6 -2
  16. package/internal/agent/codex.go +7 -2
  17. package/internal/agent/dsh_sdk.go +8 -0
  18. package/internal/agent/dsh_sdk_test.go +17 -0
  19. package/internal/agent/engine.go +20 -0
  20. package/internal/agent/engine_test.go +52 -0
  21. package/internal/agent/llmengine.go +6 -0
  22. package/internal/agent/llmengine_test.go +7 -0
  23. package/internal/agent/llmhttp.go +6 -0
  24. package/internal/agent/llmhttp_test.go +27 -0
  25. package/internal/agent/llmmodels.go +13 -0
  26. package/internal/agent/permission.go +5 -0
  27. package/internal/cli/ask.go +32 -17
  28. package/internal/cli/requirements.go +2 -0
  29. package/internal/cli/sessionlog.go +5 -0
  30. package/internal/cli/sessionlog_user_test.go +49 -0
  31. package/internal/client/cors_test.go +32 -0
  32. package/internal/client/jobs.go +1160 -0
  33. package/internal/client/jobs_test.go +369 -0
  34. package/internal/client/requirements.go +27 -7
  35. package/internal/client/requirements_test.go +51 -0
  36. package/internal/client/service.go +259 -142
  37. package/internal/client/service_test.go +78 -0
  38. package/internal/requirements/consolidate.go +86 -24
  39. package/internal/requirements/requirements.go +9 -0
  40. package/internal/requirements/requirements_test.go +129 -0
  41. package/internal/requirements/store.go +11 -3
  42. package/internal/session/session.go +15 -1
  43. package/internal/session/workspace_lock.go +165 -0
  44. package/internal/session/workspace_lock_test.go +90 -0
  45. package/internal/suggest/suggest.go +101 -7
  46. package/internal/suggest/suggest_test.go +44 -0
  47. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  48. package/npm/apk/version.json +3 -3
  49. package/npm/dist/darwin-arm64/magic-agent +0 -0
  50. package/npm/dist/darwin-arm64/magic-client +0 -0
  51. package/npm/dist/darwin-x64/magic-agent +0 -0
  52. package/npm/dist/darwin-x64/magic-client +0 -0
  53. package/npm/dist/linux-arm64/magic-agent +0 -0
  54. package/npm/dist/linux-arm64/magic-client +0 -0
  55. package/npm/dist/linux-x64/magic-agent +0 -0
  56. package/npm/dist/linux-x64/magic-client +0 -0
  57. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  58. package/npm/dist/win32-x64/magic-client.exe +0 -0
  59. package/package.json +3 -2
@@ -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
+ }
@@ -21,6 +21,7 @@ package client
21
21
  // 「一句说完、下一句还没打完」的经验值,期间再来一轮就顺延、合并成一次。
22
22
 
23
23
  import (
24
+ "bytes"
24
25
  "context"
25
26
  "encoding/json"
26
27
  "errors"
@@ -82,12 +83,13 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
82
83
  // handleRequirementsConsolidate POST /desk/requirements/consolidate:
83
84
  // 立即归纳一轮(用户点「整理」)。
84
85
  //
85
- // 请求体:{"workspace":"<ws_id|目录>", "force":false, "all":false,
86
+ // 请求体:{"workspace":"<ws_id|目录>", "module":"<模块名>", "force":false,
86
87
  //
87
- // "engine":"claude", "model":"..."}
88
+ // "all":false, "engine":"claude", "model":"..."}
88
89
  //
89
90
  // workspace 留空 = 插件自己的工作目录(与 /desk/health 的 defaultWorkspace 一致 ——
90
91
  // 那就是 core 不传 -w 时记录的 cwd)。
92
+ // module 留空 = 整理整个空间;给了就只整理那一个模块(移动端二级页的「整理」)。
91
93
  // engine / model 是客户端当前配置的默认引擎与模型(移动端「我的 → 默认引擎」):
92
94
  // 「整理的引擎就是配置的默认引擎」—— 客户端传什么,core 就按什么跑,不再自动替换。
93
95
  func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.Request) {
@@ -102,6 +104,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
102
104
  }
103
105
  var body struct {
104
106
  Workspace string `json:"workspace,omitempty"`
107
+ Module string `json:"module,omitempty"`
105
108
  Force bool `json:"force,omitempty"`
106
109
  All bool `json:"all,omitempty"`
107
110
  Engine string `json:"engine,omitempty"`
@@ -122,6 +125,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
122
125
  writeErr(w, http.StatusBadRequest, "workspace 不能为空(传 ws id 或目录)")
123
126
  return
124
127
  }
128
+ module := strings.TrimSpace(body.Module)
125
129
  engine := strings.TrimSpace(body.Engine)
126
130
  model := strings.TrimSpace(body.Model)
127
131
  if model != "" && engine == "" {
@@ -133,7 +137,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
133
137
  return
134
138
  }
135
139
  out, err := s.runCoreTimed(r.Context(), reqConsolidateTimeout,
136
- consolidateArgs(ws, body.Force, body.All, false, engine, model)...)
140
+ consolidateArgs(ws, module, body.Force, body.All, false, engine, model)...)
137
141
  if err != nil {
138
142
  code := http.StatusBadGateway
139
143
  if isCoreTimeout(err) {
@@ -150,8 +154,11 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
150
154
  // engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
151
155
  // 自动归纳(auto=true)不传:后台没有「当前设备默认引擎」这个概念,让 core 走自己的
152
156
  // 默认(增量用 llm;首次初始化也不会发生,因为 --requirement-auto 明确禁止)。
153
- func consolidateArgs(ws string, force, all, auto bool, engine, model string) []string {
157
+ func consolidateArgs(ws, module string, force, all, auto bool, engine, model string) []string {
154
158
  args := []string{"--consolidate-requirements", ws}
159
+ if module != "" {
160
+ args = append(args, "--requirement-module", module)
161
+ }
155
162
  if force {
156
163
  args = append(args, "--requirement-force")
157
164
  }
@@ -178,14 +185,27 @@ func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args
178
185
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
179
186
  // 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
180
187
  cmd.WaitDelay = 5 * time.Second
181
- out, err := cmd.Output()
188
+ var stdout, stderr bytes.Buffer
189
+ cmd.Stdout = &stdout
190
+ cmd.Stderr = &stderr
191
+ err := cmd.Run()
182
192
  if err != nil {
183
193
  if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
184
194
  return nil, fmt.Errorf("%w(%s)", errCoreTimeout, timeout)
185
195
  }
196
+ /* core 失败时也会先把结构化结果写到 stdout(`{ok:false,error:"..."}`),
197
+ 再返回非零退出码。Output() 会把它丢掉,界面只剩一句「exit status 1」——
198
+ 用户看不到真正原因(例如「模块不存在」)。这里把 stdout 交回调用方,
199
+ 由它按 JSON 里的 error 展示;真的一个字都没输出才退化成 stderr/退出码。 */
200
+ if stdout.Len() > 0 {
201
+ return stdout.Bytes(), nil
202
+ }
203
+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
204
+ return nil, fmt.Errorf("%s", msg)
205
+ }
186
206
  return nil, err
187
207
  }
188
- return out, nil
208
+ return stdout.Bytes(), nil
189
209
  }
190
210
 
191
211
  // isCoreTimeout 判断错误链里是不是「等 core 超时」。
@@ -270,7 +290,7 @@ func (s *Service) runAutoConsolidate(ws string) {
270
290
 
271
291
  // 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
272
292
  ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
273
- out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, false, false, true, "", "")...)
293
+ out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
274
294
  cancel()
275
295
  if err != nil {
276
296
  s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
@@ -131,6 +131,23 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
131
131
  if !ok {
132
132
  t.Errorf("应透传目录 / force / 配置的默认引擎与模型,实际 %v", readCalls(t, logPath))
133
133
  }
134
+ // 二级页「整理」:module 要原样透传给 core,否则会退化成整空间。
135
+ recMod := httptest.NewRecorder()
136
+ bodyMod := strings.NewReader(`{"workspace":"/proj","module":"登录","engine":"claude"}`)
137
+ svc.Handler().ServeHTTP(recMod, httptest.NewRequest(http.MethodPost,
138
+ "/desk/requirements/consolidate?token=tok", bodyMod))
139
+ if recMod.Code != http.StatusOK {
140
+ t.Fatalf("模块级归纳应 200,实际 %d: %s", recMod.Code, recMod.Body.String())
141
+ }
142
+ var sawModule bool
143
+ for _, c := range readCalls(t, logPath) {
144
+ if strings.Contains(c, "--consolidate-requirements /proj --requirement-module 登录") {
145
+ sawModule = true
146
+ }
147
+ }
148
+ if !sawModule {
149
+ t.Errorf("应透传 --requirement-module 登录,实际 %v", readCalls(t, logPath))
150
+ }
134
151
  // 归纳的结果要能解出核心字段(客户端靠它显示「更新/新建/待整理」)。
135
152
  var res struct {
136
153
  OK bool `json:"ok"`
@@ -141,6 +158,40 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
141
158
  }
142
159
  }
143
160
 
161
+ // TestRequirementsConsolidateSurfacesCoreError core 失败时先写 JSON 再 exit 1,
162
+ // 插件不能把它吞成「exit status 1」——界面要能显示「模块不存在」这类真正原因。
163
+ func TestRequirementsConsolidateSurfacesCoreError(t *testing.T) {
164
+ dir := t.TempDir()
165
+ core := filepath.Join(dir, "magic-agent")
166
+ script := `#!/bin/sh
167
+ case "$*" in
168
+ *--contract*)
169
+ echo '{"contractVersion":2,"features":["feature.session.events","feature.session.control","` + FeatureRequirements + `"],"engines":[]}'
170
+ exit 0
171
+ ;;
172
+ *--consolidate-requirements*)
173
+ echo '{"ok":false,"workspace":"ws-1","module":"不存在","error":"模块不存在: 不存在"}'
174
+ exit 1
175
+ ;;
176
+ esac
177
+ exit 0
178
+ `
179
+ if err := os.WriteFile(core, []byte(script), 0o755); err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ svc := newTestService(t, core)
183
+ rec := httptest.NewRecorder()
184
+ body := strings.NewReader(`{"workspace":"ws-1","module":"不存在"}`)
185
+ svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
186
+ "/desk/requirements/consolidate?token=tok", body))
187
+ if rec.Code != http.StatusOK {
188
+ t.Fatalf("core 的结构化失败应原样回传,实际 %d: %s", rec.Code, rec.Body.String())
189
+ }
190
+ if !strings.Contains(rec.Body.String(), "模块不存在") {
191
+ t.Errorf("应保留 core 的 error 文案,实际 %s", rec.Body.String())
192
+ }
193
+ }
194
+
144
195
  func TestMaybeAutoConsolidate(t *testing.T) {
145
196
  logPath := filepath.Join(t.TempDir(), "calls.log")
146
197
  core := reqCore(t, `"feature.session.events","feature.session.control",`+