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,342 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// trae.go - Trae CLI 引擎。
|
|
4
|
+
//
|
|
5
|
+
// 后端:trae-cli(~/.local/bin/trae-cli)。非交互模式:-p,纯文本输出。
|
|
6
|
+
// 与 claude/codebuddy 的关键差异:
|
|
7
|
+
//
|
|
8
|
+
// - 没有 --model flag;模型默认取 trae 自身配置(~/.trae/trae_cli.yaml
|
|
9
|
+
// 的 model.name)。显式指定模型用 -c "model.name=<name>" 覆盖。
|
|
10
|
+
// - --output-format 有 json,但实测纯文本已足够稳定,这里用 text +
|
|
11
|
+
// noToolSuffix(拼进 system 部分)压制 agent loop。
|
|
12
|
+
// - 自带 --query-timeout,把外层超时同时传给 CLI,保证语义一致。
|
|
13
|
+
// - 会话续接:--resume=<id>(pflag 可选值风格,必须等号传值);
|
|
14
|
+
// 没有 --continue,续接最近一次会话用裸 --resume(AUTO 模式,
|
|
15
|
+
// 实测 -p 非交互模式下可用,能接住上一轮记忆)。
|
|
16
|
+
// - 工具:禁用靠 --disallowed-tool 逐个减法;启用靠全放行开关 -y。
|
|
17
|
+
// trae 没有「只允许白名单这几个工具」的表达能力,白名单模式因此
|
|
18
|
+
// 降级为「默认开启所有工具」(详见 buildArgs 的工具模式映射)。
|
|
19
|
+
// - MaxTokens / Temperature 不支持:`-c <k>=<v>` 会整体覆盖模型配置块
|
|
20
|
+
// (实测注入 model.max_tokens 后模型名漂移并触发配额错误),因此这两个
|
|
21
|
+
// 参数构造时静默忽略(见 engine_base.go 矩阵②)。
|
|
22
|
+
//
|
|
23
|
+
// CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_TRAE_BIN → ~/.local/bin →
|
|
24
|
+
// ~/bin → PATH(探测链统一收敛在 engine_base.go 的 cliBase)。
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"context"
|
|
28
|
+
"encoding/json"
|
|
29
|
+
"fmt"
|
|
30
|
+
"os"
|
|
31
|
+
"path/filepath"
|
|
32
|
+
"strings"
|
|
33
|
+
"time"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// TraeEngine 通过 trae-cli 实现 Engine。
|
|
37
|
+
type TraeEngine struct {
|
|
38
|
+
// BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
|
|
39
|
+
BinPath string
|
|
40
|
+
|
|
41
|
+
// Model 显式模型(非空时 -c model.name=<model> 覆盖 CLI 默认)。
|
|
42
|
+
Model string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Name 实现 Engine。
|
|
46
|
+
func (e *TraeEngine) Name() string { return "trae" }
|
|
47
|
+
|
|
48
|
+
// DefaultTraeTimeout 单次尝试默认超时。
|
|
49
|
+
const DefaultTraeTimeout = 10 * time.Minute
|
|
50
|
+
|
|
51
|
+
// traeOffDisableTools off 模式下逐个 --disallowed-tool 禁用的实体工具。
|
|
52
|
+
//
|
|
53
|
+
// 取自 trae-cli 实测的 18 个内置工具里「会读文件 / 会改机器」的那批:
|
|
54
|
+
// Agent、ExitPlanMode、EnterPlanMode 是常驻元工具(实测 --disallowed-tool
|
|
55
|
+
// 摘不掉,补集禁用后仍保留),Skill、TaskCreate/Get/List/Update、
|
|
56
|
+
// BashOutput、KillShell 属会话内辅助、无外部副作用,故不在禁用之列。
|
|
57
|
+
//
|
|
58
|
+
// 注意工具名必须是真实存在的:编辑与写入在 trae 里叫 Edit / Write ——
|
|
59
|
+
// 此前这里写的是并不存在的 `Replace`,等于 off 模式漏禁 Write。
|
|
60
|
+
var traeOffDisableTools = []string{"Bash", "Edit", "Write", "Glob", "Grep", "Read"}
|
|
61
|
+
|
|
62
|
+
// bin 探测 trae-cli 路径(委托 cliBase 统一探测链)。
|
|
63
|
+
func (e *TraeEngine) bin() string {
|
|
64
|
+
return traeBase.resolve(e.BinPath)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Detect 实现 Engine。
|
|
68
|
+
func (e *TraeEngine) Detect() (bool, string) {
|
|
69
|
+
p := traeBase.resolve(e.BinPath)
|
|
70
|
+
if p == "" {
|
|
71
|
+
return false, traeBase.notFound
|
|
72
|
+
}
|
|
73
|
+
return true, p
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// TraeDefaultModel 读取 ~/.trae/trae_cli.yaml 顶层 model.name,
|
|
77
|
+
// 返回 trae CLI 当前配置的默认模型(如 "My-MiniMax-M3")。
|
|
78
|
+
// 轻量扫描解析(不引入 yaml 依赖);文件缺失/格式变化返回空串。
|
|
79
|
+
func TraeDefaultModel() string {
|
|
80
|
+
home, err := os.UserHomeDir()
|
|
81
|
+
if err != nil {
|
|
82
|
+
return ""
|
|
83
|
+
}
|
|
84
|
+
data, err := os.ReadFile(filepath.Join(home, ".trae", "trae_cli.yaml"))
|
|
85
|
+
if err != nil {
|
|
86
|
+
return ""
|
|
87
|
+
}
|
|
88
|
+
inModelBlock := false
|
|
89
|
+
for _, ln := range strings.Split(string(data), "\n") {
|
|
90
|
+
trimmed := strings.TrimSpace(ln)
|
|
91
|
+
if !inModelBlock {
|
|
92
|
+
inModelBlock = ln == trimmed && strings.HasPrefix(trimmed, "model:")
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if strings.HasPrefix(trimmed, "name:") {
|
|
96
|
+
v := strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
|
|
97
|
+
v = strings.Trim(v, `"'`)
|
|
98
|
+
if v != "" {
|
|
99
|
+
return v
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if ln == trimmed && trimmed != "" {
|
|
103
|
+
inModelBlock = false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return ""
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ListModels 实现 ModelLister:`trae-cli models --json` 是 trae 自己的
|
|
110
|
+
// 权威清单(含用户自定义模型,如 ~/.trae/trae_cli.yaml 里的 My-MiniMax-M3)。
|
|
111
|
+
// 取 name(= -c model.name=<name> 接受的那个标识),缺失时退回 real_name。
|
|
112
|
+
func (e *TraeEngine) ListModels(ctx context.Context) ([]string, error) {
|
|
113
|
+
stdout, err := probeCLI(ctx, "trae", e.bin(), "models", "--json")
|
|
114
|
+
if err != nil {
|
|
115
|
+
return nil, err
|
|
116
|
+
}
|
|
117
|
+
objs, err := jsonModelsArray(stdout)
|
|
118
|
+
if err != nil {
|
|
119
|
+
return nil, err
|
|
120
|
+
}
|
|
121
|
+
models := modelNamesFromObjects(objs, "name", "real_name")
|
|
122
|
+
if len(models) == 0 {
|
|
123
|
+
return nil, fmt.Errorf("%w: trae-cli models --json returned no entries", ErrNoModelSource)
|
|
124
|
+
}
|
|
125
|
+
return models, nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Complete 实现 Engine:单次调用 trae-cli。
|
|
129
|
+
func (e *TraeEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
130
|
+
start := time.Now()
|
|
131
|
+
bin := e.bin()
|
|
132
|
+
if bin == "" {
|
|
133
|
+
return Response{}, fmt.Errorf("trae CLI not found; set MAGIC_AGENT_TRAE_BIN")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
|
|
137
|
+
if prompt == "" {
|
|
138
|
+
return Response{}, fmt.Errorf("trae: empty prompt")
|
|
139
|
+
}
|
|
140
|
+
// trae 没有独立 system 注入 flag;无 system prompt 时约束也要生效。
|
|
141
|
+
// 仅 off 模式追加:on/白名单下 noToolSuffix 会与「允许调用工具」冲突。
|
|
142
|
+
if req.SystemPrompt == "" && toolsIsOff(req) {
|
|
143
|
+
prompt += noToolSuffix
|
|
144
|
+
}
|
|
145
|
+
// 附件:trae-cli 没有图片/附件输入通道(--help 无 image/attach/input-format),
|
|
146
|
+
// 只能把绝对路径写进提示词,靠 trae 的读文件工具看 —— 因此需要工具可用。
|
|
147
|
+
prompt = appendAttachmentSection(prompt, req.Attachments)
|
|
148
|
+
|
|
149
|
+
args := e.buildArgs(req, prompt)
|
|
150
|
+
|
|
151
|
+
// workspace:trae 无工作目录 flag(有 --add-dir 但那是追加额外目录),子进程 cwd 即原生方式
|
|
152
|
+
stdout, stderr, err := runCLIIn(ctx, req.Workspace, bin, args...)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return Response{}, wrapCliError("trae", stdout, stderr, err)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
text := strings.TrimSpace(stdout)
|
|
158
|
+
if text == "" {
|
|
159
|
+
return Response{}, fmt.Errorf("trae CLI returned empty output")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
modelLabel := stripModelPrefix(req.Model)
|
|
163
|
+
if modelLabel == "" {
|
|
164
|
+
modelLabel = e.Model
|
|
165
|
+
}
|
|
166
|
+
if modelLabel == "" {
|
|
167
|
+
if m := TraeDefaultModel(); m != "" {
|
|
168
|
+
modelLabel = m
|
|
169
|
+
} else {
|
|
170
|
+
modelLabel = "cli-default"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return Response{
|
|
174
|
+
Text: text,
|
|
175
|
+
Model: modelLabel,
|
|
176
|
+
// 纯文本模式下 CLI 不回 session_id;续接请求时把传入的 id 原样
|
|
177
|
+
// 回传,让调用方可以逐轮透传(新会话仍为空)。
|
|
178
|
+
SessionID: req.SessionID,
|
|
179
|
+
Latency: time.Since(start),
|
|
180
|
+
}, nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// buildArgs 构造 trae-cli 参数(Complete 与 Stream 共用)。
|
|
184
|
+
func (e *TraeEngine) buildArgs(req Request, prompt string) []string {
|
|
185
|
+
args := []string{"-p"}
|
|
186
|
+
switch {
|
|
187
|
+
case req.SessionID != "":
|
|
188
|
+
// 续接指定会话:pflag 可选值风格(--resume string[="AUTO"]),
|
|
189
|
+
// 必须用等号形式 --resume=<id>;空格形式下裸 id 会被吞成 AUTO 默认值。
|
|
190
|
+
args = append(args, "--resume="+req.SessionID)
|
|
191
|
+
case req.Continue:
|
|
192
|
+
// 续接最近一次会话:裸 --resume 触发 AUTO 模式(实测 -p 可用)。
|
|
193
|
+
args = append(args, "--resume")
|
|
194
|
+
}
|
|
195
|
+
// 模型:显式值 > 引擎注入值;都为空则用 CLI 自身默认。
|
|
196
|
+
model := e.Model
|
|
197
|
+
if model == "" {
|
|
198
|
+
model = stripModelPrefix(req.Model)
|
|
199
|
+
}
|
|
200
|
+
if model != "" {
|
|
201
|
+
args = append(args, "-c", "model.name="+model)
|
|
202
|
+
}
|
|
203
|
+
// 统一参数矩阵:req.MaxTokens / req.Temperature 在 trae 上明确不支持、
|
|
204
|
+
// 静默忽略 —— `-c <k>=<v>` 会整体覆盖模型配置块,实测注入
|
|
205
|
+
// model.max_tokens 后模型名漂移并触发配额错误(见 engine_base.go 矩阵②)。
|
|
206
|
+
_ = req.MaxTokens
|
|
207
|
+
_ = req.Temperature
|
|
208
|
+
// 工具模式映射。trae-cli 的权限开关实测语义(2026-09-16 真机验证):
|
|
209
|
+
//
|
|
210
|
+
// --allowed-tool 只做「自动批准该工具」,**不裁剪**工具集
|
|
211
|
+
// (实测 --allowed-tool WebFetch 仍下发全部 18 个工具)
|
|
212
|
+
// --disallowed-tool 真正做减法,从工具集里移除该工具(实测 18 → 4)
|
|
213
|
+
// -y / --yolo 跳过全部权限检查 ⇒ 全工具可用
|
|
214
|
+
//
|
|
215
|
+
// 由此得出映射:
|
|
216
|
+
//
|
|
217
|
+
// off 逐个 --disallowed-tool 关掉内置工具(Bash/Edit/Write/Glob/Grep/Read)
|
|
218
|
+
// on -y(全放行;非 -p 交互场景下的权限旁路)
|
|
219
|
+
// allowlist 默认开启所有工具 ⇒ 与 on 同一条全放行路径(-y)
|
|
220
|
+
//
|
|
221
|
+
// ⚠️ 白名单在 trae 上「降级为全放行」是刻意的默认语义,不是实现漏做:
|
|
222
|
+
// trae 无法表达「只允许这几个工具」——想收窄只能对补集逐个 --disallowed-tool,
|
|
223
|
+
// 而补集随 CLI 版本漂移(今天 18 个工具、明天可能加),漏一条就等于白名单失效,
|
|
224
|
+
// 维护成本高于收益。故认定「默认开启所有工具」为白名单模式在 trae 上的契约,
|
|
225
|
+
// 与 codex 的白名单降级(engine_base.go 矩阵④:白名单 → 全放行)保持一致。
|
|
226
|
+
//
|
|
227
|
+
// 返回值里不再出现 --allowed-tool:既然 -y 已全量放行,逐条声明只是
|
|
228
|
+
// 「看起来像收窄」的噪音(这正是此前 allowlist 被 -y 架空的观感来源)。
|
|
229
|
+
// 需要收窄时请用 off 模式(真减法),而不要指望白名单。
|
|
230
|
+
tools := toolsOrDefault(req.Tools)
|
|
231
|
+
switch {
|
|
232
|
+
case tools.IsOff():
|
|
233
|
+
// 真实工具名,逐个减法;`Replace` 不是 trae 的工具(编辑/写入是
|
|
234
|
+
// Edit / Write),此前写成 Replace 等于漏禁 Write。
|
|
235
|
+
for _, t := range traeOffDisableTools {
|
|
236
|
+
args = append(args, "--disallowed-tool", t)
|
|
237
|
+
}
|
|
238
|
+
case tools.IsOn():
|
|
239
|
+
args = append(args, "-y")
|
|
240
|
+
default:
|
|
241
|
+
// 默认开启所有工具:全放行开关是唯一有效机制。
|
|
242
|
+
args = append(args, "-y")
|
|
243
|
+
}
|
|
244
|
+
// 把外层超时透传给 CLI 的 query-timeout(取上限 600s,CLI 单查询上限)。
|
|
245
|
+
if req.Timeout > 0 {
|
|
246
|
+
qt := req.Timeout
|
|
247
|
+
if qt > 600*time.Second {
|
|
248
|
+
qt = 600 * time.Second
|
|
249
|
+
}
|
|
250
|
+
args = append(args, "--query-timeout", qt.String())
|
|
251
|
+
} else {
|
|
252
|
+
args = append(args, "--query-timeout", "600s")
|
|
253
|
+
}
|
|
254
|
+
args = append(args, prompt)
|
|
255
|
+
return args
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Stream 实现 Streamer:流式调用 trae-cli。
|
|
259
|
+
// trae 的 stream-json 用 delta.content 直出正文增量;
|
|
260
|
+
// 模型侧不开 reasoning,无 thinking 通道(Thinking 恒为空)。
|
|
261
|
+
func (e *TraeEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
262
|
+
start := time.Now()
|
|
263
|
+
bin := e.bin()
|
|
264
|
+
if bin == "" {
|
|
265
|
+
return StreamResult{}, fmt.Errorf("trae CLI not found; set MAGIC_AGENT_TRAE_BIN")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
|
|
269
|
+
if prompt == "" {
|
|
270
|
+
return StreamResult{}, fmt.Errorf("trae: empty prompt")
|
|
271
|
+
}
|
|
272
|
+
if req.SystemPrompt == "" && toolsIsOff(req) {
|
|
273
|
+
prompt += noToolSuffix
|
|
274
|
+
}
|
|
275
|
+
prompt = appendAttachmentSection(prompt, req.Attachments)
|
|
276
|
+
|
|
277
|
+
args := e.buildArgs(req, prompt)
|
|
278
|
+
args = append(args, "--output-format", "stream-json", "--include-partial-messages")
|
|
279
|
+
|
|
280
|
+
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
|
|
281
|
+
var fin struct {
|
|
282
|
+
Type string `json:"type"`
|
|
283
|
+
Subtype string `json:"subtype"`
|
|
284
|
+
IsError bool `json:"is_error"`
|
|
285
|
+
Result string `json:"result"`
|
|
286
|
+
SessionID string `json:"session_id"`
|
|
287
|
+
}
|
|
288
|
+
seenResult := false
|
|
289
|
+
|
|
290
|
+
err := runStreamCLIIn(ctx, req.Workspace, bin, args, func(line string) error {
|
|
291
|
+
isResult, perr := acc.handleNDJSONLine(line)
|
|
292
|
+
if perr != nil {
|
|
293
|
+
return perr
|
|
294
|
+
}
|
|
295
|
+
if isResult {
|
|
296
|
+
_ = json.Unmarshal([]byte(line), &fin)
|
|
297
|
+
seenResult = true
|
|
298
|
+
}
|
|
299
|
+
return nil
|
|
300
|
+
})
|
|
301
|
+
if err != nil {
|
|
302
|
+
return StreamResult{}, err
|
|
303
|
+
}
|
|
304
|
+
if !seenResult {
|
|
305
|
+
return StreamResult{}, fmt.Errorf("trae CLI stream ended without result line")
|
|
306
|
+
}
|
|
307
|
+
if fin.IsError {
|
|
308
|
+
return StreamResult{}, fmt.Errorf("trae CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
text := fin.Result
|
|
312
|
+
if text == "" {
|
|
313
|
+
text = strings.TrimSpace(acc.Text.String())
|
|
314
|
+
}
|
|
315
|
+
if text == "" {
|
|
316
|
+
return StreamResult{}, fmt.Errorf("trae CLI returned empty result")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
modelLabel := stripModelPrefix(req.Model)
|
|
320
|
+
if modelLabel == "" {
|
|
321
|
+
modelLabel = e.Model
|
|
322
|
+
}
|
|
323
|
+
if modelLabel == "" {
|
|
324
|
+
if m := TraeDefaultModel(); m != "" {
|
|
325
|
+
modelLabel = m
|
|
326
|
+
} else {
|
|
327
|
+
modelLabel = "cli-default"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return StreamResult{
|
|
331
|
+
Response: Response{
|
|
332
|
+
Engine: e.Name(),
|
|
333
|
+
Text: text,
|
|
334
|
+
Model: modelLabel,
|
|
335
|
+
SessionID: fin.SessionID,
|
|
336
|
+
Latency: time.Since(start),
|
|
337
|
+
},
|
|
338
|
+
Thinking: acc.Thinking.String(),
|
|
339
|
+
// trae 协议不暴露独立工具事件通道;Tools 恒为 nil。
|
|
340
|
+
// 见 stream.go 顶部协议说明。
|
|
341
|
+
}, nil
|
|
342
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// workspace_test.go - 「工作目录(-w/--workspace)」的引擎侧单测。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖(需求原话:「参数还要支持指定workspace 尽量使用原生cli支持的方式 不支持告诉我」):
|
|
6
|
+
// ① WorkspaceSupportOf 能力表:codex=flag:-C / claude·codebuddy·trae·openclaw=cwd / llm·arkclaw=none
|
|
7
|
+
// ② 子进程 cwd 真的换了目录(假 CLI 跑 pwd 回读)—— claude/codebuddy/trae/openclaw 的原生方式
|
|
8
|
+
// ③ codex 走原生 `-C/--cd <dir>`;没给 workspace 时不加该 flag
|
|
9
|
+
// ④ 声明为 none 的引擎不会因为 workspace 报错(参数被忽略,提示由 CLI 层负责)
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"context"
|
|
13
|
+
"os"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"runtime"
|
|
16
|
+
"strings"
|
|
17
|
+
"testing"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestWorkspaceSupportOfMapping(t *testing.T) {
|
|
21
|
+
cases := map[string]string{
|
|
22
|
+
"codex": "flag:-C",
|
|
23
|
+
"claude": "cwd",
|
|
24
|
+
"codebuddy": "cwd",
|
|
25
|
+
"codebuddy-ai": "cwd",
|
|
26
|
+
"trae": "cwd",
|
|
27
|
+
"dsh": "cwd", // 官方语义:调用时所在目录即默认 workspace 根
|
|
28
|
+
"openclaw": "none", // 实测子进程 cwd 被忽略(workspace 与 agent 绑定)
|
|
29
|
+
"llm": "none",
|
|
30
|
+
"arkclaw": "none",
|
|
31
|
+
}
|
|
32
|
+
for engine, want := range cases {
|
|
33
|
+
if got := WorkspaceSupportOf(engine); got != want {
|
|
34
|
+
t.Errorf("WorkspaceSupportOf(%q) = %q, want %q", engine, got, want)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// pwdStub 造一个假 CLI:把 `pwd` 与收到的参数写进 log,再输出一行合法 result JSON。
|
|
40
|
+
// (claude 的 Complete 要能解析出正文,故带上 {"type":"result",...}。)
|
|
41
|
+
func pwdStub(t *testing.T, dir, name string) (bin, log string) {
|
|
42
|
+
t.Helper()
|
|
43
|
+
log = filepath.Join(dir, "ws.log")
|
|
44
|
+
bin = filepath.Join(dir, name)
|
|
45
|
+
script := "#!/bin/sh\n" +
|
|
46
|
+
"{ pwd; printf 'args: %s\\n' \"$*\"; } > " + log + "\n" +
|
|
47
|
+
`echo '{"type":"result","result":"ok","session_id":"s-1"}'` + "\n"
|
|
48
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
return bin, log
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// samePath 比较两个路径是否指向同一目录(macOS 上 /var 与 /private/var 是同一处)。
|
|
55
|
+
func samePath(t *testing.T, a, b string) bool {
|
|
56
|
+
t.Helper()
|
|
57
|
+
ra, ea := filepath.EvalSymlinks(a)
|
|
58
|
+
rb, eb := filepath.EvalSymlinks(b)
|
|
59
|
+
if ea != nil || eb != nil {
|
|
60
|
+
return filepath.Clean(a) == filepath.Clean(b)
|
|
61
|
+
}
|
|
62
|
+
return ra == rb
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestRunCLIInChangesChildCwd(t *testing.T) {
|
|
66
|
+
if runtime.GOOS == "windows" {
|
|
67
|
+
t.Skip("依赖 POSIX sh/pwd")
|
|
68
|
+
}
|
|
69
|
+
ws := filepath.Join(t.TempDir(), "proj")
|
|
70
|
+
if err := os.MkdirAll(ws, 0o755); err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
bin, log := pwdStub(t, filepath.Dir(ws), "pwd-cli")
|
|
74
|
+
|
|
75
|
+
if _, _, err := runCLIIn(context.Background(), ws, bin); err != nil {
|
|
76
|
+
t.Fatalf("runCLIIn: %v", err)
|
|
77
|
+
}
|
|
78
|
+
out, _ := os.ReadFile(log)
|
|
79
|
+
got := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
80
|
+
if !samePath(t, got, ws) {
|
|
81
|
+
t.Errorf("子进程 cwd = %q,期望 workspace %q", got, ws)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestRunCLIInWithoutWorkspaceKeepsCallerCwd(t *testing.T) {
|
|
86
|
+
if runtime.GOOS == "windows" {
|
|
87
|
+
t.Skip("依赖 POSIX sh/pwd")
|
|
88
|
+
}
|
|
89
|
+
bin, log := pwdStub(t, t.TempDir(), "pwd-cli2")
|
|
90
|
+
if _, _, err := runCLIIn(context.Background(), "", bin); err != nil {
|
|
91
|
+
t.Fatalf("runCLIIn: %v", err)
|
|
92
|
+
}
|
|
93
|
+
out, _ := os.ReadFile(log)
|
|
94
|
+
got := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
|
95
|
+
wd, _ := os.Getwd()
|
|
96
|
+
if !samePath(t, got, wd) {
|
|
97
|
+
t.Errorf("不给 workspace 时应继承调用方 cwd(%q),实得 %q", wd, got)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// claude 这类没有工作目录 flag 的引擎:靠子进程 cwd 生效(引擎内部把 req.Workspace 交给 runCLIIn)。
|
|
102
|
+
func TestClaudeCompleteRunsInWorkspace(t *testing.T) {
|
|
103
|
+
if runtime.GOOS == "windows" {
|
|
104
|
+
t.Skip("依赖 POSIX sh/pwd")
|
|
105
|
+
}
|
|
106
|
+
ws := filepath.Join(t.TempDir(), "proj")
|
|
107
|
+
if err := os.MkdirAll(ws, 0o755); err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
bin, log := pwdStub(t, filepath.Dir(ws), "claude")
|
|
111
|
+
e := &ClaudeEngine{BinPath: bin}
|
|
112
|
+
resp, err := e.Complete(context.Background(), Request{
|
|
113
|
+
Workspace: ws,
|
|
114
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
115
|
+
})
|
|
116
|
+
if err != nil {
|
|
117
|
+
t.Fatalf("Complete: %v", err)
|
|
118
|
+
}
|
|
119
|
+
if resp.Text != "ok" {
|
|
120
|
+
t.Fatalf("Text = %q want ok", resp.Text)
|
|
121
|
+
}
|
|
122
|
+
out, _ := os.ReadFile(log)
|
|
123
|
+
lines := strings.SplitN(string(out), "\n", 2)
|
|
124
|
+
if len(lines) == 0 || !samePath(t, strings.TrimSpace(lines[0]), ws) {
|
|
125
|
+
t.Errorf("claude 子进程 cwd = %q,期望 %q", strings.TrimSpace(lines[0]), ws)
|
|
126
|
+
}
|
|
127
|
+
// 无原生 flag:参数里不该凭空多出目录类开关
|
|
128
|
+
if strings.Contains(string(out), "--add-dir") {
|
|
129
|
+
t.Errorf("claude 不该自动加 --add-dir(那是追加额外目录,不是主 workspace): %s", out)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func TestCodexWorkspaceUsesNativeCD(t *testing.T) {
|
|
134
|
+
dir := t.TempDir()
|
|
135
|
+
ws := filepath.Join(dir, "proj")
|
|
136
|
+
if err := os.MkdirAll(ws, 0o755); err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
140
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
141
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
142
|
+
Workspace: ws,
|
|
143
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
144
|
+
}); err != nil {
|
|
145
|
+
t.Fatalf("Complete: %v", err)
|
|
146
|
+
}
|
|
147
|
+
data, _ := os.ReadFile(log)
|
|
148
|
+
if args := string(data); !strings.Contains(args, "-C "+ws) {
|
|
149
|
+
t.Errorf("codex 应传原生 -C <workspace>,实得 args: %s", args)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func TestCodexOmitsCDWithoutWorkspace(t *testing.T) {
|
|
154
|
+
dir := t.TempDir()
|
|
155
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
156
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
157
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
158
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
159
|
+
}); err != nil {
|
|
160
|
+
t.Fatalf("Complete: %v", err)
|
|
161
|
+
}
|
|
162
|
+
data, _ := os.ReadFile(log)
|
|
163
|
+
if args := string(data); strings.Contains(args, "-C ") {
|
|
164
|
+
t.Errorf("未指定 workspace 时不该有 -C:%s", args)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* Package boundary 是「桌面是可选插件」这条约束的自动化守卫(P1 验收要求的 CI 断言)。
|
|
2
|
+
*
|
|
3
|
+
* 方案里的判据写的是「装与不装插件时主二进制 hash、体积、`--engines` 输出一致」。
|
|
4
|
+
* 直接比 hash 要构建两次、还得人为造出「不装」的状态 —— 又慢又脆,而且真出事时
|
|
5
|
+
* 它只告诉你「不一样」,不告诉你为什么。
|
|
6
|
+
*
|
|
7
|
+
* 这里改为断言**产生那些结果的源头**:主二进制是「源码 + 依赖闭包」的纯函数,
|
|
8
|
+
* 所以只要下面两条成立,hash / 体积 / 输出就必然一致:
|
|
9
|
+
*
|
|
10
|
+
* 1) core 的依赖闭包里没有插件(`internal/client`);
|
|
11
|
+
* 2) 仓库里没有任何 `//go:embed` 把插件的 UI 资源嵌进 core。
|
|
12
|
+
*
|
|
13
|
+
* 任一条破了,都说明有人把插件塞进了 core —— 那是这条约束的实质违反。
|
|
14
|
+
* 这两条断言比 hash 对比更早失败、且报错直接指向原因。
|
|
15
|
+
*/
|
|
16
|
+
package boundary
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"bufio"
|
|
20
|
+
"bytes"
|
|
21
|
+
"io/fs"
|
|
22
|
+
"os"
|
|
23
|
+
"os/exec"
|
|
24
|
+
"path/filepath"
|
|
25
|
+
"strings"
|
|
26
|
+
"testing"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// corePkg 主二进制的包路径。断言都围绕它展开。
|
|
30
|
+
const corePkg = "./cmd/magic-agent"
|
|
31
|
+
|
|
32
|
+
// pluginPkgs 只允许出现在插件侧、绝不允许进入 core 依赖闭包的包。
|
|
33
|
+
var pluginPkgs = []string{
|
|
34
|
+
"github.com/darren/magic-agent/internal/client",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// repoRoot 从当前包目录向上找到含 go.mod 的那一层。
|
|
38
|
+
func repoRoot(t *testing.T) string {
|
|
39
|
+
t.Helper()
|
|
40
|
+
dir, err := os.Getwd()
|
|
41
|
+
if err != nil {
|
|
42
|
+
t.Fatalf("取当前目录失败: %v", err)
|
|
43
|
+
}
|
|
44
|
+
for {
|
|
45
|
+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
46
|
+
return dir
|
|
47
|
+
}
|
|
48
|
+
parent := filepath.Dir(dir)
|
|
49
|
+
if parent == dir {
|
|
50
|
+
t.Fatal("向上找不到 go.mod")
|
|
51
|
+
}
|
|
52
|
+
dir = parent
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// goListDeps 列出某个包的全部依赖(含标准库)。
|
|
57
|
+
func goListDeps(t *testing.T, root, pkg string) []string {
|
|
58
|
+
t.Helper()
|
|
59
|
+
if _, err := exec.LookPath("go"); err != nil {
|
|
60
|
+
t.Skip("本机没有 go,跳过边界断言(CI 里必有)")
|
|
61
|
+
}
|
|
62
|
+
cmd := exec.Command("go", "list", "-deps", pkg)
|
|
63
|
+
cmd.Dir = root
|
|
64
|
+
var out, errBuf bytes.Buffer
|
|
65
|
+
cmd.Stdout = &out
|
|
66
|
+
cmd.Stderr = &errBuf
|
|
67
|
+
if err := cmd.Run(); err != nil {
|
|
68
|
+
t.Fatalf("go list -deps %s 失败: %v\n%s", pkg, err, errBuf.String())
|
|
69
|
+
}
|
|
70
|
+
var deps []string
|
|
71
|
+
sc := bufio.NewScanner(&out)
|
|
72
|
+
for sc.Scan() {
|
|
73
|
+
if line := strings.TrimSpace(sc.Text()); line != "" {
|
|
74
|
+
deps = append(deps, line)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if len(deps) == 0 {
|
|
78
|
+
t.Fatal("go list -deps 输出为空,断言失去意义")
|
|
79
|
+
}
|
|
80
|
+
return deps
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// TestCoreDoesNotDependOnPlugin core 的依赖闭包里不许出现插件包。
|
|
84
|
+
//
|
|
85
|
+
// 这是「可选插件」的前提:一旦 core 依赖了插件,不装插件就跑不起来,
|
|
86
|
+
// 而且插件的依赖(HTTP 服务、静态资源、profile)会被动变成 core 的负担。
|
|
87
|
+
func TestCoreDoesNotDependOnPlugin(t *testing.T) {
|
|
88
|
+
root := repoRoot(t)
|
|
89
|
+
deps := goListDeps(t, root, corePkg)
|
|
90
|
+
|
|
91
|
+
// 先确认没有写错包路径:插件包本身必须存在于依赖解析里(用 go list 单查一次)。
|
|
92
|
+
if _, err := exec.LookPath("go"); err == nil {
|
|
93
|
+
cmd := exec.Command("go", "list", pluginPkgs[0])
|
|
94
|
+
cmd.Dir = root
|
|
95
|
+
if err := cmd.Run(); err != nil {
|
|
96
|
+
t.Fatalf("插件包 %s 不存在或无法解析,本断言的包名需要更新: %v", pluginPkgs[0], err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for _, dep := range deps {
|
|
101
|
+
for _, bad := range pluginPkgs {
|
|
102
|
+
if dep == bad || strings.HasPrefix(dep, bad+"/") {
|
|
103
|
+
t.Errorf("core 依赖了插件包 %s —— 违反了「桌面是可选插件」:\n"+
|
|
104
|
+
"core(%s)只能依赖 CLI 集成相关的东西,插件要另起命令。", dep, corePkg)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// TestCoreDoesNotEmbedPluginAssets 仓库里不许有 //go:embed 把插件的 UI 资源嵌进 core。
|
|
111
|
+
//
|
|
112
|
+
// 为什么单看 //go:embed:资源一旦被 embed,就会同时进入**所有** cmd 的二进制,
|
|
113
|
+
// 体积与 hash 都会变,而且 embed 的路径看不出「这是插件的」,最容易破线。
|
|
114
|
+
func TestCoreDoesNotEmbedPluginAssets(t *testing.T) {
|
|
115
|
+
root := repoRoot(t)
|
|
116
|
+
|
|
117
|
+
// 插件资源目录(相对仓库根)。任何 embed 指令都不许点到它们。
|
|
118
|
+
// ⚠️ 旧名是 magic-client-mobile-ui(已随目录改名失效):只写旧名会让
|
|
119
|
+
// client-ui-mobile/ 变成无人看管的目录 —— 这个测试存在的意义正是「不许 embed」,
|
|
120
|
+
// 漏一个名字等于漏掉一份资源。两个名字都留着,新名在前。
|
|
121
|
+
forbiddenInEmbed := []string{"client-ui", "client-ui-mobile", "magic-client-mobile-ui"}
|
|
122
|
+
|
|
123
|
+
var scanned int
|
|
124
|
+
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
125
|
+
if err != nil {
|
|
126
|
+
return err
|
|
127
|
+
}
|
|
128
|
+
if d.IsDir() {
|
|
129
|
+
switch d.Name() {
|
|
130
|
+
case ".git", "node_modules", "npm":
|
|
131
|
+
return fs.SkipDir
|
|
132
|
+
}
|
|
133
|
+
return nil
|
|
134
|
+
}
|
|
135
|
+
if !strings.HasSuffix(path, ".go") {
|
|
136
|
+
return nil
|
|
137
|
+
}
|
|
138
|
+
scanned++
|
|
139
|
+
data, err := os.ReadFile(path)
|
|
140
|
+
if err != nil {
|
|
141
|
+
return err
|
|
142
|
+
}
|
|
143
|
+
rel, _ := filepath.Rel(root, path)
|
|
144
|
+
sc := bufio.NewScanner(bytes.NewReader(data))
|
|
145
|
+
for sc.Scan() {
|
|
146
|
+
line := strings.TrimSpace(sc.Text())
|
|
147
|
+
if !strings.HasPrefix(line, "//go:embed") {
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
for _, bad := range forbiddenInEmbed {
|
|
151
|
+
if strings.Contains(line, bad) {
|
|
152
|
+
t.Errorf("%s 里的 embed 指令指向了插件资源:\n %s\n"+
|
|
153
|
+
"插件资源(%s)只能由插件自己的命令持有。", rel, line, bad)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return nil
|
|
158
|
+
})
|
|
159
|
+
if err != nil {
|
|
160
|
+
t.Fatalf("扫描仓库失败: %v", err)
|
|
161
|
+
}
|
|
162
|
+
if scanned == 0 {
|
|
163
|
+
t.Fatal("一个 .go 文件都没扫到,断言失去意义(工作目录不对?)")
|
|
164
|
+
}
|
|
165
|
+
}
|