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,565 @@
1
+ package agent
2
+
3
+ // stream_test.go - 流式调用测试。
4
+ //
5
+ // 用 fake CLI 脚本回放三家 CLI 的真实 NDJSON 样本(2026-09-15 实测抓包),
6
+ // 验证:
7
+ // - claude/codebuddy:thinking_delta / text_delta 解析与转发
8
+ // - trae:delta.content 直出增量解析
9
+ // - result 收尾行:全文以 result.result 为准
10
+ // - 流中断(无 result 行)→ 报错
11
+ // - 超时杀进程组
12
+ // - AsStreamer / SupportsStream
13
+
14
+ import (
15
+ "context"
16
+ "os"
17
+ "path/filepath"
18
+ "strconv"
19
+ "strings"
20
+ "testing"
21
+ "time"
22
+ )
23
+
24
+ // collectEvents 收集全部事件的辅助闭包。
25
+ func collectEvents() (*[]StreamEvent, func(StreamEvent)) {
26
+ events := &[]StreamEvent{}
27
+ return events, func(ev StreamEvent) { *events = append(*events, ev) }
28
+ }
29
+
30
+ // ── claude 流式 ───────────────────────────────────────────────
31
+
32
+ func TestClaudeStreamParsesThinkingAndText(t *testing.T) {
33
+ // 样本精简自真实抓包:init → thinking 增量 → text 增量 → result。
34
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
35
+ echo '{"type":"system","subtype":"init","session_id":"s-1","model":"MiniMax-M3"}'
36
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}},"session_id":"s-1"}'
37
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户在做加法"}},"session_id":"s-1"}'
38
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":",答案是 2"}},"session_id":"s-1"}'
39
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"s-1"}'
40
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}},"session_id":"s-1"}'
41
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"2"}},"session_id":"s-1"}'
42
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"!"}},"session_id":"s-1"}'
43
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"2!","session_id":"s-1","model":"MiniMax-M3"}'
44
+ `)
45
+ e := &ClaudeEngine{BinPath: cli}
46
+ events, onEvent := collectEvents()
47
+
48
+ res, err := e.Stream(context.Background(), Request{
49
+ Messages: []Message{{Role: "user", Content: "1+1"}},
50
+ }, onEvent)
51
+ if err != nil {
52
+ t.Fatalf("Stream: %v", err)
53
+ }
54
+
55
+ if res.Text != "2!" {
56
+ t.Errorf("Text = %q, want 2!(以 result 行为准)", res.Text)
57
+ }
58
+ if res.Thinking != "用户在做加法,答案是 2" {
59
+ t.Errorf("Thinking = %q", res.Thinking)
60
+ }
61
+ if res.SessionID != "s-1" {
62
+ t.Errorf("SessionID = %q", res.SessionID)
63
+ }
64
+ if res.Model != "MiniMax-M3" {
65
+ t.Errorf("Model = %q", res.Model)
66
+ }
67
+
68
+ // 事件顺序:2 thinking + 2 text
69
+ var thinkCount, textCount int
70
+ for _, ev := range *events {
71
+ switch ev.Kind {
72
+ case KindThinking:
73
+ thinkCount++
74
+ case KindText:
75
+ textCount++
76
+ }
77
+ }
78
+ if thinkCount != 2 || textCount != 2 {
79
+ t.Errorf("events: thinking=%d text=%d, want 2/2", thinkCount, textCount)
80
+ }
81
+ if len(*events) > 0 && (*events)[0].Kind != KindThinking {
82
+ t.Errorf("first event should be thinking, got %v", (*events)[0].Kind)
83
+ }
84
+ }
85
+
86
+ // result.result 为空时回退到增量拼接的全文。
87
+ func TestClaudeStreamFallsBackToDeltas(t *testing.T) {
88
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
89
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"增量全文"}}}'
90
+ echo '{"type":"result","subtype":"success","is_error":false,"result":""}'
91
+ `)
92
+ e := &ClaudeEngine{BinPath: cli}
93
+ res, err := e.Stream(context.Background(), Request{
94
+ Messages: []Message{{Role: "user", Content: "hi"}},
95
+ }, nil)
96
+ if err != nil {
97
+ t.Fatalf("Stream: %v", err)
98
+ }
99
+ if res.Text != "增量全文" {
100
+ t.Errorf("Text = %q, want 增量全文", res.Text)
101
+ }
102
+ }
103
+
104
+ // ── codebuddy 流式(同源协议)────────────────────────────────
105
+
106
+ func TestCodeBuddyStreamParsesDeltas(t *testing.T) {
107
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
108
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"思考中"}}}'
109
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你好"}}}'
110
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"世界"}}}'
111
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"你好世界","model":"glm-5.3"}'
112
+ `)
113
+ e := &CodeBuddyEngine{BinPath: cli}
114
+ events, onEvent := collectEvents()
115
+
116
+ res, err := e.Stream(context.Background(), Request{
117
+ Messages: []Message{{Role: "user", Content: "hi"}},
118
+ }, onEvent)
119
+ if err != nil {
120
+ t.Fatalf("Stream: %v", err)
121
+ }
122
+ if res.Text != "你好世界" {
123
+ t.Errorf("Text = %q", res.Text)
124
+ }
125
+ if res.Thinking != "思考中" {
126
+ t.Errorf("Thinking = %q", res.Thinking)
127
+ }
128
+ if len(*events) != 3 {
129
+ t.Errorf("events = %d, want 3", len(*events))
130
+ }
131
+ }
132
+
133
+ // codebuddy 流式保留 user_query 回显剥离逻辑。
134
+ func TestCodeBuddyStreamStripsUserQueryEcho(t *testing.T) {
135
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
136
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"<user_query>【用户】hi</user_query>真正回答"}'
137
+ `)
138
+ e := &CodeBuddyEngine{BinPath: cli}
139
+ res, err := e.Stream(context.Background(), Request{
140
+ Messages: []Message{{Role: "user", Content: "hi"}},
141
+ }, nil)
142
+ if err != nil {
143
+ t.Fatalf("Stream: %v", err)
144
+ }
145
+ if res.Text != "真正回答" {
146
+ t.Errorf("Text = %q, want 回显剥离", res.Text)
147
+ }
148
+ }
149
+
150
+ // ── trae 流式 ─────────────────────────────────────────────────
151
+
152
+ func TestTraeStreamParsesDeltaContent(t *testing.T) {
153
+ // 样本精简自真实抓包:delta.content 直出,无 thinking 通道。
154
+ cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
155
+ echo '{"type":"system","subtype":"init","session_id":"t-1"}'
156
+ echo '{"type":"stream_event","session_id":"t-1","delta":{"role":"assistant","content":"2"}}'
157
+ echo '{"type":"stream_event","session_id":"t-1","delta":{"role":"assistant","content":"就是这个"}}'
158
+ echo '{"type":"assistant","session_id":"t-1","message":{"role":"assistant","content":"2就是这个"}}'
159
+ echo '{"type":"result","subtype":"success","session_id":"t-1","result":"2就是这个","is_error":false}'
160
+ `)
161
+ e := &TraeEngine{BinPath: cli}
162
+ events, onEvent := collectEvents()
163
+
164
+ res, err := e.Stream(context.Background(), Request{
165
+ Messages: []Message{{Role: "user", Content: "1+1"}},
166
+ }, onEvent)
167
+ if err != nil {
168
+ t.Fatalf("Stream: %v", err)
169
+ }
170
+ if res.Text != "2就是这个" {
171
+ t.Errorf("Text = %q", res.Text)
172
+ }
173
+ if res.Thinking != "" {
174
+ t.Errorf("trae 无 thinking 通道,Thinking 应为空,got %q", res.Thinking)
175
+ }
176
+ if len(*events) != 2 {
177
+ t.Errorf("events = %d, want 2(assistant 聚合行不重复 emit)", len(*events))
178
+ }
179
+ }
180
+
181
+ // ── 异常路径 ──────────────────────────────────────────────────
182
+
183
+ // 流中断(CLI 退出但无 result 行)→ 明确报错。
184
+ func TestStreamMissingResultLine(t *testing.T) {
185
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
186
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"半截"}}}'
187
+ `)
188
+ e := &ClaudeEngine{BinPath: cli}
189
+ _, err := e.Stream(context.Background(), Request{
190
+ Messages: []Message{{Role: "user", Content: "hi"}},
191
+ }, nil)
192
+ if err == nil {
193
+ t.Fatal("expected error for missing result line")
194
+ }
195
+ if !strings.Contains(err.Error(), "without result") {
196
+ t.Errorf("error should mention missing result: %v", err)
197
+ }
198
+ }
199
+
200
+ // result is_error → 报错。
201
+ func TestStreamErrorResult(t *testing.T) {
202
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
203
+ echo '{"type":"result","subtype":"error_max_turns","is_error":true,"result":"超出轮次"}'
204
+ `)
205
+ e := &ClaudeEngine{BinPath: cli}
206
+ _, err := e.Stream(context.Background(), Request{
207
+ Messages: []Message{{Role: "user", Content: "hi"}},
208
+ }, nil)
209
+ if err == nil {
210
+ t.Fatal("expected error for is_error result")
211
+ }
212
+ if !strings.Contains(err.Error(), "error_max_turns") {
213
+ t.Errorf("error should mention subtype: %v", err)
214
+ }
215
+ }
216
+
217
+ // CLI 非零退出码 → 报错。
218
+ func TestStreamCliFailure(t *testing.T) {
219
+ cli := writeFakeCLI(t, "claude", "#!/bin/sh\necho 'some error' >&2\nexit 7\n")
220
+ e := &ClaudeEngine{BinPath: cli}
221
+ _, err := e.Stream(context.Background(), Request{
222
+ Messages: []Message{{Role: "user", Content: "hi"}},
223
+ }, nil)
224
+ if err == nil {
225
+ t.Fatal("expected error for non-zero exit")
226
+ }
227
+ }
228
+
229
+ // 超时 → 杀进程组并报错(子进程不残留)。
230
+ func TestStreamTimeoutKillsProcessGroup(t *testing.T) {
231
+ dir := t.TempDir()
232
+ marker := filepath.Join(dir, "marker")
233
+ cli := writeFakeCLI(t, "claude", "#!/bin/sh\nsleep 30 && touch "+marker+"\n")
234
+ e := &ClaudeEngine{BinPath: cli}
235
+
236
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
237
+ defer cancel()
238
+ start := time.Now()
239
+ _, err := e.Stream(ctx, Request{
240
+ Messages: []Message{{Role: "user", Content: "hi"}},
241
+ }, nil)
242
+ if err == nil {
243
+ t.Fatal("expected timeout error")
244
+ }
245
+ if elapsed := time.Since(start); elapsed > 5*time.Second {
246
+ t.Errorf("timeout took too long: %v", elapsed)
247
+ }
248
+ // marker 不应出现(sleep 30 未跑完就被杀)
249
+ time.Sleep(400 * time.Millisecond)
250
+ if _, merr := os.Stat(marker); merr == nil {
251
+ t.Error("child process survived timeout (marker exists)")
252
+ }
253
+ }
254
+
255
+ // 非流式引擎 → AsStreamer 返回 nil(防呆:counterEngine 不是 Streamer)。
256
+ func TestAsStreamerNonStreamer(t *testing.T) {
257
+ e := &counterEngine{name: "x"}
258
+ if s := AsStreamer(e); s != nil {
259
+ t.Errorf("AsStreamer(non-streamer) = %v, want nil", s)
260
+ }
261
+ if SupportsStream(e) {
262
+ t.Error("SupportsStream(non-streamer) should be false")
263
+ }
264
+ for _, name := range []string{"claude", "codebuddy", "codebuddy-ai", "trae", "llm"} {
265
+ if !SupportsStream(Lookup(name)) {
266
+ t.Errorf("SupportsStream(%s) should be true", name)
267
+ }
268
+ }
269
+ }
270
+
271
+ // ── 工具调用事件(claude / codebuddy stream-json 协议)──
272
+
273
+ // claude 流式应识别 tool_use / tool_result:args 由 input_json_delta 累积,
274
+ // 收尾 stop 时一次性 emit;result 的 text 字段作为 KindToolResult 透出。
275
+ //
276
+ // 注:fake CLI 走 shell 单引号 heredoc,反斜杠不被 shell 解释,但 Go 的
277
+ // JSON 解析器会把 \" 解析为字面的 \"(反斜杠 + 引号),不是真 JSON。
278
+ // 这正好覆盖「引擎吐出的是 JSON-escape 字符串」的真实场景。
279
+ func TestClaudeStreamParsesToolUseAndResult(t *testing.T) {
280
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
281
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","name":"Bash","id":"toolu_01"}},"session_id":"s-1"}'
282
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls -la\"}"}},"session_id":"s-1"}'
283
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":2},"session_id":"s-1"}'
284
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":3,"content_block":{"type":"tool_result","tool_use_id":"toolu_01"}},"session_id":"s-1"}'
285
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"total 12\\ndrwxr-xr-x 3 user user 96 Sep 15 21:00 ."}},"session_id":"s-1"}'
286
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":3},"session_id":"s-1"}'
287
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":4,"content_block":{"type":"text","text":""}},"session_id":"s-1"}'
288
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":4,"delta":{"type":"text_delta","text":"完成"},"session_id":"s-1"}}'
289
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"完成","session_id":"s-1","model":"MiniMax-M3"}'
290
+ `)
291
+ e := &ClaudeEngine{BinPath: cli}
292
+ events, onEvent := collectEvents()
293
+
294
+ res, err := e.Stream(context.Background(), Request{
295
+ Messages: []Message{{Role: "user", Content: "看看目录"}},
296
+ }, onEvent)
297
+ if err != nil {
298
+ t.Fatalf("Stream: %v", err)
299
+ }
300
+ if res.Text != "完成" {
301
+ t.Errorf("Text = %q, want 完成", res.Text)
302
+ }
303
+
304
+ // 工具事件计数:tool_use 1 + tool_result 1。
305
+ var toolUseCount, toolResultCount, textCount int
306
+ for _, ev := range *events {
307
+ switch ev.Kind {
308
+ case KindToolUse:
309
+ toolUseCount++
310
+ if ev.Name != "Bash" {
311
+ t.Errorf("tool_use.Name = %q, want Bash", ev.Name)
312
+ }
313
+ if ev.ID != "toolu_01" {
314
+ t.Errorf("tool_use.ID = %q, want toolu_01", ev.ID)
315
+ }
316
+ if ev.Text != `{"command":"ls -la"}` {
317
+ t.Errorf("tool_use.Text (args) = %q", ev.Text)
318
+ }
319
+ case KindToolResult:
320
+ toolResultCount++
321
+ if ev.ID != "toolu_01" {
322
+ t.Errorf("tool_result.ID = %q, want toolu_01", ev.ID)
323
+ }
324
+ if !strings.Contains(ev.Text, "total 12") {
325
+ t.Errorf("tool_result.Text = %q, want ls output", ev.Text)
326
+ }
327
+ case KindText:
328
+ textCount++
329
+ }
330
+ }
331
+ if toolUseCount != 1 {
332
+ t.Errorf("toolUseCount = %d, want 1", toolUseCount)
333
+ }
334
+ if toolResultCount != 1 {
335
+ t.Errorf("toolResultCount = %d, want 1", toolResultCount)
336
+ }
337
+ if textCount != 1 {
338
+ t.Errorf("textCount = %d, want 1", textCount)
339
+ }
340
+
341
+ // 收尾汇总应带回工具调用。
342
+ if len(res.Tools) != 1 {
343
+ t.Fatalf("res.Tools = %d, want 1", len(res.Tools))
344
+ }
345
+ tc := res.Tools[0]
346
+ if tc.Name != "Bash" || tc.ID != "toolu_01" {
347
+ t.Errorf("Tools[0] = {Name:%q ID:%q}", tc.Name, tc.ID)
348
+ }
349
+ if tc.Args != `{"command":"ls -la"}` {
350
+ t.Errorf("Tools[0].Args = %q", tc.Args)
351
+ }
352
+ if !strings.Contains(tc.Result, "total 12") {
353
+ t.Errorf("Tools[0].Result = %q, want ls output", tc.Result)
354
+ }
355
+ }
356
+
357
+ // 多次连续工具调用:args 累积在 stop 时一次性 emit;Tools 列表保持调用顺序。
358
+ func TestClaudeStreamMultipleToolCalls(t *testing.T) {
359
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
360
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","name":"Read","id":"t1"}}}'
361
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"/a\"}"}}}'
362
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":2}}'
363
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":3,"content_block":{"type":"tool_use","name":"Read","id":"t2"}}}'
364
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\""}}}'
365
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"input_json_delta","partial_json":"/b\"}"}}}'
366
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":3}}'
367
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok"}'
368
+ `)
369
+ e := &ClaudeEngine{BinPath: cli}
370
+ res, err := e.Stream(context.Background(), Request{
371
+ Messages: []Message{{Role: "user", Content: "读两个文件"}},
372
+ }, nil)
373
+ if err != nil {
374
+ t.Fatalf("Stream: %v", err)
375
+ }
376
+ if len(res.Tools) != 2 {
377
+ t.Fatalf("Tools = %d, want 2", len(res.Tools))
378
+ }
379
+ if res.Tools[0].ID != "t1" || res.Tools[0].Args != `{"path":"/a"}` {
380
+ t.Errorf("Tools[0] = %+v", res.Tools[0])
381
+ }
382
+ if res.Tools[1].ID != "t2" || res.Tools[1].Args != `{"path":"/b"}` {
383
+ t.Errorf("Tools[1] = %+v (args 应由两段 partial_json 拼接)", res.Tools[1])
384
+ }
385
+ }
386
+
387
+ // trae 协议不暴露工具事件:即使用户开了 --tools on,流式输出也不带工具事件。
388
+ // 这是协议限制,不是解析缺陷。
389
+ func TestTraeStreamNoToolEvents(t *testing.T) {
390
+ cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
391
+ echo '{"type":"stream_event","delta":{"role":"assistant","content":"正文"}}'
392
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"正文"}'
393
+ `)
394
+ e := &TraeEngine{BinPath: cli}
395
+ events, onEvent := collectEvents()
396
+
397
+ res, err := e.Stream(context.Background(), Request{
398
+ Messages: []Message{{Role: "user", Content: "hi"}},
399
+ }, onEvent)
400
+ if err != nil {
401
+ t.Fatalf("Stream: %v", err)
402
+ }
403
+
404
+ for _, ev := range *events {
405
+ if ev.Kind == KindToolUse || ev.Kind == KindToolResult {
406
+ t.Errorf("trae 不应发射工具事件, got %v", ev.Kind)
407
+ }
408
+ }
409
+ if len(res.Tools) != 0 {
410
+ t.Errorf("trae res.Tools 应为 nil/空, got %d", len(res.Tools))
411
+ }
412
+ }
413
+
414
+ // 协议异常:open tool_use 没收到 stop 直接来 result → 兜底回收,不丢事件。
415
+ func TestClaudeStreamToolUseOrphanRecovery(t *testing.T) {
416
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
417
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","name":"Edit","id":"t1"}}}'
418
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"x\":1}"}}}'
419
+ # 没有 content_block_stop,直接 result → 触发 toolUseOrphanGuard 兜底。
420
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"done"}'
421
+ `)
422
+ e := &ClaudeEngine{BinPath: cli}
423
+ events, onEvent := collectEvents()
424
+
425
+ res, err := e.Stream(context.Background(), Request{
426
+ Messages: []Message{{Role: "user", Content: "hi"}},
427
+ }, onEvent)
428
+ if err != nil {
429
+ t.Fatalf("Stream: %v", err)
430
+ }
431
+
432
+ var toolUseCount int
433
+ for _, ev := range *events {
434
+ if ev.Kind == KindToolUse {
435
+ toolUseCount++
436
+ if ev.Name != "Edit" || ev.Text != `{"x":1}` {
437
+ t.Errorf("orphan tool_use = %+v", ev)
438
+ }
439
+ }
440
+ }
441
+ if toolUseCount != 1 {
442
+ t.Errorf("toolUseCount = %d, want 1(孤儿回收)", toolUseCount)
443
+ }
444
+ if len(res.Tools) != 1 || res.Tools[0].Name != "Edit" {
445
+ t.Errorf("res.Tools = %+v", res.Tools)
446
+ }
447
+ }
448
+
449
+ // ── user 块里的 tool_result(claude / codebuddy 真实协议)──
450
+
451
+ // claude 实测:tool_result 不走独立 content_block,而是嵌在 user 消息里。
452
+ // 本测试验证 handleUserToolResult 能补全 Result 字段,并 emit KindToolResult。
453
+ func TestClaudeStreamToolResultInUserBlock(t *testing.T) {
454
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
455
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"WebSearch","id":"call_001"}},"session_id":"s-1"}'
456
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"x\"}"}},"session_id":"s-1"}'
457
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"s-1"}'
458
+ echo '{"type":"user","message":{"role":"user","content":[{"tool_use_id":"call_001","type":"tool_result","content":"搜索结果:14.05亿"}]},"session_id":"s-1"}'
459
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"正文"}'
460
+ `)
461
+ e := &ClaudeEngine{BinPath: cli}
462
+ events, onEvent := collectEvents()
463
+
464
+ res, err := e.Stream(context.Background(), Request{
465
+ Messages: []Message{{Role: "user", Content: "test"}},
466
+ }, onEvent)
467
+ if err != nil {
468
+ t.Fatalf("Stream: %v", err)
469
+ }
470
+
471
+ // tool_use 事件(args 累积)。
472
+ var toolUseCount, toolResultCount int
473
+ for _, ev := range *events {
474
+ switch ev.Kind {
475
+ case KindToolUse:
476
+ toolUseCount++
477
+ if ev.Name != "WebSearch" || ev.ID != "call_001" {
478
+ t.Errorf("tool_use = %+v", ev)
479
+ }
480
+ case KindToolResult:
481
+ toolResultCount++
482
+ if ev.ID != "call_001" {
483
+ t.Errorf("tool_result.ID = %q", ev.ID)
484
+ }
485
+ if ev.Text != "搜索结果:14.05亿" {
486
+ t.Errorf("tool_result.Text = %q", ev.Text)
487
+ }
488
+ }
489
+ }
490
+ if toolUseCount != 1 || toolResultCount != 1 {
491
+ t.Errorf("counts: toolUse=%d toolResult=%d, want 1/1", toolUseCount, toolResultCount)
492
+ }
493
+
494
+ // Result 字段必须回填。
495
+ if len(res.Tools) != 1 {
496
+ t.Fatalf("res.Tools = %d, want 1", len(res.Tools))
497
+ }
498
+ if res.Tools[0].Result != "搜索结果:14.05亿" {
499
+ t.Errorf("Tools[0].Result = %q, want 搜索结果:14.05亿", res.Tools[0].Result)
500
+ }
501
+ }
502
+
503
+ // tool_result 的 content 是数组(嵌套 text/image 块)时,文本字段拼接。
504
+ func TestClaudeStreamToolResultArrayContent(t *testing.T) {
505
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
506
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"Read","id":"r1"}}}'
507
+ echo '{"type":"stream_event","event":{"type":"content_block_stop","index":1}}'
508
+ echo '{"type":"user","message":{"role":"user","content":[{"tool_use_id":"r1","type":"tool_result","content":[{"type":"text","text":"line1"},{"type":"text","text":"line2"}]}]}}'
509
+ echo '{"type":"stream_event","event":{"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}}'
510
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"D"}}}'
511
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"D"}'
512
+ `)
513
+ e := &ClaudeEngine{BinPath: cli}
514
+ res, err := e.Stream(context.Background(), Request{
515
+ Messages: []Message{{Role: "user", Content: "x"}},
516
+ }, nil)
517
+ if err != nil {
518
+ t.Fatalf("Stream: %v", err)
519
+ }
520
+ if len(res.Tools) != 1 {
521
+ t.Fatalf("Tools = %d", len(res.Tools))
522
+ }
523
+ if res.Tools[0].Result != "line1\nline2" {
524
+ t.Errorf("Tools[0].Result = %q", res.Tools[0].Result)
525
+ }
526
+ }
527
+
528
+ // ── runStreamCLI 进程退出与 reader 收尾的竞态回归 ──────────────
529
+ //
530
+ // 曾有的两类故障(同根因家族):
531
+ // 1. select 走到 waitErr 分支(进程先于 reader 收尾退出)时若对该通道
532
+ // 二次接收——waitErr 只有一个发送方,二次接收必然永久阻塞(runtime
533
+ // 报 all goroutines are asleep)。
534
+ // 2. StdoutPipe 的读端被 cmd.Wait() 提前关闭:进程退出而 reader 尚有
535
+ // 管道积压时,余量被丢弃并报 "file already closed",把成功的一次
536
+ // 流式调用变成失败。已改为自建管道(读端归 reader、Wait 不触碰)。
537
+ //
538
+ // 复现手段:用 yes|head 瞬时灌入 5 万行增量(写端远快于读端),脚本随后
539
+ // 立即退出——cmd.Wait() 稳定先于 reader 排空积压返回,确定性地走进
540
+ // waitErr 分支;修复后必须完整读到全部增量与收尾 result。
541
+ func TestStreamProcessExitBeforeReaderFinish(t *testing.T) {
542
+ const bursts = 50000
543
+ var script strings.Builder
544
+ script.WriteString("#!/bin/sh\n")
545
+ script.WriteString(`yes '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"x"}}}' | head -n ` + strconv.Itoa(bursts) + "\n")
546
+ script.WriteString(`echo '{"type":"result","subtype":"success","is_error":false,"result":"done"}'` + "\n")
547
+ cli := writeFakeCLI(t, "claude", script.String())
548
+ e := &ClaudeEngine{BinPath: cli}
549
+
550
+ var events int
551
+ res, err := e.Stream(context.Background(), Request{
552
+ Messages: []Message{{Role: "user", Content: "x"}},
553
+ }, func(StreamEvent) { events++ })
554
+ if err != nil {
555
+ t.Fatalf("Stream: %v(修复前此路径死锁或丢失尾部输出)", err)
556
+ }
557
+ if res.Text != "done" {
558
+ t.Errorf("Text = %q", res.Text)
559
+ }
560
+ if events < bursts {
561
+ t.Errorf("events = %d, want >= %d(尾部增量被丢弃?)", events, bursts)
562
+ }
563
+ }
564
+
565
+ // ── llm 引擎的流式测试见 llmengine_test.go(fake CLI 包装)──