magic-agent 1.0.49 → 1.0.109

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 (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2443 -178
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -64,7 +64,7 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
64
64
  return path
65
65
  }
66
66
 
67
- const goodContract = `{"contractVersion":1,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
67
+ const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
68
68
 
69
69
  func newTestService(t *testing.T, core string) *Service {
70
70
  t.Helper()
@@ -90,7 +90,7 @@ func TestNewRejectsContractMismatch(t *testing.T) {
90
90
  })
91
91
 
92
92
  t.Run("缺通道能力", func(t *testing.T) {
93
- core := fakeCore(t, `{"contractVersion":1,"features":["feature.session.events"],"engines":[]}`)
93
+ core := fakeCore(t, `{"contractVersion":2,"features":["feature.session.events"],"engines":[]}`)
94
94
  _, err := New(Options{MagicAgent: core, Profile: DefaultProfile()})
95
95
  if err == nil {
96
96
  t.Fatal("缺 control 能力应当报错")
@@ -125,6 +125,51 @@ func TestAuthRequired(t *testing.T) {
125
125
  }
126
126
  }
127
127
 
128
+ /*
129
+ TestHealthCarriesVersion `/desk/health.version` 必须是 core 的版本号:
130
+
131
+ 移动端「我的」页靠它显示「当前连着的 magic-agent 是哪一版」。老 core 没有该字段时
132
+ 如实给空串(界面显示「—」),不编假版本。
133
+ */
134
+ func TestHealthCarriesVersion(t *testing.T) {
135
+ cases := []struct {
136
+ name string
137
+ contract string
138
+ want string
139
+ }{
140
+ {
141
+ name: "新版 core",
142
+ contract: `{"contractVersion":2,"version":"1.0.103","features":["feature.session.events","feature.session.control"],"engines":[]}`,
143
+ want: "1.0.103",
144
+ },
145
+ {
146
+ name: "老 core 没有 version",
147
+ contract: `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[]}`,
148
+ want: "",
149
+ },
150
+ }
151
+ for _, tc := range cases {
152
+ t.Run(tc.name, func(t *testing.T) {
153
+ svc := newTestService(t, fakeCore(t, tc.contract))
154
+ req := httptest.NewRequest(http.MethodGet, "/desk/health?token=tok", nil)
155
+ rec := httptest.NewRecorder()
156
+ svc.Handler().ServeHTTP(rec, req)
157
+ if rec.Code != http.StatusOK {
158
+ t.Fatalf("health 状态码 = %d want 200(body: %s)", rec.Code, rec.Body.String())
159
+ }
160
+ var got struct {
161
+ Version string `json:"version"`
162
+ }
163
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
164
+ t.Fatalf("解析 health 失败: %v(%s)", err, rec.Body.String())
165
+ }
166
+ if got.Version != tc.want {
167
+ t.Errorf("health.version = %q want %q", got.Version, tc.want)
168
+ }
169
+ })
170
+ }
171
+ }
172
+
128
173
  func TestProfileGateEngine(t *testing.T) {
129
174
  prof := DefaultProfile()
130
175
  prof.Engines = []string{"claude"}
@@ -1,14 +1,20 @@
1
1
  package client
2
2
 
3
3
  import (
4
+ "bufio"
5
+ "context"
4
6
  "encoding/json"
5
7
  "fmt"
8
+ "io"
6
9
  "net/http"
7
10
  "net/http/httptest"
8
11
  "os"
9
12
  "path/filepath"
10
13
  "strings"
11
14
  "testing"
15
+ "time"
16
+
17
+ "github.com/darren/magic-agent/internal/session"
12
18
  )
13
19
 
14
20
  // 增量续读端点 `GET /desk/session/{id}/events?after=<seq>` 的边界。
@@ -186,6 +192,320 @@ func TestHandleSessionEvents(t *testing.T) {
186
192
  })
187
193
  }
188
194
 
195
+ // follow 端点 `GET /desk/session/{id}/follow`(SSE 持续尾随)的行为钉死。
196
+ //
197
+ // 这条端点的存在理由就是「跨进程看进度」:不靠本插件进程的 asks 内存,
198
+ // 只读 core 落的 `~/.magic-agent/sessions/<id>.jsonl`。所以这一组测试也全部
199
+ // 只碰磁盘文件(模拟「另一个进程在写」),不经过任何 ask 通道。
200
+ //
201
+ // ⚠️ SSE 是长连接,`doGET` 会读完整个 body 才能返回(永远等不到),
202
+ // 这里必须自己拿 `http.Response.Body` 逐帧读。
203
+ func TestHandleSessionFollow(t *testing.T) {
204
+ home := t.TempDir()
205
+ dir := filepath.Join(home, ".magic-agent", "sessions")
206
+ if err := os.MkdirAll(dir, 0o700); err != nil {
207
+ t.Fatal(err)
208
+ }
209
+ t.Setenv("HOME", home)
210
+ t.Setenv(session.EnvDir, dir)
211
+
212
+ writeLog := func(t *testing.T, id string, lines ...string) {
213
+ t.Helper()
214
+ if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ }
218
+ appendLog := func(t *testing.T, name, line string) {
219
+ t.Helper()
220
+ f, err := os.OpenFile(filepath.Join(dir, name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
221
+ if err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ defer f.Close()
225
+ if _, err := f.WriteString(line + "\n"); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ }
229
+
230
+ svc := newTestService(t, fakeCore(t, goodContract))
231
+ srv := httptest.NewServer(svc.Handler())
232
+ defer srv.Close()
233
+
234
+ followURL := func(id, query string) string {
235
+ return srv.URL + "/desk/session/" + id + "/follow?token=tok" + query
236
+ }
237
+
238
+ t.Run("首屏全量 → 增量 → 终态收尾", func(t *testing.T) {
239
+ const id = "sess-follow"
240
+ writeLog(t, id,
241
+ `{"seq":1,"kind":"user","text":"问题","session_id":"`+id+`"}`,
242
+ `{"seq":2,"kind":"text","text":"答一","session_id":"`+id+`"}`,
243
+ )
244
+ st := openFollow(t, followURL(id, ""))
245
+ defer st.stop()
246
+
247
+ // 首屏:先 open 帧(告诉客户端起始游标),紧接着把已有历史推完。
248
+ if f := st.next(); f.event != "open" {
249
+ t.Fatalf("首帧应是 open,实得 %q(data=%s)", f.event, f.data)
250
+ } else if m := frameData(t, f); m["session"] != id || m["after"] != float64(0) {
251
+ t.Fatalf("open 帧内容不对:%v", m)
252
+ }
253
+ if seq := frameSeq(t, st.next()); seq != 1 {
254
+ t.Fatalf("首屏首条期望 seq=1,实得 %d", seq)
255
+ }
256
+ if seq := frameSeq(t, st.next()); seq != 2 {
257
+ t.Fatalf("首屏第二条期望 seq=2,实得 %d", seq)
258
+ }
259
+
260
+ // 增量:模拟另一个进程在追写(PC 壳直连 core 的真实形态)。
261
+ appendLog(t, id+".jsonl", `{"seq":3,"kind":"text","text":"答二","session_id":"`+id+`"}`)
262
+ st.until("seq=3 增量", func(f followFrame) bool {
263
+ return f.event == "" && frameSeq(t, f) == 3
264
+ })
265
+
266
+ // 终态:turn_end 到了以后还要等 grace(把紧随其后的收尾行补齐),
267
+ // 然后必须给一帧 end —— 客户端靠它停掉「同步进度」的定时器。
268
+ appendLog(t, id+".jsonl", `{"seq":4,"kind":"turn_end","text":"完成","session_id":"`+id+`"}`)
269
+ end := st.until("end 帧", func(f followFrame) bool { return f.event == "end" })
270
+ m := frameData(t, end)
271
+ if m["reason"] != "terminal" {
272
+ t.Fatalf("end.reason 期望 terminal,实得 %v", m["reason"])
273
+ }
274
+ if m["after"] != float64(4) {
275
+ t.Fatalf("end.after 期望 4,实得 %v", m["after"])
276
+ }
277
+ })
278
+
279
+ t.Run("after 排他", func(t *testing.T) {
280
+ const id = "sess-follow-after"
281
+ writeLog(t, id,
282
+ `{"seq":1,"kind":"user","text":"一","session_id":"`+id+`"}`,
283
+ `{"seq":2,"kind":"text","text":"二","session_id":"`+id+`"}`,
284
+ `{"seq":3,"kind":"text","text":"三","session_id":"`+id+`"}`,
285
+ )
286
+ st := openFollow(t, followURL(id, "&after=1"))
287
+ defer st.stop()
288
+
289
+ if f := st.next(); f.event != "open" {
290
+ t.Fatalf("首帧应是 open,实得 %q", f.event)
291
+ } else if m := frameData(t, f); m["after"] != float64(1) {
292
+ t.Fatalf("open.after 应回显 1,实得 %v", m["after"])
293
+ }
294
+ // 排他:不能把 seq=1 再回一遍(客户端续读不该依赖去重)。
295
+ if seq := frameSeq(t, st.next()); seq != 2 {
296
+ t.Fatalf("after=1 后首条期望 seq=2,实得 %d", seq)
297
+ }
298
+ if seq := frameSeq(t, st.next()); seq != 3 {
299
+ t.Fatalf("第二条期望 seq=3,实得 %d", seq)
300
+ }
301
+ })
302
+
303
+ t.Run("游标超前 = snapshot 帧", func(t *testing.T) {
304
+ const id = "sess-follow-ahead"
305
+ writeLog(t, id,
306
+ `{"seq":1,"kind":"user","text":"一","session_id":"`+id+`"}`,
307
+ `{"seq":2,"kind":"text","text":"二","session_id":"`+id+`"}`,
308
+ )
309
+ st := openFollow(t, followURL(id, "&after=99"))
310
+ defer st.stop()
311
+
312
+ if f := st.next(); f.event != "open" {
313
+ t.Fatalf("首帧应是 open,实得 %q", f.event)
314
+ }
315
+ // 游标比磁盘还新(日志被重置 / 游标串了会话)⇒ 明说 snapshot,让客户端重拉全量。
316
+ snap := st.until("snapshot 帧", func(f followFrame) bool { return f.event == "snapshot" })
317
+ if m := frameData(t, snap); m["lastSeq"] != float64(2) {
318
+ t.Fatalf("snapshot.lastSeq 期望 2,实得 %v", m["lastSeq"])
319
+ }
320
+ })
321
+
322
+ t.Run("lean=1 裁掉 snapshot 字段", func(t *testing.T) {
323
+ const id = "sess-follow-lean"
324
+ writeLog(t, id,
325
+ `{"seq":1,"kind":"text","text":"好","snapshot":"好","session_id":"`+id+`"}`,
326
+ )
327
+ st := openFollow(t, followURL(id, "&lean=1"))
328
+ defer st.stop()
329
+
330
+ if f := st.next(); f.event != "open" {
331
+ t.Fatalf("首帧应是 open,实得 %q", f.event)
332
+ }
333
+ ev := st.until("lean 事件", func(f followFrame) bool { return f.event == "" && frameSeq(t, f) == 1 })
334
+ if _, ok := frameData(t, ev)["snapshot"]; ok {
335
+ t.Fatalf("lean=1 时不该带 snapshot:%s", ev.data)
336
+ }
337
+ })
338
+
339
+ // 「刚发起就看进度」的关键形态:core 先写登记表、后开日志文件。
340
+ // 此时不能 404(会让客户端以为会话不存在),要等到文件出现;
341
+ // 文件在进行中还会被改名(<run_id>.jsonl → <session_id>.jsonl),
342
+ // 每轮都得重新翻译,否则进度就停在改名那一刻。
343
+ t.Run("登记表有日志未建 = 放行等待,改名后继续", func(t *testing.T) {
344
+ const rid, sid = "run-pending", "sess-renamed"
345
+ now := time.Now()
346
+ rec := session.Record{
347
+ RunID: rid, SessionID: sid, Engine: "claude", State: session.StateRunning,
348
+ StartedAt: now, UpdatedAt: now,
349
+ }
350
+ body, _ := json.Marshal(rec)
351
+ if err := os.WriteFile(filepath.Join(dir, rid+".json"), body, 0o600); err != nil {
352
+ t.Fatal(err)
353
+ }
354
+ st := openFollow(t, followURL(rid, ""))
355
+ defer st.stop()
356
+
357
+ if f := st.next(); f.event != "open" {
358
+ t.Fatalf("首帧应是 open,实得 %q(data=%s)", f.event, f.data)
359
+ } else if m := frameData(t, f); m["session"] != rid {
360
+ t.Fatalf("open.session 期望 %s,实得 %v", rid, m["session"])
361
+ }
362
+ // 日志文件此刻还不存在 —— 连接必须还活着。
363
+ appendLog(t, rid+".jsonl", `{"seq":1,"kind":"user","text":"开始","session_id":"`+sid+`"}`)
364
+ st.until("登场后的 seq=1", func(f followFrame) bool {
365
+ return f.event == "" && frameSeq(t, f) == 1
366
+ })
367
+
368
+ // 进行中改名:之后的新事件只在 <session_id>.jsonl 里。
369
+ if err := os.Rename(filepath.Join(dir, rid+".jsonl"), filepath.Join(dir, sid+".jsonl")); err != nil {
370
+ t.Fatal(err)
371
+ }
372
+ appendLog(t, sid+".jsonl", `{"seq":2,"kind":"text","text":"继续","session_id":"`+sid+`"}`)
373
+ st.until("改名后的 seq=2", func(f followFrame) bool {
374
+ return f.event == "" && frameSeq(t, f) == 2
375
+ })
376
+ })
377
+
378
+ t.Run("不存在会话 = 404", func(t *testing.T) {
379
+ resp := doGET(t, followURL("no-such-session", ""), true)
380
+ if resp.statusCode != http.StatusNotFound {
381
+ t.Fatalf("期望 404,实得 %d(%s)", resp.statusCode, resp.body)
382
+ }
383
+ })
384
+
385
+ t.Run("after 非法 = 400", func(t *testing.T) {
386
+ const id = "sess-follow-bad-after"
387
+ writeLog(t, id, `{"seq":1,"kind":"user","text":"一","session_id":"`+id+`"}`)
388
+ for _, bad := range []string{"abc", "-1", "1.5"} {
389
+ resp := doGET(t, followURL(id, "&after="+bad), true)
390
+ if resp.statusCode != http.StatusBadRequest {
391
+ t.Fatalf("after=%q 期望 400,实得 %d", bad, resp.statusCode)
392
+ }
393
+ }
394
+ })
395
+
396
+ t.Run("缺令牌 = 401", func(t *testing.T) {
397
+ resp := doGET(t, srv.URL+"/desk/session/sess-follow/follow", false)
398
+ if resp.statusCode != http.StatusUnauthorized {
399
+ t.Fatalf("期望 401,实得 %d", resp.statusCode)
400
+ }
401
+ })
402
+ }
403
+
404
+ // followFrame 一帧 SSE:event 为空表示纯 data 帧(增量事件),ping 表示心跳注释。
405
+ type followFrame struct {
406
+ event string
407
+ data string
408
+ ping bool
409
+ }
410
+
411
+ type followStream struct {
412
+ t *testing.T
413
+ br *bufio.Reader
414
+ stop func()
415
+ }
416
+
417
+ // openFollow 建一条 SSE 连接。超时只兜「等不到的帧」,正常路径靠 end / stop 收尾。
418
+ func openFollow(t *testing.T, url string) *followStream {
419
+ t.Helper()
420
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
421
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
422
+ if err != nil {
423
+ cancel()
424
+ t.Fatal(err)
425
+ }
426
+ resp, err := http.DefaultClient.Do(req)
427
+ if err != nil {
428
+ cancel()
429
+ t.Fatalf("打开 follow 失败:%v", err)
430
+ }
431
+ if resp.StatusCode != http.StatusOK {
432
+ body, _ := io.ReadAll(resp.Body)
433
+ resp.Body.Close()
434
+ cancel()
435
+ t.Fatalf("follow 期望 200,实得 %d(%s)", resp.StatusCode, body)
436
+ }
437
+ return &followStream{
438
+ t: t,
439
+ br: bufio.NewReader(resp.Body),
440
+ stop: func() {
441
+ resp.Body.Close()
442
+ cancel()
443
+ },
444
+ }
445
+ }
446
+
447
+ func (s *followStream) next() followFrame {
448
+ s.t.Helper()
449
+ var f followFrame
450
+ var has bool
451
+ for {
452
+ line, err := s.br.ReadString('\n')
453
+ if err != nil {
454
+ s.t.Fatalf("读 SSE 失败:%v", err)
455
+ }
456
+ line = strings.TrimRight(line, "\r\n")
457
+ if line == "" {
458
+ if !has {
459
+ continue
460
+ }
461
+ return f
462
+ }
463
+ has = true
464
+ switch {
465
+ case strings.HasPrefix(line, "event:"):
466
+ f.event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
467
+ case strings.HasPrefix(line, "data:"):
468
+ f.data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
469
+ case strings.HasPrefix(line, ":"):
470
+ f.ping = true
471
+ }
472
+ }
473
+ }
474
+
475
+ // until 一帧帧读到 pred 成立(跳过心跳帧)。
476
+ func (s *followStream) until(what string, pred func(followFrame) bool) followFrame {
477
+ s.t.Helper()
478
+ for {
479
+ f := s.next()
480
+ if f.ping {
481
+ continue
482
+ }
483
+ if pred(f) {
484
+ return f
485
+ }
486
+ }
487
+ }
488
+
489
+ func frameData(t *testing.T, f followFrame) map[string]any {
490
+ t.Helper()
491
+ var m map[string]any
492
+ if err := json.Unmarshal([]byte(f.data), &m); err != nil {
493
+ t.Fatalf("帧不是 JSON:%v(%s)", err, f.data)
494
+ }
495
+ return m
496
+ }
497
+
498
+ // frameSeq 增量帧的 seq;不像事件帧(open/snapshot/end)时返回 0。
499
+ func frameSeq(t *testing.T, f followFrame) uint64 {
500
+ t.Helper()
501
+ if f.event != "" {
502
+ return 0
503
+ }
504
+ m := frameData(t, f)
505
+ seq, _ := m["seq"].(float64)
506
+ return uint64(seq)
507
+ }
508
+
189
509
  // TestTruncRunes 钉住按字符(不是字节)截断,且不切坏多字节字符。
190
510
  func TestTruncRunes(t *testing.T) {
191
511
  cases := []struct {
@@ -0,0 +1,204 @@
1
+ package client
2
+
3
+ // team_flags.go - 把 POST /desk/ask 的 team 字段翻成 core 的 --team-* flags。
4
+ //
5
+ // 为什么单独一个文件:这段是**纯翻译 + 预校验**,与 HTTP 无关,可以单独测
6
+ // (service_test.go 里的团队用例直接调它,不必起服务、不必 spawn core)。
7
+ //
8
+ // 分工(改这里之前先看 internal/team/route.go):
9
+ // - 插件负责:字段名 → flag 名的映射、类别名合法性、引擎白名单。
10
+ // - core 负责:类别 → 引擎的**最终裁决**(写死的任务级路由 > 配置 > 主引擎回落)。
11
+ // 两边都判一遍类别名,是因为报错要发生在**请求那一刻**(400 + 中文原因),
12
+ // 而不是等 core 跑完计划才以 exit 2 失败 —— 那时用户已经等了几十秒。
13
+
14
+ import (
15
+ "fmt"
16
+ "sort"
17
+ "strings"
18
+ )
19
+
20
+ // teamKinds 与 internal/team.RouteKinds 同源(这里不 import team 是为了让插件不必
21
+ // 依赖 core 的内部包 —— 插件走的是 core 的**命令行契约**,不是 Go API)。
22
+ // plan = 计划阶段(谁来拆任务),其余五类是子任务类别。
23
+ var teamKinds = []string{"plan", "text", "code", "image", "video", "game"}
24
+
25
+ // FeatureSessionTeam core 的团队模式能力位(与 internal/agent.FeatureSessionTeam 同值)。
26
+ //
27
+ // 这是**可选**特性:老 core 没有它,插件照常工作,只是拒绝带 team 的请求
28
+ // (见 handleAsk)—— 不塞进 requiredFeatures,否则升级插件就得同步升 core。
29
+ const FeatureSessionTeam = "feature.session.team"
30
+
31
+ // teamFlags 把请求体翻成 core 参数。err 非空时调用方应回 400。
32
+ //
33
+ // roles 是 core `--contract` 上报的角色名清单(s.teamRoleNames())。空 = 老 core,
34
+ // 此时 role:<name> 只做格式校验,名字交给 core 裁决 —— 插件不替 core 维护角色表。
35
+ func teamFlags(t *teamRequest, profile Profile, roles []string) ([]string, error) {
36
+ if t == nil {
37
+ return nil, nil
38
+ }
39
+ args := []string{"--team"}
40
+ if t.PlanOnly {
41
+ args = append(args, "--team-plan-only")
42
+ }
43
+ if t.Parallel > 0 {
44
+ args = append(args, "--team-parallel", fmt.Sprint(t.Parallel))
45
+ }
46
+ if s := strings.TrimSpace(t.TaskTimeout); s != "" {
47
+ args = append(args, "--team-task-timeout", s)
48
+ }
49
+ if s := strings.TrimSpace(t.Split); s != "" {
50
+ split, err := normalizeTeamSplit(s)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ args = append(args, "--team-split", split)
55
+ }
56
+ if t.SplitCode != nil && *t.SplitCode {
57
+ args = append(args, "--team-split-code")
58
+ }
59
+ if t.Adaptive != nil && !*t.Adaptive {
60
+ args = append(args, "--team-adaptive=false")
61
+ }
62
+ // 角色规则(「这类任务适合什么」):按角色名排序,理由同路由 —— 同样的请求
63
+ // 必须产生同样的命令行。空文本 = 用 core 内置的描述,不传。
64
+ ruleKeys := make([]string, 0, len(t.Rules))
65
+ for k := range t.Rules {
66
+ ruleKeys = append(ruleKeys, k)
67
+ }
68
+ sort.Strings(ruleKeys)
69
+ for _, rawKey := range ruleKeys {
70
+ key, err := normalizeTeamRuleKey(rawKey, roles)
71
+ if err != nil {
72
+ return nil, err
73
+ }
74
+ text := strings.Join(strings.Fields(t.Rules[rawKey]), " ")
75
+ if text == "" {
76
+ continue
77
+ }
78
+ args = append(args, "--team-rule", key+"="+text)
79
+ }
80
+ // 路由按类别排序输出:map 遍历无序,不排序的话同样的请求会产生不同的命令行,
81
+ // 排障时(`ps` 看参数 / 日志比对)会误以为请求变了。
82
+ kinds := make([]string, 0, len(t.Routes))
83
+ for k := range t.Routes {
84
+ kinds = append(kinds, k)
85
+ }
86
+ sort.Strings(kinds)
87
+ for _, rawKind := range kinds {
88
+ kind, err := normalizeTeamRouteKey(rawKind, roles)
89
+ if err != nil {
90
+ return nil, err
91
+ }
92
+ r := t.Routes[rawKind]
93
+ engine := strings.TrimSpace(r.Engine)
94
+ model := strings.TrimSpace(r.Model)
95
+ if engine == "" {
96
+ if model != "" {
97
+ // 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
98
+ return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
99
+ }
100
+ continue // 整条空 = 该类跟随主引擎
101
+ }
102
+ if !profile.AllowsEngine(engine) {
103
+ return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
104
+ }
105
+ spec := kind + "=" + engine
106
+ if model != "" {
107
+ spec += "/" + model
108
+ }
109
+ args = append(args, "--team-route", spec)
110
+ }
111
+ return args, nil
112
+ }
113
+
114
+ // normalizeTeamRuleKey 规则键归一:只认角色(`role:<name>` 或裸角色名)。
115
+ //
116
+ // 与路由键不同,这里**不**接受类别键 —— 类别没有自由文本规则(计划是拆解粒度,
117
+ // 其余类别由角色派生)。老 core(roles 为空)只做格式校验,名字交给 core 裁决。
118
+ func normalizeTeamRuleKey(raw string, roles []string) (string, error) {
119
+ name := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "role:"))
120
+ if name == "" {
121
+ return "", fmt.Errorf("team.rules 里有空角色键 %q(应写成 role:<角色名>)", raw)
122
+ }
123
+ if len(roles) > 0 && !containsStr(roles, name) {
124
+ return "", fmt.Errorf("team.rules 里有未知角色 %q(可选 %s)", raw, strings.Join(roles, " | "))
125
+ }
126
+ return "role:" + name, nil
127
+ }
128
+
129
+ // normalizeTeamSplit 拆解粒度归一(与 core 的 team.NormalizeSplit 同口径)。
130
+ //
131
+ // 插件也判一遍:粒度写错要在请求那一刻回 400,而不是等 core 跑到计划阶段才失败。
132
+ func normalizeTeamSplit(raw string) (string, error) {
133
+ switch s := strings.ToLower(strings.TrimSpace(raw)); s {
134
+ case teamSplitCoarse, teamSplitStandard, teamSplitFine:
135
+ return s, nil
136
+ default:
137
+ return "", fmt.Errorf("team.split 取值 %q 不合法(可选 %s | %s | %s)",
138
+ raw, teamSplitCoarse, teamSplitStandard, teamSplitFine)
139
+ }
140
+ }
141
+
142
+ const (
143
+ teamSplitCoarse = "coarse"
144
+ teamSplitStandard = "standard"
145
+ teamSplitFine = "fine"
146
+ )
147
+
148
+ // validTeamKind 类别名是否合法(大小写不敏感,调用方已归一)。
149
+ func validTeamKind(kind string) bool {
150
+ for _, k := range teamKinds {
151
+ if k == kind {
152
+ return true
153
+ }
154
+ }
155
+ return false
156
+ }
157
+
158
+ // normalizeTeamRouteKey 路由键归一成 core 认的写法。
159
+ //
160
+ // 两类键:
161
+ // - 类别(plan/text/code/image/video/game)→ 原样返回;
162
+ // - 角色 → "role:<name>"(core 的 --team-route 用这个前缀)。
163
+ //
164
+ // 裸角色名也认(core 的 ParseRoutes 同口径),但**只在它不是类别时** ——
165
+ // 否则将来哪天真有角色叫 code,会跟类别键抢。
166
+ func normalizeTeamRouteKey(raw string, roles []string) (string, error) {
167
+ key := strings.ToLower(strings.TrimSpace(raw))
168
+ if name, ok := strings.CutPrefix(key, "role:"); ok {
169
+ name = strings.TrimSpace(name)
170
+ if name == "" {
171
+ return "", fmt.Errorf("team.routes 里有空角色键 %q(应写成 role:<角色名>)", raw)
172
+ }
173
+ if len(roles) > 0 && !containsStr(roles, name) {
174
+ return "", fmt.Errorf("team.routes 里有未知角色 %q(可选 %s)",
175
+ raw, strings.Join(roles, " | "))
176
+ }
177
+ return "role:" + name, nil
178
+ }
179
+ if validTeamKind(key) {
180
+ return key, nil
181
+ }
182
+ if len(roles) > 0 && containsStr(roles, key) {
183
+ return "role:" + key, nil
184
+ }
185
+ return "", fmt.Errorf("team.routes 里有未知类别 %q(可选 %s;按角色指定用 role:<%s>)",
186
+ raw, strings.Join(teamKinds, " | "), roleHint(roles))
187
+ }
188
+
189
+ // roleHint 错误文案里的角色示例:有清单就列出来,没有就举个通用例子。
190
+ func roleHint(roles []string) string {
191
+ if len(roles) == 0 {
192
+ return "角色名"
193
+ }
194
+ return strings.Join(roles, "|")
195
+ }
196
+
197
+ func containsStr(list []string, s string) bool {
198
+ for _, v := range list {
199
+ if v == s {
200
+ return true
201
+ }
202
+ }
203
+ return false
204
+ }