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.
- package/CLIENT-CONTRACT.md +78 -13
- package/README.md +10 -4
- package/client-ui/pc/app.css +14 -0
- package/client-ui/pc/js/93-rail.js +3 -1
- package/client-ui/shared/conv/03-chat-message.js +20 -3
- package/client-ui/shared/conv/07-stream-delegation.js +15 -0
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/conv.css +17 -0
- package/client-ui/shared/conversation.js +209 -39
- package/client-ui/shared/transport-http.js +193 -38
- package/client-ui-mobile/index.html +733 -107
- package/client-ui-mobile/reqs.js +42 -29
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +6 -0
- package/internal/agent/llmengine_test.go +7 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +13 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +32 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/client/cors_test.go +32 -0
- package/internal/client/jobs.go +1160 -0
- package/internal/client/jobs_test.go +369 -0
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +259 -142
- package/internal/client/service_test.go +78 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- package/internal/session/session.go +15 -1
- package/internal/session/workspace_lock.go +165 -0
- package/internal/session/workspace_lock_test.go +90 -0
- package/internal/suggest/suggest.go +101 -7
- package/internal/suggest/suggest_test.go +44 -0
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- 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|目录>", "
|
|
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
|
-
|
|
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
|
|
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",`+
|