magic-agent 1.0.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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,206 @@
1
+ package agent
2
+
3
+ // codex_home.go - codex 引擎的 CODEX_HOME 隔离。
4
+ //
5
+ // 根因(2026-09-16 实测,codex-cli 0.154.0):
6
+ //
7
+ // ~/.codex/auth.json 存在 ChatGPT 登录态(auth_mode="chatgpt")时,
8
+ // codex exec 启动会拉取 cloud config bundle(企业云端配置),目标域名
9
+ // auth.openai.com。若系统 DNS 解析该域名失败/超时(本机实测 curl 8s
10
+ // 无响应,但 --resolve 指定 Cloudflare IP 后 0.9s 200 OK,纯 DNS 路径
11
+ // 问题),codex 在 15s 后报错退出:
12
+ //
13
+ // Error: timed out waiting for cloud config bundle after 15s
14
+ //
15
+ // 且 -c cloud_config.enabled=false / -c cloud_config=false 均无法绕过
16
+ // (0.154 无此配置键)。config.toml 用的 custom provider(本地代理
17
+ // 127.0.0.1:15721)根本不需要 ChatGPT 登录态 —— auth.json 是唯一触发点。
18
+ //
19
+ // 修复:magic-agent 为 codex 子进程注入独立的 CODEX_HOME(镜像目录),
20
+ // 只同步 config.toml、AGENTS.md 及 config 引用的相对路径数据文件
21
+ // (如 cc-switch 的 model_catalog_json),**绝不同步 auth.json**。
22
+ // 无登录态 → 不拉 cloud config → exec 直连 custom provider 正常返回。
23
+ //
24
+ // 行为矩阵:
25
+ //
26
+ // 外部已设 CODEX_HOME → 尊重用户环境,不做隔离/同步
27
+ // 外部已设 MAGIC_AGENT_CODEX_HOME → 直接用该目录(跳过自动同步)
28
+ // ~/.codex 不存在 → 不启用隔离(保持原行为)
29
+ // 默认 → 镜像 ~/.magic-agent/codex-home/
30
+ //
31
+ // 同步策略:源 config.toml 的 size+mtime 与镜像不一致时全量重同步
32
+ //(文件均为 KB 级,开销可忽略)。镜像内 sessions 由 codex 自行落盘,
33
+ // 续接(resume)天然工作在同一镜像内,自洽。
34
+
35
+ import (
36
+ "fmt"
37
+ "os"
38
+ "path/filepath"
39
+ "regexp"
40
+ )
41
+
42
+ // magicAgentHomeDir 引擎私有数据的根目录(~/.magic-agent)。
43
+ func magicAgentHomeDir() string {
44
+ home, err := os.UserHomeDir()
45
+ if err != nil {
46
+ return ""
47
+ }
48
+ return filepath.Join(home, ".magic-agent")
49
+ }
50
+
51
+ // defaultCodexSourceHome 用户原始 codex 配置目录。
52
+ func defaultCodexSourceHome() string {
53
+ home, err := os.UserHomeDir()
54
+ if err != nil {
55
+ return ""
56
+ }
57
+ return filepath.Join(home, ".codex")
58
+ }
59
+
60
+ // codexHomeEnvKey 引擎级 CODEX_HOME 覆盖开关。
61
+ const codexHomeEnvKey = "MAGIC_AGENT_CODEX_HOME"
62
+
63
+ // ensureCodexHome 决定 codex 子进程的 CODEX_HOME,必要时构建镜像目录。
64
+ //
65
+ // 返回 (home, injected, error):injected=true 表示调用方须将该 home 以
66
+ // CODEX_HOME 注入子进程环境。
67
+ func ensureCodexHome() (string, bool, error) {
68
+ // 用户显式指定引擎 CODEX_HOME:直接用,不同步。
69
+ if v := os.Getenv(codexHomeEnvKey); v != "" {
70
+ return v, true, nil
71
+ }
72
+ // 用户已在环境中管理 CODEX_HOME:尊重,不覆盖。
73
+ if os.Getenv("CODEX_HOME") != "" {
74
+ return "", false, nil
75
+ }
76
+ src := defaultCodexSourceHome()
77
+ if src == "" {
78
+ return "", false, nil
79
+ }
80
+ srcConfig := filepath.Join(src, "config.toml")
81
+ if _, err := os.Stat(srcConfig); err != nil {
82
+ // 没有用户级 codex 配置:无需隔离(auth.json 若存在也无法
83
+ // 触发 cloud config —— 没有自定义 provider 时 codex 行为不变)。
84
+ return "", false, nil
85
+ }
86
+ root := magicAgentHomeDir()
87
+ if root == "" {
88
+ return "", false, nil
89
+ }
90
+ dst := filepath.Join(root, "codex-home")
91
+ if err := syncCodexHome(src, dst); err != nil {
92
+ // 同步失败不阻断调用:回退到无隔离(旧行为)。
93
+ return "", false, fmt.Errorf("codex: sync CODEX_HOME: %w", err)
94
+ }
95
+ return dst, true, nil
96
+ }
97
+
98
+ // syncCodexHome 把源 CODEX_HOME 的必要文件镜像到 dst。
99
+ // 镜像文件写入后回写源的 mtime,使 size+mtime 比对成为有效的幂等判据。
100
+ func syncCodexHome(src, dst string) error {
101
+ srcConfig := filepath.Join(src, "config.toml")
102
+ dstConfig := filepath.Join(dst, "config.toml")
103
+ if si, ei, err := statBoth(srcConfig, dstConfig); err == nil && sameStat(si, ei) {
104
+ return nil // 已同步
105
+ }
106
+ if err := os.MkdirAll(dst, 0o755); err != nil {
107
+ return err
108
+ }
109
+ // 1) config.toml 本体
110
+ if err := copyPreservingMtime(srcConfig, dstConfig); err != nil {
111
+ return err
112
+ }
113
+ // 2) config.toml 引用的相对路径数据文件(如 model_catalog_json)
114
+ // + 全局 AGENTS.md(codex exec 每轮都会读取的全局指令)。
115
+ data, err := os.ReadFile(srcConfig)
116
+ if err != nil {
117
+ return err
118
+ }
119
+ names := relativeFileRefs(string(data))
120
+ if _, err := os.Stat(filepath.Join(src, "AGENTS.md")); err == nil {
121
+ names = append(names, "AGENTS.md")
122
+ }
123
+ for _, name := range names {
124
+ s := filepath.Join(src, name)
125
+ d := filepath.Join(dst, name)
126
+ if err := copyPreservingMtime(s, d); err != nil {
127
+ if os.IsNotExist(err) {
128
+ continue // 引用了但缺失:跳过,让 codex 自己报
129
+ }
130
+ return err
131
+ }
132
+ }
133
+ return nil
134
+ }
135
+
136
+ // copyPreservingMtime 拷贝单个文件并回写源 mtime(幂等同步的关键)。
137
+ func copyPreservingMtime(src, dst string) error {
138
+ si, err := os.Stat(src)
139
+ if err != nil {
140
+ return err
141
+ }
142
+ b, err := os.ReadFile(src)
143
+ if err != nil {
144
+ return err
145
+ }
146
+ if err := os.WriteFile(dst, b, 0o600); err != nil {
147
+ return err
148
+ }
149
+ return os.Chtimes(dst, si.ModTime(), si.ModTime())
150
+ }
151
+
152
+ // relativeFileRefs 提取 config.toml 中「数据文件名」形态的字符串值。
153
+ // 规则:basic string 值不含 '/' 与 '\\'(非路径)、带文件扩展名
154
+ // (过滤 model 名 / token / 枚举值等纯 token)、非空;auth.json 永不
155
+ // 同步(cloud config 触发点,隔离的意义所在)。引用了但源不存在的文件
156
+ // 由 syncCodexHome 阶段自然跳过。
157
+ var tomlStringValRe = regexp.MustCompile(`"([^"\n\\]+)"`)
158
+
159
+ func relativeFileRefs(config string) []string {
160
+ seen := map[string]bool{}
161
+ var out []string
162
+ for _, m := range tomlStringValRe.FindAllStringSubmatch(config, -1) {
163
+ v := m[1]
164
+ switch {
165
+ case v == "" || v == "auth.json":
166
+ continue
167
+ case containsAny(v, '/', '\\'):
168
+ continue // 路径形态(多为绝对路径或跨目录引用),不处理
169
+ case filepath.Ext(v) == "" || v == ".":
170
+ continue // 无扩展名:model 名/枚举值/token,非文件名
171
+ case seen[v]:
172
+ continue
173
+ }
174
+ seen[v] = true
175
+ out = append(out, v)
176
+ }
177
+ return out
178
+ }
179
+
180
+ func containsAny(s string, chars ...byte) bool {
181
+ for i := 0; i < len(s); i++ {
182
+ for _, c := range chars {
183
+ if s[i] == c {
184
+ return true
185
+ }
186
+ }
187
+ }
188
+ return false
189
+ }
190
+
191
+ // statBoth / sameStat 供同步比对用。
192
+ func statBoth(a, b string) (os.FileInfo, os.FileInfo, error) {
193
+ si, err := os.Stat(a)
194
+ if err != nil {
195
+ return nil, nil, err
196
+ }
197
+ ei, err := os.Stat(b)
198
+ if err != nil {
199
+ return nil, nil, err
200
+ }
201
+ return si, ei, nil
202
+ }
203
+
204
+ func sameStat(a, b os.FileInfo) bool {
205
+ return a.Size() == b.Size() && a.ModTime().Equal(b.ModTime())
206
+ }
@@ -0,0 +1,130 @@
1
+ package agent
2
+
3
+ // codex_home_test.go - CODEX_HOME 隔离逻辑测试。
4
+ //
5
+ // 硬约束:auth.json 永不进入镜像目录(cloud config 15s 超时根因的触发点)。
6
+
7
+ import (
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ func TestRelativeFileRefs(t *testing.T) {
15
+ config := `
16
+ model_provider = "custom"
17
+ model_catalog_json = "cc-switch-model-catalog.json"
18
+ web_search = "disabled"
19
+ experimental_bearer_token = "PROXY_MANAGED"
20
+ base_url = "http://127.0.0.1:15721/v1"
21
+ source = "/abs/path/openai-bundled"
22
+ `
23
+ got := relativeFileRefs(config)
24
+ joined := strings.Join(got, ",")
25
+ // 期望:纯文件名进入列表(去重后),路径形态/纯 token 不进入。
26
+ if !strings.Contains(joined, "cc-switch-model-catalog.json") {
27
+ t.Fatalf("model_catalog_json 未被识别: %v", got)
28
+ }
29
+ for _, banned := range []string{"custom", "PROXY_MANAGED", "disabled", "/abs/path/openai-bundled", "127.0.0.1"} {
30
+ if strings.Contains(joined, banned) {
31
+ t.Fatalf("非文件名值 %q 混入引用列表: %v", banned, got)
32
+ }
33
+ }
34
+ }
35
+
36
+ func TestRelativeFileRefsNeverAuthJSON(t *testing.T) {
37
+ config := "auth_json_note = \"auth.json\"\n"
38
+ got := relativeFileRefs(config)
39
+ for _, v := range got {
40
+ if v == "auth.json" {
41
+ t.Fatalf("auth.json 不允许进入同步列表: %v", got)
42
+ }
43
+ }
44
+ }
45
+
46
+ func TestSyncCodexHome(t *testing.T) {
47
+ src := t.TempDir()
48
+ dst := t.TempDir()
49
+ write := func(p, c string) {
50
+ if err := os.WriteFile(p, []byte(c), 0o600); err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ }
54
+ write(filepath.Join(src, "config.toml"),
55
+ "model_catalog_json = \"catalog.json\"\n")
56
+ write(filepath.Join(src, "catalog.json"), "{}")
57
+ write(filepath.Join(src, "AGENTS.md"), "global rules")
58
+ write(filepath.Join(src, "auth.json"), `{"auth_mode":"chatgpt"}`)
59
+
60
+ if err := syncCodexHome(src, dst); err != nil {
61
+ t.Fatalf("sync: %v", err)
62
+ }
63
+ for _, name := range []string{"config.toml", "catalog.json", "AGENTS.md"} {
64
+ if _, err := os.Stat(filepath.Join(dst, name)); err != nil {
65
+ t.Fatalf("%s 未同步: %v", name, err)
66
+ }
67
+ }
68
+ if _, err := os.Stat(filepath.Join(dst, "auth.json")); err == nil {
69
+ t.Fatal("auth.json 被同步进镜像目录(硬约束违反)")
70
+ }
71
+
72
+ // 二次同步:mtime/size 未变 → 幂等(不重写)。
73
+ fi1, err := os.Stat(filepath.Join(dst, "config.toml"))
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ if err := syncCodexHome(src, dst); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ fi2, err := os.Stat(filepath.Join(dst, "config.toml"))
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if !fi1.ModTime().Equal(fi2.ModTime()) {
85
+ t.Fatal("未变更时不应重写镜像文件")
86
+ }
87
+
88
+ // 源 config 变更 → 重新同步。
89
+ write(filepath.Join(src, "config.toml"),
90
+ "model_catalog_json = \"catalog2.json\"\n")
91
+ write(filepath.Join(src, "catalog2.json"), "[]")
92
+ if err := syncCodexHome(src, dst); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ if _, err := os.Stat(filepath.Join(dst, "catalog2.json")); err != nil {
96
+ t.Fatal("源变更后未重新同步")
97
+ }
98
+ }
99
+
100
+ func TestEnsureCodexHomeRespectsExternalEnv(t *testing.T) {
101
+ t.Setenv(codexHomeEnvKey, "/custom/codex-home")
102
+ home, injected, err := ensureCodexHome()
103
+ if err != nil || !injected || home != "/custom/codex-home" {
104
+ t.Fatalf("MAGIC_AGENT_CODEX_HOME 应直接生效: home=%s injected=%v err=%v", home, injected, err)
105
+ }
106
+
107
+ t.Setenv(codexHomeEnvKey, "")
108
+ t.Setenv("CODEX_HOME", "/user/managed")
109
+ home, injected, err = ensureCodexHome()
110
+ if err != nil || injected || home != "" {
111
+ t.Fatalf("外部 CODEX_HOME 应被尊重(不隔离): home=%s injected=%v err=%v", home, injected, err)
112
+ }
113
+ }
114
+
115
+ func TestCodexWebSearchOverride(t *testing.T) {
116
+ for _, v := range []string{"live", "cached", "indexed", "disabled"} {
117
+ t.Setenv("MAGIC_AGENT_CODEX_WEBSEARCH", v)
118
+ if got := codexWebSearchOverride(); got != v {
119
+ t.Fatalf("websearch override = %q, want %q", got, v)
120
+ }
121
+ }
122
+ t.Setenv("MAGIC_AGENT_CODEX_WEBSEARCH", "enabled") // 非法值域
123
+ if got := codexWebSearchOverride(); got != "" {
124
+ t.Fatalf("非法值应被忽略, got %q", got)
125
+ }
126
+ t.Setenv("MAGIC_AGENT_CODEX_WEBSEARCH", "")
127
+ if got := codexWebSearchOverride(); got != "" {
128
+ t.Fatalf("未设置时应为空, got %q", got)
129
+ }
130
+ }
@@ -0,0 +1,97 @@
1
+ package agent
2
+
3
+ // codex_stream_test.go - codex 引擎的流式单测(假 CLI 回放实测 NDJSON,不碰真网络/真 CLI)。
4
+ //
5
+ // 事件样本取自 2026-09-17 真机抓包(codex 0.133,`--json --dangerously-bypass-approvals-and-sandbox`):
6
+ // 这一版**没有逐字文本增量**,正文在 item.completed(agent_message) 里整段给出,
7
+ // 工具调用以 item.started/item.completed(command_execution) 成对出现。
8
+ // 覆盖:text/tool_use/tool_result 事件映射、会话 id、usage 计数、非 JSON 行不打断、空正文报错。
9
+
10
+ import (
11
+ "context"
12
+ "os"
13
+ "path/filepath"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // codexStreamStub 造一个回放固定 NDJSON 的假 codex。
19
+ func codexStreamStub(t *testing.T, dir string, lines []string) string {
20
+ t.Helper()
21
+ bin := filepath.Join(dir, "codex")
22
+ script := "#!/bin/sh\n"
23
+ for _, ln := range lines {
24
+ // 单引号包裹,内部单引号用 '\'' 转义
25
+ script += "printf '%s\\n' '" + strings.ReplaceAll(ln, "'", `'\''`) + "'\n"
26
+ }
27
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ return bin
31
+ }
32
+
33
+ func TestCodexStreamMapsEvents(t *testing.T) {
34
+ dir := t.TempDir()
35
+ bin := codexStreamStub(t, dir, []string{
36
+ `{"type":"thread.started","thread_id":"th-123"}`,
37
+ `{"type":"turn.started"}`,
38
+ `{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"ls -a"}}`,
39
+ `{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"ls -a","aggregated_output":"a.txt\nb.txt","exit_code":0}}`,
40
+ `garbage-not-json`, // CLI 偶尔混日志:不该打断整条流
41
+ `{"type":"item.completed","item":{"type":"agent_message","text":"目录里有两个文件"}}`,
42
+ `{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":7,"reasoning_output_tokens":3}}`,
43
+ })
44
+
45
+ var got []StreamEvent
46
+ res, err := (&CodexEngine{BinPath: bin}).Stream(context.Background(),
47
+ Request{Tools: ToolsOn, Messages: []Message{{Role: "user", Content: "看看目录"}}},
48
+ func(ev StreamEvent) { got = append(got, ev) })
49
+ if err != nil {
50
+ t.Fatalf("Stream: %v", err)
51
+ }
52
+
53
+ // ① 事件映射:tool_use → tool_result → text
54
+ if len(got) != 3 {
55
+ t.Fatalf("事件数 = %d,期望 3(tool_use/tool_result/text):%+v", len(got), got)
56
+ }
57
+ if got[0].Kind != KindToolUse || got[0].ID != "item_0" || got[0].Text != "ls -a" {
58
+ t.Errorf("tool_use 事件不对: %+v", got[0])
59
+ }
60
+ if got[1].Kind != KindToolResult || got[1].ID != "item_0" || !strings.Contains(got[1].Text, "a.txt") {
61
+ t.Errorf("tool_result 事件不对: %+v", got[1])
62
+ }
63
+ if got[2].Kind != KindText || got[2].Text != "目录里有两个文件" {
64
+ t.Errorf("text 事件不对: %+v", got[2])
65
+ }
66
+
67
+ // ② 收尾:正文 / 会话 id / token 计数(input 含 cached,output 含 reasoning)
68
+ if res.Text != "目录里有两个文件" {
69
+ t.Errorf("Text = %q", res.Text)
70
+ }
71
+ if res.SessionID != "th-123" {
72
+ t.Errorf("SessionID = %q,期望 thread_id", res.SessionID)
73
+ }
74
+ if res.InputTokens != 120 || res.OutputTokens != 10 || res.TotalTokens != 130 {
75
+ t.Errorf("token 计数 = %d/%d/%d,期望 120/10/130", res.InputTokens, res.OutputTokens, res.TotalTokens)
76
+ }
77
+ }
78
+
79
+ func TestCodexStreamEmptyTextFails(t *testing.T) {
80
+ dir := t.TempDir()
81
+ bin := codexStreamStub(t, dir, []string{
82
+ `{"type":"thread.started","thread_id":"th-1"}`,
83
+ `{"type":"turn.completed","usage":{"input_tokens":1}}`,
84
+ })
85
+ _, err := (&CodexEngine{BinPath: bin}).Stream(context.Background(),
86
+ Request{Tools: ToolsOn, Messages: []Message{{Role: "user", Content: "hi"}}}, nil)
87
+ if err == nil || !strings.Contains(err.Error(), "empty result") {
88
+ t.Errorf("没有正文时应报错,得 %v", err)
89
+ }
90
+ }
91
+
92
+ // Streamer 能力表:codex 现在实现了 Stream(其余不支持的引擎由 --engines 的 streaming 字段暴露)。
93
+ func TestCodexImplementsStreamer(t *testing.T) {
94
+ if AsStreamer(&CodexEngine{}) == nil {
95
+ t.Error("CodexEngine 应实现 Streamer(2026-09-17 补齐)")
96
+ }
97
+ }