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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- 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
|
+
}
|