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,858 @@
1
+ package agent
2
+
3
+ // engine_test.go - 引擎层单元测试。
4
+ //
5
+ // 用 fake CLI 脚本(写入临时目录的 shell 脚本)模拟三家 CLI 的行为,
6
+ // 验证:
7
+ // - 参数构造(--model、--tools ""、--output-format、-c model.name、
8
+ // --query-timeout 映射)
9
+ // - 输出解析(claude JSON envelope、codebuddy 三种 envelope、
10
+ // user_query 回显剥离、trae 纯文本)
11
+ // - FlattenPrompt 扁平化逻辑
12
+ // - 引擎注册表 / Lookup
13
+
14
+ import (
15
+ "context"
16
+ "os"
17
+ "path/filepath"
18
+ "strings"
19
+ "testing"
20
+ "time"
21
+ )
22
+
23
+ // writeFakeCLI 写一个假 CLI 脚本并返回路径。脚本内容直接是 shell 代码。
24
+ func writeFakeCLI(t *testing.T, name, script string) string {
25
+ t.Helper()
26
+ dir := t.TempDir()
27
+ p := filepath.Join(dir, name)
28
+ if err := os.WriteFile(p, []byte(script), 0o755); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ return p
32
+ }
33
+
34
+ // ── FlattenPrompt ─────────────────────────────────────────────
35
+
36
+ func TestFlattenPrompt(t *testing.T) {
37
+ msgs := []Message{
38
+ {Role: "user", Content: "你好"},
39
+ {Role: "assistant", Content: "你好!"},
40
+ }
41
+
42
+ got := FlattenPrompt("你是助手", msgs, false)
43
+ if !strings.Contains(got, "【系统指令】\n你是助手") {
44
+ t.Errorf("system prompt missing: %q", got)
45
+ }
46
+ if !strings.Contains(got, "【用户】\n你好") {
47
+ t.Errorf("user turn missing: %q", got)
48
+ }
49
+ if !strings.Contains(got, "【助手】\n你好!") {
50
+ t.Errorf("assistant turn missing: %q", got)
51
+ }
52
+ // 末尾 assistant → 自动补「请继续」
53
+ if !strings.HasSuffix(got, "【用户】\n请继续。") {
54
+ t.Errorf("continue suffix missing: %q", got)
55
+ }
56
+
57
+ // noToolSuffix
58
+ got2 := FlattenPrompt("s", nil, true)
59
+ if !strings.Contains(got2, "[约束]") {
60
+ t.Errorf("noToolSuffix missing: %q", got2)
61
+ }
62
+
63
+ // 空
64
+ if got3 := FlattenPrompt("", nil, false); got3 != "" {
65
+ t.Errorf("empty input should give empty prompt, got %q", got3)
66
+ }
67
+ }
68
+
69
+ // ── 注册表 ────────────────────────────────────────────────────
70
+
71
+ func TestLookup(t *testing.T) {
72
+ for _, name := range []string{"claude", "codebuddy", "codebuddy-ai", "trae"} {
73
+ e := Lookup(name)
74
+ if e == nil {
75
+ t.Fatalf("Lookup(%q) = nil", name)
76
+ }
77
+ if e.Name() != name {
78
+ t.Errorf("Name() = %q want %q", e.Name(), name)
79
+ }
80
+ }
81
+ // 大小写不敏感
82
+ if Lookup("CLAUDE") == nil {
83
+ t.Error("Lookup should be case-insensitive")
84
+ }
85
+ if Lookup("nope") != nil {
86
+ t.Error("Lookup(nope) should be nil")
87
+ }
88
+ if Lookup("") != nil {
89
+ t.Error("Lookup(\"\") should be nil")
90
+ }
91
+ }
92
+
93
+ // ── Claude 引擎 ───────────────────────────────────────────────
94
+
95
+ func TestClaudeCompleteParsesEnvelope(t *testing.T) {
96
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
97
+ # 校验关键 flag
98
+ for a in "$@"; do
99
+ case "$a" in
100
+ -p|--print|--no-session-persistence) ;;
101
+ esac
102
+ done
103
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"答案是2","session_id":"s-123","model":"claude-sonnet-4-6"}'
104
+ `)
105
+ e := &ClaudeEngine{BinPath: cli}
106
+ if ok, _ := e.Detect(); !ok {
107
+ t.Fatal("Detect should succeed with fake CLI")
108
+ }
109
+ resp, err := e.Complete(context.Background(), Request{
110
+ SystemPrompt: "你是一个计算器",
111
+ Messages: []Message{{Role: "user", Content: "1+1=?"}},
112
+ })
113
+ if err != nil {
114
+ t.Fatalf("Complete: %v", err)
115
+ }
116
+ if resp.Text != "答案是2" {
117
+ t.Errorf("Text = %q", resp.Text)
118
+ }
119
+ if resp.SessionID != "s-123" {
120
+ t.Errorf("SessionID = %q", resp.SessionID)
121
+ }
122
+ if resp.Model != "claude-sonnet-4-6" {
123
+ t.Errorf("Model = %q", resp.Model)
124
+ }
125
+ }
126
+
127
+ func TestClaudePassesModelFlag(t *testing.T) {
128
+ var captured string
129
+ _ = captured
130
+ dir := t.TempDir()
131
+ log := filepath.Join(dir, "args.log")
132
+ cli := filepath.Join(dir, "claude")
133
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n"
134
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
135
+ t.Fatal(err)
136
+ }
137
+ e := &ClaudeEngine{BinPath: cli}
138
+ _, err := e.Complete(context.Background(), Request{
139
+ Model: "claude/sonnet",
140
+ Messages: []Message{{Role: "user", Content: "hi"}},
141
+ })
142
+ if err != nil {
143
+ t.Fatalf("Complete: %v", err)
144
+ }
145
+ data, _ := os.ReadFile(log)
146
+ args := string(data)
147
+ if !strings.Contains(args, "--model sonnet") {
148
+ t.Errorf("--model sonnet not passed: %q", args)
149
+ }
150
+ if !strings.Contains(args, "--tools ") {
151
+ t.Errorf("--tools not passed: %q", args)
152
+ }
153
+ if !strings.Contains(args, "--output-format json") {
154
+ t.Errorf("--output-format json not passed: %q", args)
155
+ }
156
+ }
157
+
158
+ func TestClaudeErrorEnvelope(t *testing.T) {
159
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
160
+ echo '{"type":"result","subtype":"error_max_turns","is_error":true,"result":"超出轮次"}'
161
+ `)
162
+ e := &ClaudeEngine{BinPath: cli}
163
+ _, err := e.Complete(context.Background(), Request{
164
+ Messages: []Message{{Role: "user", Content: "hi"}},
165
+ })
166
+ if err == nil {
167
+ t.Fatal("expected error for is_error=true")
168
+ }
169
+ if !strings.Contains(err.Error(), "error_max_turns") {
170
+ t.Errorf("error should mention subtype: %v", err)
171
+ }
172
+ }
173
+
174
+ func TestClaudeFallbackToRawText(t *testing.T) {
175
+ cli := writeFakeCLI(t, "claude", "#!/bin/sh\necho 'plain text answer'\n")
176
+ e := &ClaudeEngine{BinPath: cli}
177
+ resp, err := e.Complete(context.Background(), Request{
178
+ Messages: []Message{{Role: "user", Content: "hi"}},
179
+ })
180
+ if err != nil {
181
+ t.Fatalf("Complete: %v", err)
182
+ }
183
+ if resp.Text != "plain text answer" {
184
+ t.Errorf("Text = %q", resp.Text)
185
+ }
186
+ }
187
+
188
+ func TestClaudeNotFound(t *testing.T) {
189
+ e := &ClaudeEngine{BinPath: "/nonexistent/claude"}
190
+ _, err := e.Complete(context.Background(), Request{
191
+ Messages: []Message{{Role: "user", Content: "hi"}},
192
+ })
193
+ if err == nil {
194
+ t.Fatal("expected error for missing CLI")
195
+ }
196
+ }
197
+
198
+ // ── CodeBuddy 引擎 ────────────────────────────────────────────
199
+
200
+ func TestCodeBuddySingleEnvelope(t *testing.T) {
201
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
202
+ echo '{"type":"result","is_error":false,"result":"你好,世界"}'
203
+ `)
204
+ e := &CodeBuddyEngine{BinPath: cli}
205
+ resp, err := e.Complete(context.Background(), Request{
206
+ Messages: []Message{{Role: "user", Content: "打个招呼"}},
207
+ })
208
+ if err != nil {
209
+ t.Fatalf("Complete: %v", err)
210
+ }
211
+ if resp.Text != "你好,世界" {
212
+ t.Errorf("Text = %q", resp.Text)
213
+ }
214
+ }
215
+
216
+ func TestCodeBuddyArrayEnvelope(t *testing.T) {
217
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
218
+ echo '[{"type":"system","subtype":"init"},{"type":"assistant","message":{"content":[{"type":"text","text":"部分1"}]}},{"type":"result","result":"最终答案"}]'
219
+ `)
220
+ e := &CodeBuddyEngine{BinPath: cli}
221
+ resp, err := e.Complete(context.Background(), Request{
222
+ Messages: []Message{{Role: "user", Content: "hi"}},
223
+ })
224
+ if err != nil {
225
+ t.Fatalf("Complete: %v", err)
226
+ }
227
+ if resp.Text != "最终答案" {
228
+ t.Errorf("Text = %q want 最终答案", resp.Text)
229
+ }
230
+ }
231
+
232
+ func TestCodeBuddyArrayAssistantFallback(t *testing.T) {
233
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
234
+ echo '[{"type":"assistant","message":{"content":[{"type":"text","text":"第一段"}]}},{"role":"assistant","content":[{"type":"output_text","text":"第二段"}]}]'
235
+ `)
236
+ e := &CodeBuddyEngine{BinPath: cli}
237
+ resp, err := e.Complete(context.Background(), Request{
238
+ Messages: []Message{{Role: "user", Content: "hi"}},
239
+ })
240
+ if err != nil {
241
+ t.Fatalf("Complete: %v", err)
242
+ }
243
+ if resp.Text != "第一段\n第二段" {
244
+ t.Errorf("Text = %q want 两段拼接", resp.Text)
245
+ }
246
+ }
247
+
248
+ func TestCodeBuddyUserQueryEcho(t *testing.T) {
249
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
250
+ echo '<user_query>【系统指令】你是助手</user_query>真正的回答'
251
+ `)
252
+ e := &CodeBuddyEngine{BinPath: cli}
253
+ resp, err := e.Complete(context.Background(), Request{
254
+ SystemPrompt: "你是助手",
255
+ Messages: []Message{{Role: "user", Content: "hi"}},
256
+ })
257
+ if err != nil {
258
+ t.Fatalf("Complete: %v", err)
259
+ }
260
+ if resp.Text != "真正的回答" {
261
+ t.Errorf("Text = %q want 回显被剥离", resp.Text)
262
+ }
263
+
264
+ // 全回显(无闭标签后正文)→ 报错
265
+ cli2 := writeFakeCLI(t, "codebuddy", "#!/bin/sh\necho '<user_query>只有回显</user_query>'\n")
266
+ e2 := &CodeBuddyEngine{BinPath: cli2}
267
+ if _, err := e2.Complete(context.Background(), Request{
268
+ Messages: []Message{{Role: "user", Content: "hi"}},
269
+ }); err == nil {
270
+ t.Error("expected error for echo-only output")
271
+ }
272
+ }
273
+
274
+ func TestCodeBuddyPassesModel(t *testing.T) {
275
+ dir := t.TempDir()
276
+ log := filepath.Join(dir, "args.log")
277
+ cli := filepath.Join(dir, "codebuddy")
278
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n"
279
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ e := &CodeBuddyEngine{BinPath: cli}
283
+ _, err := e.Complete(context.Background(), Request{
284
+ Model: "codebuddy/hy3",
285
+ Messages: []Message{{Role: "user", Content: "hi"}},
286
+ })
287
+ if err != nil {
288
+ t.Fatalf("Complete: %v", err)
289
+ }
290
+ data, _ := os.ReadFile(log)
291
+ args := string(data)
292
+ if !strings.Contains(args, "--model hy3") {
293
+ t.Errorf("--model hy3 not passed: %q", args)
294
+ }
295
+ if !strings.Contains(args, "--append-system-prompt") {
296
+ t.Errorf("--append-system-prompt not passed: %q", args)
297
+ }
298
+ }
299
+
300
+ // 未指定模型时 codebuddy 引擎默认 --model hy3;显式指定时用显式值。
301
+ func TestCodeBuddyDefaultModel(t *testing.T) {
302
+ dir := t.TempDir()
303
+ log := filepath.Join(dir, "args.log")
304
+ cli := filepath.Join(dir, "codebuddy")
305
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n"
306
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ e := &CodeBuddyEngine{BinPath: cli}
310
+
311
+ // 默认:hy3
312
+ if _, err := e.Complete(context.Background(), Request{
313
+ Messages: []Message{{Role: "user", Content: "hi"}},
314
+ }); err != nil {
315
+ t.Fatalf("Complete: %v", err)
316
+ }
317
+ data, _ := os.ReadFile(log)
318
+ if !strings.Contains(string(data), "--model hy3") {
319
+ t.Errorf("default model hy3 not passed: %q", string(data))
320
+ }
321
+
322
+ // 显式覆盖:glm-5.3
323
+ if err := os.WriteFile(log, nil, 0o644); err != nil {
324
+ t.Fatal(err)
325
+ }
326
+ if _, err := e.Complete(context.Background(), Request{
327
+ Model: "glm-5.3",
328
+ Messages: []Message{{Role: "user", Content: "hi"}},
329
+ }); err != nil {
330
+ t.Fatalf("Complete: %v", err)
331
+ }
332
+ data, _ = os.ReadFile(log)
333
+ if !strings.Contains(string(data), "--model glm-5.3") {
334
+ t.Errorf("explicit model not passed: %q", string(data))
335
+ }
336
+ if strings.Count(string(data), "--model") != 1 {
337
+ t.Errorf("model flag should appear exactly once: %q", string(data))
338
+ }
339
+ }
340
+
341
+ // ── Trae 引擎 ─────────────────────────────────────────────────
342
+
343
+ func TestTraeCompletePlainText(t *testing.T) {
344
+ dir := t.TempDir()
345
+ log := filepath.Join(dir, "args.log")
346
+ cli := filepath.Join(dir, "trae-cli")
347
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'trae 的回答'\n"
348
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
349
+ t.Fatal(err)
350
+ }
351
+ e := &TraeEngine{BinPath: cli}
352
+ resp, err := e.Complete(context.Background(), Request{
353
+ Messages: []Message{{Role: "user", Content: "hi"}},
354
+ Timeout: 90 * time.Second,
355
+ })
356
+ if err != nil {
357
+ t.Fatalf("Complete: %v", err)
358
+ }
359
+ if resp.Text != "trae 的回答" {
360
+ t.Errorf("Text = %q", resp.Text)
361
+ }
362
+
363
+ data, _ := os.ReadFile(log)
364
+ args := string(data)
365
+ // 默认模型:不传 -c model.name
366
+ if strings.Contains(args, "model.name") {
367
+ t.Errorf("default model should not pass -c model.name: %q", args)
368
+ }
369
+ // 超时映射到 --query-timeout
370
+ if !strings.Contains(args, "--query-timeout 1m30s") {
371
+ t.Errorf("--query-timeout not mapped: %q", args)
372
+ }
373
+ // 工具禁用
374
+ if !strings.Contains(args, "--disallowed-tool Bash") {
375
+ t.Errorf("--disallowed-tool Bash missing: %q", args)
376
+ }
377
+ // system prompt 带 noToolSuffix
378
+ if !strings.Contains(args, "[约束]") {
379
+ t.Errorf("noToolSuffix missing in prompt: %q", args)
380
+ }
381
+ }
382
+
383
+ func TestTraeExplicitModel(t *testing.T) {
384
+ dir := t.TempDir()
385
+ log := filepath.Join(dir, "args.log")
386
+ cli := filepath.Join(dir, "trae-cli")
387
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
388
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
389
+ t.Fatal(err)
390
+ }
391
+ e := &TraeEngine{BinPath: cli}
392
+ resp, err := e.Complete(context.Background(), Request{
393
+ Model: "My-MiniMax-M3",
394
+ Messages: []Message{{Role: "user", Content: "hi"}},
395
+ })
396
+ if err != nil {
397
+ t.Fatalf("Complete: %v", err)
398
+ }
399
+ if resp.Model != "My-MiniMax-M3" {
400
+ t.Errorf("Model = %q", resp.Model)
401
+ }
402
+ data, _ := os.ReadFile(log)
403
+ args := string(data)
404
+ if !strings.Contains(args, "-c model.name=My-MiniMax-M3") {
405
+ t.Errorf("-c model.name not passed: %q", args)
406
+ }
407
+ }
408
+
409
+ func TestTraeTimeoutCappedAt600s(t *testing.T) {
410
+ dir := t.TempDir()
411
+ log := filepath.Join(dir, "args.log")
412
+ cli := filepath.Join(dir, "trae-cli")
413
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
414
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
415
+ t.Fatal(err)
416
+ }
417
+ e := &TraeEngine{BinPath: cli}
418
+ _, err := e.Complete(context.Background(), Request{
419
+ Messages: []Message{{Role: "user", Content: "hi"}},
420
+ Timeout: 30 * time.Minute,
421
+ })
422
+ if err != nil {
423
+ t.Fatalf("Complete: %v", err)
424
+ }
425
+ data, _ := os.ReadFile(log)
426
+ if !strings.Contains(string(data), "--query-timeout 10m0s") {
427
+ t.Errorf("query-timeout should cap at 600s: %q", string(data))
428
+ }
429
+ }
430
+
431
+ // ── TraeDefaultModel 配置探测 ─────────────────────────────────
432
+
433
+ func TestTraeDefaultModel(t *testing.T) {
434
+ home := t.TempDir()
435
+ t.Setenv("HOME", home)
436
+
437
+ // 无配置文件 → 空串
438
+ if got := TraeDefaultModel(); got != "" {
439
+ t.Errorf("no config: got %q want empty", got)
440
+ }
441
+
442
+ // 有配置 → 读 model.name
443
+ cfgDir := filepath.Join(home, ".trae")
444
+ if err := os.MkdirAll(cfgDir, 0o755); err != nil {
445
+ t.Fatal(err)
446
+ }
447
+ yaml := "model:\n name: My-MiniMax-M3\nmodels:\n- name: \"My-MiniMax-M3\"\n"
448
+ if err := os.WriteFile(filepath.Join(cfgDir, "trae_cli.yaml"), []byte(yaml), 0o644); err != nil {
449
+ t.Fatal(err)
450
+ }
451
+ if got := TraeDefaultModel(); got != "My-MiniMax-M3" {
452
+ t.Errorf("got %q want My-MiniMax-M3", got)
453
+ }
454
+ }
455
+
456
+ // ── 会话续接(SessionID → --resume 映射)──────────────────────
457
+
458
+ // argsCaptureCLI 写一个把 "$*" 逐行记入 args.log 的假 CLI,返回 (引擎, log 路径)。
459
+ func argsCaptureCLI(t *testing.T, dir, name, cannedOutput string) (*engineWithLog, string) {
460
+ t.Helper()
461
+ log := filepath.Join(dir, "args.log")
462
+ cli := filepath.Join(dir, name)
463
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\n" + cannedOutput + "\n"
464
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
465
+ t.Fatal(err)
466
+ }
467
+ return &engineWithLog{log: log, bin: cli}, log
468
+ }
469
+
470
+ type engineWithLog struct {
471
+ log string
472
+ bin string
473
+ }
474
+
475
+ // claudeArgs 构造带 args.log 的 ClaudeEngine。
476
+ func (w engineWithLog) claude() *ClaudeEngine { return &ClaudeEngine{BinPath: w.bin} }
477
+
478
+ // codebuddyArgs 构造带 args.log 的 CodeBuddyEngine。
479
+ func (w engineWithLog) codebuddy() *CodeBuddyEngine { return &CodeBuddyEngine{BinPath: w.bin} }
480
+
481
+ // traeArgs 构造带 args.log 的 TraeEngine。
482
+ func (w engineWithLog) trae() *TraeEngine { return &TraeEngine{BinPath: w.bin} }
483
+
484
+ func TestClaudeResumeSession(t *testing.T) {
485
+ dir := t.TempDir()
486
+ w, log := argsCaptureCLI(t, dir, "claude", `echo '{"type":"result","result":"续接成功","session_id":"s-abc"}'`)
487
+ e := w.claude()
488
+ resp, err := e.Complete(context.Background(), Request{
489
+ Messages: []Message{{Role: "user", Content: "hi"}},
490
+ SessionID: "s-abc",
491
+ })
492
+ if err != nil {
493
+ t.Fatalf("Complete: %v", err)
494
+ }
495
+ if resp.SessionID != "s-abc" {
496
+ t.Errorf("Response.SessionID = %q want s-abc", resp.SessionID)
497
+ }
498
+ data, _ := os.ReadFile(log)
499
+ args := string(data)
500
+ if !strings.Contains(args, "--resume s-abc") {
501
+ t.Errorf("--resume s-abc not passed: %q", args)
502
+ }
503
+ if strings.Contains(args, "--no-session-persistence") {
504
+ t.Errorf("--resume 与 --no-session-persistence 互斥,不应同时出现: %q", args)
505
+ }
506
+ }
507
+
508
+ func TestClaudeNewSessionDefault(t *testing.T) {
509
+ dir := t.TempDir()
510
+ w, log := argsCaptureCLI(t, dir, "claude", `echo '{"type":"result","result":"ok"}'`)
511
+ e := w.claude()
512
+ if _, err := e.Complete(context.Background(), Request{
513
+ Messages: []Message{{Role: "user", Content: "hi"}},
514
+ }); err != nil {
515
+ t.Fatalf("Complete: %v", err)
516
+ }
517
+ data, _ := os.ReadFile(log)
518
+ args := string(data)
519
+ if strings.Contains(args, "--resume") {
520
+ t.Errorf("默认新会话不应带 --resume: %q", args)
521
+ }
522
+ // 新会话必须可落盘:--no-session-persistence 的会话 "cannot be resumed",
523
+ // 传了会导致首轮 session_id 无法用于续接。
524
+ if strings.Contains(args, "--no-session-persistence") {
525
+ t.Errorf("默认新会话不应带 --no-session-persistence(会无法续接): %q", args)
526
+ }
527
+ }
528
+
529
+ func TestCodeBuddyResumeSession(t *testing.T) {
530
+ dir := t.TempDir()
531
+ w, log := argsCaptureCLI(t, dir, "codebuddy", `echo '{"type":"result","result":"ok"}'`)
532
+ e := w.codebuddy()
533
+ if _, err := e.Complete(context.Background(), Request{
534
+ Messages: []Message{{Role: "user", Content: "hi"}},
535
+ SessionID: "cb-77",
536
+ }); err != nil {
537
+ t.Fatalf("Complete: %v", err)
538
+ }
539
+ data, _ := os.ReadFile(log)
540
+ args := string(data)
541
+ if !strings.Contains(args, "--resume cb-77") {
542
+ t.Errorf("--resume cb-77 not passed: %q", args)
543
+ }
544
+ if strings.Contains(args, "--no-session-persistence") {
545
+ t.Errorf("两个 flag 互斥,不应同时出现: %q", args)
546
+ }
547
+ }
548
+
549
+ // extractCodeBuddySession 兼容单对象 envelope 与 stream 消息数组两种形态。
550
+ func TestCodeBuddySessionExtract(t *testing.T) {
551
+ cases := []struct {
552
+ name string
553
+ raw string
554
+ want string
555
+ }{
556
+ {"single object", `{"type":"result","result":"ok","session_id":"s-1"}`, "s-1"},
557
+ {"array with result", `[{"type":"system","session_id":"s-2"},{"type":"result","session_id":"s-3"}]`, "s-3"},
558
+ {"array fallback first", `[{"type":"system","session_id":"s-4"},{"type":"assistant"}]`, "s-4"},
559
+ {"array no session", `[{"type":"assistant"},{"type":"result"}]`, ""},
560
+ {"empty", "", ""},
561
+ {"plain text", "随便一段文本", ""},
562
+ {"no session field", `{"type":"result","result":"ok"}`, ""},
563
+ }
564
+ for _, tc := range cases {
565
+ if got := extractCodeBuddySession(tc.raw); got != tc.want {
566
+ t.Errorf("%s: got %q want %q", tc.name, got, tc.want)
567
+ }
568
+ }
569
+ }
570
+
571
+ // codebuddy 数组形态下 Complete 也要把 session_id 挖出来回传。
572
+ func TestCodeBuddyCompleteExtractsSessionFromArray(t *testing.T) {
573
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
574
+ echo '[{"type":"system","session_id":"cb-init"},{"type":"result","result":"好"}]'
575
+ `)
576
+ e := &CodeBuddyEngine{BinPath: cli}
577
+ resp, err := e.Complete(context.Background(), Request{
578
+ Messages: []Message{{Role: "user", Content: "hi"}},
579
+ })
580
+ if err != nil {
581
+ t.Fatalf("Complete: %v", err)
582
+ }
583
+ if resp.SessionID != "cb-init" {
584
+ t.Errorf("SessionID = %q want cb-init", resp.SessionID)
585
+ }
586
+ }
587
+
588
+ func TestTraeResumeSessionEqualsForm(t *testing.T) {
589
+ dir := t.TempDir()
590
+ w, log := argsCaptureCLI(t, dir, "trae-cli", `echo '续上了'`)
591
+ e := w.trae()
592
+ resp, err := e.Complete(context.Background(), Request{
593
+ Messages: []Message{{Role: "user", Content: "hi"}},
594
+ SessionID: "tr-9",
595
+ })
596
+ if err != nil {
597
+ t.Fatalf("Complete: %v", err)
598
+ }
599
+ // trae 纯文本模式 CLI 不回 session_id → 回显请求里的 id,保证逐轮透传。
600
+ if resp.SessionID != "tr-9" {
601
+ t.Errorf("Response.SessionID = %q want tr-9(请求回显)", resp.SessionID)
602
+ }
603
+ data, _ := os.ReadFile(log)
604
+ args := string(data)
605
+ // pflag 可选值风格:必须等号形式,空格形式下裸 id 会被吞成 AUTO。
606
+ if !strings.Contains(args, "--resume=tr-9") {
607
+ t.Errorf("--resume=tr-9 (equals form) not passed: %q", args)
608
+ }
609
+ }
610
+
611
+ func TestTraeNewSessionDefault(t *testing.T) {
612
+ dir := t.TempDir()
613
+ w, log := argsCaptureCLI(t, dir, "trae-cli", `echo 'ok'`)
614
+ e := w.trae()
615
+ resp, err := e.Complete(context.Background(), Request{
616
+ Messages: []Message{{Role: "user", Content: "hi"}},
617
+ })
618
+ if err != nil {
619
+ t.Fatalf("Complete: %v", err)
620
+ }
621
+ // 新会话:请求里没有 id,回显也是空。
622
+ if resp.SessionID != "" {
623
+ t.Errorf("Response.SessionID = %q want empty", resp.SessionID)
624
+ }
625
+ data, _ := os.ReadFile(log)
626
+ if strings.Contains(string(data), "--resume") {
627
+ t.Errorf("默认新会话不应带 --resume: %q", string(data))
628
+ }
629
+ }
630
+
631
+ // ---------- Continue:续接最近一次会话(-c/--continue 语义) ----------
632
+
633
+ func TestClaudeContinueSession(t *testing.T) {
634
+ dir := t.TempDir()
635
+ w, log := argsCaptureCLI(t, dir, "claude", `echo '{"type":"result","result":"ok"}'`)
636
+ e := w.claude()
637
+ if _, err := e.Complete(context.Background(), Request{
638
+ Messages: []Message{{Role: "user", Content: "hi"}},
639
+ Continue: true,
640
+ }); err != nil {
641
+ t.Fatalf("Complete: %v", err)
642
+ }
643
+ data, _ := os.ReadFile(log)
644
+ args := string(data)
645
+ if !strings.Contains(args, "--continue") {
646
+ t.Errorf("--continue not passed: %q", args)
647
+ }
648
+ if strings.Contains(args, "--resume") {
649
+ t.Errorf("Continue 不应带 --resume: %q", args)
650
+ }
651
+ }
652
+
653
+ func TestCodeBuddyContinueSession(t *testing.T) {
654
+ dir := t.TempDir()
655
+ w, log := argsCaptureCLI(t, dir, "codebuddy", `echo '{"type":"result","result":"ok"}'`)
656
+ e := w.codebuddy()
657
+ if _, err := e.Complete(context.Background(), Request{
658
+ Messages: []Message{{Role: "user", Content: "hi"}},
659
+ Continue: true,
660
+ }); err != nil {
661
+ t.Fatalf("Complete: %v", err)
662
+ }
663
+ data, _ := os.ReadFile(log)
664
+ args := string(data)
665
+ if !strings.Contains(args, "--continue") {
666
+ t.Errorf("--continue not passed: %q", args)
667
+ }
668
+ if strings.Contains(args, "--resume") {
669
+ t.Errorf("Continue 不应带 --resume: %q", args)
670
+ }
671
+ }
672
+
673
+ // trae 无 --continue:裸 --resume 触发 AUTO(续接最近一次会话),
674
+ // 且不能是等号形式(等号形式是显式 id 续接)。
675
+ func TestTraeContinueBareResume(t *testing.T) {
676
+ dir := t.TempDir()
677
+ w, log := argsCaptureCLI(t, dir, "trae-cli", `echo 'ok'`)
678
+ e := w.trae()
679
+ if _, err := e.Complete(context.Background(), Request{
680
+ Messages: []Message{{Role: "user", Content: "hi"}},
681
+ Continue: true,
682
+ }); err != nil {
683
+ t.Fatalf("Complete: %v", err)
684
+ }
685
+ data, _ := os.ReadFile(log)
686
+ args := string(data)
687
+ if !strings.Contains(args, "--resume") {
688
+ t.Errorf("bare --resume not passed: %q", args)
689
+ }
690
+ if strings.Contains(args, "--resume=") {
691
+ t.Errorf("Continue 应为裸 --resume,不带 =id: %q", args)
692
+ }
693
+ }
694
+
695
+ // SessionID 优先于 Continue:两者同时设置时走显式 id 续接,
696
+ // 不应出现 --continue(trae 不应退化为裸 --resume)。
697
+ func TestSessionPriorityOverContinue(t *testing.T) {
698
+ cases := []struct {
699
+ name string
700
+ sessionID string
701
+ want string // 必须出现的参数片段
702
+ forbid string // 不应出现的参数片段
703
+ fakeName string
704
+ canned string
705
+ }{
706
+ {"claude", "s-1", "--resume s-1", "--continue", "claude", `echo '{"type":"result","result":"ok"}'`},
707
+ {"codebuddy", "cb-1", "--resume cb-1", "--continue", "codebuddy", `echo '{"type":"result","result":"ok"}'`},
708
+ {"trae", "tr-1", "--resume=tr-1", "--continue", "trae-cli", `echo 'ok'`},
709
+ }
710
+ for _, tc := range cases {
711
+ t.Run(tc.name, func(t *testing.T) {
712
+ dir := t.TempDir()
713
+ w, log := argsCaptureCLI(t, dir, tc.fakeName, tc.canned)
714
+ var err error
715
+ req := Request{
716
+ Messages: []Message{{Role: "user", Content: "hi"}},
717
+ SessionID: tc.sessionID,
718
+ Continue: true,
719
+ }
720
+ switch tc.name {
721
+ case "claude":
722
+ _, err = w.claude().Complete(context.Background(), req)
723
+ case "codebuddy":
724
+ _, err = w.codebuddy().Complete(context.Background(), req)
725
+ case "trae":
726
+ _, err = w.trae().Complete(context.Background(), req)
727
+ }
728
+ if err != nil {
729
+ t.Fatalf("Complete: %v", err)
730
+ }
731
+ data, _ := os.ReadFile(log)
732
+ args := string(data)
733
+ if !strings.Contains(args, tc.want) {
734
+ t.Errorf("%s not passed: %q", tc.want, args)
735
+ }
736
+ if strings.Contains(args, tc.forbid) {
737
+ t.Errorf("%s 不应与 %s 同时出现: %q", tc.forbid, tc.want, args)
738
+ }
739
+ })
740
+ }
741
+ }
742
+
743
+ // TestClaudeMaxTokensSettings 统一参数矩阵:claude 经 --settings 注入
744
+ // env CLAUDE_CODE_MAX_OUTPUT_TOKENS(无原生 max-tokens flag)。
745
+ func TestClaudeMaxTokensSettings(t *testing.T) {
746
+ dir := t.TempDir()
747
+ w, log := argsCaptureCLI(t, dir, "claude", `echo '{"type":"result","result":"ok"}'`)
748
+ if _, err := w.claude().Complete(context.Background(), Request{
749
+ Messages: []Message{{Role: "user", Content: "hi"}},
750
+ MaxTokens: 8000,
751
+ }); err != nil {
752
+ t.Fatalf("Complete: %v", err)
753
+ }
754
+ data, _ := os.ReadFile(log)
755
+ args := string(data)
756
+ want := `--settings {"env":{"CLAUDE_CODE_MAX_OUTPUT_TOKENS":"8000"}}`
757
+ if !strings.Contains(args, want) {
758
+ t.Errorf("--settings 载荷未透传: %q", args)
759
+ }
760
+ }
761
+
762
+ // TestCodeBuddyMaxTokensSettings 同上,作用于 codebuddy。
763
+ func TestCodeBuddyMaxTokensSettings(t *testing.T) {
764
+ dir := t.TempDir()
765
+ w, log := argsCaptureCLI(t, dir, "codebuddy", `echo '{"type":"result","result":"ok"}'`)
766
+ if _, err := w.codebuddy().Complete(context.Background(), Request{
767
+ Messages: []Message{{Role: "user", Content: "hi"}},
768
+ MaxTokens: 4096,
769
+ }); err != nil {
770
+ t.Fatalf("Complete: %v", err)
771
+ }
772
+ data, _ := os.ReadFile(log)
773
+ args := string(data)
774
+ want := `--settings {"env":{"CLAUDE_CODE_MAX_OUTPUT_TOKENS":"4096"}}`
775
+ if !strings.Contains(args, want) {
776
+ t.Errorf("--settings 载荷未透传: %q", args)
777
+ }
778
+ }
779
+
780
+ // TestNoSettingsWhenMaxTokensUnset MaxTokens 未设置时不注入 --settings。
781
+ func TestNoSettingsWhenMaxTokensUnset(t *testing.T) {
782
+ cases := []struct {
783
+ name string
784
+ bin string
785
+ canned string
786
+ complete func(*engineWithLog, Request) error
787
+ }{
788
+ {"claude", "claude", `echo '{"type":"result","result":"ok"}'`,
789
+ func(w *engineWithLog, req Request) error {
790
+ _, err := w.claude().Complete(context.Background(), req)
791
+ return err
792
+ }},
793
+ {"codebuddy", "codebuddy", `echo '{"type":"result","result":"ok"}'`,
794
+ func(w *engineWithLog, req Request) error {
795
+ _, err := w.codebuddy().Complete(context.Background(), req)
796
+ return err
797
+ }},
798
+ }
799
+ for _, tc := range cases {
800
+ t.Run(tc.name, func(t *testing.T) {
801
+ dir := t.TempDir()
802
+ w, log := argsCaptureCLI(t, dir, tc.bin, tc.canned)
803
+ if err := tc.complete(w, Request{Messages: []Message{{Role: "user", Content: "hi"}}}); err != nil {
804
+ t.Fatalf("Complete: %v", err)
805
+ }
806
+ data, _ := os.ReadFile(log)
807
+ if strings.Contains(string(data), "--settings") {
808
+ t.Errorf("MaxTokens 未设置时不应出现 --settings: %q", string(data))
809
+ }
810
+ })
811
+ }
812
+ }
813
+
814
+ // TestTemperatureIgnoredByCLIs 统一参数矩阵:Temperature 仅 llm 支持,
815
+ // 其余引擎必须静默忽略(任何形式都不应出现温度参数)。
816
+ func TestTemperatureIgnoredByCLIs(t *testing.T) {
817
+ temp := 0.7
818
+ cases := []struct {
819
+ name string
820
+ bin string
821
+ canned string
822
+ complete func(*engineWithLog, Request) error
823
+ }{
824
+ {"claude", "claude", `echo '{"type":"result","result":"ok"}'`,
825
+ func(w *engineWithLog, req Request) error {
826
+ _, err := w.claude().Complete(context.Background(), req)
827
+ return err
828
+ }},
829
+ {"codebuddy", "codebuddy", `echo '{"type":"result","result":"ok"}'`,
830
+ func(w *engineWithLog, req Request) error {
831
+ _, err := w.codebuddy().Complete(context.Background(), req)
832
+ return err
833
+ }},
834
+ {"trae", "trae-cli", `echo 'ok'`,
835
+ func(w *engineWithLog, req Request) error {
836
+ _, err := w.trae().Complete(context.Background(), req)
837
+ return err
838
+ }},
839
+ }
840
+ for _, tc := range cases {
841
+ t.Run(tc.name, func(t *testing.T) {
842
+ dir := t.TempDir()
843
+ w, log := argsCaptureCLI(t, dir, tc.bin, tc.canned)
844
+ req := Request{
845
+ Messages: []Message{{Role: "user", Content: "hi"}},
846
+ Temperature: &temp,
847
+ }
848
+ if err := tc.complete(w, req); err != nil {
849
+ t.Fatalf("Complete: %v", err)
850
+ }
851
+ data, _ := os.ReadFile(log)
852
+ args := string(data)
853
+ if strings.Contains(strings.ToLower(args), "temperature") {
854
+ t.Errorf("温度参数应被静默忽略: %q", args)
855
+ }
856
+ })
857
+ }
858
+ }