magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,451 @@
1
+ package agent
2
+
3
+ // openclaw_test.go - OpenClaw 引擎单元测试。
4
+ //
5
+ // 覆盖:注册表、buildArgs 关键 flag 映射、envelope 解析(plugin 日志混入场景)、
6
+ // Continue 走 sessions --json 查 sessionId、max-tokens/temperature/tools 静默忽略、
7
+ // CLI 缺失报错。
8
+
9
+ import (
10
+ "context"
11
+ "encoding/json"
12
+ "os"
13
+ "path/filepath"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ func TestOpenClawLookup(t *testing.T) {
19
+ e := Lookup("openclaw")
20
+ if e == nil {
21
+ t.Fatal("Lookup(\"openclaw\") = nil")
22
+ }
23
+ if e.Name() != "openclaw" {
24
+ t.Errorf("Name() = %q want openclaw", e.Name())
25
+ }
26
+ }
27
+
28
+ func TestOpenClawBuildArgsNewSession(t *testing.T) {
29
+ dir := t.TempDir()
30
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"hi"}],"meta":{"agentMeta":{"sessionId":"s1","model":"m1"}}}'`)
31
+ e := &OpenClawEngine{BinPath: w.bin, Agent: "main"}
32
+ if _, err := e.Complete(context.Background(), Request{
33
+ Messages: []Message{{Role: "user", Content: "hi"}},
34
+ }); err != nil {
35
+ t.Fatalf("Complete: %v", err)
36
+ }
37
+ data, _ := os.ReadFile(log)
38
+ args := string(data)
39
+ for _, want := range []string{
40
+ "agent",
41
+ "--local",
42
+ "--agent main",
43
+ "--json",
44
+ "hi", // FlattenPrompt 后正文是 "【用户】\nhi",至少含 "hi"
45
+ "--timeout 600",
46
+ } {
47
+ if !strings.Contains(args, want) {
48
+ t.Errorf("want %q in args, got %q", want, args)
49
+ }
50
+ }
51
+ if !strings.Contains(args, "--message ") {
52
+ t.Errorf("--message flag 应被使用: %q", args)
53
+ }
54
+ }
55
+
56
+ func TestOpenClawBuildArgsModel(t *testing.T) {
57
+ dir := t.TempDir()
58
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"hi"}]}'`)
59
+ e := &OpenClawEngine{BinPath: w.bin}
60
+ if _, err := e.Complete(context.Background(), Request{
61
+ Model: "openclaw/deepseek-v4-pro",
62
+ Messages: []Message{{Role: "user", Content: "hi"}},
63
+ }); err != nil {
64
+ t.Fatalf("Complete: %v", err)
65
+ }
66
+ data, _ := os.ReadFile(log)
67
+ if !strings.Contains(string(data), "--model deepseek-v4-pro") {
68
+ t.Errorf("model flag missing: %q", string(data))
69
+ }
70
+ }
71
+
72
+ func TestOpenClawResumeSession(t *testing.T) {
73
+ dir := t.TempDir()
74
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"ok"}],"meta":{"agentMeta":{"sessionId":"sess-1"}}}'`)
75
+ e := &OpenClawEngine{BinPath: w.bin}
76
+ if _, err := e.Complete(context.Background(), Request{
77
+ Messages: []Message{{Role: "user", Content: "hi"}},
78
+ SessionID: "sess-1",
79
+ }); err != nil {
80
+ t.Fatalf("Complete: %v", err)
81
+ }
82
+ data, _ := os.ReadFile(log)
83
+ if !strings.Contains(string(data), "--session-id sess-1") {
84
+ t.Errorf("--session-id sess-1 not passed: %q", string(data))
85
+ }
86
+ }
87
+
88
+ func TestOpenClawContinueResolvesLatest(t *testing.T) {
89
+ // 第一次调用:sessions --json 拿最近一次 sessionId
90
+ // 第二次调用:agent --session-id <id>
91
+ dir := t.TempDir()
92
+ log := filepath.Join(dir, "args.log")
93
+ cli := filepath.Join(dir, "openclaw")
94
+ script := `#!/bin/sh
95
+ # 第一次调用是 sessions,其余都是 agent
96
+ if [ "$1" = "sessions" ]; then
97
+ echo '{"path":"/x","count":1,"totalCount":1,"limitApplied":1,"hasMore":false,"sessions":[{"key":"k1","updatedAt":1,"sessionId":"sess-from-sessions","model":"m1"}]}'
98
+ exit 0
99
+ fi
100
+ printf '%s\n' "$*" > ` + log + `
101
+ echo '{"payloads":[{"text":"ok"}],"meta":{"agentMeta":{"sessionId":"sess-from-sessions","model":"m1"}}}'
102
+ `
103
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ e := &OpenClawEngine{BinPath: cli}
107
+ resp, err := e.Complete(context.Background(), Request{
108
+ Messages: []Message{{Role: "user", Content: "hi"}},
109
+ Continue: true,
110
+ })
111
+ if err != nil {
112
+ t.Fatalf("Complete: %v", err)
113
+ }
114
+ if resp.SessionID != "sess-from-sessions" {
115
+ t.Errorf("SessionID = %q want sess-from-sessions", resp.SessionID)
116
+ }
117
+ data, _ := os.ReadFile(log)
118
+ // agent 调用时 args 应包含 --session-id sess-from-sessions
119
+ if !strings.Contains(string(data), "--session-id sess-from-sessions") {
120
+ t.Errorf("agent 阶段未传续接 id: %q", string(data))
121
+ }
122
+ }
123
+
124
+ func TestOpenClawParseStdoutWithPluginLogs(t *testing.T) {
125
+ // 模拟 plugin 日志污染 stdout 的场景
126
+ stdout := strings.Join([]string{
127
+ `[plugins] openviking: resolveAgentId ...`,
128
+ `[plugins] openviking: diag ...`,
129
+ `[model-fallback/decision] model fallback decision: ...`,
130
+ `{"payloads":[{"text":"pong","mediaUrl":null}],"meta":{"durationMs":1234,"agentMeta":{"sessionId":"sid-1","provider":"papergames","model":"deepseek-v4-pro","usage":{"input":100,"output":10,"cacheRead":0,"cacheWrite":0,"total":110}},"aborted":false}}`,
131
+ ``, // 末尾空行
132
+ }, "\n")
133
+ e := &OpenClawEngine{}
134
+ resp, err := e.parseStdout(stdout)
135
+ if err != nil {
136
+ t.Fatalf("parseStdout: %v", err)
137
+ }
138
+ if resp.Text != "pong" {
139
+ t.Errorf("Text = %q", resp.Text)
140
+ }
141
+ if resp.SessionID != "sid-1" {
142
+ t.Errorf("SessionID = %q", resp.SessionID)
143
+ }
144
+ if resp.Model != "deepseek-v4-pro" {
145
+ t.Errorf("Model = %q", resp.Model)
146
+ }
147
+ if resp.OutputTokens != 10 {
148
+ t.Errorf("OutputTokens = %d", resp.OutputTokens)
149
+ }
150
+ if resp.TotalTokens != 110 {
151
+ t.Errorf("TotalTokens = %d", resp.TotalTokens)
152
+ }
153
+ }
154
+
155
+ func TestOpenClawParseStdoutShortOutput(t *testing.T) {
156
+ // 整段就是 JSON 的"短输出"路径
157
+ stdout := `{"payloads":[{"text":"短回答"}],"meta":{"agentMeta":{"sessionId":"sid-2","model":"m2","usage":{"input":1,"output":1,"total":2}}}}`
158
+ e := &OpenClawEngine{}
159
+ resp, err := e.parseStdout(stdout)
160
+ if err != nil {
161
+ t.Fatalf("parseStdout: %v", err)
162
+ }
163
+ if resp.Text != "短回答" {
164
+ t.Errorf("Text = %q", resp.Text)
165
+ }
166
+ }
167
+
168
+ func TestOpenClawParseStdoutNoEnvelope(t *testing.T) {
169
+ e := &OpenClawEngine{}
170
+ if _, err := e.parseStdout("only logs, no JSON\nmore logs\n"); err == nil {
171
+ t.Error("expected error when envelope missing")
172
+ }
173
+ }
174
+
175
+ func TestOpenClawMaxTokensIgnored(t *testing.T) {
176
+ dir := t.TempDir()
177
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"ok"}]}'`)
178
+ e := &OpenClawEngine{BinPath: w.bin}
179
+ if _, err := e.Complete(context.Background(), Request{
180
+ Messages: []Message{{Role: "user", Content: "hi"}},
181
+ MaxTokens: 4096,
182
+ }); err != nil {
183
+ t.Fatalf("Complete: %v", err)
184
+ }
185
+ data, _ := os.ReadFile(log)
186
+ if strings.Contains(strings.ToLower(string(data)), "max_tokens") {
187
+ t.Errorf("MaxTokens 应静默忽略: %q", string(data))
188
+ }
189
+ }
190
+
191
+ func TestOpenClawTemperatureIgnored(t *testing.T) {
192
+ dir := t.TempDir()
193
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"ok"}]}'`)
194
+ e := &OpenClawEngine{BinPath: w.bin}
195
+ temp := 0.7
196
+ if _, err := e.Complete(context.Background(), Request{
197
+ Messages: []Message{{Role: "user", Content: "hi"}},
198
+ Temperature: &temp,
199
+ }); err != nil {
200
+ t.Fatalf("Complete: %v", err)
201
+ }
202
+ data, _ := os.ReadFile(log)
203
+ if strings.Contains(strings.ToLower(string(data)), "temperature") {
204
+ t.Errorf("temperature 应静默忽略: %q", string(data))
205
+ }
206
+ }
207
+
208
+ func TestOpenClawToolsIgnored(t *testing.T) {
209
+ // ToolsMode 任意值都不应改变 args(openclaw 内嵌 agent,自身决定工具使用)
210
+ dir := t.TempDir()
211
+ w, log := argsCaptureCLI(t, dir, "openclaw", `echo '{"payloads":[{"text":"ok"}]}'`)
212
+ e := &OpenClawEngine{BinPath: w.bin}
213
+ if _, err := e.Complete(context.Background(), Request{
214
+ Messages: []Message{{Role: "user", Content: "hi"}},
215
+ Tools: ToolsOn, // 即便 on 也不应改 args
216
+ }); err != nil {
217
+ t.Fatalf("Complete: %v", err)
218
+ }
219
+ data, _ := os.ReadFile(log)
220
+ args := string(data)
221
+ for _, forbid := range []string{"--tools", "--disallowed-tool", "--allowed-tool", "--dangerously-skip-permissions"} {
222
+ if strings.Contains(args, forbid) {
223
+ t.Errorf("openclaw 不应透传工具 flag %s: %q", forbid, args)
224
+ }
225
+ }
226
+ }
227
+
228
+ func TestOpenClawNotFound(t *testing.T) {
229
+ e := &OpenClawEngine{BinPath: "/nonexistent/openclaw"}
230
+ _, err := e.Complete(context.Background(), Request{
231
+ Messages: []Message{{Role: "user", Content: "hi"}},
232
+ })
233
+ if err == nil {
234
+ t.Fatal("expected error for missing CLI")
235
+ }
236
+ }
237
+
238
+ func TestOpenClawWriteMessageArgShortUsesInline(t *testing.T) {
239
+ dir := t.TempDir()
240
+ args, cleanup, err := writeMessageArg(dir, "短文本")
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ if cleanup != nil {
245
+ t.Error("短文本不该返回 cleanup")
246
+ }
247
+ if len(args) != 2 || args[0] != "--message" || args[1] != "短文本" {
248
+ t.Errorf("短文本应走 --message 内联, got %v", args)
249
+ }
250
+ }
251
+
252
+ func TestOpenClawWriteMessageArgLongUsesFile(t *testing.T) {
253
+ dir := t.TempDir()
254
+ long := strings.Repeat("x", 5000) // > 4KB 阈值
255
+ args, cleanup, err := writeMessageArg(dir, long)
256
+ if err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ if cleanup == nil {
260
+ t.Error("长文本必须返回 cleanup")
261
+ } else {
262
+ cleanup()
263
+ }
264
+ if len(args) != 2 || args[0] != "--message-file" {
265
+ t.Errorf("长文本应走 --message-file, got %v", args)
266
+ }
267
+ if !strings.HasPrefix(args[1], dir) {
268
+ t.Errorf("file path 应在 tmpDir 下, got %q", args[1])
269
+ }
270
+ }
271
+
272
+ func TestOpenClawEnvelopeRoundTrip(t *testing.T) {
273
+ // 通过 JSON 序列化/反序列化验证 envelope 结构稳定
274
+ raw := `{"payloads":[{"text":"abc","mediaUrl":null}],"meta":{"durationMs":1,"agentMeta":{"sessionId":"s","model":"m","usage":{"input":2,"output":3,"total":5}},"aborted":false}}`
275
+ var env openclawEnvelope
276
+ if err := json.Unmarshal([]byte(raw), &env); err != nil {
277
+ t.Fatalf("unmarshal: %v", err)
278
+ }
279
+ resp := envelopeToResponse(env)
280
+ if resp.Text != "abc" || resp.SessionID != "s" || resp.Model != "m" {
281
+ t.Errorf("resp = %+v", resp)
282
+ }
283
+ if resp.InputTokens != 2 || resp.OutputTokens != 3 || resp.TotalTokens != 5 {
284
+ t.Errorf("tokens = %+v", resp)
285
+ }
286
+ }
287
+
288
+ /* ── 嵌入式凭据失败 → 回退 Gateway(2026-09-23)──
289
+ *
290
+ * 现场(用户报障「openclaw 的引擎没对接好 不显示」的下半场):`--local` 嵌入式路径的
291
+ * provider key 只从 **shell 环境变量**取,`openclaw.json` 里配好的 key 在这条路上用不上
292
+ * → 「模型只配在 openclaw.json」的用户必然 `401 invalid api key (2049)`。
293
+ * 桩复刻这个形状:**带 `--local` 就 401、不带就正常出 envelope**。 */
294
+
295
+ // openclawAuthStub 造一个「--local 必 401、Gateway 路径正常」的桩,返回 bin 与调用日志路径。
296
+ // ⚠️ 日志把 argv 里的换行压成空格(`tr '\n' ' '`):prompt 经 FlattenPrompt 后自带换行,
297
+ // 不压平会让「一次调用」在日志里占两行 —— 按行数断言就必然假红(实测踩过)。
298
+ func openclawAuthStub(t *testing.T, dir string) (bin, log string) {
299
+ t.Helper()
300
+ log = filepath.Join(dir, "calls.log")
301
+ bin = filepath.Join(dir, "openclaw")
302
+ script := `#!/bin/sh
303
+ printf '%s\n' "$*" | tr '\n' ' ' >> ` + log + `
304
+ echo >> ` + log + `
305
+ case "$*" in
306
+ *--local*)
307
+ echo 'FailoverError: Authentication failed (provider returned HTTP 401). detail=401 invalid api key (2049)' >&2
308
+ exit 1
309
+ ;;
310
+ esac
311
+ echo '{"payloads":[{"text":"收到"}],"meta":{"agentMeta":{"sessionId":"gw-1","model":"minimax/MiniMax-M3"}}}'
312
+ exit 0
313
+ `
314
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
315
+ t.Fatal(err)
316
+ }
317
+ return bin, log
318
+ }
319
+
320
+ func TestOpenClawAuthFailureFallsBackToGateway(t *testing.T) {
321
+ dir := t.TempDir()
322
+ bin, log := openclawAuthStub(t, dir)
323
+ e := &OpenClawEngine{BinPath: bin, Agent: "main"}
324
+ resp, err := e.Complete(context.Background(), Request{
325
+ Model: "minimax/MiniMax-M3",
326
+ Messages: []Message{{Role: "user", Content: "只回两个字:收到"}},
327
+ })
328
+ if err != nil {
329
+ t.Fatalf("Complete 应回退 Gateway 后成功, got err = %v", err)
330
+ }
331
+ if resp.Text != "收到" {
332
+ t.Errorf("Text = %q want 收到", resp.Text)
333
+ }
334
+ if resp.SessionID != "gw-1" {
335
+ t.Errorf("SessionID = %q want gw-1(取自 Gateway 那次 envelope)", resp.SessionID)
336
+ }
337
+ data, _ := os.ReadFile(log)
338
+ var calls []string
339
+ for _, ln := range strings.Split(strings.TrimSpace(string(data)), "\n") {
340
+ // 桩用 `tr '\n' ' '` 压平 argv,末尾会留一个空格(被换掉的那个换行)→ 统一去掉再比。
341
+ calls = append(calls, strings.TrimRight(strings.TrimRight(ln, "\r"), " "))
342
+ }
343
+ if len(calls) != 2 {
344
+ t.Fatalf("应恰好两次调用(嵌入式 → Gateway),got %d: %q", len(calls), calls)
345
+ }
346
+ if !strings.Contains(calls[0], "--local") {
347
+ t.Errorf("第 1 次应走嵌入式(带 --local): %q", calls[0])
348
+ }
349
+ if strings.Contains(calls[1], "--local") {
350
+ t.Errorf("第 2 次应走 Gateway(去掉 --local): %q", calls[1])
351
+ }
352
+ // 两次的**其余参数必须完全一致**(只差一个 flag)—— 否则回退会把模型 / 会话丢掉。
353
+ if strings.Replace(calls[0], " --local", "", 1) != calls[1] {
354
+ t.Errorf("回退时除 --local 外不该有别的差异:\n 嵌入式: %q\n Gateway: %q", calls[0], calls[1])
355
+ }
356
+ }
357
+
358
+ func TestOpenClawNonAuthFailureDoesNotRetry(t *testing.T) {
359
+ // 非凭据类失败(超时 / 会话接不上)**不许**重试:换条路也一样失败,白多一次往返。
360
+ dir := t.TempDir()
361
+ log := filepath.Join(dir, "calls.log")
362
+ bin := filepath.Join(dir, "openclaw")
363
+ script := `#!/bin/sh
364
+ printf '%s\n' "$*" | tr '\n' ' ' >> ` + log + `
365
+ echo >> ` + log + `
366
+ echo 'openclaw: 会话已过期,无法续接' >&2
367
+ exit 1
368
+ `
369
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
370
+ t.Fatal(err)
371
+ }
372
+ e := &OpenClawEngine{BinPath: bin}
373
+ if _, err := e.Complete(context.Background(), Request{
374
+ Messages: []Message{{Role: "user", Content: "hi"}},
375
+ }); err == nil {
376
+ t.Fatal("应报错")
377
+ }
378
+ data, _ := os.ReadFile(log)
379
+ if n := len(strings.Split(strings.TrimSpace(string(data)), "\n")); n != 1 {
380
+ t.Errorf("非凭据类失败只该调一次, got %d 次: %q", n, string(data))
381
+ }
382
+ }
383
+
384
+ /* ── Gateway envelope 多包一层 result(2026-09-23)──
385
+ * 实测:`--local` 是顶层 `payloads`,Gateway 是 `{runId,status,summary,result:{payloads,meta}}`。
386
+ * 只认前者的话,回退 Gateway 明明拿到了正文却报「未找到有效 JSON envelope」。 */
387
+ func TestOpenClawGatewayEnvelopeShape(t *testing.T) {
388
+ // 复刻实测形状:pretty JSON + 前面混 plugin 日志 + 后面还有别的输出
389
+ stdout := `[plugins] openviking: loaded plugin config
390
+ {
391
+ "runId": "e0dda575-3a50-482d-9ab0-e6df7f9db86a",
392
+ "status": "ok",
393
+ "summary": "completed",
394
+ "result": {
395
+ "payloads": [ { "text": "收到", "mediaUrl": null } ],
396
+ "meta": { "durationMs": 1479, "agentMeta": {
397
+ "sessionId": "ff91a857-0d63-4ebc-ad81-6f03dfdc8427",
398
+ "provider": "minimax", "model": "MiniMax-M3" } }
399
+ }
400
+ }
401
+ [state-migrations] 尾部噪声`
402
+ e := &OpenClawEngine{}
403
+ resp, err := e.parseStdout(stdout)
404
+ if err != nil {
405
+ t.Fatalf("Gateway 形状应能解析, got err = %v", err)
406
+ }
407
+ if resp.Text != "收到" {
408
+ t.Errorf("Text = %q want 收到", resp.Text)
409
+ }
410
+ if resp.SessionID != "ff91a857-0d63-4ebc-ad81-6f03dfdc8427" {
411
+ t.Errorf("SessionID = %q(应取自 result.meta.agentMeta)", resp.SessionID)
412
+ }
413
+ if resp.Model != "MiniMax-M3" {
414
+ t.Errorf("Model = %q want MiniMax-M3", resp.Model)
415
+ }
416
+ }
417
+
418
+ func TestOpenClawGatewayStatusErrorSurfaces(t *testing.T) {
419
+ // Gateway 明确失败(status != ok)→ 错误里要带 status/summary,不许只说「找不到 envelope」
420
+ stdout := `{"runId":"x","status":"error","summary":"provider 鉴权失败"}`
421
+ if _, err := (&OpenClawEngine{}).parseStdout(stdout); err == nil {
422
+ t.Fatal("应报错")
423
+ } else if !strings.Contains(err.Error(), "status=error") || !strings.Contains(err.Error(), "provider 鉴权失败") {
424
+ t.Errorf("错误应带 status 与 summary, got %q", err.Error())
425
+ }
426
+ }
427
+
428
+ func TestIsOpenClawAuthFailure(t *testing.T) {
429
+ yes := []string{
430
+ "FailoverError: Authentication failed (provider returned HTTP 401).",
431
+ "401 invalid api key (2049)",
432
+ "error: Unauthorized",
433
+ "your provider token may have expired — re-authenticate this provider",
434
+ }
435
+ for _, s := range yes {
436
+ if !isOpenClawAuthFailure("", s) {
437
+ t.Errorf("应判为凭据失败: %q", s)
438
+ }
439
+ }
440
+ no := []string{
441
+ "openclaw: 会话已过期,无法续接",
442
+ "context deadline exceeded",
443
+ "gateway connect failed: connection refused",
444
+ "usage: input=1401 output=22", // 1401 里的 401 不是状态码
445
+ }
446
+ for _, s := range no {
447
+ if isOpenClawAuthFailure("", s) {
448
+ t.Errorf("不该判为凭据失败: %q", s)
449
+ }
450
+ }
451
+ }
@@ -0,0 +1,191 @@
1
+ package agent
2
+
3
+ // output.go - 固定输出格式。
4
+ //
5
+ // magic-agent 的输出只有两种形态,由 -o 选定,与引擎无关:
6
+ //
7
+ // json(默认):单行 JSON envelope,字段稳定,stdout 专为程序解析设计:
8
+ // 成功 {"engine":"claude","model":"...","session_id":"...",
9
+ // "attempts":1,"latency_ms":1234,"text":"..."}
10
+ // 失败(打到 stderr,stdout 恒为空,exit != 0)
11
+ // {"engine":"claude","attempts":3,"error":"...","reason":"..."}
12
+ // text:stdout 只含模型正文 + 尾换行;失败时 stderr 一行 "magic-agent: <error>"。
13
+ //
14
+ // 失败 envelope 的两个错误字段分工:
15
+ // error 完整错误链(含重试/引擎包装),面向人排查
16
+ // reason 最内层根因(stderr 摘要 / 超时 / 非零退出码等),面向程序分支
17
+ // 两者都做 JSON 转义,单行可 jq。
18
+ //
19
+ // 参数类错误(usageError,exit 2)不走 WriteError:它发生在引擎选择/
20
+ // 参数解析之前,此时 -o 尚未解析,语义上没有"输出格式"可言,由
21
+ // Execute 统一以纯文本打到 stderr。
22
+
23
+ import (
24
+ "encoding/json"
25
+ "errors"
26
+ "fmt"
27
+ "io"
28
+ )
29
+
30
+ // OutputFormat 输出格式。
31
+ type OutputFormat string
32
+
33
+ const (
34
+ FormatText OutputFormat = "text"
35
+ FormatJSON OutputFormat = "json"
36
+ )
37
+
38
+ // ParseFormat 校验输出格式参数。
39
+ func ParseFormat(s string) (OutputFormat, error) {
40
+ switch OutputFormat(s) {
41
+ case FormatText, FormatJSON:
42
+ return OutputFormat(s), nil
43
+ default:
44
+ return "", fmt.Errorf("invalid output format %q (want \"text\" or \"json\")", s)
45
+ }
46
+ }
47
+
48
+ // JSONResult json 输出 envelope(成功)。
49
+ type JSONResult struct {
50
+ Engine string `json:"engine"`
51
+ Model string `json:"model"`
52
+ SessionID string `json:"session_id,omitempty"`
53
+ Attempts int `json:"attempts"`
54
+ LatencyMS int64 `json:"latency_ms"`
55
+ Text string `json:"text"`
56
+ // token 计数(尽力而为,引擎不上报时省略)。
57
+ InputTokens int `json:"input_tokens,omitempty"`
58
+ OutputTokens int `json:"output_tokens,omitempty"`
59
+ TotalTokens int `json:"total_tokens,omitempty"`
60
+ }
61
+
62
+ // JSONError json 失败 envelope。
63
+ //
64
+ // Error 是完整错误链;Reason 是根因摘要(优先取最内层 CauseError),
65
+ // 无根因时与 Error 相同。两个都保证非空,调用方二选一消费。
66
+ type JSONError struct {
67
+ Engine string `json:"engine"`
68
+ Attempts int `json:"attempts"`
69
+ Error string `json:"error"`
70
+ Reason string `json:"reason"`
71
+ }
72
+
73
+ // CauseError 从错误链中提取最内层的"根因"错误。
74
+ // 引擎包装链形如 "claude: all 3 attempts failed: claude CLI: exit status 7 (stderr: some bad error)",
75
+ // 最内层才是 CLI 的真实失败原因(超时 / 非零退出码 / stderr 摘要)。
76
+ // 支持 %w 包装与 Cause() error 接口两种形式。
77
+ func CauseError(err error) error {
78
+ if err == nil {
79
+ return nil
80
+ }
81
+ type causer interface{ Cause() error }
82
+ // 逐层下钻,直到没有更深一层的 Cause/Unwrap。
83
+ for {
84
+ if c, ok := err.(causer); ok {
85
+ if c.Cause() == nil {
86
+ return err
87
+ }
88
+ err = c.Cause()
89
+ continue
90
+ }
91
+ u, ok := err.(interface{ Unwrap() error })
92
+ if !ok || u.Unwrap() == nil {
93
+ return err
94
+ }
95
+ err = u.Unwrap()
96
+ }
97
+ }
98
+
99
+ // reasonOf 提取给 reason 字段的根因文本。
100
+ // 优先取 cliCauseError 的 stderr 摘要(最接近真实原因);
101
+ // 否则用最内层错误;都不可得时退回完整错误串。
102
+ func reasonOf(err error) string {
103
+ if err == nil {
104
+ return ""
105
+ }
106
+ // 沿链找最近的 cliCauseError(带摘要的优先)。
107
+ var best string
108
+ for e := err; e != nil; {
109
+ if c, ok := e.(*cliCauseError); ok {
110
+ if s := c.Summary(); s != "" {
111
+ return s
112
+ }
113
+ if best == "" {
114
+ best = c.cause.Error()
115
+ }
116
+ }
117
+ if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
118
+ e = u.Unwrap()
119
+ continue
120
+ }
121
+ if c, ok := e.(interface{ Cause() error }); ok && c.Cause() != nil {
122
+ e = c.Cause()
123
+ continue
124
+ }
125
+ break
126
+ }
127
+ if best != "" {
128
+ return best
129
+ }
130
+ if c := CauseError(err); c != nil {
131
+ return c.Error()
132
+ }
133
+ return err.Error()
134
+ }
135
+
136
+ // ReasonOf 把 reasonOf 暴露给包外(流式失败事件要用同一个根因文案 ——
137
+ // 见 internal/cli 的 writeStreamErrorJSON)。**只此一个入口**,别在包外另写一份
138
+ // 「取最内层错误」的逻辑:两处算法一旦漂移,同一次失败在 envelope 与事件流里
139
+ // 会写出两句不同的原因。
140
+ func ReasonOf(err error) string { return reasonOf(err) }
141
+
142
+ // WriteOutput 按格式把成功结果写到 w。
143
+ func WriteOutput(w io.Writer, format OutputFormat, resp Response) error {
144
+ if format == FormatJSON {
145
+ out := JSONResult{
146
+ Engine: resp.Engine,
147
+ Model: resp.Model,
148
+ SessionID: resp.SessionID,
149
+ Attempts: resp.Attempts,
150
+ LatencyMS: resp.Latency.Milliseconds(),
151
+ Text: resp.Text,
152
+ InputTokens: resp.InputTokens,
153
+ OutputTokens: resp.OutputTokens,
154
+ TotalTokens: resp.TotalTokens,
155
+ }
156
+ data, err := json.Marshal(out)
157
+ if err != nil {
158
+ return err
159
+ }
160
+ _, err = fmt.Fprintln(w, string(data))
161
+ return err
162
+ }
163
+ _, err := fmt.Fprintln(w, resp.Text)
164
+ return err
165
+ }
166
+
167
+ // WriteError 按格式把失败信息写到 w。
168
+ // json 模式输出带 error + reason 双字段的 envelope;text 模式一行纯文本。
169
+ func WriteError(w io.Writer, format OutputFormat, engine string, attempts int, err error) error {
170
+ if err == nil {
171
+ return nil
172
+ }
173
+ if format == FormatJSON {
174
+ out := JSONError{
175
+ Engine: engine,
176
+ Attempts: attempts,
177
+ Error: err.Error(),
178
+ Reason: reasonOf(err),
179
+ }
180
+ data, jerr := json.Marshal(out)
181
+ if jerr != nil {
182
+ return jerr
183
+ }
184
+ _, ferr := fmt.Fprintln(w, string(data))
185
+ return ferr
186
+ }
187
+ _, ferr := fmt.Fprintf(w, "magic-agent: %v\n", err)
188
+ return ferr
189
+ }
190
+
191
+ var _ = errors.Is // 保留 errors 依赖(CauseError 语义参考)