magic-agent 2.4.3 → 2.6.0
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 +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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 +8 -2
package/internal/agent/models.go
CHANGED
|
@@ -11,7 +11,7 @@ package agent
|
|
|
11
11
|
// codebuddy 扩展来源链(两端同链 ③):客户端合并配置缓存 acc-product-config
|
|
12
12
|
// → 远程配置缓存 ∪ App 包 product.json → `codebuddy --help` 的
|
|
13
13
|
// "Currently supported: (...)" 清单(末级兜底)
|
|
14
|
-
// trae `
|
|
14
|
+
// trae `traex models --json`
|
|
15
15
|
// llm `llm models`(用户经 llm CLI 自注册的模型)
|
|
16
16
|
// codex `codex debug models`(raw model catalog)
|
|
17
17
|
// openclaw `openclaw models list --json`
|
|
@@ -94,6 +94,22 @@ func ModelCreditListOf(e Engine) ModelCreditLister {
|
|
|
94
94
|
return l
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// ModelVendorLister 可选接口:引擎能给出各模型的**供应商展示名**
|
|
98
|
+
// (只用于选择器的第二行,不参与 -m / 落库)。键与 ListModels 的模型 id 同形;
|
|
99
|
+
// 拿不到(或引擎不提供)返回 nil —— 界面不显示第二行。
|
|
100
|
+
//
|
|
101
|
+
// 现状只有 codebuddy 系实现:值取自它自己 models.json 里 `vendor` 字段
|
|
102
|
+
// (空 / "Custom" 这类无信息量的值会被过滤,见 CLIModelVendors)。
|
|
103
|
+
type ModelVendorLister interface {
|
|
104
|
+
ModelVendors(ctx context.Context) map[string]string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ModelVendorListOf 返回引擎的 ModelVendorLister 实现(未实现返回 nil)。
|
|
108
|
+
func ModelVendorListOf(e Engine) ModelVendorLister {
|
|
109
|
+
l, _ := e.(ModelVendorLister)
|
|
110
|
+
return l
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
// parseCreditMultiplier 把客户端计费字段规范化成纯倍率数字字符串:
|
|
98
114
|
//
|
|
99
115
|
// "x2.20 credits" -> "2.20" "x0.77" -> "0.77" " X1.5 CREDITS " -> "1.5"
|
|
@@ -511,6 +527,42 @@ func remoteConfigCacheCatalog(localStorageDir string) ([]string, map[string]stri
|
|
|
511
527
|
return ids, credits
|
|
512
528
|
}
|
|
513
529
|
|
|
530
|
+
// remoteCacheCatalogWithSelector 用「本账号 CLI 自己下发的远程配置缓存」当注册表,
|
|
531
|
+
// 并用**同一目录**里的客户端选择器清单(agents[].models)过滤,产出与 acc 缓存
|
|
532
|
+
// 同语义的模型清单 + 倍率表。
|
|
533
|
+
//
|
|
534
|
+
// 为什么需要它:codebuddy-ai 的账号已与桌面 App 物理隔离(独立 homedir),App 的
|
|
535
|
+
// acc 缓存(~/.workbuddy-ai)属于另一个账号、只有 5 条模型,会把清单钉死在 2 个
|
|
536
|
+
// custom-local。隔离后的引擎应改用 CLI 自己拉到的配置(<config_dir>/local_storage)。
|
|
537
|
+
//
|
|
538
|
+
// 过滤语义与 readAccCatalog 一致:custom-local:* 一律保留(客户端也展示,只是不计
|
|
539
|
+
// 倍率),具名模型只在选择器清单里才展示;缓存里没有选择器清单时不过滤(宁可多不可少)。
|
|
540
|
+
func remoteCacheCatalogWithSelector(localStorageDir string) ([]string, map[string]string) {
|
|
541
|
+
ids, credits := remoteConfigCacheCatalog(localStorageDir)
|
|
542
|
+
if len(ids) == 0 {
|
|
543
|
+
return nil, nil
|
|
544
|
+
}
|
|
545
|
+
allow, ok := agentSelectorModels(localStorageDir)
|
|
546
|
+
if !ok || len(allow) == 0 {
|
|
547
|
+
return ids, credits
|
|
548
|
+
}
|
|
549
|
+
on := make(map[string]bool, len(allow))
|
|
550
|
+
for _, id := range allow {
|
|
551
|
+
on[id] = true
|
|
552
|
+
}
|
|
553
|
+
kept := make([]string, 0, len(ids))
|
|
554
|
+
for _, id := range ids {
|
|
555
|
+
if strings.HasPrefix(id, "custom-local:") || on[id] {
|
|
556
|
+
kept = append(kept, id)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if len(kept) == 0 {
|
|
560
|
+
// 选择器清单与注册表完全对不上(配置漂移):退回全量,别给出空清单。
|
|
561
|
+
return ids, credits
|
|
562
|
+
}
|
|
563
|
+
return kept, credits
|
|
564
|
+
}
|
|
565
|
+
|
|
514
566
|
// agentSelectorModels 从远程配置缓存里抽**客户端模型选择器真正展示的清单**
|
|
515
567
|
// —— agents[].models 数组:
|
|
516
568
|
//
|
|
@@ -157,7 +157,10 @@ func TestJSONModelsArray(t *testing.T) {
|
|
|
157
157
|
func TestEngineListModelsWithFakeCLI(t *testing.T) {
|
|
158
158
|
// 隔离 HOME:codebuddy-ai 的扩展来源会扫 ~/.codebuddy-ai 与 ~/.codebuddy 的
|
|
159
159
|
// local_storage 缓存,不隔离会吃到真机缓存导致断言失败。
|
|
160
|
-
|
|
160
|
+
// Windows 上 os.UserHomeDir() 读 USERPROFILE,两者都要设。
|
|
161
|
+
home := t.TempDir()
|
|
162
|
+
t.Setenv("HOME", home)
|
|
163
|
+
t.Setenv("USERPROFILE", home)
|
|
161
164
|
// 同一道理,CODEBUDDY_CONFIG_DIR 一设,「只信该目录」的规则就生效,
|
|
162
165
|
// 本用例凭空搭的 HOME 缓存被绕过(见 TestCodeBuddyListModelsProductJSON 的注释)。
|
|
163
166
|
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
@@ -242,7 +245,9 @@ func TestEngineListModelsWithFakeCLI(t *testing.T) {
|
|
|
242
245
|
func TestEngineListModelsFailures(t *testing.T) {
|
|
243
246
|
// 隔离 HOME 与配置目录:codebuddy 的清单现在先读扩展来源(acc 缓存 /
|
|
244
247
|
// 远程配置缓存),不隔离会吃到真机的 ~/.workbuddy 缓存而"意外成功"。
|
|
245
|
-
|
|
248
|
+
home := t.TempDir()
|
|
249
|
+
t.Setenv("HOME", home)
|
|
250
|
+
t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir 读 USERPROFILE
|
|
246
251
|
t.Setenv("CODEBUDDY_CONFIG_DIR", filepath.Join(t.TempDir(), "cfg"))
|
|
247
252
|
|
|
248
253
|
// 三级来源全空、--help 里也没有 "Currently supported" → ErrNoModelSource 语义的错误。
|
|
@@ -389,7 +394,10 @@ func TestReadRemoteConfigCacheModels(t *testing.T) {
|
|
|
389
394
|
// codebuddy-ai 首选 ~/.workbuddy-ai/cache/acc-product-config-v*.json(客户端模型
|
|
390
395
|
// 选择器同源);多版本取 mtime 最新;缺失时回退「远程配置缓存 ∪ product.json」。
|
|
391
396
|
func TestCodeBuddyListModelsAccConfig(t *testing.T) {
|
|
392
|
-
|
|
397
|
+
home := t.TempDir()
|
|
398
|
+
// Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机缓存。
|
|
399
|
+
t.Setenv("HOME", home)
|
|
400
|
+
t.Setenv("USERPROFILE", home)
|
|
393
401
|
cacheDir := filepath.Join(os.Getenv("HOME"), ".workbuddy-ai", "cache")
|
|
394
402
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
|
395
403
|
t.Fatal(err)
|
|
@@ -488,7 +496,10 @@ func TestCodeBuddyListModelsAccConfig(t *testing.T) {
|
|
|
488
496
|
// 2026-09-28 起 codebuddy(WorkBuddy 端)也启用扩展链,故 D 段与 AI 端同构。
|
|
489
497
|
func TestCodeBuddyListModelsProductJSON(t *testing.T) {
|
|
490
498
|
// 隔离 HOME:扩展来源扫 ~/.codebuddy-ai 与 ~/.codebuddy 的缓存,不隔离会吃到真机缓存。
|
|
491
|
-
|
|
499
|
+
// Windows 上 os.UserHomeDir() 读 USERPROFILE,两者都要设。
|
|
500
|
+
home := t.TempDir()
|
|
501
|
+
t.Setenv("HOME", home)
|
|
502
|
+
t.Setenv("USERPROFILE", home)
|
|
492
503
|
/* 还要清 CODEBUDDY_CONFIG_DIR:用户显式设了它时,`codebuddyModelCacheDirs` 只返回
|
|
493
504
|
该目录下的 local_storage、**不再兜底共享 ~/.codebuddy**(产品行为是对的:显式
|
|
494
505
|
配置就该独占)。但本用例全程用 HOME 凭空搭缓存,环境里一带这个变量,B/D2 段
|
|
@@ -646,7 +657,10 @@ func TestCodeBuddyListModelsProductJSON(t *testing.T) {
|
|
|
646
657
|
// 此前它只按 --help 出清单、倍率却按 acc 缓存取,同一引擎两个字段读自两个文件)。
|
|
647
658
|
// 同时验证清单与倍率全程不启动 CLI(acc 命中时只读缓存文件)。
|
|
648
659
|
func TestCodeBuddyModelCredits(t *testing.T) {
|
|
649
|
-
|
|
660
|
+
home := t.TempDir()
|
|
661
|
+
// Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机缓存。
|
|
662
|
+
t.Setenv("HOME", home)
|
|
663
|
+
t.Setenv("USERPROFILE", home)
|
|
650
664
|
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
651
665
|
|
|
652
666
|
const help = `Options:
|
|
@@ -777,6 +791,7 @@ func appPkgCLI(t *testing.T, helpOut, productJSON string) (bin, argsLog string)
|
|
|
777
791
|
func TestClaudeListModelsFromConfig(t *testing.T) {
|
|
778
792
|
home := t.TempDir()
|
|
779
793
|
t.Setenv("HOME", home)
|
|
794
|
+
t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir 读 USERPROFILE
|
|
780
795
|
t.Setenv("CLAUDE_CONFIG_DIR", "")
|
|
781
796
|
|
|
782
797
|
dir := filepath.Join(home, ".claude")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package agent
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"os"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// PIDAlive 的两条硬要求(2026-10-24「返回会话列表很忙」的回归闸门):
|
|
12
|
+
//
|
|
13
|
+
// ① 判得准:自己活着、不存在的 pid 说不在;
|
|
14
|
+
// ② **不许起子进程** —— 原实现 `tasklist /FI "PID eq N"` 本机实测每次 6.7–7.5 秒,
|
|
15
|
+
// `--sessions` 对每条活着的记录问一次 ⇒ 列表 5–10 秒。这里用 50 次调用的总耗时
|
|
16
|
+
// 卡住回归:走 OpenProcess 是微秒级,一旦退回起进程,这条会**红**(而不是悄悄变慢)。
|
|
17
|
+
func TestPIDAliveSelfAndBogus(t *testing.T) {
|
|
18
|
+
if !PIDAlive(os.Getpid()) {
|
|
19
|
+
t.Fatal("自己的 pid 应判为活着")
|
|
20
|
+
}
|
|
21
|
+
if PIDAlive(1 << 30) {
|
|
22
|
+
t.Fatal("不存在的 pid 应判为不在")
|
|
23
|
+
}
|
|
24
|
+
if PIDAlive(0) || PIDAlive(-1) {
|
|
25
|
+
t.Fatal("非正 pid 应判为不在")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestPIDAliveIsFast(t *testing.T) {
|
|
30
|
+
self := os.Getpid()
|
|
31
|
+
st := time.Now()
|
|
32
|
+
for i := 0; i < 50; i++ {
|
|
33
|
+
if !PIDAlive(self) {
|
|
34
|
+
t.Fatal("自己的 pid 应判为活着")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if d := time.Since(st); d > 2*time.Second {
|
|
38
|
+
t.Fatalf("50 次 PIDAlive 用了 %v —— 又回到起子进程(tasklist)的实现了吗?", d)
|
|
39
|
+
}
|
|
40
|
+
}
|
package/internal/agent/runcmd.go
CHANGED
|
@@ -51,6 +51,22 @@ func notifySpawn(ctx context.Context, pid int) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// ConfigureProcGroup 让子进程进入**独立进程组**(供本仓之外的调用方使用)。
|
|
55
|
+
//
|
|
56
|
+
// 目前是 internal/client 的脚本管理(见 scripts.go):脚本被启动后要能被
|
|
57
|
+
// **整组**停掉 —— unix 上 kill(-pid) 一把带走全部后代,Windows 上 taskkill /T
|
|
58
|
+
// 带走整棵进程树。若不设这一层,脚本 spawn 出来的 worker 会变孤儿进程,
|
|
59
|
+
// 停脚本只停掉了壳。
|
|
60
|
+
//
|
|
61
|
+
// 各平台的实现分别在 runcmd_unix.go(Setpgid)与 runcmd_windows.go
|
|
62
|
+
// (CREATE_NEW_PROCESS_GROUP)。
|
|
63
|
+
func ConfigureProcGroup(cmd *exec.Cmd) {
|
|
64
|
+
if cmd == nil {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
configureProcAttr(cmd)
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
// runCLI 在独立进程组里执行 bin args,返回 (stdout, stderr, err)。
|
|
55
71
|
// 超时/取消时杀掉整个进程组(含 CLI 的全部后代进程)。
|
|
56
72
|
func runCLI(ctx context.Context, bin string, args ...string) (string, string, error) {
|
|
@@ -73,6 +89,13 @@ func runCLIEnv(ctx context.Context, extra []string, bin string, args ...string)
|
|
|
73
89
|
|
|
74
90
|
// runCLIEnvIn = runCLIIn + runCLIEnv(工作目录 + 追加环境变量)。
|
|
75
91
|
func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, args ...string) (string, string, error) {
|
|
92
|
+
return runCLIEnvInStdin(ctx, dir, extra, "", bin, args...)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// runCLIEnvInStdin 同 runCLIEnvIn,额外把 stdinText 喂给子进程 stdin(空 = 保持旧行为,
|
|
96
|
+
// stdin 是空的)。dsh 的 headless 在 Windows 上只能用这条路传多行提示词(见 dsh.go::
|
|
97
|
+
// dshHeadlessPromptArg),其它引擎都不用。
|
|
98
|
+
func runCLIEnvInStdin(ctx context.Context, dir string, extra []string, stdinText, bin string, args ...string) (string, string, error) {
|
|
76
99
|
// launchWrap 与流式路径同源:.cmd/.bat 在 Windows 上不能直接 exec,
|
|
77
100
|
// 参数会被 cmd.exe 拆坏(实测含换行的提示词整段丢失)。unix 上原样透传。
|
|
78
101
|
spec := launchWrap(bin, args)
|
|
@@ -80,6 +103,9 @@ func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, ar
|
|
|
80
103
|
if dir != "" {
|
|
81
104
|
cmd.Dir = dir
|
|
82
105
|
}
|
|
106
|
+
if stdinText != "" {
|
|
107
|
+
cmd.Stdin = strings.NewReader(stdinText)
|
|
108
|
+
}
|
|
83
109
|
configureProcAttr(cmd)
|
|
84
110
|
|
|
85
111
|
/* stdout/stderr 用**自建管道 + 自己 drain**,不直挂 bytes.Buffer:
|
|
@@ -16,8 +16,8 @@ import (
|
|
|
16
16
|
"errors"
|
|
17
17
|
"os/exec"
|
|
18
18
|
"strconv"
|
|
19
|
-
"strings"
|
|
20
19
|
"syscall"
|
|
20
|
+
"unsafe"
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
// createNewProcessGroup 对应 Windows CREATE_NEW_PROCESS_GROUP 标志。
|
|
@@ -53,14 +53,45 @@ func KillPID(pid int, group bool) error {
|
|
|
53
53
|
return exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/*
|
|
57
|
+
── PIDAlive:直接问内核,**绝不起子进程** ──
|
|
58
|
+
|
|
59
|
+
2026-10-24 实测:原来的 `tasklist /FI "PID eq N"` 每次要 **6.7–7.5 秒**
|
|
60
|
+
(控制台进程 + Defender 扫描)。而 `--sessions` 会对每条**活着的**记录问一次
|
|
61
|
+
(normalizeDead)——有团队任务在跑时就是 5–10 秒,手机端「返回会话列表很忙」
|
|
62
|
+
的真正根因(插件侧 exec 的 3 秒是另一层,见 client/corecmd.go)。
|
|
63
|
+
OpenProcess + GetExitCodeProcess 是纯内核调用,微秒级。
|
|
64
|
+
*/
|
|
65
|
+
var (
|
|
66
|
+
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
67
|
+
procOpenProcess = kernel32.NewProc("OpenProcess")
|
|
68
|
+
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const (
|
|
72
|
+
// processQueryLimitedInformation 只要查询权限(Vista+),比 QUERY_INFORMATION 更容易拿到。
|
|
73
|
+
processQueryLimitedInformation = 0x1000
|
|
74
|
+
// stillActive STILL_ACTIVE:进程还活着时 GetExitCodeProcess 返回它。
|
|
75
|
+
stillActive = 259
|
|
76
|
+
// errorAccessDenied 打不开句柄(受保护 / 提权进程):进程**存在**,但查不了状态。
|
|
77
|
+
errorAccessDenied = syscall.Errno(5)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// PIDAlive 该 pid 是否还在(OpenProcess / GetExitCodeProcess,无子进程)。
|
|
57
81
|
func PIDAlive(pid int) bool {
|
|
58
82
|
if pid <= 0 {
|
|
59
83
|
return false
|
|
60
84
|
}
|
|
61
|
-
|
|
62
|
-
if
|
|
85
|
+
h, _, callErr := procOpenProcess.Call(uintptr(processQueryLimitedInformation), 0, uintptr(pid))
|
|
86
|
+
if h == 0 {
|
|
87
|
+
/* 打不开:ACCESS_DENIED 说明进程在(只是权限不够)→ 当活着,
|
|
88
|
+
避免把受保护进程误判成 gone;其它错误(INVALID_PARAMETER)才是真没了。 */
|
|
89
|
+
return errors.Is(callErr, errorAccessDenied)
|
|
90
|
+
}
|
|
91
|
+
defer func() { _ = syscall.CloseHandle(syscall.Handle(h)) }()
|
|
92
|
+
var code uint32
|
|
93
|
+
if r, _, _ := procGetExitCodeProcess.Call(h, uintptr(unsafe.Pointer(&code))); r == 0 {
|
|
63
94
|
return false
|
|
64
95
|
}
|
|
65
|
-
return
|
|
96
|
+
return code == stillActive
|
|
66
97
|
}
|
package/internal/agent/stream.go
CHANGED
|
@@ -27,11 +27,9 @@ package agent
|
|
|
27
27
|
// 收尾 {"type":"result","subtype":"success","result":"全文",...}
|
|
28
28
|
//
|
|
29
29
|
// trae:
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// trae 的 stream-json 协议**不暴露独立工具事件通道**,KindToolUse/KindToolResult
|
|
34
|
-
// 在 trae 下恒为空——这是协议限制,不是解析缺陷。
|
|
30
|
+
// traex exec --json,Codex 同源事件流(thread.started / item.* /
|
|
31
|
+
// turn.completed),解析在 trae_exec.go;正文、思考与工具都在 item 事件里。
|
|
32
|
+
// 无逐 token 正文增量:正文在 item.completed(agent_message) 整段给出。
|
|
35
33
|
|
|
36
34
|
import (
|
|
37
35
|
"bufio"
|
|
@@ -150,6 +148,15 @@ const (
|
|
|
150
148
|
// 为什么不再走「每个子任务一张委派卡」:子任务已经是**独立会话**
|
|
151
149
|
//(2026-10-22 用户定稿),过程在自己的会话里看;主会话只需要一张进度总览。
|
|
152
150
|
KindTasks StreamEventKind = "tasks"
|
|
151
|
+
// KindFallback 主备降级(2026-10-26):主引擎失败 / 只有过程没有正文,本轮已由
|
|
152
|
+
// 备用引擎(或同引擎的备用模型)接上。StreamEvent.Fallback 带 from/to 两端。
|
|
153
|
+
//
|
|
154
|
+
// 为什么要有它:以前降级只发一条 notice(人话一行),客户端无法**结构化地**知道
|
|
155
|
+
// 「这条回答其实是我的备用模型给的」—— 额度用尽时用户配的主备是否真的生效,
|
|
156
|
+
// 界面上没有任何可认的证据(2026-10-26 报障:「主备模型还没真的发挥作用」,
|
|
157
|
+
// 作业记录里主备两条都配了、却查不到降级发生过没有)。
|
|
158
|
+
// 这条帧紧跟降级之后、备用引擎第一条事件之前;没降级时的原因走 `notice`。
|
|
159
|
+
KindFallback StreamEventKind = "fallback"
|
|
153
160
|
)
|
|
154
161
|
|
|
155
162
|
// StreamStatus 事件所描述的 item 状态 —— 对齐 agents-anywhere 的
|
|
@@ -196,6 +203,8 @@ type StreamEvent struct {
|
|
|
196
203
|
// 有了它,调用方不必再分辨这次是 claude 的 tool_use 形态还是 control_request 形态
|
|
197
204
|
//(两种形态在 wire 上字段完全不同,见 ask.go 的 ParseAskLine)。
|
|
198
205
|
Ask *AskRequest
|
|
206
|
+
// Fallback 降级详情(仅 KindFallback 携带;其余为 nil)。
|
|
207
|
+
Fallback *FallbackEvent
|
|
199
208
|
|
|
200
209
|
/* ── 状态收敛字段(2026-10-02,对齐 agents-anywhere 的 timeline item)──
|
|
201
210
|
*
|
|
@@ -312,7 +321,8 @@ func streamArgsFor(e Engine) []string {
|
|
|
312
321
|
case *CodeBuddyAIEngine:
|
|
313
322
|
return []string{"stream-json"}
|
|
314
323
|
case *TraeEngine:
|
|
315
|
-
|
|
324
|
+
// Codex 同源的 `exec --json` JSONL 事件流(trae_exec.go)。
|
|
325
|
+
return []string{"jsonl"}
|
|
316
326
|
case *LLMEngine:
|
|
317
327
|
return []string{"stream"} // llm prompt 默认流式(纯文本 stdout)
|
|
318
328
|
case *DshEngine:
|
|
@@ -526,10 +536,19 @@ func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []strin
|
|
|
526
536
|
// extraEnv 追加到继承环境之后(重复 key 取后者)—— dsh 的权限档位就是这么下发的
|
|
527
537
|
// (它没有逐调用的权限 flag,只有 DSH_PERMISSION_MODE,见 permission.go)。
|
|
528
538
|
func runStreamLinesIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, streamStdout bool, onLine func(line string) error) (string, string, error) {
|
|
539
|
+
return runStreamLinesInStdin(ctx, dir, extraEnv, "", bin, args, streamStdout, onLine)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// runStreamLinesInStdin 同 runStreamLinesIn,额外把 stdinText 喂给子进程 stdin
|
|
543
|
+
// (空 = 旧行为)。dsh headless 在 Windows 上靠它传多行提示词,见 dshHeadlessPromptArg。
|
|
544
|
+
func runStreamLinesInStdin(ctx context.Context, dir string, extraEnv []string, stdinText, bin string, args []string, streamStdout bool, onLine func(line string) error) (string, string, error) {
|
|
529
545
|
cmd := newStreamCmdIn(dir, bin, args)
|
|
530
546
|
if len(extraEnv) > 0 {
|
|
531
547
|
cmd.Env = append(cmd.Env, extraEnv...)
|
|
532
548
|
}
|
|
549
|
+
if stdinText != "" {
|
|
550
|
+
cmd.Stdin = strings.NewReader(stdinText)
|
|
551
|
+
}
|
|
533
552
|
|
|
534
553
|
stdoutR, stdoutW, err := os.Pipe()
|
|
535
554
|
if err != nil {
|
|
@@ -748,6 +767,12 @@ type streamAccumulator struct {
|
|
|
748
767
|
// 万一引擎推了多条 max_tokens 也只报一次(与 askSeen / imgSeen 同一手法)。
|
|
749
768
|
maxTokensNoticed bool
|
|
750
769
|
|
|
770
|
+
// toolMissingSeen 已提示过「工具不在当前工具集」的工具名(2026-10-24)。
|
|
771
|
+
//
|
|
772
|
+
// 同一句 CLI 错误可能走多条路各出现一次(增量 delta + assistant 聚合行 +
|
|
773
|
+
// result 行),提示只该出现一次 —— 与 askSeen / imgSeen 同一手法。
|
|
774
|
+
toolMissingSeen map[string]bool
|
|
775
|
+
|
|
751
776
|
/* ── 状态收敛状态机(2026-10-02,见 StreamEvent 的状态收敛字段注释)──
|
|
752
777
|
*
|
|
753
778
|
* agents-anywhere 的 ClaudeStreamAccumulator(runtimes/claude/timeline/stream.py)
|
|
@@ -931,6 +956,16 @@ func (a *streamAccumulator) emitTextDelta(index int, kind StreamEventKind, text
|
|
|
931
956
|
if text == "" {
|
|
932
957
|
return
|
|
933
958
|
}
|
|
959
|
+
/* 「工具不存在」是 CLI 注入的**正文**(见 stripToolMissingLines 的说明):
|
|
960
|
+
摘掉并改发过程提示,别让一句英文报错混进回答。 */
|
|
961
|
+
if kind == KindText {
|
|
962
|
+
var missing []string
|
|
963
|
+
text, missing = stripToolMissingLines(text)
|
|
964
|
+
a.noteToolMissing(missing)
|
|
965
|
+
if text == "" {
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
}
|
|
934
969
|
if a.textBlocks == nil {
|
|
935
970
|
a.textBlocks = make(map[int]string)
|
|
936
971
|
}
|
|
@@ -964,6 +999,15 @@ func (a *streamAccumulator) emitTextDelta(index int, kind StreamEventKind, text
|
|
|
964
999
|
// 所以先与已累积正文做前缀比对:完全一致静默收敛、多尾巴只补差量。
|
|
965
1000
|
// 旧版 CLI(聚合行是正文唯一来源)行为不变:accumulated 为空时原样发。
|
|
966
1001
|
func (a *streamAccumulator) emitAggregateText(full string) {
|
|
1002
|
+
if full == "" {
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
/* 过滤必须放在**前缀比对之前**:聚合行是整条消息全文,若把错误行留在
|
|
1006
|
+
里面,与已发增量(已过滤)永远对不上 → 走 default 分支整段重发,
|
|
1007
|
+
正文里又会多出一份全文。 */
|
|
1008
|
+
var missing []string
|
|
1009
|
+
full, missing = stripToolMissingLines(full)
|
|
1010
|
+
a.noteToolMissing(missing)
|
|
967
1011
|
if full == "" {
|
|
968
1012
|
return
|
|
969
1013
|
}
|
|
@@ -1408,6 +1452,88 @@ func extractToolResultText(raw json.RawMessage) string {
|
|
|
1408
1452
|
return ""
|
|
1409
1453
|
}
|
|
1410
1454
|
|
|
1455
|
+
/* ── 「工具不在当前工具集」的 CLI 错误行(2026-10-24)──────────────
|
|
1456
|
+
*
|
|
1457
|
+
* 现象(真机抓包,codebuddy + hy3):模型凭训练记忆调用 AskUserQuestion,
|
|
1458
|
+
* codebuddy CLI 没有这个工具,于是把错误当成**正文 text 块**推回来:
|
|
1459
|
+
*
|
|
1460
|
+
* {"type":"assistant","message":{"content":[{"type":"text",
|
|
1461
|
+
* "text":"Tool \"AskUserQuestion\" does not exist in the current tool set."}]}}
|
|
1462
|
+
*
|
|
1463
|
+
* 不处理的话这句话被拼进正文(用户看到「一段回答中间夹着这句英文」),
|
|
1464
|
+
* 历史回放同样脏。这里把它从正文里摘掉,改发一条 KindNotice(工具失败提示),
|
|
1465
|
+
* 正文只留模型真正说的话。
|
|
1466
|
+
*
|
|
1467
|
+
* 只认这一句固定模板(CLI 自己注入的错误,不可能是模型正文);模型正常讨论
|
|
1468
|
+
* 工具时不会逐字写成这个形状。 */
|
|
1469
|
+
const (
|
|
1470
|
+
toolMissingPrefix = `Tool "`
|
|
1471
|
+
toolMissingSuffix = ` does not exist in the current tool set.`
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
// stripToolMissingLines 从正文里摘掉 CLI 注入的「工具不存在」错误行。
|
|
1475
|
+
// 返回清洗后的正文与被摘掉的工具名(调用方据此发提示)。
|
|
1476
|
+
func stripToolMissingLines(s string) (string, []string) {
|
|
1477
|
+
if !strings.Contains(s, toolMissingPrefix) {
|
|
1478
|
+
return s, nil
|
|
1479
|
+
}
|
|
1480
|
+
var (
|
|
1481
|
+
b strings.Builder
|
|
1482
|
+
tools []string
|
|
1483
|
+
)
|
|
1484
|
+
for {
|
|
1485
|
+
i := strings.Index(s, toolMissingPrefix)
|
|
1486
|
+
if i < 0 {
|
|
1487
|
+
break
|
|
1488
|
+
}
|
|
1489
|
+
rest := s[i+len(toolMissingPrefix):]
|
|
1490
|
+
j := strings.IndexByte(rest, '"')
|
|
1491
|
+
if j <= 0 {
|
|
1492
|
+
break
|
|
1493
|
+
}
|
|
1494
|
+
name := rest[:j]
|
|
1495
|
+
after := rest[j+1:]
|
|
1496
|
+
if !strings.HasPrefix(after, toolMissingSuffix) {
|
|
1497
|
+
// 只是正文里恰好出现了同样的开头 → 原样留下,继续往后找。
|
|
1498
|
+
b.WriteString(s[:i+len(toolMissingPrefix)])
|
|
1499
|
+
s = rest
|
|
1500
|
+
continue
|
|
1501
|
+
}
|
|
1502
|
+
b.WriteString(s[:i])
|
|
1503
|
+
tools = append(tools, name)
|
|
1504
|
+
s = after[len(toolMissingSuffix):]
|
|
1505
|
+
// 摘掉整行:两侧各吞掉一个换行,避免正文里多出空行。
|
|
1506
|
+
left := b.String()
|
|
1507
|
+
if strings.HasSuffix(left, "\n") && strings.HasPrefix(s, "\n") {
|
|
1508
|
+
s = s[1:]
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
b.WriteString(s)
|
|
1512
|
+
if len(tools) == 0 {
|
|
1513
|
+
// 只是正文里出现了同样的开头:原样返回(b 就是原文,不能返回被
|
|
1514
|
+
// 拆过的 s —— 那会丢掉前面已写进 b 的部分)。
|
|
1515
|
+
return b.String(), nil
|
|
1516
|
+
}
|
|
1517
|
+
return strings.Trim(b.String(), "\n"), tools
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// noteToolMissing 为被摘掉的错误行发一条过程提示(同一工具名只报一次)。
|
|
1521
|
+
func (a *streamAccumulator) noteToolMissing(names []string) {
|
|
1522
|
+
if len(names) == 0 {
|
|
1523
|
+
return
|
|
1524
|
+
}
|
|
1525
|
+
if a.toolMissingSeen == nil {
|
|
1526
|
+
a.toolMissingSeen = make(map[string]bool)
|
|
1527
|
+
}
|
|
1528
|
+
for _, n := range names {
|
|
1529
|
+
if a.toolMissingSeen[n] {
|
|
1530
|
+
continue
|
|
1531
|
+
}
|
|
1532
|
+
a.toolMissingSeen[n] = true
|
|
1533
|
+
a.emit(KindNotice, fmt.Sprintf("工具 %q 不在当前工具集,本次调用已忽略", n))
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1411
1537
|
// assistantLineText 从 assistant 聚合行里取出纯文本正文。
|
|
1412
1538
|
//
|
|
1413
1539
|
// 行形状(claude / codebuddy 同族):
|
|
@@ -1551,6 +1677,10 @@ func (a *streamAccumulator) emitTurnEnd(text, sessionID string) {
|
|
|
1551
1677
|
if a.OnEvent == nil {
|
|
1552
1678
|
return
|
|
1553
1679
|
}
|
|
1680
|
+
// result 行也可能带着同一条 CLI 错误(与增量路重复出现时 noteToolMissing 去重)。
|
|
1681
|
+
var missing []string
|
|
1682
|
+
text, missing = stripToolMissingLines(text)
|
|
1683
|
+
a.noteToolMissing(missing)
|
|
1554
1684
|
if a.SanitizeTurnText != nil {
|
|
1555
1685
|
text = a.SanitizeTurnText(text)
|
|
1556
1686
|
}
|
|
@@ -5,7 +5,7 @@ package agent
|
|
|
5
5
|
// 用 fake CLI 脚本回放三家 CLI 的真实 NDJSON 样本(2026-09-15 实测抓包),
|
|
6
6
|
// 验证:
|
|
7
7
|
// - claude/codebuddy:thinking_delta / text_delta 解析与转发
|
|
8
|
-
// - trae:
|
|
8
|
+
// - trae 2.0:exec --json 的 JSONL(reasoning / command_execution / agent_message)
|
|
9
9
|
// - result 收尾行:全文以 result.result 为准
|
|
10
10
|
// - 流中断(无 result 行)→ 报错
|
|
11
11
|
// - 超时杀进程组
|
|
@@ -150,14 +150,16 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"<user_quer
|
|
|
150
150
|
|
|
151
151
|
// ── trae 流式 ─────────────────────────────────────────────────
|
|
152
152
|
|
|
153
|
-
func
|
|
154
|
-
//
|
|
153
|
+
func TestTraeStreamExecJSON(t *testing.T) {
|
|
154
|
+
// 2.0:Codex 同源 JSONL(reasoning / command_execution / agent_message)。
|
|
155
155
|
cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
printf '%s\n' '{"type":"thread.started","thread_id":"t-1"}'
|
|
157
|
+
printf '%s\n' '{"type":"item.completed","item":{"id":"r","type":"reasoning","text":"想"}}'
|
|
158
|
+
printf '%s\n' '{"type":"item.started","item":{"id":"c","type":"command_execution","command":"ls"}}'
|
|
159
|
+
printf '%s\n' '{"type":"item.completed","item":{"id":"c","type":"command_execution","command":"ls","aggregated_output":"a.txt"}}'
|
|
160
|
+
printf '%s\n' '{"type":"item.updated","item":{"id":"m","type":"agent_message","text":"2"}}'
|
|
161
|
+
printf '%s\n' '{"type":"item.completed","item":{"id":"m","type":"agent_message","text":"2就是这个"}}'
|
|
162
|
+
printf '%s\n' '{"type":"turn.completed"}'
|
|
161
163
|
`)
|
|
162
164
|
e := &TraeEngine{BinPath: cli}
|
|
163
165
|
events, onEvent := collectEvents()
|
|
@@ -171,11 +173,20 @@ echo '{"type":"result","subtype":"success","session_id":"t-1","result":"2就是
|
|
|
171
173
|
if res.Text != "2就是这个" {
|
|
172
174
|
t.Errorf("Text = %q", res.Text)
|
|
173
175
|
}
|
|
174
|
-
if res.Thinking != "" {
|
|
175
|
-
t.Errorf("
|
|
176
|
+
if res.Thinking != "想" {
|
|
177
|
+
t.Errorf("Thinking = %q want 想", res.Thinking)
|
|
176
178
|
}
|
|
177
|
-
if
|
|
178
|
-
t.Errorf("
|
|
179
|
+
if res.SessionID != "t-1" {
|
|
180
|
+
t.Errorf("SessionID = %q want t-1", res.SessionID)
|
|
181
|
+
}
|
|
182
|
+
wantKinds := []StreamEventKind{KindThinking, KindToolUse, KindToolResult, KindText, KindText, KindTurnEnd}
|
|
183
|
+
if len(*events) != len(wantKinds) {
|
|
184
|
+
t.Fatalf("events = %d want %d: %+v", len(*events), len(wantKinds), *events)
|
|
185
|
+
}
|
|
186
|
+
for i, want := range wantKinds {
|
|
187
|
+
if (*events)[i].Kind != want {
|
|
188
|
+
t.Errorf("events[%d] = %v want %v", i, (*events)[i].Kind, want)
|
|
189
|
+
}
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
|
@@ -385,32 +396,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok"}'
|
|
|
385
396
|
}
|
|
386
397
|
}
|
|
387
398
|
|
|
388
|
-
// trae
|
|
389
|
-
//
|
|
390
|
-
func TestTraeStreamNoToolEvents(t *testing.T) {
|
|
391
|
-
cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
|
|
392
|
-
echo '{"type":"stream_event","delta":{"role":"assistant","content":"正文"}}'
|
|
393
|
-
echo '{"type":"result","subtype":"success","is_error":false,"result":"正文"}'
|
|
394
|
-
`)
|
|
395
|
-
e := &TraeEngine{BinPath: cli}
|
|
396
|
-
events, onEvent := collectEvents()
|
|
397
|
-
|
|
398
|
-
res, err := e.Stream(context.Background(), Request{
|
|
399
|
-
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
400
|
-
}, onEvent)
|
|
401
|
-
if err != nil {
|
|
402
|
-
t.Fatalf("Stream: %v", err)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
for _, ev := range *events {
|
|
406
|
-
if ev.Kind == KindToolUse || ev.Kind == KindToolResult {
|
|
407
|
-
t.Errorf("trae 不应发射工具事件, got %v", ev.Kind)
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
if len(res.Tools) != 0 {
|
|
411
|
-
t.Errorf("trae res.Tools 应为 nil/空, got %d", len(res.Tools))
|
|
412
|
-
}
|
|
413
|
-
}
|
|
399
|
+
// trae 2.0 的工具事件由 TestTraeStreamExecJSON 覆盖(command_execution →
|
|
400
|
+
// tool_use/tool_result);这里不再保留 1.x「协议不暴露工具」的旧断言。
|
|
414
401
|
|
|
415
402
|
// 协议异常:open tool_use 没收到 stop 直接来 result → 兜底回收,不丢事件。
|
|
416
403
|
func TestClaudeStreamToolUseOrphanRecovery(t *testing.T) {
|
|
@@ -704,43 +704,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"<think>思
|
|
|
704
704
|
}
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
func TestTraeStreamStillDeltaOnly(t *testing.T) {
|
|
710
|
-
skipIfNoShCLI(t)
|
|
711
|
-
cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
|
|
712
|
-
echo '{"type":"stream_event","delta":{"role":"assistant","content":"你"}}'
|
|
713
|
-
echo '{"type":"stream_event","delta":{"role":"assistant","content":"好"}}'
|
|
714
|
-
echo '{"type":"result","subtype":"success","is_error":false,"result":"你好"}'
|
|
715
|
-
`)
|
|
716
|
-
e := &TraeEngine{BinPath: cli}
|
|
717
|
-
events, onEvent := collectEvents()
|
|
718
|
-
if _, err := e.Stream(context.Background(), Request{
|
|
719
|
-
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
720
|
-
}, onEvent); err != nil {
|
|
721
|
-
t.Fatalf("Stream: %v", err)
|
|
722
|
-
}
|
|
723
|
-
var id string
|
|
724
|
-
var lastSnap string
|
|
725
|
-
for _, ev := range *events {
|
|
726
|
-
if ev.Kind != KindText {
|
|
727
|
-
continue
|
|
728
|
-
}
|
|
729
|
-
if ev.ItemID == "" {
|
|
730
|
-
t.Fatal("trae 的正文也应有 item id(降级序号)")
|
|
731
|
-
}
|
|
732
|
-
if id == "" {
|
|
733
|
-
id = ev.ItemID
|
|
734
|
-
}
|
|
735
|
-
if ev.ItemID != id {
|
|
736
|
-
t.Fatalf("trae 降级 id 不稳定:%q vs %q", id, ev.ItemID)
|
|
737
|
-
}
|
|
738
|
-
lastSnap = ev.Snapshot
|
|
739
|
-
}
|
|
740
|
-
if lastSnap != "你好" {
|
|
741
|
-
t.Errorf("最终 snapshot = %q,want 你好", lastSnap)
|
|
742
|
-
}
|
|
743
|
-
}
|
|
707
|
+
// trae 2.0 走 trae_exec.go 的独立累加器(不使用 streamAccumulator),
|
|
708
|
+
// 其事件映射由 stream_test.go 的 TestTraeStreamExecJSON 覆盖。
|
|
744
709
|
|
|
745
710
|
// ── arkclaw 专项(A2A message/stream)─────────────────────────────────
|
|
746
711
|
//
|