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,406 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// openclaw_acp_test.go - openclaw 流式(ACP)通道的单测:**假 CLI 回放**,不碰真 Gateway。
|
|
4
|
+
//
|
|
5
|
+
// 假 openclaw 是一个交互式 shell 脚本:从 stdin 逐行读 JSON-RPC 帧、按 method 回响应,
|
|
6
|
+
// 并把收到的每一帧追加到 stdin.log 供断言(「客户端到底发了什么」也要钉住 ——
|
|
7
|
+
// 会话 key 是经 `_meta.sessionKey` 传的,这一条不测就等于没接线)。
|
|
8
|
+
//
|
|
9
|
+
// 覆盖:
|
|
10
|
+
// ① 握手与事件映射(banner 噪声不打断 / 正文增量 / tool_call / tool_call_update / stopReason)
|
|
11
|
+
// ② 会话锚点:新会话自造 key、已给的 key 原样透传、老 uuid 经 sessions 反查成 key
|
|
12
|
+
// ③ exec 审批:execute 类拒绝、read 类放行(含 MAGIC_AGENT_OPENCLAW_ACP_APPROVE=all 覆盖)
|
|
13
|
+
// ④ 桥不可用(Gateway 没起 / scope 未批)→ 自动回退内嵌一次性调用(正文补发成一条 text 增量)
|
|
14
|
+
// ⑤ SupportsStream / --engines 的 streaming 标记
|
|
15
|
+
|
|
16
|
+
import (
|
|
17
|
+
"context"
|
|
18
|
+
"os"
|
|
19
|
+
"path/filepath"
|
|
20
|
+
"strconv"
|
|
21
|
+
"strings"
|
|
22
|
+
"testing"
|
|
23
|
+
"time"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// useTempBreaker 把冷却标记文件指到临时路径,返回该路径。
|
|
27
|
+
// 每个用例各自隔离:既不互相影响,也不受本机真实冷却文件影响。
|
|
28
|
+
func useTempBreaker(t *testing.T) string {
|
|
29
|
+
t.Helper()
|
|
30
|
+
p := filepath.Join(t.TempDir(), "openclaw-acp-broken.json")
|
|
31
|
+
t.Setenv("MAGIC_AGENT_OPENCLAW_ACP_BREAKER", p)
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
|
36
|
+
|
|
37
|
+
// acpStubOpts 造桩时可调的几处。
|
|
38
|
+
type acpStubOpts struct {
|
|
39
|
+
// permKind session/request_permission 里那条工具调用的 kind(决定我们放行还是拒绝)。
|
|
40
|
+
permKind string
|
|
41
|
+
// bridgeFail 为真时:只往 stderr 打「gateway connect failed…」并 exit 1(模拟桥连不上)。
|
|
42
|
+
bridgeFail bool
|
|
43
|
+
// sessionsJSON `openclaw sessions --json` 的返回(老 uuid → key 反查用);空则不接管该子命令。
|
|
44
|
+
sessionsJSON string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// acpStub 写一个假 openclaw 到临时目录,返回可执行路径与 stdin 日志路径。
|
|
48
|
+
func acpStub(t *testing.T, o acpStubOpts) (bin, logPath string) {
|
|
49
|
+
t.Helper()
|
|
50
|
+
dir := t.TempDir()
|
|
51
|
+
bin = filepath.Join(dir, "openclaw")
|
|
52
|
+
logPath = filepath.Join(dir, "stdin.log")
|
|
53
|
+
|
|
54
|
+
perm := `printf '%s\n' '{"jsonrpc":"2.0","id":99,"method":"session/request_permission","params":{"sessionId":"acp-sid-1","toolCall":{"toolCallId":"tc-2","title":"Bash","kind":"` + o.permKind + `"},"options":[{"optionId":"allow-1","name":"允许","kind":"allow_once"},{"optionId":"reject-1","name":"拒绝","kind":"reject_once"}]}}'`
|
|
55
|
+
|
|
56
|
+
var b strings.Builder
|
|
57
|
+
b.WriteString("#!/bin/sh\n")
|
|
58
|
+
b.WriteString("LOG=" + logPath + "\n")
|
|
59
|
+
if o.sessionsJSON != "" {
|
|
60
|
+
b.WriteString("case \"$*\" in\n *sessions*) printf '%s\\n' '" + o.sessionsJSON + "'; exit 0 ;;\nesac\n")
|
|
61
|
+
}
|
|
62
|
+
// 内嵌回退路径(Complete):`agent --local --agent main --json …`
|
|
63
|
+
b.WriteString("case \"$*\" in\n *agent*--json*) printf '%s\\n' '{\"payloads\":[{\"text\":\"内嵌回退的正文\",\"mediaUrl\":null}],\"meta\":{\"agentMeta\":{\"sessionId\":\"sid-fallback\",\"model\":\"m-fb\",\"usage\":{\"input\":1,\"output\":2,\"total\":3}}}}'; exit 0 ;;\nesac\n")
|
|
64
|
+
if o.bridgeFail {
|
|
65
|
+
b.WriteString("printf 'spawn\\n' >> " + filepath.Join(dir, "spawn.log") + "\n")
|
|
66
|
+
b.WriteString("echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval' >&2\n")
|
|
67
|
+
b.WriteString("echo 'ACP bridge failed: scope upgrade pending approval' >&2\n")
|
|
68
|
+
b.WriteString("exit 1\n")
|
|
69
|
+
}
|
|
70
|
+
// ACP 会话:噪声 banner(非 JSON 帧)+ 逐帧应答
|
|
71
|
+
b.WriteString("printf '%s\\n' '◇ Doctor warnings ───────────────────────────'\n")
|
|
72
|
+
b.WriteString("while IFS= read -r line; do\n")
|
|
73
|
+
b.WriteString(" printf '%s\\n' \"$line\" >> \"$LOG\"\n")
|
|
74
|
+
b.WriteString(" case \"$line\" in\n")
|
|
75
|
+
b.WriteString(" *'\"method\":\"initialize\"'*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":1,\"agentCapabilities\":{}}}' ;;\n")
|
|
76
|
+
b.WriteString(" *'\"method\":\"session/new\"'*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sessionId\":\"acp-sid-1\"}}' ;;\n")
|
|
77
|
+
b.WriteString(" *'\"method\":\"session/prompt\"'*)\n")
|
|
78
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-sid-1\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"逐字\"}}}}'\n")
|
|
79
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-sid-1\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"增量\"}}}}'\n")
|
|
80
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-sid-1\",\"update\":{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"tc-1\",\"title\":\"Bash\",\"kind\":\"read\",\"rawInput\":{\"command\":\"echo hi\"}}}}'\n")
|
|
81
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-sid-1\",\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"tc-1\",\"status\":\"completed\",\"rawOutput\":\"hi\"}}}'\n")
|
|
82
|
+
// 同一次调用再推一条(真机实测会收到 content 与 rawOutput 两条)→ 客户端必须去重
|
|
83
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-sid-1\",\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"tc-1\",\"status\":\"completed\",\"content\":[{\"type\":\"content\",\"content\":{\"type\":\"text\",\"text\":\"hi(dup)\"}}]}}}'\n")
|
|
84
|
+
b.WriteString(" not-json-noise\n")
|
|
85
|
+
b.WriteString(" " + perm + "\n")
|
|
86
|
+
b.WriteString(" printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"stopReason\":\"end_turn\"}}' ;;\n")
|
|
87
|
+
b.WriteString(" esac\n")
|
|
88
|
+
b.WriteString("done\n")
|
|
89
|
+
if err := os.WriteFile(bin, []byte(b.String()), 0o755); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
return bin, logPath
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// readStubLog 读回桩记录到的所有请求帧。
|
|
96
|
+
func readStubLog(t *testing.T, path string) string {
|
|
97
|
+
t.Helper()
|
|
98
|
+
b, err := os.ReadFile(path)
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatalf("读桩日志失败: %v", err)
|
|
101
|
+
}
|
|
102
|
+
return string(b)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func TestOpenClawStreamACP_MapsEventsAndCarriesSessionKey(t *testing.T) {
|
|
106
|
+
useTempBreaker(t)
|
|
107
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "read"})
|
|
108
|
+
|
|
109
|
+
var got []StreamEvent
|
|
110
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
111
|
+
Request{Messages: []Message{{Role: "user", Content: "看看目录"}}},
|
|
112
|
+
func(ev StreamEvent) { got = append(got, ev) })
|
|
113
|
+
if err != nil {
|
|
114
|
+
t.Fatalf("Stream: %v", err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ① 事件映射:两条正文增量 → tool_use → tool_result(噪声行与 banner 都被跳过)
|
|
118
|
+
kinds := []StreamEventKind{}
|
|
119
|
+
for _, ev := range got {
|
|
120
|
+
kinds = append(kinds, ev.Kind)
|
|
121
|
+
}
|
|
122
|
+
want := []StreamEventKind{KindText, KindText, KindToolUse, KindToolResult}
|
|
123
|
+
if len(kinds) != len(want) {
|
|
124
|
+
t.Fatalf("事件数 = %d(%v),期望 %d:%v", len(kinds), kinds, len(want), kinds)
|
|
125
|
+
}
|
|
126
|
+
for i := range want {
|
|
127
|
+
if kinds[i] != want[i] {
|
|
128
|
+
t.Fatalf("第 %d 个事件类型 = %s,期望 %s(全部:%v)", i, kinds[i], want[i], kinds)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if got[0].Text != "逐字" || got[1].Text != "增量" {
|
|
132
|
+
t.Errorf("正文增量不对: %q %q", got[0].Text, got[1].Text)
|
|
133
|
+
}
|
|
134
|
+
if got[2].Name != "read" || got[2].ID != "tc-1" || !strings.Contains(got[2].Text, "echo hi") {
|
|
135
|
+
t.Errorf("tool_use 事件不对: %+v", got[2])
|
|
136
|
+
}
|
|
137
|
+
if got[3].ID != "tc-1" || got[3].Text != "hi" {
|
|
138
|
+
t.Errorf("tool_result 事件不对: %+v", got[3])
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ② 收尾:正文拼接 / 工具记录 / 会话锚点是**我们造的 key**(不是桥的 acp-sid-1)
|
|
142
|
+
if res.Text != "逐字增量" {
|
|
143
|
+
t.Errorf("Text = %q", res.Text)
|
|
144
|
+
}
|
|
145
|
+
if len(res.Tools) != 1 || res.Tools[0].Name != "read" || res.Tools[0].Result != "hi" {
|
|
146
|
+
t.Errorf("Tools = %+v(同一次调用只该有一条记录)", res.Tools)
|
|
147
|
+
}
|
|
148
|
+
if !strings.HasPrefix(res.SessionID, "agent:main:acp-") {
|
|
149
|
+
t.Errorf("SessionID = %q,期望自造的 session key(前缀 agent:main:acp-)", res.SessionID)
|
|
150
|
+
}
|
|
151
|
+
if res.SessionID == "acp-sid-1" {
|
|
152
|
+
t.Errorf("SessionID 不该用桥随机生成的 acp sessionId(出了进程就没意义)")
|
|
153
|
+
}
|
|
154
|
+
if res.Model != "" {
|
|
155
|
+
t.Errorf("Model = %q(ACP 侧给不出模型;没指定主选时就是空,界面按「引擎默认」显示)", res.Model)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ③ 客户端到底发了什么:协议版本 / 会话 key 经 _meta 传 / 提示词进了 prompt 块
|
|
159
|
+
log := readStubLog(t, logPath)
|
|
160
|
+
if !strings.Contains(log, `"protocolVersion":1`) {
|
|
161
|
+
t.Errorf("initialize 未带协议版本:%s", log)
|
|
162
|
+
}
|
|
163
|
+
if !strings.Contains(log, `"_meta":{"sessionKey":"agent:main:acp-`) {
|
|
164
|
+
t.Errorf("session/new 未把自造 key 经 _meta.sessionKey 传给桥:%s", log)
|
|
165
|
+
}
|
|
166
|
+
if !strings.Contains(log, `"sessionId":"acp-sid-1"`) || !strings.Contains(log, `看看目录`) {
|
|
167
|
+
t.Errorf("session/prompt 载荷不对:%s", log)
|
|
168
|
+
}
|
|
169
|
+
// ④ 审批:read 类 → 必须回「选 allow-1」(不是拒绝、也不是不回)
|
|
170
|
+
if !strings.Contains(log, `"optionId":"allow-1"`) {
|
|
171
|
+
t.Errorf("read 类审批未放行:%s", log)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func TestOpenClawStreamACP_KeepsGivenSessionKey(t *testing.T) {
|
|
176
|
+
useTempBreaker(t)
|
|
177
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "read"})
|
|
178
|
+
const key = "agent:main:acp-existing-key"
|
|
179
|
+
|
|
180
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
181
|
+
Request{SessionID: key, Messages: []Message{{Role: "user", Content: "接着说"}}},
|
|
182
|
+
func(StreamEvent) {})
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatalf("Stream: %v", err)
|
|
185
|
+
}
|
|
186
|
+
if res.SessionID != key {
|
|
187
|
+
t.Errorf("SessionID = %q,期望原样回传 %q(续接靠它)", res.SessionID, key)
|
|
188
|
+
}
|
|
189
|
+
if !strings.Contains(readStubLog(t, logPath), `"_meta":{"sessionKey":"`+key+`"`) {
|
|
190
|
+
t.Errorf("已给的 key 未原样透传")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func TestOpenClawStreamACP_MapsLegacyIDToSessionKey(t *testing.T) {
|
|
195
|
+
useTempBreaker(t)
|
|
196
|
+
// 老的非流式路径返回的是 Gateway **session id**(裸 uuid):必须用 sessions 反查成 key,
|
|
197
|
+
// 否则同一轮对话的上下文会在升级后静默丢掉。
|
|
198
|
+
const legacy = "97579a21-58f0-4574-a8fd-93fb95d8711b"
|
|
199
|
+
bin, logPath := acpStub(t, acpStubOpts{
|
|
200
|
+
permKind: "read",
|
|
201
|
+
sessionsJSON: `{"sessions":[{"sessionId":"` + legacy + `","key":"agent:main:keep","model":"m","updatedAt":1}]}`,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
205
|
+
Request{SessionID: legacy, Messages: []Message{{Role: "user", Content: "接着说"}}},
|
|
206
|
+
func(StreamEvent) {})
|
|
207
|
+
if err != nil {
|
|
208
|
+
t.Fatalf("Stream: %v", err)
|
|
209
|
+
}
|
|
210
|
+
if res.SessionID != "agent:main:keep" {
|
|
211
|
+
t.Errorf("SessionID = %q,期望反查到的 key agent:main:keep", res.SessionID)
|
|
212
|
+
}
|
|
213
|
+
if !strings.Contains(readStubLog(t, logPath), `"_meta":{"sessionKey":"agent:main:keep"`) {
|
|
214
|
+
t.Errorf("反查到的 key 未透传给桥")
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func TestOpenClawStreamACP_DeniesExecuteByDefault(t *testing.T) {
|
|
219
|
+
useTempBreaker(t)
|
|
220
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "execute"})
|
|
221
|
+
if _, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
222
|
+
Request{Messages: []Message{{Role: "user", Content: "删掉临时文件"}}},
|
|
223
|
+
func(StreamEvent) {}); err != nil {
|
|
224
|
+
t.Fatalf("Stream: %v", err)
|
|
225
|
+
}
|
|
226
|
+
log := readStubLog(t, logPath)
|
|
227
|
+
if !strings.Contains(log, `"optionId":"reject-1"`) {
|
|
228
|
+
t.Errorf("execute 类审批**必须**默认拒绝(不许替用户扩大权限):%s", log)
|
|
229
|
+
}
|
|
230
|
+
if strings.Contains(log, `"optionId":"allow-1"`) {
|
|
231
|
+
t.Errorf("execute 类不该出现放行:%s", log)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func TestOpenClawStreamACP_ApproveAllOverride(t *testing.T) {
|
|
236
|
+
useTempBreaker(t)
|
|
237
|
+
t.Setenv("MAGIC_AGENT_OPENCLAW_ACP_APPROVE", "all")
|
|
238
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "execute"})
|
|
239
|
+
if _, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
240
|
+
Request{Messages: []Message{{Role: "user", Content: "删掉临时文件"}}},
|
|
241
|
+
func(StreamEvent) {}); err != nil {
|
|
242
|
+
t.Fatalf("Stream: %v", err)
|
|
243
|
+
}
|
|
244
|
+
if !strings.Contains(readStubLog(t, logPath), `"optionId":"allow-1"`) {
|
|
245
|
+
t.Errorf("设了 MAGIC_AGENT_OPENCLAW_ACP_APPROVE=all 就该全放行")
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func TestOpenClawStreamACP_FallsBackWhenBridgeUnavailable(t *testing.T) {
|
|
250
|
+
useTempBreaker(t)
|
|
251
|
+
bin, _ := acpStub(t, acpStubOpts{bridgeFail: true})
|
|
252
|
+
|
|
253
|
+
var got []StreamEvent
|
|
254
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
255
|
+
Request{Messages: []Message{{Role: "user", Content: "你好"}}},
|
|
256
|
+
func(ev StreamEvent) { got = append(got, ev) })
|
|
257
|
+
if err != nil {
|
|
258
|
+
t.Fatalf("桥不可用时应回退而不是报错:%v", err)
|
|
259
|
+
}
|
|
260
|
+
// 回退走 Complete(内嵌一次性调用):正文来自那份 envelope,并以**一条 text 增量**补发出去,
|
|
261
|
+
// 这样调用方(对话渲染)拿到的仍是「有正文的一轮」,只是没有逐字。
|
|
262
|
+
if res.Text != "内嵌回退的正文" {
|
|
263
|
+
t.Errorf("Text = %q(应来自内嵌 envelope)", res.Text)
|
|
264
|
+
}
|
|
265
|
+
if res.SessionID != "sid-fallback" {
|
|
266
|
+
t.Errorf("SessionID = %q(回退路径用内嵌的 session_id)", res.SessionID)
|
|
267
|
+
}
|
|
268
|
+
if len(got) != 1 || got[0].Kind != KindText || got[0].Text != "内嵌回退的正文" {
|
|
269
|
+
t.Errorf("回退未把正文补发成一条 text 增量:%+v", got)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func TestOpenClawStreamACP_ModelForcesNonStream(t *testing.T) {
|
|
274
|
+
useTempBreaker(t)
|
|
275
|
+
// ACP 桥不暴露模型选择:指定了模型就必须走嵌套路(`agent --model` 忠实生效),
|
|
276
|
+
// 否则用户选的模型被静默换掉 —— 比没有逐字增量糟糕得多。
|
|
277
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "read"})
|
|
278
|
+
|
|
279
|
+
var got []StreamEvent
|
|
280
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
281
|
+
Request{Model: "minimax/MiniMax-M3", Messages: []Message{{Role: "user", Content: "你好"}}},
|
|
282
|
+
func(ev StreamEvent) { got = append(got, ev) })
|
|
283
|
+
if err != nil {
|
|
284
|
+
t.Fatalf("Stream: %v", err)
|
|
285
|
+
}
|
|
286
|
+
if res.Text != "内嵌回退的正文" {
|
|
287
|
+
t.Errorf("Text = %q(指定模型时应走内嵌 envelope)", res.Text)
|
|
288
|
+
}
|
|
289
|
+
if len(got) != 1 || got[0].Kind != KindText {
|
|
290
|
+
t.Errorf("应把正文补发成一条 text 增量:%+v", got)
|
|
291
|
+
}
|
|
292
|
+
if _, statErr := os.Stat(logPath); statErr == nil {
|
|
293
|
+
t.Errorf("指定模型时不该起 ACP 桥(桩不该收到任何 ACP 帧)")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func TestOpenClawStreamACP_BreakerSkipsRespawnAcrossRounds(t *testing.T) {
|
|
298
|
+
// 为什么是**文件**而不是进程内变量:上层一轮一个 CLI 进程(实测进程内冷却两轮都照样 spawn)。
|
|
299
|
+
breaker := useTempBreaker(t)
|
|
300
|
+
bin, _ := acpStub(t, acpStubOpts{bridgeFail: true})
|
|
301
|
+
spawnLog := filepath.Join(filepath.Dir(bin), "spawn.log") // 桩自己记的「被起过几次」
|
|
302
|
+
|
|
303
|
+
e := &OpenClawEngine{BinPath: bin}
|
|
304
|
+
req := Request{Messages: []Message{{Role: "user", Content: "你好"}}}
|
|
305
|
+
for i := 0; i < 2; i++ {
|
|
306
|
+
if _, err := e.Stream(context.Background(), req, func(StreamEvent) {}); err != nil {
|
|
307
|
+
t.Fatalf("第 %d 轮失败:%v", i+1, err)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
raw, rerr := os.ReadFile(spawnLog)
|
|
311
|
+
if rerr != nil {
|
|
312
|
+
t.Fatalf("桩没被起过?: %v", rerr)
|
|
313
|
+
}
|
|
314
|
+
if n := strings.Count(string(raw), "spawn"); n != 1 {
|
|
315
|
+
t.Errorf("两轮里桥被起了 %d 次,期望 1 次(第二轮应被冷却挡住、直接走嵌套路)", n)
|
|
316
|
+
}
|
|
317
|
+
if _, statErr := os.Stat(breaker); statErr != nil {
|
|
318
|
+
t.Errorf("冷却标记文件没落盘:%v", statErr)
|
|
319
|
+
}
|
|
320
|
+
// 冷却过期(把 until 拨到过去)→ 应重新尝试桥
|
|
321
|
+
_ = os.WriteFile(breaker, []byte(`{"until":1}`), 0o644)
|
|
322
|
+
if _, err := e.Stream(context.Background(), req, func(StreamEvent) {}); err != nil {
|
|
323
|
+
t.Fatalf("冷却过期后的一轮失败:%v", err)
|
|
324
|
+
}
|
|
325
|
+
raw2, _ := os.ReadFile(spawnLog)
|
|
326
|
+
if n := strings.Count(string(raw2), "spawn"); n != 2 {
|
|
327
|
+
t.Errorf("冷却过期后应重试一次(累计 2 次),实测 %d 次", n)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func TestOpenClawStreamACP_BreakerSkipsEvenWhenBridgeWouldWork(t *testing.T) {
|
|
332
|
+
// 冷却期内**即便桥其实能跑通**也不再 spawn(跨进程省时间;到点自动重试,见上一个用例)。
|
|
333
|
+
breaker := useTempBreaker(t)
|
|
334
|
+
_ = os.WriteFile(breaker, []byte(`{"until":`+itoa(time.Now().Add(time.Hour).Unix())+`}`), 0o644)
|
|
335
|
+
bin, logPath := acpStub(t, acpStubOpts{permKind: "read"})
|
|
336
|
+
|
|
337
|
+
res, err := (&OpenClawEngine{BinPath: bin}).Stream(context.Background(),
|
|
338
|
+
Request{Messages: []Message{{Role: "user", Content: "你好"}}},
|
|
339
|
+
func(StreamEvent) {})
|
|
340
|
+
if err != nil {
|
|
341
|
+
t.Fatalf("Stream: %v", err)
|
|
342
|
+
}
|
|
343
|
+
if res.Text != "内嵌回退的正文" {
|
|
344
|
+
t.Errorf("Text = %q(冷却期内应直接走嵌套路)", res.Text)
|
|
345
|
+
}
|
|
346
|
+
if _, statErr := os.Stat(logPath); statErr == nil {
|
|
347
|
+
t.Errorf("冷却期内不该驱动 ACP 桥(桩不该收到帧)")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func TestOpenClawSupportsStream(t *testing.T) {
|
|
352
|
+
e := &OpenClawEngine{}
|
|
353
|
+
if !SupportsStream(e) {
|
|
354
|
+
t.Fatal("openclaw 应报告支持流式(`--engines` 的 streaming 字段由它决定)")
|
|
355
|
+
}
|
|
356
|
+
if got := streamArgsFor(e); len(got) != 1 || got[0] != "acp" {
|
|
357
|
+
t.Errorf("流式标记 = %v,期望 [acp]", got)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func TestACPPermissionPolicy(t *testing.T) {
|
|
362
|
+
t.Setenv("MAGIC_AGENT_OPENCLAW_ACP_APPROVE", "")
|
|
363
|
+
cases := map[string]string{
|
|
364
|
+
"read": "allow", "search": "allow", "fetch": "allow", "think": "allow", "other": "allow", "": "allow",
|
|
365
|
+
"execute": "reject", "edit": "reject", "delete": "reject", "move": "reject",
|
|
366
|
+
"EXECUTE": "reject", // 大小写不敏感
|
|
367
|
+
}
|
|
368
|
+
for kind, want := range cases {
|
|
369
|
+
if got := acpPermissionPolicy(kind); got != want {
|
|
370
|
+
t.Errorf("acpPermissionPolicy(%q) = %q,期望 %q", kind, got, want)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
t.Setenv("MAGIC_AGENT_OPENCLAW_ACP_APPROVE", "all")
|
|
374
|
+
if got := acpPermissionPolicy("execute"); got != "allow" {
|
|
375
|
+
t.Errorf("APPROVE=all 时 execute 应放行,实测 %q", got)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// TestStreamAvailableBreaker 流式可用性要跟着「已知失败」的冷却走:
|
|
380
|
+
// 冷却中 → false(调用方不传 --stream、界面不画流式标记);过期 / 无文件 / 其他引擎 → true。
|
|
381
|
+
func TestStreamAvailableBreaker(t *testing.T) {
|
|
382
|
+
path := filepath.Join(t.TempDir(), "breaker.json")
|
|
383
|
+
t.Setenv("MAGIC_AGENT_OPENCLAW_ACP_BREAKER", path)
|
|
384
|
+
|
|
385
|
+
if !StreamAvailable("openclaw") {
|
|
386
|
+
t.Fatalf("无标记文件时 openclaw 应可用")
|
|
387
|
+
}
|
|
388
|
+
// 冷却中(until 在未来)
|
|
389
|
+
if err := os.WriteFile(path, []byte(`{"until":`+strconv.FormatInt(time.Now().Add(3*time.Minute).Unix(), 10)+`}`), 0o600); err != nil {
|
|
390
|
+
t.Fatal(err)
|
|
391
|
+
}
|
|
392
|
+
if StreamAvailable("openclaw") {
|
|
393
|
+
t.Errorf("冷却中 openclaw 不该报可用(否则界面会画流式标记、还会白等一次桥失败)")
|
|
394
|
+
}
|
|
395
|
+
// 过期(until 在过去)
|
|
396
|
+
if err := os.WriteFile(path, []byte(`{"until":`+strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)+`}`), 0o600); err != nil {
|
|
397
|
+
t.Fatal(err)
|
|
398
|
+
}
|
|
399
|
+
if !StreamAvailable("openclaw") {
|
|
400
|
+
t.Errorf("冷却过期后应恢复可用")
|
|
401
|
+
}
|
|
402
|
+
// 其他引擎不受这个文件影响
|
|
403
|
+
if !StreamAvailable("claude") || !StreamAvailable("arkclaw") {
|
|
404
|
+
t.Errorf("别的引擎不该被 openclaw 的冷却波及")
|
|
405
|
+
}
|
|
406
|
+
}
|