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,428 @@
1
+ package agent
2
+
3
+ // arkclaw_stream_test.go - ArkClaw 流式通道(A2A message/stream 的 SSE)单元测试。
4
+ //
5
+ // 覆盖:接口契约(实现了 Streamer + sse 标记)、请求形状(method / accept / contextId
6
+ // 续接落点)、SSE 逐帧解析(心跳与分隔行跳过、working 帧不发事件、completed 发正文)、
7
+ // 整段重复去重、中间 artifact 增量转发、失败态 / JSON-RPC error / 401 / 无终态帧 /
8
+ // 空正文、非 SSE 响应的一次性回退、JSONSchema 后处理、超时中断。
9
+ //
10
+ // 全部走 httptest,不触网(真实网关的行为见 arkclaw_stream.go 文件头的实测记录)。
11
+
12
+ import (
13
+ "context"
14
+ "encoding/json"
15
+ "fmt"
16
+ "io"
17
+ "net/http"
18
+ "net/http/httptest"
19
+ "strings"
20
+ "testing"
21
+ "time"
22
+ )
23
+
24
+ // ---------- 假网关与帧构造 ----------
25
+
26
+ // arkClawSSEServer 起一个假网关,把 body 原样当 SSE 回(content-type: text/event-stream)。
27
+ func arkClawSSEServer(t *testing.T, body string) (*httptest.Server, *arkClawRec) {
28
+ t.Helper()
29
+ return arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
30
+ w.Header().Set("content-type", "text/event-stream")
31
+ w.Header().Set("cache-control", "no-cache")
32
+ _, _ = io.WriteString(w, body)
33
+ })
34
+ }
35
+
36
+ // arkClawSSEFrame 组一帧 SSE(`data: <json>` + 空行分隔)。
37
+ func arkClawSSEFrame(jsonBody string) string { return "data: " + jsonBody + "\n\n" }
38
+
39
+ // arkClawWorkingFrame 组一个受理帧(state=working,无正文)—— 真实网关的**第一帧**长这样。
40
+ func arkClawWorkingFrame(contextID string) string {
41
+ return fmt.Sprintf(`{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1",`+
42
+ `"contextId":%q,"status":{"state":"working","timestamp":"2026-09-22T07:31:28.894Z"},`+
43
+ `"history":[]}}`, contextID)
44
+ }
45
+
46
+ // arkClawArtifactFrame 组一个把正文挂在 artifacts 上的帧(网关将来吐增量时的形态)。
47
+ func arkClawArtifactFrame(state, contextID, text string) string {
48
+ return fmt.Sprintf(`{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1",`+
49
+ `"contextId":%q,"status":{"state":%q},"artifacts":[{"artifactId":"a1",`+
50
+ `"parts":[{"kind":"text","text":%q}]}]}}`, contextID, state, text)
51
+ }
52
+
53
+ // ---------- 接口契约 ----------
54
+
55
+ func TestArkClawIsStreamer(t *testing.T) {
56
+ // 流式走 A2A 官方 SSE(message/stream):**不是** WebSocket —— 实测网关不回 101
57
+ // 升级响应,且 A2A 规范里 WS 属自定义绑定、火山网关也没有 WS API 类型。
58
+ e := &ArkClawEngine{}
59
+ if s := AsStreamer(e); s == nil {
60
+ t.Fatal("arkclaw 应实现 Streamer(走 A2A 官方 SSE,见 arkclaw_stream.go)")
61
+ }
62
+ if !SupportsStream(e) {
63
+ t.Error("SupportsStream(arkclaw) 应为 true(`--engines` 的 streaming 字段由它决定)")
64
+ }
65
+ if got := streamArgsFor(e); len(got) != 1 || got[0] != "sse" {
66
+ t.Errorf("流式标记 = %v,期望 [sse]", got)
67
+ }
68
+ }
69
+
70
+ // ---------- 正常路径 ----------
71
+
72
+ func TestArkClawStreamSSECompleted(t *testing.T) {
73
+ const text = "西湖,位于浙江省杭州市城西,是中国最著名的淡水湖泊之一。"
74
+ body := arkClawSSEFrame(arkClawWorkingFrame("ctx-s1")) +
75
+ arkClawSSEFrame(arkClawTextEnvelope(text, "ctx-s1"))
76
+ srv, rec := arkClawSSEServer(t, body)
77
+ e := arkClawServerEngine(srv)
78
+
79
+ events, onEvent := collectEvents()
80
+ res, err := e.Stream(context.Background(), Request{
81
+ Messages: []Message{{Role: "user", Content: "介绍一下西湖"}},
82
+ }, onEvent)
83
+ if err != nil {
84
+ t.Fatalf("Stream: %v", err)
85
+ }
86
+
87
+ // 请求形状:凭据仍走 query,method 换成 message/stream,并要求 SSE 响应。
88
+ if rec.Path != "/a2a/jsonrpc" {
89
+ t.Errorf("path = %q", rec.Path)
90
+ }
91
+ if rec.Query["apikey"] != "test-key" || rec.Query["clawId"] != "ci-test" {
92
+ t.Errorf("query = %v", rec.Query)
93
+ }
94
+ if got := rec.Header.Get("accept"); got != "text/event-stream" {
95
+ t.Errorf("accept = %q want text/event-stream", got)
96
+ }
97
+ var sent arkClawRequest
98
+ if err := json.Unmarshal([]byte(rec.Body), &sent); err != nil {
99
+ t.Fatalf("请求体不是合法 JSON: %v", err)
100
+ }
101
+ if sent.Method != "message/stream" {
102
+ t.Errorf("method = %q want message/stream", sent.Method)
103
+ }
104
+
105
+ // 事件:working 帧**不发事件**(网关没有增量可发),completed 帧发一条正文增量。
106
+ if len(*events) != 1 {
107
+ t.Fatalf("事件数 = %d want 1: %+v", len(*events), *events)
108
+ }
109
+ if ev := (*events)[0]; ev.Kind != KindText || ev.Text != text {
110
+ t.Errorf("事件 = %+v want KindText/%q", ev, text)
111
+ }
112
+
113
+ if res.Text != text {
114
+ t.Errorf("Text = %q", res.Text)
115
+ }
116
+ if res.SessionID != "ctx-s1" {
117
+ t.Errorf("SessionID = %q want ctx-s1", res.SessionID)
118
+ }
119
+ if res.Engine != "arkclaw" || res.Attempts != 1 || res.Latency <= 0 {
120
+ t.Errorf("res = %+v", res.Response)
121
+ }
122
+ }
123
+
124
+ func TestArkClawStreamSkipsHeartbeatAndNonDataLines(t *testing.T) {
125
+ // SSE 里的分隔空行、":" 注释(心跳)、event:/id:/retry: 字段与 [DONE] 都不是数据帧。
126
+ body := ": keep-alive\n\n" +
127
+ "event: task\nid: 7\nretry: 1000\n" +
128
+ arkClawSSEFrame(arkClawTextEnvelope("正文", "c1")) +
129
+ "data: [DONE]\n\n"
130
+ srv, _ := arkClawSSEServer(t, body)
131
+
132
+ events, onEvent := collectEvents()
133
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
134
+ Messages: []Message{{Role: "user", Content: "hi"}},
135
+ }, onEvent)
136
+ if err != nil {
137
+ t.Fatalf("Stream: %v", err)
138
+ }
139
+ if len(*events) != 1 || (*events)[0].Text != "正文" {
140
+ t.Errorf("事件 = %+v want 单条正文", *events)
141
+ }
142
+ if res.Text != "正文" {
143
+ t.Errorf("Text = %q", res.Text)
144
+ }
145
+ }
146
+
147
+ func TestArkClawStreamDedupWhenArtifactsRepeat(t *testing.T) {
148
+ // 实测:completed 帧把同一份正文**同时**放在 status.message 与 artifacts 里。
149
+ // 两处都发会让调用方看到两份 → 必须只发一次。
150
+ frame := `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"c1",` +
151
+ `"status":{"state":"completed","message":{"kind":"message","role":"agent",` +
152
+ `"parts":[{"kind":"text","text":"同一份正文"}]}},` +
153
+ `"artifacts":[{"artifactId":"a1","parts":[{"kind":"text","text":"同一份正文"}]}]}}`
154
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(frame))
155
+
156
+ events, onEvent := collectEvents()
157
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
158
+ Messages: []Message{{Role: "user", Content: "hi"}},
159
+ }, onEvent)
160
+ if err != nil {
161
+ t.Fatalf("Stream: %v", err)
162
+ }
163
+ if len(*events) != 1 {
164
+ t.Fatalf("事件数 = %d want 1(同一份正文不得发两次): %+v", len(*events), *events)
165
+ }
166
+ if res.Text != "同一份正文" {
167
+ t.Errorf("Text = %q", res.Text)
168
+ }
169
+ }
170
+
171
+ func TestArkClawStreamForwardsArtifactIncrements(t *testing.T) {
172
+ // 中间帧若挂了正文(网关当前不发,留着以防将来漏掉):按增量转发,
173
+ // completed 帧的整段正文只补发**剩下的部分**(前缀去重),不重复。
174
+ body := arkClawSSEFrame(arkClawArtifactFrame("working", "c1", "第一段")) +
175
+ arkClawSSEFrame(arkClawTextEnvelope("第一段第二段", "c1"))
176
+ srv, _ := arkClawSSEServer(t, body)
177
+
178
+ events, onEvent := collectEvents()
179
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
180
+ Messages: []Message{{Role: "user", Content: "hi"}},
181
+ }, onEvent)
182
+ if err != nil {
183
+ t.Fatalf("Stream: %v", err)
184
+ }
185
+ if len(*events) != 2 {
186
+ t.Fatalf("事件数 = %d want 2: %+v", len(*events), *events)
187
+ }
188
+ if (*events)[0].Text != "第一段" || (*events)[1].Text != "第二段" {
189
+ t.Errorf("增量 = %q / %q", (*events)[0].Text, (*events)[1].Text)
190
+ }
191
+ if res.Text != "第一段第二段" {
192
+ t.Errorf("Text = %q", res.Text)
193
+ }
194
+ }
195
+
196
+ func TestArkClawStreamResumeUsesMessageContextID(t *testing.T) {
197
+ srv, rec := arkClawSSEServer(t, arkClawSSEFrame(arkClawTextEnvelope("紫色大象", "ctx-prev")))
198
+ e := arkClawServerEngine(srv)
199
+
200
+ res, err := e.Stream(context.Background(), Request{
201
+ Messages: []Message{{Role: "user", Content: "暗号是什么"}},
202
+ SessionID: "ctx-prev",
203
+ }, func(StreamEvent) {})
204
+ if err != nil {
205
+ t.Fatalf("Stream: %v", err)
206
+ }
207
+ if res.SessionID != "ctx-prev" {
208
+ t.Errorf("SessionID = %q want ctx-prev", res.SessionID)
209
+ }
210
+
211
+ // 续接 id 必须落在 message.contextId(外层 params.contextId 会被网关忽略)。
212
+ var outer map[string]json.RawMessage
213
+ if err := json.Unmarshal([]byte(rec.Body), &outer); err != nil {
214
+ t.Fatalf("unmarshal: %v", err)
215
+ }
216
+ var params map[string]json.RawMessage
217
+ if err := json.Unmarshal(outer["params"], &params); err != nil {
218
+ t.Fatalf("unmarshal params: %v", err)
219
+ }
220
+ if raw, ok := params["contextId"]; ok {
221
+ t.Errorf("外层 params.contextId 不应出现: %s", raw)
222
+ }
223
+ var msg struct {
224
+ ContextID string `json:"contextId"`
225
+ }
226
+ if err := json.Unmarshal(params["message"], &msg); err != nil {
227
+ t.Fatalf("unmarshal message: %v", err)
228
+ }
229
+ if msg.ContextID != "ctx-prev" {
230
+ t.Errorf("message.contextId = %q want ctx-prev", msg.ContextID)
231
+ }
232
+ }
233
+
234
+ func TestArkClawStreamJSONSchemaPostProcess(t *testing.T) {
235
+ // JSONSchema 后处理要在**发出增量之前**做,保证事件里的正文与收尾正文是同一份。
236
+ body := arkClawSSEFrame(arkClawTextEnvelope(
237
+ "好的,结果如下:\n```json\n{\"city\":\"北京\",\"temp\":21}\n```\n需要我继续吗?", "c1"))
238
+ srv, _ := arkClawSSEServer(t, body)
239
+
240
+ events, onEvent := collectEvents()
241
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
242
+ Messages: []Message{{Role: "user", Content: "北京天气"}},
243
+ JSONSchema: &JSONSchema{
244
+ Type: "object",
245
+ Properties: map[string]map[string]any{
246
+ "city": {"type": "string"},
247
+ "temp": {"type": "number"},
248
+ },
249
+ Required: []string{"city", "temp"},
250
+ },
251
+ }, onEvent)
252
+ if err != nil {
253
+ t.Fatalf("Stream: %v", err)
254
+ }
255
+ var out map[string]any
256
+ if err := json.Unmarshal([]byte(res.Text), &out); err != nil {
257
+ t.Fatalf("Text 不是纯 JSON: %v (%q)", err, res.Text)
258
+ }
259
+ if len(*events) != 1 || (*events)[0].Text != res.Text {
260
+ t.Errorf("增量应与收尾正文一致: events=%+v text=%q", *events, res.Text)
261
+ }
262
+ }
263
+
264
+ // ---------- 错误路径 ----------
265
+
266
+ func TestArkClawStreamTaskFailed(t *testing.T) {
267
+ frame := `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"ctx-9",` +
268
+ `"status":{"state":"failed","message":{"kind":"message","parts":[{"kind":"text","text":"上游超时"}]}}}}`
269
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(arkClawWorkingFrame("ctx-9"))+arkClawSSEFrame(frame))
270
+
271
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
272
+ Messages: []Message{{Role: "user", Content: "hi"}},
273
+ }, func(StreamEvent) {})
274
+ if err == nil {
275
+ t.Fatal("expected error for status.state=failed")
276
+ }
277
+ if !strings.Contains(err.Error(), "failed") || !strings.Contains(err.Error(), "上游超时") {
278
+ t.Errorf("err = %v", err)
279
+ }
280
+ // 失败也要把 contextId 交回调用方(排查 / 续接都要它)。
281
+ if res.SessionID != "ctx-9" {
282
+ t.Errorf("SessionID = %q want ctx-9", res.SessionID)
283
+ }
284
+ }
285
+
286
+ func TestArkClawStreamRPCError(t *testing.T) {
287
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(
288
+ `{"jsonrpc":"2.0","id":"req-1","error":{"code":-32602,"message":"invalid params"}}`))
289
+
290
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
291
+ Messages: []Message{{Role: "user", Content: "hi"}},
292
+ }, func(StreamEvent) {})
293
+ if err == nil || !strings.Contains(err.Error(), "invalid params") {
294
+ t.Errorf("err = %v", err)
295
+ }
296
+ }
297
+
298
+ func TestArkClawStreamHTTP401PlainText(t *testing.T) {
299
+ // 鉴权失败:网关回 401 + text/plain(非 SSE)→ 走一次性响应那条路,错误必须带状态码与正文。
300
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
301
+ w.Header().Set("content-type", "text/plain")
302
+ w.WriteHeader(http.StatusUnauthorized)
303
+ _, _ = io.WriteString(w, "External authentication failed.")
304
+ })
305
+
306
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
307
+ Messages: []Message{{Role: "user", Content: "hi"}},
308
+ }, func(StreamEvent) {})
309
+ if err == nil {
310
+ t.Fatal("expected 401 error")
311
+ }
312
+ if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "External authentication failed") {
313
+ t.Errorf("err = %v", err)
314
+ }
315
+ }
316
+
317
+ func TestArkClawStreamMissingCompletedFrame(t *testing.T) {
318
+ // 流断在没有终态帧的地方(网关半途掉线)→ 必须明确报「没有 completed 帧」,
319
+ // 不能拿空正文糊弄过去。
320
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(arkClawWorkingFrame("c1")))
321
+
322
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
323
+ Messages: []Message{{Role: "user", Content: "hi"}},
324
+ }, func(StreamEvent) {})
325
+ if err == nil || !strings.Contains(err.Error(), "completed 帧") {
326
+ t.Errorf("err = %v want 缺 completed 帧", err)
327
+ }
328
+ }
329
+
330
+ func TestArkClawStreamEmptyText(t *testing.T) {
331
+ frame := `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"c",` +
332
+ `"status":{"state":"completed","message":{"parts":[]}}}}`
333
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(frame))
334
+
335
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
336
+ Messages: []Message{{Role: "user", Content: "hi"}},
337
+ }, func(StreamEvent) {})
338
+ if err == nil || !strings.Contains(err.Error(), "空正文") {
339
+ t.Errorf("err = %v want 空正文", err)
340
+ }
341
+ }
342
+
343
+ func TestArkClawStreamContinueUnsupported(t *testing.T) {
344
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", Key: "k", ClawID: "c"}
345
+ _, err := e.Stream(context.Background(), Request{
346
+ Messages: []Message{{Role: "user", Content: "hi"}},
347
+ Continue: true,
348
+ }, func(StreamEvent) {})
349
+ if err == nil || (!strings.Contains(err.Error(), "continue") && !strings.Contains(err.Error(), "续接")) {
350
+ t.Errorf("err = %v", err)
351
+ }
352
+ }
353
+
354
+ func TestArkClawStreamNotConfigured(t *testing.T) {
355
+ clearArkClawEnv(t)
356
+ e := &ArkClawEngine{ConfigPath: t.TempDir() + "/absent.json"}
357
+ _, err := e.Stream(context.Background(), Request{
358
+ Messages: []Message{{Role: "user", Content: "hi"}},
359
+ }, func(StreamEvent) {})
360
+ if err == nil || !strings.Contains(err.Error(), "未配置") {
361
+ t.Errorf("err = %v want 未配置", err)
362
+ }
363
+ }
364
+
365
+ func TestArkClawStreamEmptyPrompt(t *testing.T) {
366
+ e := &ArkClawEngine{URL: "https://h/a2a/jsonrpc", Key: "k", ClawID: "c"}
367
+ _, err := e.Stream(context.Background(), Request{}, func(StreamEvent) {})
368
+ if err == nil || !strings.Contains(err.Error(), "empty prompt") {
369
+ t.Errorf("err = %v want empty prompt", err)
370
+ }
371
+ }
372
+
373
+ // ---------- 回退与超时 ----------
374
+
375
+ func TestArkClawStreamNonSSEFallback(t *testing.T) {
376
+ // 网关没按 SSE 回(老网关 / 不认 message/stream)→ 按一次性响应处理并把正文补发成一条增量,
377
+ // 而不是拿「没有 completed 帧」报错。
378
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
379
+ w.Header().Set("content-type", "application/json")
380
+ _, _ = io.WriteString(w, arkClawTextEnvelope("一次性正文", "ctx-f1"))
381
+ })
382
+
383
+ old := stderr
384
+ defer func() { stderr = old }()
385
+ var buf strings.Builder
386
+ stderr = &buf
387
+
388
+ events, onEvent := collectEvents()
389
+ res, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
390
+ Messages: []Message{{Role: "user", Content: "hi"}},
391
+ }, onEvent)
392
+ if err != nil {
393
+ t.Fatalf("Stream: %v", err)
394
+ }
395
+ if res.Text != "一次性正文" || res.SessionID != "ctx-f1" {
396
+ t.Errorf("res = %+v", res.Response)
397
+ }
398
+ if len(*events) != 1 || (*events)[0].Text != "一次性正文" {
399
+ t.Errorf("事件 = %+v want 单条正文", *events)
400
+ }
401
+ if !strings.Contains(buf.String(), "SSE") {
402
+ t.Errorf("回退应在 stderr 说明原因: %q", buf.String())
403
+ }
404
+ }
405
+
406
+ func TestArkClawStreamTimeoutInterruptsRead(t *testing.T) {
407
+ // 长任务中网关只回了受理帧就「卡住」:超时必须能打断读流(请求头早到了,卡的是 body)。
408
+ srv, _ := arkClawTestServer(t, func(w http.ResponseWriter, r *http.Request, _ string) {
409
+ w.Header().Set("content-type", "text/event-stream")
410
+ _, _ = io.WriteString(w, arkClawSSEFrame(arkClawWorkingFrame("c1")))
411
+ if fl, ok := w.(http.Flusher); ok {
412
+ fl.Flush()
413
+ }
414
+ <-r.Context().Done() // 客户端取消(超时)后立刻收工,不拖慢测试
415
+ })
416
+
417
+ start := time.Now()
418
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
419
+ Messages: []Message{{Role: "user", Content: "hi"}},
420
+ Timeout: 300 * time.Millisecond,
421
+ }, func(StreamEvent) {})
422
+ if err == nil {
423
+ t.Fatal("expected timeout error")
424
+ }
425
+ if elapsed := time.Since(start); elapsed > 3*time.Second {
426
+ t.Errorf("超时未生效: %v", elapsed)
427
+ }
428
+ }