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,551 @@
1
+ package agent
2
+
3
+ // llmengine_test.go - llm 引擎(simonw/LLM CLI 包装)测试。
4
+ //
5
+ // 用 fake CLI 脚本模拟 llm prompt 的行为,验证:
6
+ // - buildArgs 参数构造(-m、-s、--no-stream、prompt 位置)
7
+ // - Complete:--no-stream + 标签剥离
8
+ // - Stream:纯文本逐行流式 + thinkSplitter 标签路由
9
+ // - 标签被行边界切开的边界情况
10
+ // - CLI 失败报错
11
+ //
12
+ // 标签字面量同样分段拼接(见 tags.go 头注释)。
13
+
14
+ import (
15
+ "context"
16
+ "encoding/json"
17
+ "os"
18
+ "path/filepath"
19
+ "strings"
20
+ "testing"
21
+ )
22
+
23
+ // llmTag 拼接思维链标签(避免源码出现连续 token)。
24
+ func llmTag(parts ...string) string { return strings.Join(parts, "") }
25
+
26
+ // TestLLMBuildArgs 参数构造:会话(--cid / -c)、-m / -s 按需;prompt 收尾。
27
+ // 不含 -n/--no-log(会话闭环依赖 llm 本地日志持久化,见 llmengine.go 头注释)。
28
+ func TestLLMBuildArgs(t *testing.T) {
29
+ e := &LLMEngine{}
30
+
31
+ // 最小形式
32
+ got := e.buildArgs(Request{Model: "minimax-m3"}, "你好")
33
+ want := []string{"prompt", "-m", "minimax-m3", "你好"}
34
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
35
+ t.Errorf("buildArgs = %q, want %q", got, want)
36
+ }
37
+
38
+ // 带 system + engine/model 前缀剥离
39
+ got = e.buildArgs(Request{Model: "llm/minimax-m3", SystemPrompt: "你是助手"}, "问题")
40
+ want = []string{"prompt", "-m", "minimax-m3", "-s", "你是助手", "问题"}
41
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
42
+ t.Errorf("buildArgs = %q, want %q", got, want)
43
+ }
44
+
45
+ // 无模型:不传 -m(llm 用自己的默认模型)
46
+ got = e.buildArgs(Request{}, "hi")
47
+ want = []string{"prompt", "hi"}
48
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
49
+ t.Errorf("buildArgs = %q, want %q", got, want)
50
+ }
51
+
52
+ // 续接指定会话:--cid <id>(统一参数矩阵;id 通常来自上一轮
53
+ // --json envelope 的 conversation_id)
54
+ got = e.buildArgs(Request{SessionID: "01m2kx2qa0mbefrm2bk06mzz6r"}, "接着说")
55
+ want = []string{"prompt", "--cid", "01m2kx2qa0mbefrm2bk06mzz6r", "接着说"}
56
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
57
+ t.Errorf("buildArgs = %q, want %q", got, want)
58
+ }
59
+
60
+ // 续接最近一次会话:-c
61
+ got = e.buildArgs(Request{Continue: true}, "接着说")
62
+ want = []string{"prompt", "-c", "接着说"}
63
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
64
+ t.Errorf("buildArgs = %q, want %q", got, want)
65
+ }
66
+
67
+ // SessionID 与 Continue 同时设置:SessionID 优先(与 Request 注释一致)
68
+ got = e.buildArgs(Request{SessionID: "conv-1", Continue: true}, "接着说")
69
+ want = []string{"prompt", "--cid", "conv-1", "接着说"}
70
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
71
+ t.Errorf("buildArgs = %q, want %q (SessionID 应优先于 Continue)", got, want)
72
+ }
73
+ }
74
+
75
+ // TestLLMBuildArgsModelOptions 模型选项经 -o 透传:
76
+ //
77
+ // MaxTokens > 0 → -o max_tokens N
78
+ // Temperature != nil → -o temperature T
79
+ //
80
+ // 缺省(0 / nil)时两个都不出现,避免给模型塞无意义的默认值。
81
+ func TestLLMBuildArgsModelOptions(t *testing.T) {
82
+ e := &LLMEngine{}
83
+
84
+ // 两个都给
85
+ temp := 0.3
86
+ got := e.buildArgs(Request{Model: "minimax-m3", MaxTokens: 32000, Temperature: &temp}, "写一集")
87
+ want := []string{"prompt", "-m", "minimax-m3", "-o", "max_tokens", "32000", "-o", "temperature", "0.3", "写一集"}
88
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
89
+ t.Errorf("buildArgs = %q, want %q", got, want)
90
+ }
91
+
92
+ // 只给 max-tokens
93
+ got = e.buildArgs(Request{MaxTokens: 8000}, "hi")
94
+ want = []string{"prompt", "-o", "max_tokens", "8000", "hi"}
95
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
96
+ t.Errorf("buildArgs = %q, want %q", got, want)
97
+ }
98
+
99
+ // 整数温度不留 ".0" 尾巴;0 是合法温度(必须出现)
100
+ zero := 0.0
101
+ got = e.buildArgs(Request{Temperature: &zero}, "hi")
102
+ want = []string{"prompt", "-o", "temperature", "0", "hi"}
103
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
104
+ t.Errorf("buildArgs = %q, want %q (温度 0 必须透传)", got, want)
105
+ }
106
+
107
+ // 都不给
108
+ got = e.buildArgs(Request{Model: "minimax-m3"}, "hi")
109
+ for _, a := range got {
110
+ if a == "-o" {
111
+ t.Errorf("buildArgs = %q, want no -o when unset", got)
112
+ }
113
+ }
114
+ }
115
+
116
+ // TestLLMCompletePassesModelOptions --max-tokens / --temperature 真的落到命令行。
117
+ func TestLLMCompletePassesModelOptions(t *testing.T) {
118
+ dir := t.TempDir()
119
+ log := filepath.Join(dir, "args.log")
120
+ cli := filepath.Join(dir, "llm")
121
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
122
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ e := &LLMEngine{BinPath: cli}
126
+
127
+ temp := 0.3
128
+ _, err := e.Complete(context.Background(), Request{
129
+ Model: "minimax-m3",
130
+ MaxTokens: 32000,
131
+ Temperature: &temp,
132
+ Messages: []Message{{Role: "user", Content: "hi"}},
133
+ })
134
+ if err != nil {
135
+ t.Fatalf("Complete: %v", err)
136
+ }
137
+ data, _ := os.ReadFile(log)
138
+ args := string(data)
139
+ for _, want := range []string{"-o max_tokens 32000", "-o temperature 0.3"} {
140
+ if !strings.Contains(args, want) {
141
+ t.Errorf("%q missing in args: %q", want, args)
142
+ }
143
+ }
144
+ }
145
+
146
+ // TestLLMCompleteStripsThinking Complete 模式:--no-stream 全量输出,
147
+ // 标签块被剥离、正文与思维链分离。
148
+ func TestLLMCompleteStripsThinking(t *testing.T) {
149
+ open := llmTag("<", "think", ">")
150
+ closeTag := llmTag("<", "/", "think", ">")
151
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+open+"先想一下"+closeTag+"答案是 2\nEOF\n")
152
+ e := &LLMEngine{BinPath: cli}
153
+
154
+ resp, err := e.Complete(context.Background(), Request{
155
+ Model: "minimax-m3",
156
+ Messages: []Message{{Role: "user", Content: "1+1"}},
157
+ })
158
+ if err != nil {
159
+ t.Fatalf("Complete: %v", err)
160
+ }
161
+ if resp.Text != "答案是 2" {
162
+ t.Errorf("Text = %q, want 答案是 2", resp.Text)
163
+ }
164
+ if resp.Engine != "llm" {
165
+ t.Errorf("Engine = %q, want llm", resp.Engine)
166
+ }
167
+ if resp.Model != "minimax-m3" {
168
+ t.Errorf("Model = %q, want minimax-m3", resp.Model)
169
+ }
170
+ }
171
+
172
+ // TestLLMCompletePlainText 无标签模型:正文原样返回。
173
+ func TestLLMCompletePlainText(t *testing.T) {
174
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\necho '普通回答'\n")
175
+ e := &LLMEngine{BinPath: cli}
176
+
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 != "普通回答" {
184
+ t.Errorf("Text = %q, want 普通回答", resp.Text)
185
+ }
186
+ }
187
+
188
+ // TestLLMCompletePassesFlags --no-stream 与 -s 落到真实命令行。
189
+ func TestLLMCompletePassesFlags(t *testing.T) {
190
+ dir := t.TempDir()
191
+ log := filepath.Join(dir, "args.log")
192
+ cli := filepath.Join(dir, "llm")
193
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\necho 'ok'\n"
194
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ e := &LLMEngine{BinPath: cli}
198
+
199
+ _, err := e.Complete(context.Background(), Request{
200
+ Model: "minimax-m3",
201
+ SystemPrompt: "你是助手",
202
+ Messages: []Message{{Role: "user", Content: "hi"}},
203
+ })
204
+ if err != nil {
205
+ t.Fatalf("Complete: %v", err)
206
+ }
207
+ data, _ := os.ReadFile(log)
208
+ args := string(data)
209
+ for _, want := range []string{"prompt", "-m minimax-m3", "-s 你是助手", "--no-stream"} {
210
+ if !strings.Contains(args, want) {
211
+ t.Errorf("%q missing in args: %q", want, args)
212
+ }
213
+ }
214
+ }
215
+
216
+ // TestLLMCompleteCliError CLI 非零退出 → 报错。
217
+ func TestLLMCompleteCliError(t *testing.T) {
218
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\necho 'Error: no such model' >&2\nexit 1\n")
219
+ e := &LLMEngine{BinPath: cli}
220
+
221
+ _, err := e.Complete(context.Background(), Request{
222
+ Messages: []Message{{Role: "user", Content: "hi"}},
223
+ })
224
+ if err == nil {
225
+ t.Fatal("expected error for non-zero exit")
226
+ }
227
+ if !strings.Contains(err.Error(), "llm") {
228
+ t.Errorf("error should mention llm: %v", err)
229
+ }
230
+ }
231
+
232
+ // TestLLMStreamSplitsThinkingTags 流式:标签块路由到 thinking,
233
+ // 标签外正文路由到 text;换行保留。
234
+ func TestLLMStreamSplitsThinkingTags(t *testing.T) {
235
+ open := llmTag("<", "think", ">")
236
+ closeTag := llmTag("<", "/", "think", ">")
237
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+open+"\n用户在做加法。\n"+closeTag+"\n答案是 2。\nEOF\n")
238
+ e := &LLMEngine{BinPath: cli}
239
+
240
+ events, onEvent := collectEvents()
241
+ res, err := e.Stream(context.Background(), Request{
242
+ Model: "minimax-m3",
243
+ Messages: []Message{{Role: "user", Content: "1+1"}},
244
+ }, onEvent)
245
+ if err != nil {
246
+ t.Fatalf("Stream: %v", err)
247
+ }
248
+ if res.Text != "答案是 2。" {
249
+ t.Errorf("Text = %q, want 答案是 2。", res.Text)
250
+ }
251
+ // 思维链内容:开闭标签之间的文本(收尾 TrimSpace 去掉标签行的换行)。
252
+ if res.Thinking != "用户在做加法。" {
253
+ t.Errorf("Thinking = %q, want 用户在做加法。", res.Thinking)
254
+ }
255
+ // 至少各一条增量
256
+ var hasThink, hasText bool
257
+ for _, ev := range *events {
258
+ switch ev.Kind {
259
+ case KindThinking:
260
+ hasThink = true
261
+ case KindText:
262
+ hasText = true
263
+ }
264
+ }
265
+ if !hasThink || !hasText {
266
+ t.Errorf("events missing kinds: thinking=%v text=%v (events=%v)", hasThink, hasText, *events)
267
+ }
268
+ // 第一条事件必须是 thinking(开标签在最前)
269
+ if len(*events) > 0 && (*events)[0].Kind != KindThinking {
270
+ t.Errorf("first event = %v, want thinking", (*events)[0].Kind)
271
+ }
272
+ }
273
+
274
+ // TestLLMStreamFakeTagNotSplit 被换行打断的伪标签(如 "<th\nink>")
275
+ // 不是标签——标签本身不含换行,不可能被 Scanner 的行边界切开。
276
+ // 行为与非流式 splitThinkingTags 一致:视为正文原样输出。
277
+ func TestLLMStreamFakeTagNotSplit(t *testing.T) {
278
+ open := llmTag("<", "think", ">")
279
+ closeTag := llmTag("<", "/", "think", ">")
280
+ half1, half2 := open[:3], open[3:]
281
+ chalf1, chalf2 := closeTag[:4], closeTag[4:]
282
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+half1+"\n"+half2+"推理"+chalf1+"\n"+chalf2+"正文\nEOF\n")
283
+ e := &LLMEngine{BinPath: cli}
284
+
285
+ res, err := e.Stream(context.Background(), Request{
286
+ Messages: []Message{{Role: "user", Content: "hi"}},
287
+ }, nil)
288
+ if err != nil {
289
+ t.Fatalf("Stream: %v", err)
290
+ }
291
+ // 切开的伪标签整体属于正文(换行保留,行首尾空白已在收尾 TrimSpace)。
292
+ want := half1 + "\n" + half2 + "推理" + chalf1 + "\n" + chalf2 + "正文"
293
+ if res.Text != want {
294
+ t.Errorf("Text = %q, want %q(伪标签不是标签,原样输出)", res.Text, want)
295
+ }
296
+ if res.Thinking != "" {
297
+ t.Errorf("Thinking = %q, want empty", res.Thinking)
298
+ }
299
+ }
300
+
301
+ // TestLLMStreamTagAcrossLines 真标签跨行结构:开标签后正文多行、
302
+ // 闭标签独立成行、闭标签后还有正文——思维链块跨行正确聚合。
303
+ func TestLLMStreamTagAcrossLines(t *testing.T) {
304
+ open := llmTag("<", "think", ">")
305
+ closeTag := llmTag("<", "/", "think", ">")
306
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+open+"\n第一行推理\n第二行推理\n"+closeTag+"\n最终答案\nEOF\n")
307
+ e := &LLMEngine{BinPath: cli}
308
+
309
+ events, onEvent := collectEvents()
310
+ res, err := e.Stream(context.Background(), Request{
311
+ Messages: []Message{{Role: "user", Content: "hi"}},
312
+ }, onEvent)
313
+ if err != nil {
314
+ t.Fatalf("Stream: %v", err)
315
+ }
316
+ if res.Text != "最终答案" {
317
+ t.Errorf("Text = %q, want 最终答案", res.Text)
318
+ }
319
+ if res.Thinking != "第一行推理\n第二行推理" {
320
+ t.Errorf("Thinking = %q, want 跨行思维链完整聚合(首尾换行已 trim)", res.Thinking)
321
+ }
322
+ if len(*events) == 0 || (*events)[0].Kind != KindThinking {
323
+ t.Errorf("first event should be thinking: %v", *events)
324
+ }
325
+ }
326
+
327
+ // TestLLMStreamPlainText 无标签流式:全部走 text 通道,换行保留。
328
+ func TestLLMStreamPlainText(t *testing.T) {
329
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n第一行\n第二行\nEOF\n")
330
+ e := &LLMEngine{BinPath: cli}
331
+
332
+ events, onEvent := collectEvents()
333
+ res, err := e.Stream(context.Background(), Request{
334
+ Messages: []Message{{Role: "user", Content: "hi"}},
335
+ }, onEvent)
336
+ if err != nil {
337
+ t.Fatalf("Stream: %v", err)
338
+ }
339
+ if res.Text != "第一行\n第二行" {
340
+ t.Errorf("Text = %q, want 第一行\\n第二行", res.Text)
341
+ }
342
+ if res.Thinking != "" {
343
+ t.Errorf("Thinking = %q, want empty", res.Thinking)
344
+ }
345
+ for _, ev := range *events {
346
+ if ev.Kind != KindText {
347
+ t.Errorf("all events should be text, got %v", ev.Kind)
348
+ }
349
+ }
350
+ }
351
+
352
+ // TestLLMCompleteParsesJSONUsage --json 路径:正文取自 response(思维链已剥离),
353
+ // token 计数从 input_tokens / output_tokens 读出,model 用 llm 回报的实际模型名;
354
+ // envelope 的 conversation_id 回填 Response.SessionID,供下一轮 --cid 续接。
355
+ func TestLLMCompleteParsesJSONUsage(t *testing.T) {
356
+ open := llmTag("<", "think", ">")
357
+ closeTag := llmTag("<", "/", "think", ">")
358
+ payload := `[{"model":"minimax-m3","input_tokens":192,"output_tokens":38,` +
359
+ `"conversation_id":"01m2kxhae6x0chahn5e71nvm84","response":"` +
360
+ open + `想一下` + closeTag + `\n\n收到"}]`
361
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+payload+"\nEOF\n")
362
+ e := &LLMEngine{BinPath: cli}
363
+
364
+ resp, err := e.Complete(context.Background(), Request{
365
+ Model: "llm/minimax-m3",
366
+ Messages: []Message{{Role: "user", Content: "hi"}},
367
+ })
368
+ if err != nil {
369
+ t.Fatalf("Complete: %v", err)
370
+ }
371
+ if resp.Text != "收到" {
372
+ t.Errorf("Text = %q, want 收到(思维链应被剥离)", resp.Text)
373
+ }
374
+ if resp.InputTokens != 192 || resp.OutputTokens != 38 {
375
+ t.Errorf("tokens = in:%d out:%d, want in:192 out:38", resp.InputTokens, resp.OutputTokens)
376
+ }
377
+ if resp.TotalTokens != 230 {
378
+ t.Errorf("TotalTokens = %d, want 230", resp.TotalTokens)
379
+ }
380
+ // llm 回报的 model 优先于请求里的 "llm/minimax-m3"(前缀已剥)。
381
+ if resp.Model != "minimax-m3" {
382
+ t.Errorf("Model = %q, want minimax-m3", resp.Model)
383
+ }
384
+ // 会话闭环:envelope 的 conversation_id → Response.SessionID,
385
+ // 下一轮 Request.SessionID → buildArgs 的 --cid(见 TestLLMBuildArgs)。
386
+ if resp.SessionID != "01m2kxhae6x0chahn5e71nvm84" {
387
+ t.Errorf("SessionID = %q, want 01m2kxhae6x0chahn5e71nvm84(conversation_id 应回填)", resp.SessionID)
388
+ }
389
+ }
390
+
391
+ // TestLLMCompleteJSONPicksLastEntry 工具链场景:数组多项时取最后一项的正文。
392
+ func TestLLMCompleteJSONPicksLastEntry(t *testing.T) {
393
+ payload := `[{"model":"m","response":"中间结果","input_tokens":10,"output_tokens":1},` +
394
+ `{"model":"m","response":"最终答复","input_tokens":20,"output_tokens":2}]`
395
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+payload+"\nEOF\n")
396
+ e := &LLMEngine{BinPath: cli}
397
+
398
+ resp, err := e.Complete(context.Background(), Request{
399
+ Messages: []Message{{Role: "user", Content: "hi"}},
400
+ })
401
+ if err != nil {
402
+ t.Fatalf("Complete: %v", err)
403
+ }
404
+ if resp.Text != "最终答复" {
405
+ t.Errorf("Text = %q, want 最终答复", resp.Text)
406
+ }
407
+ if resp.InputTokens != 20 {
408
+ t.Errorf("InputTokens = %d, want 20(取最终项)", resp.InputTokens)
409
+ }
410
+ }
411
+
412
+ // TestParseLLMJSONEntryNonJSON 非 JSON 输出 → ok=false(走纯文本兜底)。
413
+ func TestParseLLMJSONEntryNonJSON(t *testing.T) {
414
+ if _, ok := parseLLMJSONEntry("就是一段纯文本"); ok {
415
+ t.Error("plain text should not parse as JSON entry")
416
+ }
417
+ if _, ok := parseLLMJSONEntry(`{"response":"对象不是数组"}`); ok {
418
+ t.Error("JSON object should not parse as entry array")
419
+ }
420
+ if _, ok := parseLLMJSONEntry(`[{"model":"m","response":" "}]`); ok {
421
+ t.Error("entry with blank response should be skipped")
422
+ }
423
+ }
424
+
425
+ // TestLLMCompleteDoesNotPassSchemaToCLI magic-agent 收到 JSONSchema 时**不传**
426
+ // 给 simonw llm CLI(实测 minimax-m3 等 OpenAI Chat 类模型不支持 schema,
427
+ // 会报 "does not support schemas")。schema 走 system prompt 提示词约束,
428
+ // 引擎里只做 response 后处理(extractJSONObjectStrict)。
429
+ func TestLLMCompleteDoesNotPassSchemaToCLI(t *testing.T) {
430
+ dir := t.TempDir()
431
+ log := filepath.Join(dir, "args.log")
432
+ cli := filepath.Join(dir, "llm")
433
+ script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log + "\nprintf '%s\\n' '[{\"model\":\"m\",\"input_tokens\":1,\"output_tokens\":2,\"response\":\"ok\"}]'\n"
434
+ if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
435
+ t.Fatal(err)
436
+ }
437
+ e := &LLMEngine{BinPath: cli}
438
+ if _, err := e.Complete(context.Background(), Request{
439
+ Model: "minimax-m3",
440
+ Messages: []Message{{Role: "user", Content: "hi"}},
441
+ JSONSchema: &JSONSchema{
442
+ Type: "object",
443
+ Required: []string{"score"},
444
+ },
445
+ }); err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ data, _ := os.ReadFile(log)
449
+ args := string(data)
450
+ if strings.Contains(args, "--schema ") {
451
+ t.Errorf("--schema should NOT be passed to llm CLI (would fail on OpenAI Chat models): %q", args)
452
+ }
453
+ }
454
+
455
+ // TestLLMBuildArgsOmitsSchemaWhenUnset 没 JSONSchema 时不带 --schema。
456
+ func TestLLMBuildArgsOmitsSchemaWhenUnset(t *testing.T) {
457
+ e := &LLMEngine{}
458
+ got := e.buildArgs(Request{Model: "minimax-m3"}, "hi")
459
+ for _, a := range got {
460
+ if a == "--schema" {
461
+ t.Errorf("--schema should not appear when JSONSchema unset: %q", got)
462
+ }
463
+ }
464
+ }
465
+
466
+ // TestLLMCompleteAppliesJSONSchema 当请求带 JSONSchema 且模型把对象字符串化在
467
+ // response 里时,Complete 路径应把它解析回紧凑 JSON。
468
+ //
469
+ // 模拟真实场景:llm CLI 返回 <response> 字段是「字符串化的 JSON」,
470
+ // 而非已经 parse 好的对象。这是 OpenAI chat/completions 端点的常见形态。
471
+ func TestLLMCompleteAppliesJSONSchema(t *testing.T) {
472
+ // 用 string 模型构造 envelope,response 是被 json.Marshal 后的对象。
473
+ obj := map[string]any{
474
+ "setting": 12, "character": 13, "pacing": 14,
475
+ "hook": 15, "prose": 12, "density": 13, "style": 12,
476
+ "issues": []string{"意象重复"},
477
+ "suggestions": []string{"替换具体细节"},
478
+ }
479
+ objJSON, _ := json.Marshal(obj)
480
+ payload, _ := json.Marshal([]map[string]any{{
481
+ "model": "minimax-m3",
482
+ "input_tokens": 100,
483
+ "output_tokens": len(objJSON),
484
+ "response": string(objJSON), // ← 这里是「字符串化的 JSON」
485
+ }})
486
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+string(payload)+"\nEOF\n")
487
+ e := &LLMEngine{BinPath: cli}
488
+
489
+ resp, err := e.Complete(context.Background(), Request{
490
+ Model: "llm/minimax-m3",
491
+ Messages: []Message{{Role: "user", Content: "打分"}},
492
+ JSONSchema: &JSONSchema{
493
+ Type: "object",
494
+ Required: []string{"setting", "character", "pacing", "issues", "suggestions"},
495
+ },
496
+ })
497
+ if err != nil {
498
+ t.Fatalf("Complete: %v", err)
499
+ }
500
+ // resp.Text 必须是合法 JSON 字符串,且 Required 顶层字段都在
501
+ var back map[string]any
502
+ if err := json.Unmarshal([]byte(resp.Text), &back); err != nil {
503
+ t.Fatalf("Text not valid JSON: %v (got=%q)", err, resp.Text)
504
+ }
505
+ for _, k := range []string{"setting", "issues", "suggestions"} {
506
+ if _, ok := back[k]; !ok {
507
+ t.Errorf("missing %q in extracted: %s", k, resp.Text)
508
+ }
509
+ }
510
+ // 紧凑化:不能带多余空白
511
+ if strings.Contains(resp.Text, "\n") || strings.Contains(resp.Text, " ") {
512
+ t.Errorf("extracted JSON should be compact: %q", resp.Text)
513
+ }
514
+ }
515
+
516
+ // TestLLMCompleteMissingRequiredKeepsRaw 当 model 输出缺 Required 字段时,
517
+ // 不改写 Response.Text,让上游 base/score 的宽松解析兜底。
518
+ func TestLLMCompleteMissingRequiredKeepsRaw(t *testing.T) {
519
+ // 注意:payload 的 response 字段直接是 JSON 字符串(这是 llm CLI
520
+ // 真实行为),所以不需要再次 json.Marshal。
521
+ payload := `[{"model":"m","input_tokens":1,"output_tokens":2,"response":"{\"setting\":12,\"character\":13}"}]`
522
+ cli := writeFakeCLI(t, "llm", "#!/bin/sh\ncat <<'EOF'\n"+payload+"\nEOF\n")
523
+ e := &LLMEngine{BinPath: cli}
524
+
525
+ resp, err := e.Complete(context.Background(), Request{
526
+ Messages: []Message{{Role: "user", Content: "打分"}},
527
+ JSONSchema: &JSONSchema{
528
+ Type: "object",
529
+ Required: []string{"setting", "hook"}, // hook 缺失
530
+ },
531
+ })
532
+ if err != nil {
533
+ t.Fatalf("Complete: %v", err)
534
+ }
535
+ // Text 保留原始字符串(带原始字段,未经过 extract 改写)
536
+ if !strings.Contains(resp.Text, `"character":13`) {
537
+ t.Errorf("Text should keep raw content, got %q", resp.Text)
538
+ }
539
+ }
540
+
541
+ // TestLLMDetectMissingCli CLI 不存在 → Detect 失败并给出路径。
542
+ func TestLLMDetectMissingCli(t *testing.T) {
543
+ e := &LLMEngine{BinPath: "/nonexistent/llm"}
544
+ ok, note := e.Detect()
545
+ if ok {
546
+ t.Fatal("Detect should fail for missing CLI")
547
+ }
548
+ if !strings.Contains(note, "/nonexistent/llm") {
549
+ t.Errorf("note = %q, want the missing path", note)
550
+ }
551
+ }