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,827 @@
1
+ package agent
2
+
3
+ // arkclaw_test.go - ArkClaw(A2A JSON-RPC 网关)引擎单元测试。
4
+ //
5
+ // 覆盖:注册表、message/send 请求体形状、contextId 续接位置(必须在 message 内部)、
6
+ // 401 纯文本鉴权失败、JSON-RPC error、任务失败态、artifacts 兜底取文、空正文、
7
+ // 凭据缺失、Continue 显式拒绝、JSONSchema 后处理、端点拼接与配置文件读取。
8
+ //
9
+ // 流式通道(message/stream 的 SSE)在 arkclaw_stream_test.go。
10
+ //
11
+ // 全部走 httptest,不触网。
12
+
13
+ import (
14
+ "context"
15
+ "encoding/json"
16
+ "fmt"
17
+ "io"
18
+ "net/http"
19
+ "net/http/httptest"
20
+ "os"
21
+ "path/filepath"
22
+ "strings"
23
+ "testing"
24
+ "time"
25
+
26
+ "github.com/darren/magic-agent/internal/config"
27
+ )
28
+
29
+ // arkClawRec 记录假网关收到的一次请求。
30
+ type arkClawRec struct {
31
+ Method string
32
+ Path string
33
+ Query map[string]string
34
+ Header http.Header
35
+ Body string
36
+ }
37
+
38
+ // arkClawTestServer 起一个假 A2A 网关:先记录请求,再交给 handler 决定响应。
39
+ func arkClawTestServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, body string)) (*httptest.Server, *arkClawRec) {
40
+ t.Helper()
41
+ rec := &arkClawRec{}
42
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
+ raw, _ := io.ReadAll(r.Body)
44
+ rec.Method = r.Method
45
+ rec.Path = r.URL.Path
46
+ rec.Query = map[string]string{}
47
+ for k, vs := range r.URL.Query() {
48
+ if len(vs) > 0 {
49
+ rec.Query[k] = vs[0]
50
+ }
51
+ }
52
+ rec.Header = r.Header.Clone()
53
+ rec.Body = string(raw)
54
+ handler(w, r, string(raw))
55
+ }))
56
+ t.Cleanup(srv.Close)
57
+ return srv, rec
58
+ }
59
+
60
+ // arkClawTextEnvelope 组一个正常完成(completed)的 A2A 响应体。
61
+ func arkClawTextEnvelope(text, contextID string) string {
62
+ return fmt.Sprintf(
63
+ `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1","contextId":%q,`+
64
+ `"status":{"state":"completed","message":{"kind":"message","role":"agent",`+
65
+ `"parts":[{"kind":"text","text":%q}]}},"history":[],"artifacts":[]}}`,
66
+ contextID, text)
67
+ }
68
+
69
+ // arkClawServerEngine 返回一个指向假网关的引擎(凭据走显式字段,不读配置文件)。
70
+ func arkClawServerEngine(srv *httptest.Server) *ArkClawEngine {
71
+ return &ArkClawEngine{
72
+ URL: srv.URL + "/a2a/jsonrpc",
73
+ Key: "test-key",
74
+ ClawID: "ci-test",
75
+ }
76
+ }
77
+
78
+ // ---------- 注册表与接口契约 ----------
79
+
80
+ func TestArkClawLookup(t *testing.T) {
81
+ e := Lookup("arkclaw")
82
+ if e == nil {
83
+ t.Fatal(`Lookup("arkclaw") = nil`)
84
+ }
85
+ if e.Name() != "arkclaw" {
86
+ t.Errorf("Name() = %q want arkclaw", e.Name())
87
+ }
88
+ // 大小写不敏感(与其余引擎一致)。
89
+ if Lookup("ArkClaw") == nil {
90
+ t.Error(`Lookup("ArkClaw") = nil, want case-insensitive match`)
91
+ }
92
+ }
93
+
94
+ func TestArkClawIsEngine(t *testing.T) {
95
+ var e Engine = &ArkClawEngine{}
96
+ if e.Name() != "arkclaw" {
97
+ t.Errorf("Name() = %q", e.Name())
98
+ }
99
+ }
100
+
101
+ func TestArkClawStreamerMarkerIsSSE(t *testing.T) {
102
+ // 接口契约与 SSE 行为测试在 arkclaw_stream_test.go(那边覆盖流式通道本身)。
103
+ if got := streamArgsFor(&ArkClawEngine{}); len(got) != 1 || got[0] != "sse" {
104
+ t.Errorf("流式标记 = %v want [sse]", got)
105
+ }
106
+ }
107
+
108
+ func TestArkClawDetectConfigured(t *testing.T) {
109
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", Key: "k", ClawID: "c"}
110
+ ok, note := e.Detect()
111
+ if !ok {
112
+ t.Fatalf("Detect() ok=false, note=%q", note)
113
+ }
114
+ if note != "https://h/a2a/jsonrpc" {
115
+ t.Errorf("Detect() note = %q want url", note)
116
+ }
117
+ }
118
+
119
+ func TestArkClawDetectMissingCreds(t *testing.T) {
120
+ clearArkClawEnv(t)
121
+ e := &ArkClawEngine{ConfigPath: filepath.Join(t.TempDir(), "absent.json")}
122
+ ok, note := e.Detect()
123
+ if ok {
124
+ t.Fatalf("Detect() ok=true, note=%q", note)
125
+ }
126
+ for _, want := range []string{"url", "key", "claw_id"} {
127
+ if !strings.Contains(note, want) {
128
+ t.Errorf("note %q 未提及缺字段 %q", note, want)
129
+ }
130
+ }
131
+ }
132
+
133
+ // ---------- 正常路径 ----------
134
+
135
+ func TestArkClawCompleteSuccess(t *testing.T) {
136
+ srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
137
+ w.Header().Set("content-type", "application/json")
138
+ _, _ = io.WriteString(w, arkClawTextEnvelope("你好,我是 arkclaw", "ctx-1"))
139
+ })
140
+ e := arkClawServerEngine(srv)
141
+
142
+ resp, err := e.Complete(context.Background(), Request{
143
+ Messages: []Message{{Role: "user", Content: "你好"}},
144
+ })
145
+ if err != nil {
146
+ t.Fatalf("Complete: %v", err)
147
+ }
148
+ if resp.Text != "你好,我是 arkclaw" {
149
+ t.Errorf("Text = %q", resp.Text)
150
+ }
151
+ if resp.SessionID != "ctx-1" {
152
+ t.Errorf("SessionID = %q want ctx-1", resp.SessionID)
153
+ }
154
+ if resp.Engine != "arkclaw" {
155
+ t.Errorf("Engine = %q want arkclaw", resp.Engine)
156
+ }
157
+ if resp.Latency <= 0 {
158
+ t.Errorf("Latency = %v want > 0", resp.Latency)
159
+ }
160
+
161
+ // 凭据走 query,路径保持 /a2a/jsonrpc。
162
+ if rec.Path != "/a2a/jsonrpc" {
163
+ t.Errorf("path = %q", rec.Path)
164
+ }
165
+ if rec.Query["apikey"] != "test-key" || rec.Query["clawId"] != "ci-test" {
166
+ t.Errorf("query = %v", rec.Query)
167
+ }
168
+ if ct := rec.Header.Get("content-type"); ct != "application/json" {
169
+ t.Errorf("content-type = %q", ct)
170
+ }
171
+
172
+ // 请求体必须是 message/send + 单条 text part。
173
+ var got arkClawRequest
174
+ if err := json.Unmarshal([]byte(rec.Body), &got); err != nil {
175
+ t.Fatalf("请求体不是合法 JSON: %v (%s)", err, rec.Body)
176
+ }
177
+ if got.JSONRPC != "2.0" || got.Method != "message/send" {
178
+ t.Errorf("envelope = %+v", got)
179
+ }
180
+ if got.ID == "" || got.Params.Message.MessageID == "" {
181
+ t.Errorf("id / messageId 不应为空: %+v", got)
182
+ }
183
+ if got.Params.Message.Kind != "message" || got.Params.Message.Role != "user" {
184
+ t.Errorf("message = %+v", got.Params.Message)
185
+ }
186
+ if got.Params.Message.ContextID != "" {
187
+ t.Errorf("新会话不应带 contextId, got %q", got.Params.Message.ContextID)
188
+ }
189
+ if len(got.Params.Message.Parts) != 1 || got.Params.Message.Parts[0].Kind != "text" {
190
+ t.Fatalf("parts = %+v", got.Params.Message.Parts)
191
+ }
192
+ if !strings.Contains(got.Params.Message.Parts[0].Text, "你好") {
193
+ t.Errorf("prompt 未含用户输入: %q", got.Params.Message.Parts[0].Text)
194
+ }
195
+ }
196
+
197
+ func TestArkClawSystemPromptFlattened(t *testing.T) {
198
+ srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
199
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "ctx-1"))
200
+ })
201
+ e := arkClawServerEngine(srv)
202
+
203
+ // 协议无独立 system 角色 → 应展平进 message 正文。
204
+ if _, err := e.Complete(context.Background(), Request{
205
+ SystemPrompt: "你是运维助手",
206
+ Messages: []Message{{Role: "user", Content: "hi"}},
207
+ }); err != nil {
208
+ t.Fatalf("Complete: %v", err)
209
+ }
210
+ if !strings.Contains(rec.Body, "运维助手") {
211
+ t.Errorf("system prompt 未展平进正文: %q", rec.Body)
212
+ }
213
+ }
214
+
215
+ func TestArkClawResumeUsesMessageContextID(t *testing.T) {
216
+ srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
217
+ _, _ = io.WriteString(w, arkClawTextEnvelope("紫色大象", "ctx-1"))
218
+ })
219
+ e := arkClawServerEngine(srv)
220
+
221
+ resp, err := e.Complete(context.Background(), Request{
222
+ Messages: []Message{{Role: "user", Content: "我刚才说的暗号是什么"}},
223
+ SessionID: "ctx-1",
224
+ })
225
+ if err != nil {
226
+ t.Fatalf("Complete: %v", err)
227
+ }
228
+ if resp.SessionID != "ctx-1" {
229
+ t.Errorf("SessionID = %q want ctx-1", resp.SessionID)
230
+ }
231
+
232
+ // 关键回归点:续接 id 必须落在 message.contextId(消息对象内部)。
233
+ // 放到外层 params.contextId 无效 —— 网关会另开上下文。
234
+ var outer map[string]json.RawMessage
235
+ if err := json.Unmarshal([]byte(rec.Body), &outer); err != nil {
236
+ t.Fatalf("unmarshal: %v", err)
237
+ }
238
+ var params map[string]json.RawMessage
239
+ if err := json.Unmarshal(outer["params"], &params); err != nil {
240
+ t.Fatalf("unmarshal params: %v", err)
241
+ }
242
+ if raw, ok := params["contextId"]; ok {
243
+ t.Errorf("外层 params.contextId 不应出现(会被网关忽略): %s", raw)
244
+ }
245
+ var msg struct {
246
+ ContextID string `json:"contextId"`
247
+ }
248
+ if err := json.Unmarshal(params["message"], &msg); err != nil {
249
+ t.Fatalf("unmarshal message: %v", err)
250
+ }
251
+ if msg.ContextID != "ctx-1" {
252
+ t.Errorf("message.contextId = %q want ctx-1", msg.ContextID)
253
+ }
254
+ }
255
+
256
+ func TestArkClawSessionIDFallsBackToRequest(t *testing.T) {
257
+ // 网关没回 contextId 时,沿用请求里的 id,保证调用方拿到的 session 可用。
258
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
259
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", ""))
260
+ })
261
+ e := arkClawServerEngine(srv)
262
+
263
+ resp, err := e.Complete(context.Background(), Request{
264
+ Messages: []Message{{Role: "user", Content: "hi"}},
265
+ SessionID: "ctx-keep",
266
+ })
267
+ if err != nil {
268
+ t.Fatalf("Complete: %v", err)
269
+ }
270
+ if resp.SessionID != "ctx-keep" {
271
+ t.Errorf("SessionID = %q want ctx-keep", resp.SessionID)
272
+ }
273
+ }
274
+
275
+ // ---------- 错误路径 ----------
276
+
277
+ func TestArkClawHTTP401PlainText(t *testing.T) {
278
+ // 鉴权失败时网关回 text/plain 纯文本(非 JSON)。
279
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
280
+ w.Header().Set("content-type", "text/plain")
281
+ w.WriteHeader(http.StatusUnauthorized)
282
+ _, _ = io.WriteString(w, "External authentication failed.")
283
+ })
284
+ e := arkClawServerEngine(srv)
285
+
286
+ _, err := e.Complete(context.Background(), Request{
287
+ Messages: []Message{{Role: "user", Content: "hi"}},
288
+ })
289
+ if err == nil {
290
+ t.Fatal("expected 401 error")
291
+ }
292
+ msg := err.Error()
293
+ if !strings.Contains(msg, "401") {
294
+ t.Errorf("错误应含状态码 401: %v", err)
295
+ }
296
+ if !strings.Contains(msg, "External authentication failed") {
297
+ t.Errorf("错误应含网关正文: %v", err)
298
+ }
299
+ }
300
+
301
+ func TestArkClawRPCError(t *testing.T) {
302
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
303
+ _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":"req-1","error":{"code":-32602,"message":"invalid params"}}`)
304
+ })
305
+ e := arkClawServerEngine(srv)
306
+
307
+ _, err := e.Complete(context.Background(), Request{
308
+ Messages: []Message{{Role: "user", Content: "hi"}},
309
+ })
310
+ if err == nil {
311
+ t.Fatal("expected JSON-RPC error")
312
+ }
313
+ if !strings.Contains(err.Error(), "invalid params") {
314
+ t.Errorf("err = %v", err)
315
+ }
316
+ }
317
+
318
+ func TestArkClawTaskFailed(t *testing.T) {
319
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
320
+ _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"ctx-9",`+
321
+ `"status":{"state":"failed","message":{"kind":"message","parts":[{"kind":"text","text":"上游超时"}]}}}}`)
322
+ })
323
+ e := arkClawServerEngine(srv)
324
+
325
+ resp, err := e.Complete(context.Background(), Request{
326
+ Messages: []Message{{Role: "user", Content: "hi"}},
327
+ })
328
+ if err == nil {
329
+ t.Fatal("expected failure for status.state=failed")
330
+ }
331
+ if !strings.Contains(err.Error(), "failed") || !strings.Contains(err.Error(), "上游超时") {
332
+ t.Errorf("err = %v", err)
333
+ }
334
+ // 失败也要把 contextId 交回调用方,便于排查/续接。
335
+ if resp.SessionID != "ctx-9" {
336
+ t.Errorf("SessionID = %q want ctx-9", resp.SessionID)
337
+ }
338
+ }
339
+
340
+ func TestArkClawEmptyText(t *testing.T) {
341
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
342
+ _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"c",`+
343
+ `"status":{"state":"completed","message":{"parts":[]}}}}`)
344
+ })
345
+ e := arkClawServerEngine(srv)
346
+
347
+ _, err := e.Complete(context.Background(), Request{
348
+ Messages: []Message{{Role: "user", Content: "hi"}},
349
+ })
350
+ if err == nil || !strings.Contains(err.Error(), "空正文") {
351
+ t.Errorf("err = %v want 空正文", err)
352
+ }
353
+ }
354
+
355
+ func TestArkClawNotConfigured(t *testing.T) {
356
+ clearArkClawEnv(t)
357
+ e := &ArkClawEngine{ConfigPath: filepath.Join(t.TempDir(), "absent.json")}
358
+
359
+ _, err := e.Complete(context.Background(), Request{
360
+ Messages: []Message{{Role: "user", Content: "hi"}},
361
+ })
362
+ if err == nil {
363
+ t.Fatal("expected error when creds missing")
364
+ }
365
+ if !strings.Contains(err.Error(), "未配置") {
366
+ t.Errorf("err = %v want 未配置", err)
367
+ }
368
+ }
369
+
370
+ func TestArkClawContinueUnsupported(t *testing.T) {
371
+ // A2A 无「查询最近上下文」接口 → Continue 必须显式报错,而不是静默新开会话。
372
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", Key: "k", ClawID: "c"}
373
+ _, err := e.Complete(context.Background(), Request{
374
+ Messages: []Message{{Role: "user", Content: "hi"}},
375
+ Continue: true,
376
+ })
377
+ if err == nil {
378
+ t.Fatal("expected error for Continue")
379
+ }
380
+ if !strings.Contains(err.Error(), "continue") && !strings.Contains(err.Error(), "续接") {
381
+ t.Errorf("err = %v", err)
382
+ }
383
+ }
384
+
385
+ func TestArkClawEmptyPrompt(t *testing.T) {
386
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", Key: "k", ClawID: "c"}
387
+ _, err := e.Complete(context.Background(), Request{})
388
+ if err == nil || !strings.Contains(err.Error(), "empty prompt") {
389
+ t.Errorf("err = %v want empty prompt", err)
390
+ }
391
+ }
392
+
393
+ // ---------- 解析细节 ----------
394
+
395
+ func TestArkClawArtifactsFallback(t *testing.T) {
396
+ // status.message 缺正文时退回 artifacts[].parts[].text。
397
+ raw := `{"jsonrpc":"2.0","id":"1","result":{"kind":"task","id":"t","contextId":"c",` +
398
+ `"status":{"state":"completed"},"artifacts":[{"artifactId":"a1","name":"out",` +
399
+ `"parts":[{"kind":"text","text":"来自产物"}]}]}}`
400
+ resp, err := arkClawParseResponse(http.StatusOK, []byte(raw))
401
+ if err != nil {
402
+ t.Fatalf("arkClawParseResponse: %v", err)
403
+ }
404
+ if resp.Text != "来自产物" {
405
+ t.Errorf("Text = %q", resp.Text)
406
+ }
407
+ }
408
+
409
+ func TestArkClawJoinParts(t *testing.T) {
410
+ got := arkClawJoinParts([]arkClawPart{
411
+ {Kind: "text", Text: "第一段"},
412
+ {Kind: "text", Text: " "}, // 空白块跳过
413
+ {Kind: "text", Text: "第二段"},
414
+ })
415
+ if got != "第一段\n第二段" {
416
+ t.Errorf("arkClawJoinParts = %q", got)
417
+ }
418
+ }
419
+
420
+ func TestArkClawParseNonJSONBody(t *testing.T) {
421
+ _, err := arkClawParseResponse(http.StatusOK, []byte("<html>bad gateway</html>"))
422
+ if err == nil || !strings.Contains(err.Error(), "不是 JSON") {
423
+ t.Errorf("err = %v", err)
424
+ }
425
+ }
426
+
427
+ func TestArkClawParseHandlesLeadingLogLine(t *testing.T) {
428
+ // 少量网关会在 JSON 前混入日志行 → 退回括号计数法抽首个对象。
429
+ body := "[gw] request accepted\n" + arkClawTextEnvelope("带日志的正文", "ctx-7")
430
+ resp, err := arkClawParseResponse(http.StatusOK, []byte(body))
431
+ if err != nil {
432
+ t.Fatalf("arkClawParseResponse: %v", err)
433
+ }
434
+ if resp.Text != "带日志的正文" || resp.SessionID != "ctx-7" {
435
+ t.Errorf("resp = %+v", resp)
436
+ }
437
+ }
438
+
439
+ func TestArkClawEndpointURL(t *testing.T) {
440
+ got, err := arkClawEndpointURL("https://h/a2a/jsonrpc", "k 1", "c/2")
441
+ if err != nil {
442
+ t.Fatalf("arkClawEndpointURL: %v", err)
443
+ }
444
+ // 凭据必须被 URL 编码。
445
+ if !strings.Contains(got, "apikey=k+1") && !strings.Contains(got, "apikey=k%201") {
446
+ t.Errorf("apikey 未正确编码: %q", got)
447
+ }
448
+ if !strings.Contains(got, "clawId=c%2F2") {
449
+ t.Errorf("clawId 未正确编码: %q", got)
450
+ }
451
+
452
+ // 原 URL 自带的 query 参数应保留。
453
+ got, err = arkClawEndpointURL("https://h/a2a/jsonrpc?trace=1", "k", "c")
454
+ if err != nil {
455
+ t.Fatalf("arkClawEndpointURL: %v", err)
456
+ }
457
+ if !strings.Contains(got, "trace=1") {
458
+ t.Errorf("原有 query 丢失: %q", got)
459
+ }
460
+ }
461
+
462
+ func TestArkClawEndpointURLRejectsRelative(t *testing.T) {
463
+ for _, bad := range []string{"", "/a2a/jsonrpc", "h/a2a/jsonrpc", "://x"} {
464
+ if _, err := arkClawEndpointURL(bad, "k", "c"); err == nil {
465
+ t.Errorf("arkClawEndpointURL(%q) 应报错", bad)
466
+ }
467
+ }
468
+ }
469
+
470
+ func TestArkClawRandomIDUniqueAndShaped(t *testing.T) {
471
+ seen := map[string]bool{}
472
+ for i := 0; i < 200; i++ {
473
+ id := arkClawRandomID()
474
+ if seen[id] {
475
+ t.Fatalf("重复 id: %q", id)
476
+ }
477
+ seen[id] = true
478
+ if len(id) != 36 || strings.Count(id, "-") != 4 {
479
+ t.Fatalf("id 不是 UUIDv4 形态: %q", id)
480
+ }
481
+ }
482
+ }
483
+
484
+ // ---------- 配置来源 ----------
485
+
486
+ func TestArkClawCredsFromConfigFile(t *testing.T) {
487
+ clearArkClawEnv(t)
488
+ srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
489
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "ctx-1"))
490
+ })
491
+
492
+ // 配置文件里的 url 指向假网关;key/claw_id 也来自文件。
493
+ dir := t.TempDir()
494
+ cfgPath := filepath.Join(dir, "config.json")
495
+ body := fmt.Sprintf(`{"arkclaw":{"url":%q,"key":"file-key","claw_id":"file-claw"}}`,
496
+ srv.URL+"/a2a/jsonrpc")
497
+ if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
498
+ t.Fatal(err)
499
+ }
500
+
501
+ e := &ArkClawEngine{ConfigPath: cfgPath}
502
+ if _, err := e.Complete(context.Background(), Request{
503
+ Messages: []Message{{Role: "user", Content: "hi"}},
504
+ }); err != nil {
505
+ t.Fatalf("Complete: %v", err)
506
+ }
507
+ if rec.Query["apikey"] != "file-key" || rec.Query["clawId"] != "file-claw" {
508
+ t.Errorf("文件凭据未生效: %v", rec.Query)
509
+ }
510
+ }
511
+
512
+ func TestArkClawExplicitOverridesEnvAndFile(t *testing.T) {
513
+ // 优先级:显式字段 > 环境变量 > 配置文件。
514
+ t.Setenv("MAGIC_AGENT_ARKCLAW_URL", "https://env.invalid/a2a/jsonrpc")
515
+ t.Setenv("MAGIC_AGENT_ARKCLAW_KEY", "env-key")
516
+ t.Setenv("MAGIC_AGENT_ARKCLAW_CLAW_ID", "env-claw")
517
+
518
+ srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
519
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "ctx-1"))
520
+ })
521
+
522
+ e := &ArkClawEngine{
523
+ URL: srv.URL + "/a2a/jsonrpc", // 显式覆盖环境变量
524
+ Key: "explicit-key",
525
+ ClawID: "explicit-claw",
526
+ }
527
+ if _, err := e.Complete(context.Background(), Request{
528
+ Messages: []Message{{Role: "user", Content: "hi"}},
529
+ }); err != nil {
530
+ t.Fatalf("Complete: %v", err)
531
+ }
532
+ if rec.Query["apikey"] != "explicit-key" || rec.Query["clawId"] != "explicit-claw" {
533
+ t.Errorf("显式字段未覆盖环境变量: %v", rec.Query)
534
+ }
535
+ }
536
+
537
+ func TestArkClawMalformedConfigReported(t *testing.T) {
538
+ clearArkClawEnv(t)
539
+ dir := t.TempDir()
540
+ cfgPath := filepath.Join(dir, "config.json")
541
+ if err := os.WriteFile(cfgPath, []byte("{ not json"), 0o600); err != nil {
542
+ t.Fatal(err)
543
+ }
544
+ e := &ArkClawEngine{ConfigPath: cfgPath}
545
+ _, err := e.Complete(context.Background(), Request{
546
+ Messages: []Message{{Role: "user", Content: "hi"}},
547
+ })
548
+ if err == nil {
549
+ t.Fatal("expected parse error to surface")
550
+ }
551
+ if !strings.Contains(err.Error(), "parse config") {
552
+ t.Errorf("err = %v", err)
553
+ }
554
+ }
555
+
556
+ // ---------- JSONSchema 后处理 ----------
557
+
558
+ func TestArkClawJSONSchemaPostProcess(t *testing.T) {
559
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
560
+ // 网关爱说废话:正文里包了 markdown 围栏 + 前后解释。
561
+ _, _ = io.WriteString(w, arkClawTextEnvelope(
562
+ "好的,结果如下:\n```json\n{\"city\":\"北京\",\"temp\":21}\n```\n需要我继续吗?", "ctx-1"))
563
+ })
564
+ e := arkClawServerEngine(srv)
565
+
566
+ resp, err := e.Complete(context.Background(), Request{
567
+ Messages: []Message{{Role: "user", Content: "北京天气"}},
568
+ JSONSchema: &JSONSchema{
569
+ Type: "object",
570
+ Properties: map[string]map[string]any{
571
+ "city": {"type": "string"},
572
+ "temp": {"type": "number"},
573
+ },
574
+ Required: []string{"city", "temp"},
575
+ },
576
+ })
577
+ if err != nil {
578
+ t.Fatalf("Complete: %v", err)
579
+ }
580
+ var out map[string]any
581
+ if err := json.Unmarshal([]byte(resp.Text), &out); err != nil {
582
+ t.Fatalf("resp.Text 不是纯 JSON: %v (%q)", err, resp.Text)
583
+ }
584
+ if out["city"] != "北京" {
585
+ t.Errorf("out = %v", out)
586
+ }
587
+ }
588
+
589
+ func TestArkClawTimeoutFromRequest(t *testing.T) {
590
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
591
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "c"))
592
+ })
593
+ e := arkClawServerEngine(srv)
594
+
595
+ // req.Timeout 应生效(此处给足,验证不因超时配置而失败)。
596
+ if _, err := e.Complete(context.Background(), Request{
597
+ Messages: []Message{{Role: "user", Content: "hi"}},
598
+ Timeout: 30_000_000_000, // 30s
599
+ }); err != nil {
600
+ t.Fatalf("Complete: %v", err)
601
+ }
602
+ }
603
+
604
+ // TestArkClawModelNotForwarded 验证 -m 在 arkclaw 上不能透传:
605
+ // 实测网关按 clawId 绑死模型,magic-agent 收到 -m 也要明确告警。
606
+ // 同时确认告警「不影响调用」——网关响应照样被解析、调用照样成功。
607
+ func TestArkClawModelNotForwarded(t *testing.T) {
608
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
609
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "c"))
610
+ })
611
+ e := arkClawServerEngine(srv)
612
+
613
+ // 临时把 stderr 接到 buffer,验证告警文本。
614
+ old := stderr
615
+ defer func() { stderr = old }()
616
+ var buf strings.Builder
617
+ stderr = &buf
618
+
619
+ resp, err := e.Complete(context.Background(), Request{
620
+ Model: "minimax-m3",
621
+ Messages: []Message{{Role: "user", Content: "hi"}},
622
+ })
623
+ if err != nil {
624
+ t.Fatalf("Complete: %v", err)
625
+ }
626
+ if resp.Text != "ok" {
627
+ t.Errorf("Text = %q", resp.Text)
628
+ }
629
+ warn := buf.String()
630
+ if !strings.Contains(warn, "arkclaw") || !strings.Contains(warn, "minimax-m3") {
631
+ t.Errorf("告警文案未提及 arkclaw/minimax-m3: %q", warn)
632
+ }
633
+ if !strings.Contains(warn, "claw_id") {
634
+ t.Errorf("告警应引导用户改 claw_id: %q", warn)
635
+ }
636
+ }
637
+
638
+ // TestArkClawNoModelNoWarning 验证未传 -m 时不要无脑打告警。
639
+ func TestArkClawNoModelNoWarning(t *testing.T) {
640
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
641
+ _, _ = io.WriteString(w, arkClawTextEnvelope("ok", "c"))
642
+ })
643
+ e := arkClawServerEngine(srv)
644
+
645
+ old := stderr
646
+ defer func() { stderr = old }()
647
+ var buf strings.Builder
648
+ stderr = &buf
649
+
650
+ if _, err := e.Complete(context.Background(), Request{
651
+ Messages: []Message{{Role: "user", Content: "hi"}},
652
+ }); err != nil {
653
+ t.Fatalf("Complete: %v", err)
654
+ }
655
+ if buf.Len() != 0 {
656
+ t.Errorf("未传 -m 时不应有 stderr 告警, got %q", buf.String())
657
+ }
658
+ }
659
+
660
+ func TestDefaultArkClawTimeoutCoversObservedLatency(t *testing.T) {
661
+ // 实测短问答 8~23s,但**带工具循环的长任务**要 5 分钟(2026-09-24:「生成周报」304s 才回,
662
+ // 期间每 15s 一个 working 心跳)。默认值必须留足余量,且**不得短于 CLI 的 -t 默认(600s)**
663
+ // —— 短了就会出现「什么都没显示 · 调用失败 context deadline exceeded」。
664
+ if DefaultArkClawTimeout < 10*time.Minute {
665
+ t.Errorf("DefaultArkClawTimeout = %v, 实测长任务 304s + CLI -t 默认 600s,应 ≥ 10 分钟", DefaultArkClawTimeout)
666
+ }
667
+ }
668
+
669
+ // clearArkClawEnv 清掉可能干扰测试的 arkclaw 相关环境变量。
670
+ func clearArkClawEnv(t *testing.T) {
671
+ t.Helper()
672
+ for _, k := range []string{
673
+ "MAGIC_AGENT_CONFIG",
674
+ "MAGIC_AGENT_ARKCLAW_URL",
675
+ "MAGIC_AGENT_ARKCLAW_KEY",
676
+ "MAGIC_AGENT_ARKCLAW_CLAW_ID",
677
+ } {
678
+ t.Setenv(k, "")
679
+ }
680
+ }
681
+
682
+ /* ── 具名 A2A agent(2026-09-23)──
683
+ * 用户原话:「应该放在 ~/.magic-agent/ 下,新增 <url> 名字叫 MagicAI」。
684
+ * 落地 = 配置的 `agents` 数组,每个条目注册成一个 ArkClawEngine(只换名字与端点)。
685
+ * 下面几条钉住:名字生效、URL 内嵌凭据算「配齐」、空名/重名被跳过、注册出来的引擎真能用。 */
686
+
687
+ func TestArkClawDisplayNameBecomesEngineName(t *testing.T) {
688
+ if got := (&ArkClawEngine{}).Name(); got != "arkclaw" {
689
+ t.Errorf("空 DisplayName 的 Name() = %q want arkclaw(老行为不许变)", got)
690
+ }
691
+ if got := (&ArkClawEngine{DisplayName: "MagicAI"}).Name(); got != "MagicAI" {
692
+ t.Errorf("Name() = %q want MagicAI", got)
693
+ }
694
+ // 只有空白也算「没给」——否则 `-e " "` 这种名字会进注册表。
695
+ if got := (&ArkClawEngine{DisplayName: " "}).Name(); got != "arkclaw" {
696
+ t.Errorf("空白 DisplayName 的 Name() = %q want arkclaw", got)
697
+ }
698
+ }
699
+
700
+ func TestArkClawURLHasCreds(t *testing.T) {
701
+ cases := []struct {
702
+ url string
703
+ wantKey bool
704
+ wantClaw bool
705
+ }{
706
+ {"https://h/a2a/jsonrpc?apikey=K&clawId=C", true, true},
707
+ {"https://h/a2a/jsonrpc?apikey=K", true, false},
708
+ {"https://h/a2a/jsonrpc?clawId=C", false, true},
709
+ {"https://h/a2a/jsonrpc", false, false},
710
+ {"https://h/a2a/jsonrpc?apikey=&clawId=", false, false}, // 空值不算带
711
+ {"", false, false},
712
+ {"://bad", false, false},
713
+ }
714
+ for _, c := range cases {
715
+ gotKey, gotClaw := arkClawURLHasCreds(c.url)
716
+ if gotKey != c.wantKey || gotClaw != c.wantClaw {
717
+ t.Errorf("arkClawURLHasCreds(%q) = (%v,%v) want (%v,%v)", c.url, gotKey, gotClaw, c.wantKey, c.wantClaw)
718
+ }
719
+ }
720
+ }
721
+
722
+ func TestArkClawDetectReadyWhenCredsEmbeddedInURL(t *testing.T) {
723
+ clearArkClawEnv(t)
724
+ url := "https://h/a2a/jsonrpc?apikey=K&clawId=C"
725
+ e := &ArkClawEngine{
726
+ DisplayName: "MagicAI",
727
+ URL: url,
728
+ ConfigPath: filepath.Join(t.TempDir(), "不存在.json"), // 隔离真机配置
729
+ }
730
+ ok, info := e.Detect()
731
+ if !ok {
732
+ t.Fatalf("URL 里已带 apikey/clawId 应判为配齐,got: %s", info)
733
+ }
734
+ if info != url {
735
+ t.Errorf("Detect 信息 = %q want 端点原样 %q(--engines 的 bin 用它)", info, url)
736
+ }
737
+ }
738
+
739
+ func TestArkClawDetectStillNeedsCredsWhenNotInURL(t *testing.T) {
740
+ clearArkClawEnv(t)
741
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", ConfigPath: filepath.Join(t.TempDir(), "不存在.json")}
742
+ ok, info := e.Detect()
743
+ if ok {
744
+ t.Fatal("光有 url、凭据也没有 → 不该判为可用")
745
+ }
746
+ // 报错要点名是哪个引擎、缺什么(具名 agent 时尤其重要:它不在 arkclaw 那一节)
747
+ if !strings.Contains(info, "key") || !strings.Contains(info, "claw_id") {
748
+ t.Errorf("缺失说明应列出 key/claw_id,got %q", info)
749
+ }
750
+ }
751
+
752
+ func TestAgentEnginesFromSkipsEmptyAndDuplicateNames(t *testing.T) {
753
+ existing := []Engine{&ClaudeEngine{}, &ArkClawEngine{}}
754
+ got := agentEnginesFrom([]config.AgentConfig{
755
+ {Name: "", URL: "https://x"}, // 空名 → 跳过
756
+ {Name: " ", URL: "https://x"}, // 全空白 → 跳过
757
+ {Name: "arkclaw", URL: "https://dup"}, // 与内置重名 → 跳过
758
+ {Name: "Claude", URL: "https://dup"}, // 重名不分大小写 → 跳过
759
+ {Name: "MagicAI", URL: "https://h/a2a/jsonrpc?apikey=K&clawId=C"},
760
+ {Name: "MagicAI", URL: "https://again"}, // 同批里重复 → 只留第一条
761
+ }, existing)
762
+ if len(got) != 1 {
763
+ t.Fatalf("应只注册 1 个(MagicAI),got %d: %v", len(got), got)
764
+ }
765
+ if got[0].Name() != "MagicAI" {
766
+ t.Errorf("Name() = %q want MagicAI", got[0].Name())
767
+ }
768
+ if ok, info := got[0].Detect(); !ok {
769
+ t.Errorf("注册出来的引擎应可用(URL 内嵌凭据),got: %s", info)
770
+ }
771
+ }
772
+
773
+ func TestAgentEnginesFromEmptyInput(t *testing.T) {
774
+ // 老配置没有 agents 节 → 不注册任何东西(老行为一字不变)。
775
+ if got := agentEnginesFrom(nil, []Engine{&ArkClawEngine{}}); got != nil {
776
+ t.Errorf("无 agents 条目时应返回 nil,got %v", got)
777
+ }
778
+ }
779
+
780
+ /* 能力表要按**协议家族**归属,不能按名字 —— 否则具名 agent 会落进 default 那一行。
781
+ * 实测踩到(2026-09-23):MagicAI 一开始被报成 `workspace=cwd` / `attachments=prompt`,
782
+ * 而它实际是 `none` / `part:file`(后者会让界面白白警告「图贴了但模型看不到」)。 */
783
+ func TestNamedAgentInheritsProtocolCapabilities(t *testing.T) {
784
+ if got := (&ArkClawEngine{}).CapabilityFamily(); got != "arkclaw" {
785
+ t.Fatalf("ArkClawEngine 的家族名 = %q want arkclaw", got)
786
+ }
787
+ /* 注册一个探针引擎(名字唯一,不碰真配置):CapabilityFamilyOf 走的是**包级注册表**,
788
+ * 不注册进去就查不到家族 —— 那正是线上会走通的路径,所以这里照走。
789
+ * 注册表只增不减;前缀下划线保证不与任何真实引擎或其它用例撞名。 */
790
+ const probe = "__capfam_probe__"
791
+ Register(&ArkClawEngine{DisplayName: probe, URL: "https://h/a2a/jsonrpc?apikey=K&clawId=C"})
792
+
793
+ if got := CapabilityFamilyOf(probe); got != "arkclaw" {
794
+ t.Fatalf("CapabilityFamilyOf(%q) = %q want arkclaw", probe, got)
795
+ }
796
+ if got, want := WorkspaceSupportOf(probe), WorkspaceSupportOf("arkclaw"); got != want {
797
+ t.Errorf("workspace = %q want %q(具名 agent 不许退回 default 的 cwd)", got, want)
798
+ }
799
+ if got := AttachmentSupportOf(probe); got != "part:file" {
800
+ t.Errorf("attachments = %q want part:file(不许退回 default 的 prompt)", got)
801
+ }
802
+ if got := PermissionSupportOf(probe); got != "none" {
803
+ t.Errorf("permission = %q want none", got)
804
+ }
805
+ if AskSupportsEngine(probe) {
806
+ t.Error("ask 应为不支持(A2A 网关无 AskUserQuestion)")
807
+ }
808
+ if AppendSupportOf(probe) {
809
+ t.Error("append 应为 false(A2A 无追加通道)")
810
+ }
811
+ if ToolsSwitchableOf(probe) {
812
+ t.Error("tools 应为不可切换(工具循环在网关侧)")
813
+ }
814
+ if got := InstallCommandOf(probe); got != "" {
815
+ t.Errorf("install = %q want 空(HTTP 引擎没有可执行安装命令)", got)
816
+ }
817
+ // 内置引擎与未知名必须一字不变(家族解析只对「注册表里认得、且声明了家族」的名字生效)
818
+ if got := CapabilityFamilyOf("claude"); got != "claude" {
819
+ t.Errorf("CapabilityFamilyOf(claude) = %q want claude", got)
820
+ }
821
+ if got := CapabilityFamilyOf("__没这个引擎__"); got != "__没这个引擎__" {
822
+ t.Errorf("未知名应原样返回,got %q", got)
823
+ }
824
+ if got := CapabilityFamilyOf(""); got != "" {
825
+ t.Errorf("空名应返回空串,got %q", got)
826
+ }
827
+ }