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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2443 -178
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- 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 +1 -1
|
@@ -24,7 +24,10 @@ import "sort"
|
|
|
24
24
|
//
|
|
25
25
|
// 递增规则:只增字段(向后兼容)不升版本;一旦有字段改名、语义变化或删除,
|
|
26
26
|
// 必须升版本 —— 老客户端会因版本不匹配而拒绝启动,这正是我们想要的结果。
|
|
27
|
-
|
|
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
|
}
|
package/internal/agent/claude.go
CHANGED
|
@@ -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:
|
|
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())
|
package/internal/agent/dsh.go
CHANGED
|
@@ -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
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
235
|
+
_, err := e.Complete(context.Background(), Request{
|
|
234
236
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
235
237
|
})
|
|
236
|
-
if err
|
|
237
|
-
t.
|
|
238
|
+
if err == nil {
|
|
239
|
+
t.Fatal("裸文本(无 result 行)必须报错")
|
|
238
240
|
}
|
|
239
|
-
if
|
|
240
|
-
t.Errorf("
|
|
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
|
|
319
|
+
func TestCodeBuddyResultLineWinsOverAssistant(t *testing.T) {
|
|
320
|
+
// 协议是逐行 stream-json:assistant 聚合行只是过程快照,全文以 result 行为准。
|
|
318
321
|
cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
|
|
319
|
-
echo '
|
|
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
|
|
338
|
+
func TestCodeBuddyEmptyResultFallsBackToDeltas(t *testing.T) {
|
|
339
|
+
// result 行正文为空时,finalizeStreamText 回落到增量拼接。
|
|
334
340
|
cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
|
|
335
|
-
echo '
|
|
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 != "
|
|
345
|
-
t.Errorf("Text = %q want
|
|
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
|
|
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
|
-
//
|
|
673
|
-
func
|
|
686
|
+
// 只出现在 init 行的 session_id,Complete 也要挖出来回传(续接链路依赖它)。
|
|
687
|
+
func TestCodeBuddyCompleteExtractsSessionFromInitLine(t *testing.T) {
|
|
674
688
|
cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
|
|
675
|
-
echo '
|
|
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
|
-
//
|
|
103
|
-
|
|
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":
|
|
129
|
-
"SERVER__HOST":
|
|
130
|
-
"SERVER__ANYTHING":
|
|
131
|
-
"CODEBUDDY_SESSION_ID":
|
|
132
|
-
|
|
133
|
-
|
|
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":
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/internal/agent/stream.go
CHANGED
|
@@ -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 一次完整的工具调用记录(用于收尾汇总)。
|