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,295 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// permission_test.go - CLI 层的四档权限模型测试(--permission)。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:
|
|
6
|
+
// - resolvePermissionTier 的解析与两道校验(取值合法性 / 引擎是否接线)
|
|
7
|
+
// - flag 默认值契约(默认 full = 保持既有行为)
|
|
8
|
+
// - --engines 输出里的 permission 能力字段
|
|
9
|
+
// - prepareAsk 把 --permission* 正确写进 agent.Request(flag → Request 的接线)
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ 本包测试进程里的引擎注册表是**假的**(见 workspace_test.go 的说明:
|
|
12
|
+
// agent.Engines() 只在 registry 为空时才 initEngines(),而本包测试先 Register 了
|
|
13
|
+
// 假引擎 → 真实引擎不会出现)。所以这里一律按**名字**注册假引擎,并只断言
|
|
14
|
+
// 与引擎实现无关的东西(名字驱动的能力函数、prepareAsk 产出的 Request)。
|
|
15
|
+
// 「Request → claude argv → 子进程」那一段在 internal/agent/permission_test.go
|
|
16
|
+
// 里用真 ClaudeEngine + 假 CLI 覆盖(那才是能直接构造引擎的层)。
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"encoding/json"
|
|
20
|
+
"os"
|
|
21
|
+
"path/filepath"
|
|
22
|
+
"strings"
|
|
23
|
+
"testing"
|
|
24
|
+
|
|
25
|
+
"github.com/spf13/cobra"
|
|
26
|
+
|
|
27
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// TestResolvePermissionTier 解析 + 校验。
|
|
31
|
+
func TestResolvePermissionTier(t *testing.T) {
|
|
32
|
+
cases := []struct {
|
|
33
|
+
name string
|
|
34
|
+
engine string
|
|
35
|
+
raw string
|
|
36
|
+
explicit bool
|
|
37
|
+
want agent.PermissionTier
|
|
38
|
+
wantErr bool
|
|
39
|
+
errHas string
|
|
40
|
+
}{
|
|
41
|
+
{"四档规范名", "claude", "manual", true, agent.PermissionManual, false, ""},
|
|
42
|
+
{"accept-edits", "claude", "accept-edits", true, agent.PermissionAcceptEdits, false, ""},
|
|
43
|
+
{"auto", "codebuddy", "auto", true, agent.PermissionAuto, false, ""},
|
|
44
|
+
{"full", "claude", "full", true, agent.PermissionFull, false, ""},
|
|
45
|
+
{"别名 default", "claude", "default", true, agent.PermissionManual, false, ""},
|
|
46
|
+
{"数字档位", "claude", "3", true, agent.PermissionAuto, false, ""},
|
|
47
|
+
{"大小写不敏感", "claude", "AUTO", true, agent.PermissionAuto, false, ""},
|
|
48
|
+
{"非法取值 plan", "claude", "plan", true, "", true, "invalid --permission"},
|
|
49
|
+
{"非法取值 dontAsk", "claude", "dontAsk", true, "", true, "invalid --permission"},
|
|
50
|
+
{"空取值", "claude", "", true, "", true, "invalid --permission"},
|
|
51
|
+
// 未接线引擎:显式传才报错(默认值不能把 `-e codex` 这类既有调用打挂)
|
|
52
|
+
{"未接线引擎显式传", "codex", "auto", true, "", true, "暂不支持"},
|
|
53
|
+
{"未接线引擎 trae", "trae", "manual", true, "", true, "暂不支持"},
|
|
54
|
+
{"未接线引擎默认值放行", "codex", "full", false, agent.PermissionFull, false, ""},
|
|
55
|
+
{"已接线引擎默认值", "claude", "full", false, agent.PermissionFull, false, ""},
|
|
56
|
+
}
|
|
57
|
+
for _, c := range cases {
|
|
58
|
+
t.Run(c.name, func(t *testing.T) {
|
|
59
|
+
got, err := resolvePermissionTier(c.engine, c.raw, c.explicit)
|
|
60
|
+
if c.wantErr {
|
|
61
|
+
if err == nil {
|
|
62
|
+
t.Fatalf("期望报错,得到 tier=%q", got)
|
|
63
|
+
}
|
|
64
|
+
if c.errHas != "" && !strings.Contains(err.Error(), c.errHas) {
|
|
65
|
+
t.Errorf("错误信息 %q 不含 %q", err.Error(), c.errHas)
|
|
66
|
+
}
|
|
67
|
+
// 参数类错误必须是 usageError(exit 2)。
|
|
68
|
+
if _, ok := err.(*usageError); !ok {
|
|
69
|
+
t.Errorf("应为 *usageError(exit 2),得到 %T", err)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if err != nil {
|
|
74
|
+
t.Fatalf("不该报错: %v", err)
|
|
75
|
+
}
|
|
76
|
+
if got != c.want {
|
|
77
|
+
t.Errorf("tier = %q, want %q", got, c.want)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// TestPermissionFlagDefaults 默认值契约:--permission 默认 full。
|
|
84
|
+
//
|
|
85
|
+
// 改默认档等于静默变更既有调用方的行为(agent 会突然开始弹审批 / 被沙箱拦),
|
|
86
|
+
// 所以这个默认值必须被测试钉住。
|
|
87
|
+
func TestPermissionFlagDefaults(t *testing.T) {
|
|
88
|
+
cmd := NewRootCommand()
|
|
89
|
+
f := cmd.PersistentFlags().Lookup("permission")
|
|
90
|
+
if f == nil {
|
|
91
|
+
t.Fatal("--permission flag 未注册")
|
|
92
|
+
}
|
|
93
|
+
if f.DefValue != "full" {
|
|
94
|
+
t.Errorf("--permission 默认值 = %q, want full(= agent.DefaultPermissionTier,保持既有行为)", f.DefValue)
|
|
95
|
+
}
|
|
96
|
+
if f.DefValue != string(agent.DefaultPermissionTier) {
|
|
97
|
+
t.Errorf("--permission 默认值 = %q 与 agent.DefaultPermissionTier=%q 不一致",
|
|
98
|
+
f.DefValue, agent.DefaultPermissionTier)
|
|
99
|
+
}
|
|
100
|
+
// 配套 flags 也要在(否则 --permission-deny 之类会被当成 prompt 发给引擎)。
|
|
101
|
+
for _, name := range []string{
|
|
102
|
+
"sandbox-exclude", "sandbox-domain", "auto-mode-env", "permission-deny", "permission-ask",
|
|
103
|
+
} {
|
|
104
|
+
if cmd.PersistentFlags().Lookup(name) == nil {
|
|
105
|
+
t.Errorf("--%s 未注册", name)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// TestEnginesReportsPermission --engines 每行要带 permission 能力字段。
|
|
111
|
+
//
|
|
112
|
+
// 按 workspace_test.go 的既有约定按名字注册假引擎(本包真实引擎不会出现)。
|
|
113
|
+
func TestEnginesReportsPermission(t *testing.T) {
|
|
114
|
+
for _, n := range []string{"claude", "codebuddy", "codebuddy-ai", "trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
|
|
115
|
+
registerFake(&stringEngine{name: n, text: "x"})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatalf("--engines: %v", err)
|
|
121
|
+
}
|
|
122
|
+
var rows []struct {
|
|
123
|
+
Engine string `json:"engine"`
|
|
124
|
+
Permission string `json:"permission"`
|
|
125
|
+
}
|
|
126
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
127
|
+
t.Fatalf("--engines 输出不是 JSON 数组: %v\n%s", err, stdout)
|
|
128
|
+
}
|
|
129
|
+
seen := map[string]string{}
|
|
130
|
+
for _, r := range rows {
|
|
131
|
+
seen[r.Engine] = r.Permission
|
|
132
|
+
}
|
|
133
|
+
if seen["claude"] != "flag:--permission-mode" {
|
|
134
|
+
t.Errorf("claude 的 permission 字段 = %q, want flag:--permission-mode", seen["claude"])
|
|
135
|
+
}
|
|
136
|
+
if seen["codebuddy"] != "flag:--permission-mode" {
|
|
137
|
+
t.Errorf("codebuddy 的 permission 字段 = %q, want flag:--permission-mode", seen["codebuddy"])
|
|
138
|
+
}
|
|
139
|
+
if seen["codebuddy-ai"] != "flag:--permission-mode" {
|
|
140
|
+
t.Errorf("codebuddy-ai 的 permission 字段 = %q, want flag:--permission-mode", seen["codebuddy-ai"])
|
|
141
|
+
}
|
|
142
|
+
for _, e := range []string{"trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
|
|
143
|
+
if v, ok := seen[e]; !ok {
|
|
144
|
+
t.Errorf("--engines 缺 %s 行", e)
|
|
145
|
+
} else if v != "none" {
|
|
146
|
+
t.Errorf("%s 的 permission 字段 = %q, want none", e, v)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// TestPermissionUnsupportedEngineExitsUsage 未接线引擎 + 显式 --permission → usage 错误。
|
|
152
|
+
func TestPermissionUnsupportedEngineExitsUsage(t *testing.T) {
|
|
153
|
+
registerFake(&stringEngine{name: "fake-perm-none", text: "x"})
|
|
154
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-perm-none", "--permission", "auto", "-p", "hi")
|
|
155
|
+
if err == nil {
|
|
156
|
+
t.Fatal("期望报错(引擎未接线)")
|
|
157
|
+
}
|
|
158
|
+
if !strings.Contains(err.Error(), "暂不支持") {
|
|
159
|
+
t.Errorf("错误信息 = %q,应说明引擎未接线", err.Error())
|
|
160
|
+
}
|
|
161
|
+
if _, ok := err.(*usageError); !ok {
|
|
162
|
+
t.Errorf("应为 *usageError(exit 2),得到 %T", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// TestPermissionInvalidValueExitsUsage 非法取值 → usage 错误。
|
|
167
|
+
func TestPermissionInvalidValueExitsUsage(t *testing.T) {
|
|
168
|
+
registerFake(&stringEngine{name: "fake-perm-bad", text: "x"})
|
|
169
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-perm-bad", "--permission", "yolo-nope", "-p", "hi")
|
|
170
|
+
if err == nil {
|
|
171
|
+
t.Fatal("期望报错(取值非法)")
|
|
172
|
+
}
|
|
173
|
+
if !strings.Contains(err.Error(), "invalid --permission") {
|
|
174
|
+
t.Errorf("错误信息 = %q", err.Error())
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// TestPermissionToolsOffHint --tools off 下显式传非默认档 → stderr 提示(不静默)。
|
|
179
|
+
//
|
|
180
|
+
// 该提示由 prepareAsk 依 flag 是否显式传过决定,与引擎实现无关,故对假引擎同样成立。
|
|
181
|
+
func TestPermissionToolsOffHint(t *testing.T) {
|
|
182
|
+
registerFake(&stringEngine{name: "fake-perm-hint", text: "x"})
|
|
183
|
+
// 假引擎不在支持列表里,所以这里只用「默认档 + 显式传」的组合验证不提示;
|
|
184
|
+
// 非默认档的提示路径用 claude 这个名字(名字即被支持)验证。
|
|
185
|
+
_, stderr, err := runAskCmd(t, "", "-e", "fake-perm-hint", "-p", "hi")
|
|
186
|
+
if err != nil {
|
|
187
|
+
t.Fatalf("不该失败: %v", err)
|
|
188
|
+
}
|
|
189
|
+
if strings.Contains(stderr, "不会生效") {
|
|
190
|
+
t.Errorf("默认档不该提示档位不生效,得到 %q", stderr)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
registerFake(&stringEngine{name: "claude", text: "ok"})
|
|
194
|
+
_, stderr2, err := runAskCmd(t, "", "-e", "claude", "--tools", "off",
|
|
195
|
+
"--permission", "manual", "-o", "text", "-p", "hi")
|
|
196
|
+
if err != nil {
|
|
197
|
+
t.Fatalf("不该失败: %v", err)
|
|
198
|
+
}
|
|
199
|
+
if !strings.Contains(stderr2, "不会生效") {
|
|
200
|
+
t.Errorf("stderr 应提示档位在 --tools off 下不生效,得到 %q", stderr2)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── flag → agent.Request 的接线 ───────────────────────────────
|
|
205
|
+
|
|
206
|
+
// TestPrepareAskWiresPermission prepareAsk 必须把 --permission* 原样写进 Request。
|
|
207
|
+
//
|
|
208
|
+
// 这是四档模型在 CLI 层的接线证据。断言只针对 Request 的字段(与哪个引擎实现
|
|
209
|
+
// 被 Lookup 命中无关),所以对本包的假引擎注册表也稳定。
|
|
210
|
+
func TestPrepareAskWiresPermission(t *testing.T) {
|
|
211
|
+
// 让引擎预检无论如何都能过:真 ClaudeEngine 走这个假 CLI,假引擎自带 Detect=true。
|
|
212
|
+
dummy := filepath.Join(t.TempDir(), "claude")
|
|
213
|
+
if err := os.WriteFile(dummy, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
214
|
+
t.Fatal(err)
|
|
215
|
+
}
|
|
216
|
+
t.Setenv("MAGIC_AGENT_CLAUDE_BIN", dummy)
|
|
217
|
+
|
|
218
|
+
opts := newAskOptions()
|
|
219
|
+
cmd := &cobra.Command{Use: "t", SilenceUsage: true, SilenceErrors: true}
|
|
220
|
+
bindAskFlags(cmd, opts)
|
|
221
|
+
var req agent.Request
|
|
222
|
+
cmd.RunE = func(c *cobra.Command, args []string) error {
|
|
223
|
+
_, _, r, err := prepareAsk(c, args, opts)
|
|
224
|
+
req = r
|
|
225
|
+
return err
|
|
226
|
+
}
|
|
227
|
+
cmd.SetIn(strings.NewReader(""))
|
|
228
|
+
cmd.SetArgs([]string{
|
|
229
|
+
"-e", "claude", "--tools", "on",
|
|
230
|
+
"--permission", "accept-edits",
|
|
231
|
+
"--sandbox-exclude", "docker,watchman",
|
|
232
|
+
"--sandbox-domain", "api.example.com",
|
|
233
|
+
"--auto-mode-env", "Source control: github.example.com/acme-corp",
|
|
234
|
+
"--permission-deny", "Bash(rm -rf *)",
|
|
235
|
+
"--permission-ask", "Bash(git push *)",
|
|
236
|
+
"-p", "hi",
|
|
237
|
+
})
|
|
238
|
+
if err := cmd.Execute(); err != nil {
|
|
239
|
+
t.Fatalf("prepareAsk 失败: %v", err)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if req.Permission != agent.PermissionAcceptEdits {
|
|
243
|
+
t.Errorf("Request.Permission = %q, want %q", req.Permission, agent.PermissionAcceptEdits)
|
|
244
|
+
}
|
|
245
|
+
po := req.PermissionOptions
|
|
246
|
+
if strings.Join(po.ExcludedCommands, ",") != "docker,watchman" {
|
|
247
|
+
t.Errorf("ExcludedCommands = %v", po.ExcludedCommands)
|
|
248
|
+
}
|
|
249
|
+
if len(po.AllowedDomains) != 1 || po.AllowedDomains[0] != "api.example.com" {
|
|
250
|
+
t.Errorf("AllowedDomains = %v", po.AllowedDomains)
|
|
251
|
+
}
|
|
252
|
+
if len(po.AutoModeEnvironment) != 1 || po.AutoModeEnvironment[0] != "Source control: github.example.com/acme-corp" {
|
|
253
|
+
t.Errorf("AutoModeEnvironment = %v", po.AutoModeEnvironment)
|
|
254
|
+
}
|
|
255
|
+
if len(po.Deny) != 1 || po.Deny[0] != "Bash(rm -rf *)" {
|
|
256
|
+
t.Errorf("Deny = %v", po.Deny)
|
|
257
|
+
}
|
|
258
|
+
if len(po.Ask) != 1 || po.Ask[0] != "Bash(git push *)" {
|
|
259
|
+
t.Errorf("Ask = %v", po.Ask)
|
|
260
|
+
}
|
|
261
|
+
if !agent.ToolsIsOn(req.Tools) {
|
|
262
|
+
t.Errorf("Tools 应为 on,得到 %v", req.Tools)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// TestPrepareAskPermissionDefaults Request.Permission 在未传 flag 时是默认档 full。
|
|
267
|
+
func TestPrepareAskPermissionDefaults(t *testing.T) {
|
|
268
|
+
dummy := filepath.Join(t.TempDir(), "claude")
|
|
269
|
+
if err := os.WriteFile(dummy, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
270
|
+
t.Fatal(err)
|
|
271
|
+
}
|
|
272
|
+
t.Setenv("MAGIC_AGENT_CLAUDE_BIN", dummy)
|
|
273
|
+
|
|
274
|
+
opts := newAskOptions()
|
|
275
|
+
cmd := &cobra.Command{Use: "t", SilenceUsage: true, SilenceErrors: true}
|
|
276
|
+
bindAskFlags(cmd, opts)
|
|
277
|
+
var req agent.Request
|
|
278
|
+
cmd.RunE = func(c *cobra.Command, args []string) error {
|
|
279
|
+
_, _, r, err := prepareAsk(c, args, opts)
|
|
280
|
+
req = r
|
|
281
|
+
return err
|
|
282
|
+
}
|
|
283
|
+
cmd.SetIn(strings.NewReader(""))
|
|
284
|
+
cmd.SetArgs([]string{"-e", "claude", "-p", "hi"})
|
|
285
|
+
if err := cmd.Execute(); err != nil {
|
|
286
|
+
t.Fatalf("prepareAsk 失败: %v", err)
|
|
287
|
+
}
|
|
288
|
+
if req.Permission != agent.PermissionFull {
|
|
289
|
+
t.Errorf("Request.Permission = %q, want full(默认档)", req.Permission)
|
|
290
|
+
}
|
|
291
|
+
// 未传可选项时应当是零值(零值即最严格)。
|
|
292
|
+
if len(req.PermissionOptions.Deny) != 0 || len(req.PermissionOptions.Ask) != 0 {
|
|
293
|
+
t.Errorf("未传规则时不该有值: %+v", req.PermissionOptions)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// prompt_source_test.go - 「提示词可以是文件路径」的单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 用户 2026-09-17 的要求:「传了提示词需要支持传入文件路径」。
|
|
6
|
+
// 覆盖:-p / 位置参数 / -s / 配置里的 systemPrompt 四条入口 + @ 强制形式 +
|
|
7
|
+
// 保守判据(多行、目录、超长、不存在的路径都按普通文本处理,不能被误判)。
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"strings"
|
|
14
|
+
"testing"
|
|
15
|
+
|
|
16
|
+
"github.com/darren/magic-agent/internal/config"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// writePromptFile 在临时目录写一个提示词文件,返回其路径。
|
|
20
|
+
func writePromptFile(t *testing.T, name, content string) string {
|
|
21
|
+
t.Helper()
|
|
22
|
+
p := filepath.Join(t.TempDir(), name)
|
|
23
|
+
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
24
|
+
t.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
return p
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── expandPromptSource 单测 ──────────────────────────────────
|
|
30
|
+
|
|
31
|
+
func TestExpandPromptSourcePlainText(t *testing.T) {
|
|
32
|
+
// 普通提示词原样返回,且不应产生提示(否则每次调用都刷一行 stderr)。
|
|
33
|
+
for _, s := range []string{"你好", "解释一下 README.md", "多行\n提示词\n不受影响", ""} {
|
|
34
|
+
text, note, err := expandPromptSource(s)
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatalf("%q: %v", s, err)
|
|
37
|
+
}
|
|
38
|
+
if text != s || note != "" {
|
|
39
|
+
t.Errorf("%q → text=%q note=%q(应原样、无提示)", s, text, note)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestExpandPromptSourceExistingFile(t *testing.T) {
|
|
45
|
+
f := writePromptFile(t, "p.md", " 文件里的提示词\n")
|
|
46
|
+
text, note, err := expandPromptSource(f)
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("err = %v", err)
|
|
49
|
+
}
|
|
50
|
+
if text != "文件里的提示词" {
|
|
51
|
+
t.Errorf("text = %q want 文件内容(去首尾空白)", text)
|
|
52
|
+
}
|
|
53
|
+
if !strings.Contains(note, "命中文件") || !strings.Contains(note, f) {
|
|
54
|
+
t.Errorf("note 应说明命中文件并给出路径, got %q", note)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestExpandPromptSourceForcedAt(t *testing.T) {
|
|
59
|
+
f := writePromptFile(t, "sys.md", "你是审稿人")
|
|
60
|
+
text, note, err := expandPromptSource("@" + f)
|
|
61
|
+
if err != nil {
|
|
62
|
+
t.Fatalf("err = %v", err)
|
|
63
|
+
}
|
|
64
|
+
if text != "你是审稿人" || note == "" {
|
|
65
|
+
t.Errorf("text = %q note = %q", text, note)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// @ 后为空 / 文件不存在 / 是目录 → 报错(强制语义下不猜测)
|
|
69
|
+
if _, _, err := expandPromptSource("@"); err == nil {
|
|
70
|
+
t.Error("@ 空路径应报错")
|
|
71
|
+
}
|
|
72
|
+
if _, _, err := expandPromptSource("@/no/such/file.md"); err == nil {
|
|
73
|
+
t.Error("不存在的文件(强制)应报错")
|
|
74
|
+
}
|
|
75
|
+
if _, _, err := expandPromptSource("@" + t.TempDir()); err == nil {
|
|
76
|
+
t.Error("目录(强制)应报错")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestExpandPromptSourceConservativeGuards(t *testing.T) {
|
|
81
|
+
// 目录:非强制时按普通文本处理(不能把 -p <目录> 读崩)
|
|
82
|
+
dir := t.TempDir()
|
|
83
|
+
if text, note, err := expandPromptSource(dir); err != nil || text != dir || note != "" {
|
|
84
|
+
t.Errorf("目录应原样当文本: text=%q note=%q err=%v", text, note, err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 不存在的路径:原样当文本
|
|
88
|
+
if text, note, err := expandPromptSource("/no/such/file.md"); err != nil || text != "/no/such/file.md" || note != "" {
|
|
89
|
+
t.Errorf("不存在的路径应原样: text=%q note=%q err=%v", text, note, err)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 多行文本即使含真实文件路径也不识别(按正文用)
|
|
93
|
+
f := writePromptFile(t, "p.md", "内容")
|
|
94
|
+
multi := "看看这个文件\n" + f
|
|
95
|
+
if text, note, err := expandPromptSource(multi); err != nil || text != multi || note != "" {
|
|
96
|
+
t.Errorf("多行应原样: text=%q note=%q err=%v", text, note, err)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 超长单行也不例外(超过 maxPromptPathLen 直接按文本)
|
|
100
|
+
long := f + strings.Repeat("x", maxPromptPathLen)
|
|
101
|
+
if text, note, err := expandPromptSource(long); err != nil || text != long || note != "" {
|
|
102
|
+
t.Errorf("超长应原样: note=%q err=%v", note, err)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 端到端:四条入口 ────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
// -p 给文件路径 → 用文件内容(并向 stderr 说明)。
|
|
109
|
+
func TestPromptFlagAcceptsFilePath(t *testing.T) {
|
|
110
|
+
capEng := &capturingEngine{name: "fake-pfile"}
|
|
111
|
+
registerFake(capEng)
|
|
112
|
+
f := writePromptFile(t, "ask.md", "总结这份文档")
|
|
113
|
+
|
|
114
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-pfile", "-p", f)
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatalf("ask: %v", err)
|
|
117
|
+
}
|
|
118
|
+
if capEng.lastPrompt != "总结这份文档" {
|
|
119
|
+
t.Errorf("prompt = %q want 文件内容", capEng.lastPrompt)
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(stderr, "命中文件") {
|
|
122
|
+
t.Errorf("stderr 应说明按文件读取, got %q", stderr)
|
|
123
|
+
}
|
|
124
|
+
if strings.Contains(stdout, "命中文件") {
|
|
125
|
+
t.Errorf("提示不该污染 stdout, got %q", stdout)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -p 给文件路径 + 追加文本:文件内容在前、追加在后(与 -f 语义一致)。
|
|
130
|
+
func TestPromptFlagFilePlusText(t *testing.T) {
|
|
131
|
+
capEng := &capturingEngine{name: "fake-pfile2"}
|
|
132
|
+
registerFake(capEng)
|
|
133
|
+
f := writePromptFile(t, "ask.md", "上下文内容")
|
|
134
|
+
|
|
135
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-pfile2", "-p", f, "补充一句"); err != nil {
|
|
136
|
+
t.Fatalf("ask: %v", err)
|
|
137
|
+
}
|
|
138
|
+
if want := "上下文内容\n\n补充一句"; capEng.lastPrompt != want {
|
|
139
|
+
t.Errorf("prompt = %q want %q", capEng.lastPrompt, want)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 位置参数给文件路径 → 同样按文件读。
|
|
144
|
+
func TestPositionalArgAcceptsFilePath(t *testing.T) {
|
|
145
|
+
capEng := &capturingEngine{name: "fake-argfile"}
|
|
146
|
+
registerFake(capEng)
|
|
147
|
+
f := writePromptFile(t, "task.md", "任务描述")
|
|
148
|
+
|
|
149
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-argfile", f); err != nil {
|
|
150
|
+
t.Fatalf("ask: %v", err)
|
|
151
|
+
}
|
|
152
|
+
if capEng.lastPrompt != "任务描述" {
|
|
153
|
+
t.Errorf("prompt = %q want 文件内容", capEng.lastPrompt)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -p @不存在的文件 → usage error(exit 2),不猜测、不静默当文本。
|
|
158
|
+
func TestPromptFlagForcedAtMissingIsUsageError(t *testing.T) {
|
|
159
|
+
registerFake(&stringEngine{name: "fake-atmiss", text: "x"})
|
|
160
|
+
|
|
161
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-atmiss", "-p", "@/no/such/prompt.md")
|
|
162
|
+
if err == nil {
|
|
163
|
+
t.Fatal("应报错")
|
|
164
|
+
}
|
|
165
|
+
if _, ok := err.(*usageError); !ok {
|
|
166
|
+
t.Errorf("应是 usageError(exit 2), got %T: %v", err, err)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// -s 给文件路径 → 用文件内容作为 system prompt。
|
|
171
|
+
func TestSystemFlagAcceptsFilePath(t *testing.T) {
|
|
172
|
+
capEng := &capturingEngine{name: "fake-sfile"}
|
|
173
|
+
registerFake(capEng)
|
|
174
|
+
f := writePromptFile(t, "reviewer.md", "你是严格的审稿人")
|
|
175
|
+
|
|
176
|
+
_, stderr, err := runAskCmd(t, "", "-e", "fake-sfile", "-s", f, "看这段")
|
|
177
|
+
if err != nil {
|
|
178
|
+
t.Fatalf("ask: %v", err)
|
|
179
|
+
}
|
|
180
|
+
if capEng.lastSystem != "你是严格的审稿人" {
|
|
181
|
+
t.Errorf("SystemPrompt = %q want 文件内容", capEng.lastSystem)
|
|
182
|
+
}
|
|
183
|
+
if !strings.Contains(stderr, "命中文件") {
|
|
184
|
+
t.Errorf("stderr 应说明按文件读取, got %q", stderr)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 配置里的默认 systemPrompt 指向文件 → 也按文件读;@ 不存在的文件 → 不注入(不阻断调用)。
|
|
189
|
+
func TestConfigSystemPromptAcceptsFilePath(t *testing.T) {
|
|
190
|
+
capEng := &capturingEngine{name: "fake-cfgsys"}
|
|
191
|
+
registerFake(capEng)
|
|
192
|
+
f := writePromptFile(t, "cn.md", "你是一个中文助手,始终用中文回答所有问题。")
|
|
193
|
+
|
|
194
|
+
// ① 值就是文件路径(自动识别)
|
|
195
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":`+jsonQuote(t, f)+`}`))
|
|
196
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cfgsys", "hi"); err != nil {
|
|
197
|
+
t.Fatalf("ask: %v", err)
|
|
198
|
+
}
|
|
199
|
+
if capEng.lastSystem != "你是一个中文助手,始终用中文回答所有问题。" {
|
|
200
|
+
t.Errorf("SystemPrompt = %q want 文件内容", capEng.lastSystem)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ② @ 强制形式指向不存在的文件 → 本次不注入,但调用照常成功
|
|
204
|
+
capEng.lastSystem = ""
|
|
205
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":"@/no/such/system.md"}`))
|
|
206
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cfgsys", "hi"); err != nil {
|
|
207
|
+
t.Fatalf("坏 @ 路径不应让调用失败: %v", err)
|
|
208
|
+
}
|
|
209
|
+
if capEng.lastSystem != "" {
|
|
210
|
+
t.Errorf("应不注入, got %q", capEng.lastSystem)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// jsonQuote 把路径安全地塞进测试用 JSON 字符串(路径可能含中文/反斜杠)。
|
|
215
|
+
func jsonQuote(t *testing.T, s string) string {
|
|
216
|
+
t.Helper()
|
|
217
|
+
b, err := json.Marshal(s)
|
|
218
|
+
if err != nil {
|
|
219
|
+
t.Fatal(err)
|
|
220
|
+
}
|
|
221
|
+
return string(b)
|
|
222
|
+
}
|