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
@@ -24,7 +24,10 @@ import "sort"
24
24
  //
25
25
  // 递增规则:只增字段(向后兼容)不升版本;一旦有字段改名、语义变化或删除,
26
26
  // 必须升版本 —— 老客户端会因版本不匹配而拒绝启动,这正是我们想要的结果。
27
- const ContractVersion = 1
27
+ //
28
+ // v2(2026-10-22):团队模式 `tasks[]` 的 `role` 改为**必出**且是唯一作者字段,
29
+ // `kind` 由角色派生(只读)。老客户端会把 `kind` 当作者字段解释,语义已变。
30
+ const ContractVersion = 2
28
31
 
29
32
  // 能力 id。命名规则:`域.能力`,域只有 session / workspace / attachment /
30
33
  // model / engine 五个,客户端按域分组渲染即可。
@@ -146,9 +149,20 @@ const (
146
149
  FeatureSessionEvents = "feature.session.events"
147
150
  // FeatureSessionControl `--stream --control`:stdin NDJSON 控制通道(打断 / 收工 / 回审批)。
148
151
  FeatureSessionControl = "feature.session.control"
152
+ // FeatureSessionTeam `--team`:团队模式(主引擎拆任务、子引擎分头执行、主引擎汇总)。
153
+ //
154
+ // 为什么归 core 而不是某个引擎:它是**会话层的一种运行模式**,任何能当主引擎的
155
+ // 引擎都能用它(见 internal/team)。客户端据此决定要不要显示团队开关 ——
156
+ // 老版本 core 没有这个 feature,界面必须把开关置灰而不是静默下发一个不认识的字段。
157
+ FeatureSessionTeam = "feature.session.team"
158
+ // FeatureRequirements `--requirements` / `--consolidate-requirements`:会话自动聚合成需求。
159
+ //
160
+ // 客户端据此决定要不要显示「需求」页签:老版本 core 没有这个 feature,
161
+ // 界面必须把入口藏起来,而不是给一个点下去只会报错的按钮。
162
+ FeatureRequirements = "feature.requirements"
149
163
  )
150
164
 
151
165
  // CoreFeatures 返回 core 自身提供的通道能力(字典序,稳定)。
152
166
  func CoreFeatures() []string {
153
- return []string{FeatureSessionControl, FeatureSessionEvents}
167
+ return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionTeam}
154
168
  }
@@ -232,10 +232,11 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
232
232
  args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
233
233
  "--permission-mode", claudePermissionMode(req.Permission))
234
234
  }
235
+ /* 没指定模型就**不传**这个 flag,让 CLI 用自己的默认档。
236
+ 以前缺省分支会 append(DefaultClaudeModel),而它是空串 —— 命令行于是变成
237
+ `--model --effort max`(空串占位)。空值参数没有语义,纯属给 CLI 埋雷。 */
235
238
  if m := stripModelPrefix(req.Model); m != "" {
236
239
  args = append(args, "--model", m)
237
- } else {
238
- args = append(args, "--model", DefaultClaudeModel)
239
240
  }
240
241
  // 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
241
242
  // 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
@@ -307,10 +307,16 @@ func (c codebuddyCore) complete(ctx context.Context, req Request) (Response, err
307
307
  if strings.TrimSpace(text) == "" {
308
308
  return Response{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
309
309
  }
310
+ // session_id 通常出现在 result 行;老版本 / 部分模型只在 init 行给,
311
+ // 此时回落到流里记住的那个,保证续接链路不断。
312
+ sid := fin.SessionID
313
+ if sid == "" {
314
+ sid = acc.SessionID
315
+ }
310
316
  return Response{
311
317
  Text: text,
312
318
  Model: fin.Model,
313
- SessionID: fin.SessionID,
319
+ SessionID: sid,
314
320
  Latency: time.Since(start),
315
321
  }, nil
316
322
  }
@@ -27,7 +27,10 @@ func TestCodexBuildArgsNewSession(t *testing.T) {
27
27
  dir := t.TempDir()
28
28
  w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("hi from codex"))
29
29
  e := &CodexEngine{BinPath: w.bin}
30
+ // 工具默认是 on(toolsOrDefault),on/白名单会走全放行;这里显式关掉,
31
+ // 断言 off 模式的 read-only 沙箱减法。
30
32
  if _, err := e.Complete(context.Background(), Request{
33
+ Tools: ToolsOff,
31
34
  Messages: []Message{{Role: "user", Content: "hi"}},
32
35
  }); err != nil {
33
36
  t.Fatalf("Complete: %v", err)
@@ -440,6 +440,11 @@ func TestSyncCustomModelsFromApp_CorruptExistingFileLeftUntouched(t *testing.T)
440
440
  }
441
441
 
442
442
  func TestEnsureCustomModelEndpoint_OnlyCustomLocal(t *testing.T) {
443
+ // 前两段断言走注入的 configDir,前提是环境里没有 CODEBUDDY_CONFIG_DIR ——
444
+ // 显式设了它时落点就该跟着走(第三段才验这个)。本机 shell 带着它,
445
+ // 不清掉的话「自定义模型应触发同步」会去查一个没被写入的目录。
446
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
447
+
443
448
  dir := t.TempDir()
444
449
  acc := filepath.Join(dir, "acc.json")
445
450
  writeJSON(t, acc, appCacheFixture())
@@ -549,17 +549,20 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
549
549
 
550
550
  var (
551
551
  thinking strings.Builder
552
+ body strings.Builder // text 事件累积(final 缺失时的正文兜底)
552
553
  tools []ToolCall
553
554
  callIdx = map[string]int{}
554
555
  final string // `--json` 的 final 事件(整轮正文),权威
555
556
  learnedID string // 会话抬头里的 id(老接口另用:本轮的会话 id)
556
557
  streamed bool // 是否已逐条发过正文
558
+ sawEvent bool // 是否真的解析出过事件行(= stdout 是事件流,不能当正文)
557
559
  )
558
560
  onLine := func(line string) error {
559
561
  ev, ok := parseDshJSONEvent(line)
560
562
  if !ok {
561
563
  return nil // 非事件行(启动日志 / 坏行)忽略
562
564
  }
565
+ sawEvent = true
563
566
  switch ev.Type {
564
567
  case "session":
565
568
  // 会话抬头:headless 回的是**同一条会话**的 id,回报给「看历史」持久化。
@@ -578,6 +581,7 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
578
581
  return nil
579
582
  }
580
583
  streamed = true
584
+ body.WriteString(ev.Text)
581
585
  emit(StreamEvent{Kind: KindText, Text: ev.Text})
582
586
  case "tool_call":
583
587
  callArgs := dshJSONArgs(ev.Input)
@@ -623,9 +627,17 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
623
627
  })
624
628
  }
625
629
 
626
- // 正文:final 事件最权威;没有(老版本 / 事件缺失)就用 stdout 全文。
630
+ // 正文:final 事件最权威;final 缺失时用 text 事件累积的正文兜底。
631
+ // ⚠️ stdout 一旦被解析成事件流(sawEvent),就**绝不能**再当正文:本轮没提交正文时
632
+ // dsh 的 final.text 就是空串,拿 stdout 兜底会把一屏 `{"type":"session"...}` 原样
633
+ // 渲染进气泡(真实报障:对话正文显示原始事件 JSON)。能力探测失败时 caps 是乐观默认
634
+ // (JSON=true),所以判据用 sawEvent 而不是 caps.JSON —— 真老版本输出纯文本,解析不出
635
+ // 任何事件行,仍按「stdout 即正文」处理。
627
636
  text := strings.TrimSpace(final)
628
- if text == "" {
637
+ if text == "" && sawEvent {
638
+ text = strings.TrimSpace(body.String())
639
+ }
640
+ if text == "" && !sawEvent {
629
641
  text = strings.TrimSpace(stdout)
630
642
  }
631
643
 
@@ -655,6 +667,10 @@ func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessio
655
667
  wrapCliError("dsh", stdout, stderrText, runErr)
656
668
  }
657
669
  if resp.Text == "" {
670
+ if sawEvent {
671
+ return StreamResult{Response: resp, Thinking: thinking.String(), Tools: tools},
672
+ fmt.Errorf("dsh 本轮没有输出最终正文(--json 事件流里既没有 final.text 也没有 text 事件)")
673
+ }
658
674
  return StreamResult{Response: resp, Thinking: thinking.String(), Tools: tools},
659
675
  fmt.Errorf("dsh CLI returned empty result")
660
676
  }
@@ -75,8 +75,10 @@ func TestDshCapabilities(t *testing.T) {
75
75
  if got := AttachmentSupportOf("dsh"); got != "prompt" {
76
76
  t.Errorf("headless 下 AttachmentSupportOf(dsh) = %q want prompt(无图片通道)", got)
77
77
  }
78
- if got := PermissionSupportOf("dsh"); got != "none" {
79
- t.Errorf("PermissionSupportOf(dsh) = %q want none(预设无法表达四档)", got)
78
+ // 四档走子进程环境变量:dsh 是 profile 启动器,没有逐调用的权限 flag,
79
+ // shipped 的 sdk profile 直接读 DSH_PERMISSION_MODE(映射见 dshPermissionMode)。
80
+ if got := PermissionSupportOf("dsh"); got != "env:DSH_PERMISSION_MODE" {
81
+ t.Errorf("PermissionSupportOf(dsh) = %q want env:DSH_PERMISSION_MODE", got)
80
82
  }
81
83
  if got := AskSupportOf("dsh"); got != "none" {
82
84
  t.Errorf("AskSupportOf(dsh) = %q want none", got)
@@ -326,6 +328,55 @@ func TestDshStreamHeadlessJSONEvents(t *testing.T) {
326
328
  }
327
329
  }
328
330
 
331
+ // final 为空(本轮没提交正文)时不能把原始 NDJSON 事件流当正文:
332
+ // 用 text 事件累积兜底;连 text 也没有就明确报错。
333
+ func TestDshStreamHeadlessEmptyFinalNeverLeaksEventStream(t *testing.T) {
334
+ run := func(lines []string) (StreamResult, error) {
335
+ dir := t.TempDir()
336
+ body := "case \"$*\" in *--help*) echo 'Usage: dsh --profile headless [--json] [--session-id <id>] [task...]'; exit 0;; esac\n"
337
+ for _, l := range lines {
338
+ body += "printf '%s\\n' '" + l + "'\n"
339
+ }
340
+ w, _ := argsCaptureCLI(t, dir, "dsh", body)
341
+ return (&DshEngine{BinPath: w.bin}).Stream(context.Background(), Request{
342
+ Messages: []Message{{Role: "user", Content: "问"}},
343
+ }, func(StreamEvent) {})
344
+ }
345
+
346
+ // final 空串 + 有 text 事件 → 正文取 text 累积。
347
+ res, err := run([]string{
348
+ `{"type":"session","sessionId":"sess-1","cwd":"/tmp"}`,
349
+ `{"type":"status","phase":"turn_start","turn":1}`,
350
+ `{"type":"text","text":"第一段"}`,
351
+ `{"type":"text","text":"第二段"}`,
352
+ `{"type":"status","phase":"turn_end","turn":1,"reason":{"kind":"completed"}}`,
353
+ `{"type":"final","text":""}`,
354
+ })
355
+ if err != nil {
356
+ t.Fatalf("Stream: %v", err)
357
+ }
358
+ if res.Response.Text != "第一段第二段" {
359
+ t.Errorf("Text = %q want 第一段第二段", res.Response.Text)
360
+ }
361
+ if strings.Contains(res.Response.Text, `{"type":`) {
362
+ t.Errorf("正文泄漏了原始事件流:%q", res.Response.Text)
363
+ }
364
+
365
+ // final 空串 + 没有 text 事件 → 报错,正文保持为空(绝不回落 stdout 事件流)。
366
+ res, err = run([]string{
367
+ `{"type":"session","sessionId":"sess-1","cwd":"/tmp"}`,
368
+ `{"type":"thinking","text":"想了想"}`,
369
+ `{"type":"status","phase":"turn_end","turn":1,"reason":{"kind":"completed"}}`,
370
+ `{"type":"final","text":""}`,
371
+ })
372
+ if err == nil {
373
+ t.Fatalf("空正文应报错,got Text = %q", res.Response.Text)
374
+ }
375
+ if res.Response.Text != "" {
376
+ t.Errorf("空正文时 Text 应为空,got %q", res.Response.Text)
377
+ }
378
+ }
379
+
329
380
  // 参数形态:启动器 flag 在前,--session-id / --json 在 app 参数区(headless 自己的 flag)。
330
381
  func TestDshHeadlessArgs(t *testing.T) {
331
382
  cases := []struct {
@@ -150,8 +150,8 @@ func TestClaudePassesModelFlag(t *testing.T) {
150
150
  if !strings.Contains(args, "--tools ") {
151
151
  t.Errorf("--tools not passed: %q", args)
152
152
  }
153
- if !strings.Contains(args, "--output-format json") {
154
- t.Errorf("--output-format json not passed: %q", args)
153
+ if !strings.Contains(args, "--output-format stream-json") {
154
+ t.Errorf("--output-format stream-json not passed: %q", args)
155
155
  }
156
156
  }
157
157
 
@@ -227,17 +227,19 @@ echo '{"type":"result","subtype":"error_max_turns","is_error":true,"result":"超
227
227
  }
228
228
  }
229
229
 
230
- func TestClaudeFallbackToRawText(t *testing.T) {
230
+ func TestClaudeRawTextRejected(t *testing.T) {
231
+ // 协议已全量走 stream-json:没有 result 行的裸文本不再当作正文(旧版回落已删除),
232
+ // 必须报「协议未跑完」,否则会把 CLI 的报错/日志当成模型答案。
231
233
  cli := writeFakeCLI(t, "claude", "#!/bin/sh\necho 'plain text answer'\n")
232
234
  e := &ClaudeEngine{BinPath: cli}
233
- resp, err := e.Complete(context.Background(), Request{
235
+ _, err := e.Complete(context.Background(), Request{
234
236
  Messages: []Message{{Role: "user", Content: "hi"}},
235
237
  })
236
- if err != nil {
237
- t.Fatalf("Complete: %v", err)
238
+ if err == nil {
239
+ t.Fatal("裸文本(无 result 行)必须报错")
238
240
  }
239
- if resp.Text != "plain text answer" {
240
- t.Errorf("Text = %q", resp.Text)
241
+ if !strings.Contains(err.Error(), "stream ended without result line") {
242
+ t.Errorf("err = %v want stream ended without result line", err)
241
243
  }
242
244
  }
243
245
 
@@ -314,9 +316,12 @@ exit 0
314
316
  })
315
317
  }
316
318
 
317
- func TestCodeBuddyArrayEnvelope(t *testing.T) {
319
+ func TestCodeBuddyResultLineWinsOverAssistant(t *testing.T) {
320
+ // 协议是逐行 stream-json:assistant 聚合行只是过程快照,全文以 result 行为准。
318
321
  cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
319
- echo '[{"type":"system","subtype":"init"},{"type":"assistant","message":{"content":[{"type":"text","text":"部分1"}]}},{"type":"result","result":"最终答案"}]'
322
+ echo '{"type":"system","subtype":"init"}'
323
+ echo '{"type":"assistant","message":{"content":[{"type":"text","text":"部分1"}]}}'
324
+ echo '{"type":"result","result":"最终答案"}'
320
325
  `)
321
326
  e := &CodeBuddyEngine{BinPath: cli}
322
327
  resp, err := e.Complete(context.Background(), Request{
@@ -330,9 +335,12 @@ echo '[{"type":"system","subtype":"init"},{"type":"assistant","message":{"conten
330
335
  }
331
336
  }
332
337
 
333
- func TestCodeBuddyArrayAssistantFallback(t *testing.T) {
338
+ func TestCodeBuddyEmptyResultFallsBackToDeltas(t *testing.T) {
339
+ // result 行正文为空时,finalizeStreamText 回落到增量拼接。
334
340
  cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
335
- echo '[{"type":"assistant","message":{"content":[{"type":"text","text":"第一段"}]}},{"role":"assistant","content":[{"type":"output_text","text":"第二段"}]}]'
341
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"第一段"}}}'
342
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"第二段"}}}'
343
+ echo '{"type":"result","result":""}'
336
344
  `)
337
345
  e := &CodeBuddyEngine{BinPath: cli}
338
346
  resp, err := e.Complete(context.Background(), Request{
@@ -341,14 +349,14 @@ echo '[{"type":"assistant","message":{"content":[{"type":"text","text":"第一
341
349
  if err != nil {
342
350
  t.Fatalf("Complete: %v", err)
343
351
  }
344
- if resp.Text != "第一段\n第二段" {
345
- t.Errorf("Text = %q want 两段拼接", resp.Text)
352
+ if resp.Text != "第一段第二段" {
353
+ t.Errorf("Text = %q want 增量拼接", resp.Text)
346
354
  }
347
355
  }
348
356
 
349
357
  func TestCodeBuddyUserQueryEcho(t *testing.T) {
350
358
  cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
351
- echo '<user_query>【系统指令】你是助手</user_query>真正的回答'
359
+ echo '{"type":"result","result":"<user_query>【系统指令】你是助手</user_query>真正的回答"}'
352
360
  `)
353
361
  e := &CodeBuddyEngine{BinPath: cli}
354
362
  resp, err := e.Complete(context.Background(), Request{
@@ -363,7 +371,7 @@ echo '<user_query>【系统指令】你是助手</user_query>真正的回答'
363
371
  }
364
372
 
365
373
  // 全回显(无闭标签后正文)→ 报错
366
- cli2 := writeFakeCLI(t, "codebuddy", "#!/bin/sh\necho '<user_query>只有回显</user_query>'\n")
374
+ cli2 := writeFakeCLI(t, "codebuddy", "#!/bin/sh\necho '{\"type\":\"result\",\"result\":\"<user_query>只有回显</user_query>\"}'\n")
367
375
  e2 := &CodeBuddyEngine{BinPath: cli2}
368
376
  if _, err := e2.Complete(context.Background(), Request{
369
377
  Messages: []Message{{Role: "user", Content: "hi"}},
@@ -381,9 +389,12 @@ func TestCodeBuddyPassesModel(t *testing.T) {
381
389
  t.Fatal(err)
382
390
  }
383
391
  e := &CodeBuddyEngine{BinPath: cli}
392
+ // noToolSuffix 只在 off 模式注入(on/白名单下会压制工具调用),
393
+ // 故这里显式关工具来断言 --append-system-prompt。
384
394
  _, err := e.Complete(context.Background(), Request{
385
395
  Model: "codebuddy/hy3",
386
396
  Messages: []Message{{Role: "user", Content: "hi"}},
397
+ Tools: ToolsOff,
387
398
  })
388
399
  if err != nil {
389
400
  t.Fatalf("Complete: %v", err)
@@ -450,9 +461,12 @@ func TestTraeCompletePlainText(t *testing.T) {
450
461
  t.Fatal(err)
451
462
  }
452
463
  e := &TraeEngine{BinPath: cli}
464
+ // 工具默认是 on(toolsOrDefault),此处显式关掉以断言 off 模式的
465
+ // --disallowed-tool 减法与 noToolSuffix。
453
466
  resp, err := e.Complete(context.Background(), Request{
454
467
  Messages: []Message{{Role: "user", Content: "hi"}},
455
468
  Timeout: 90 * time.Second,
469
+ Tools: ToolsOff,
456
470
  })
457
471
  if err != nil {
458
472
  t.Fatalf("Complete: %v", err)
@@ -669,10 +683,11 @@ func TestCodeBuddySessionExtract(t *testing.T) {
669
683
  }
670
684
  }
671
685
 
672
- // codebuddy 数组形态下 Complete 也要把 session_id 挖出来回传。
673
- func TestCodeBuddyCompleteExtractsSessionFromArray(t *testing.T) {
686
+ // 只出现在 init 行的 session_id,Complete 也要挖出来回传(续接链路依赖它)。
687
+ func TestCodeBuddyCompleteExtractsSessionFromInitLine(t *testing.T) {
674
688
  cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
675
- echo '[{"type":"system","session_id":"cb-init"},{"type":"result","result":"好"}]'
689
+ echo '{"type":"system","session_id":"cb-init"}'
690
+ echo '{"type":"result","result":"好"}'
676
691
  `)
677
692
  e := &CodeBuddyEngine{BinPath: cli}
678
693
  resp, err := e.Complete(context.Background(), Request{
@@ -99,8 +99,10 @@ func TestEnvDeniedCaseInsensitive(t *testing.T) {
99
99
  "pythonhome": true,
100
100
  "PATH": false,
101
101
  "HOME": false,
102
- // 仍必须放行:引擎靠它选配置目录(见 codebuddyAccountEnv)
103
- "codebuddy_config_dir": false,
102
+ // CODEBUDDY_* 一刀切剔除(见 env.go 的 codebuddyEnvPrefix):
103
+ // 宿主注入的 CONFIG_DIR 会被丢掉,账号隔离需要的值由
104
+ // codebuddyAccountEnv 经 ChildEnvWith 的 extraEnv 显式补回。
105
+ "codebuddy_config_dir": true,
104
106
  }
105
107
  for name, want := range cases {
106
108
  if got := envDenied(name); got != want {
@@ -125,15 +127,18 @@ func TestEnvironStripsMixedCaseServerPrefix(t *testing.T) {
125
127
 
126
128
  func TestEnvDenied(t *testing.T) {
127
129
  cases := map[string]bool{
128
- "SERVER__PORT": true,
129
- "SERVER__HOST": true,
130
- "SERVER__ANYTHING": true,
131
- "CODEBUDDY_SESSION_ID": true,
132
- "CODEBUDDY_SERVICE_PROXY_URL": false, // 保留:CLI 可能依赖父会话服务
133
- "CODEBUDDY_MCP_CONFIG": false,
130
+ "SERVER__PORT": true,
131
+ "SERVER__HOST": true,
132
+ "SERVER__ANYTHING": true,
133
+ "CODEBUDDY_SESSION_ID": true,
134
+ // CODEBUDDY_* 一律剔除(env.go:宿主注入的 MCP 配置会让 CLI 启动即退)。
135
+ // 引擎自己需要的 CODEBUDDY_CONFIG_DIR / ACC_PRODUCT_CONFIG_V3 由
136
+ // codebuddyAccountEnv 通过 extraEnv 重新注入,不靠继承。
137
+ "CODEBUDDY_SERVICE_PROXY_URL": true,
138
+ "CODEBUDDY_MCP_CONFIG": true,
134
139
  "HOME": false,
135
140
  "PATH": false,
136
- "CODEBUDDY_CONFIG_DIR": false,
141
+ "CODEBUDDY_CONFIG_DIR": true,
137
142
  }
138
143
  for name, want := range cases {
139
144
  if got := envDenied(name); got != want {
@@ -158,6 +158,9 @@ func TestEngineListModelsWithFakeCLI(t *testing.T) {
158
158
  // 隔离 HOME:codebuddy-ai 的扩展来源会扫 ~/.codebuddy-ai 与 ~/.codebuddy 的
159
159
  // local_storage 缓存,不隔离会吃到真机缓存导致断言失败。
160
160
  t.Setenv("HOME", t.TempDir())
161
+ // 同一道理,CODEBUDDY_CONFIG_DIR 一设,「只信该目录」的规则就生效,
162
+ // 本用例凭空搭的 HOME 缓存被绕过(见 TestCodeBuddyListModelsProductJSON 的注释)。
163
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
161
164
  cases := []struct {
162
165
  name string
163
166
  cliName string
@@ -486,6 +489,12 @@ func TestCodeBuddyListModelsAccConfig(t *testing.T) {
486
489
  func TestCodeBuddyListModelsProductJSON(t *testing.T) {
487
490
  // 隔离 HOME:扩展来源扫 ~/.codebuddy-ai 与 ~/.codebuddy 的缓存,不隔离会吃到真机缓存。
488
491
  t.Setenv("HOME", t.TempDir())
492
+ /* 还要清 CODEBUDDY_CONFIG_DIR:用户显式设了它时,`codebuddyModelCacheDirs` 只返回
493
+ 该目录下的 local_storage、**不再兜底共享 ~/.codebuddy**(产品行为是对的:显式
494
+ 配置就该独占)。但本用例全程用 HOME 凭空搭缓存,环境里一带这个变量,B/D2 段
495
+ 写的缓存就永远读不到,表现为「ai 只剩 product.json 的 4 条」「workbuddy ② 退回
496
+ --help」。实测本机 shell 就带着它(2026-10-21 定位,与代码无关)。 */
497
+ t.Setenv("CODEBUDDY_CONFIG_DIR", "")
489
498
 
490
499
  const aiHelp = `Options:
491
500
  --model <model> Model ID. Currently supported: (fast-model, balanced-model, primary-model, deep-model)
@@ -27,11 +27,32 @@ func TestRunCLIKillsProcessGroupOnTimeout(t *testing.T) {
27
27
  t.Fatal(err)
28
28
  }
29
29
 
30
- ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
30
+ ctx, cancel := context.WithCancel(context.Background())
31
31
  defer cancel()
32
+ done := make(chan error, 1)
33
+ go func() {
34
+ _, _, err := runCLI(ctx, cli)
35
+ done <- err
36
+ }()
37
+
38
+ // 先等 worker 真起来再触发取消:整包并发跑时 sh 启动可能慢于固定
39
+ // 超时窗口,直接按 800ms 计时会假红(marker 还没建就被杀了)。
40
+ deadline := time.Now().Add(10 * time.Second)
41
+ for {
42
+ if _, err := os.Stat(marker); err == nil {
43
+ break
44
+ }
45
+ if time.Now().After(deadline) {
46
+ cancel()
47
+ <-done
48
+ t.Fatal("marker should exist (worker never started)")
49
+ }
50
+ time.Sleep(10 * time.Millisecond)
51
+ }
52
+
32
53
  start := time.Now()
33
- _, _, err := runCLI(ctx, cli)
34
- if err == nil {
54
+ cancel()
55
+ if err := <-done; err == nil {
35
56
  t.Fatal("expected timeout error")
36
57
  }
37
58
  if elapsed := time.Since(start); elapsed > 5*time.Second {
@@ -137,6 +137,19 @@ const (
137
137
  // 是 item 生命周期,这里对齐的是渲染词表。前端 09 的 case "command" 用终端
138
138
  // 图标(SquareTerminal)渲染标记行。
139
139
  KindCommand StreamEventKind = "command"
140
+ // KindTasks 团队任务列表快照(2026-10-22):一张卡列出全部子任务 + 每行状态。
141
+ //
142
+ // Text 是 JSON(快照语义,每次任一子任务状态变化就整份重发):
143
+ //
144
+ // {"tasks":[{"id","title","role","kind","engine","model",
145
+ // "status","deps","note","session_id","run_id","output"}]}
146
+ //
147
+ // status 词表 pending / running / done / failed / skipped,渲染层映射成
148
+ // in_progress / completed / failed。
149
+ //
150
+ // 为什么不再走「每个子任务一张委派卡」:子任务已经是**独立会话**
151
+ //(2026-10-22 用户定稿),过程在自己的会话里看;主会话只需要一张进度总览。
152
+ KindTasks StreamEventKind = "tasks"
140
153
  )
141
154
 
142
155
  // StreamStatus 事件所描述的 item 状态 —— 对齐 agents-anywhere 的
@@ -172,6 +185,12 @@ type StreamEvent struct {
172
185
  // 落库/绑定 —— 否则下一轮追问会找不到锚点,只能退化成 `-c` 续接最近会话。
173
186
  // 引擎在每轮 result 行就能拿到 session_id(claude/codebuddy 的收尾行自带)。
174
187
  SessionID string
188
+ // SubSessionID 子会话 id(团队模式):这条事件所属**子任务**自己的引擎会话 id。
189
+ //
190
+ // 2026-10-22 起子任务是独立会话:它的事件写进**自己的**日志,SessionID 就是
191
+ // 子会话锚点,不再抹掉。这个字段只在主会话里出现 —— 任务列表卡用它把某一行
192
+ // 链到对应子会话(历史回放同样靠它复原链接)。
193
+ SubSessionID string
175
194
  // Ask 归一化后的「需要用户选择」请求(仅 KindAsk 携带;其余为 nil)。
176
195
  //
177
196
  // 有了它,调用方不必再分辨这次是 claude 的 tool_use 形态还是 control_request 形态
@@ -229,6 +248,20 @@ type StreamEvent struct {
229
248
  // Reason 根因摘要(仅 KindTurnFailed 携带),与 -o json 的失败 envelope 同源
230
249
  //(同走 agent.ReasonOf),同一次失败在事件流与 stderr 两处的说法必然一致。
231
250
  Reason string
251
+
252
+ /* Parent 父委派 id(2026-10-21,团队模式)。
253
+ *
254
+ * 语义与 claude 系原生的 `parent_tool_use_id` 完全一致:非空表示「这条事件发生在
255
+ * id 为 Parent 的委派卡里面」。客户端画布据此把思考 / 正文 / 工具事件挂进父卡的
256
+ * 嵌套区(见 client-ui/shared/conv/09-stream-apply.js 的 evParent 分支)。
257
+ *
258
+ * ⚠️ 2026-10-22 起团队模式**不再**用它:子任务是独立会话,各自的事件写进自己的
259
+ * 日志,主会话只发 KindTasks 快照。字段保留给引擎原生的子代理委派(claude 的
260
+ * parent_tool_use_id)与旧日志回放。
261
+ *
262
+ * 空串 = 主会话自己的事件(omitempty,老消费者字段集不变)。
263
+ */
264
+ Parent string
232
265
  }
233
266
 
234
267
  // ToolCall 一次完整的工具调用记录(用于收尾汇总)。