magic-agent 2.4.2 → 2.6.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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -0,0 +1,433 @@
1
+ package agent
2
+
3
+ // trae_exec.go - TRAE CLI 2.0 的 `exec --json` 适配。
4
+ //
5
+ // 参数(`traex exec --help` / `exec resume --help` 实测 0.211.1):
6
+ //
7
+ // exec --json --skip-git-repo-check [-C ws] [-m model] <tools> <prompt>
8
+ // exec resume <id>|--last --json --skip-git-repo-check [-m model] <tools> <prompt>
9
+ //
10
+ // 事件流与 Codex 同源(二进制里就是 codex_core):
11
+ //
12
+ // {"type":"thread.started","thread_id":"…"} — 会话 id
13
+ // {"type":"turn.started"}
14
+ // {"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"…"}}
15
+ // {"type":"item.completed","item":{"id":"item_0","type":"command_execution",
16
+ // "aggregated_output":"…","exit_code":0}}
17
+ // {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"…"}}
18
+ // {"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"…"}}
19
+ // {"type":"turn.completed","usage":{"input_tokens":…,"output_tokens":…}}
20
+ // {"type":"turn.failed","error":{"message":"…"}}
21
+ //
22
+ // 没有 --query-timeout:超时由外层 runner 杀进程组。
23
+
24
+ import (
25
+ "context"
26
+ "encoding/json"
27
+ "fmt"
28
+ "os"
29
+ "strings"
30
+ "time"
31
+ )
32
+
33
+ // traeDefaultModelFromTOML 从 ~/.trae/traecli.toml 取顶层 model。
34
+ // 只认 `model = "..."` 这一种形态(与 `-c model="o3"` 的配置键一致);
35
+ // `model_provider = "..."` 之类不会被误命中。
36
+ func traeDefaultModelFromTOML(path string) string {
37
+ data, err := os.ReadFile(path)
38
+ if err != nil {
39
+ return ""
40
+ }
41
+ for _, ln := range strings.Split(string(data), "\n") {
42
+ t := strings.TrimSpace(ln)
43
+ if t == "" || strings.HasPrefix(t, "#") || !strings.HasPrefix(t, "model") {
44
+ continue
45
+ }
46
+ rest := strings.TrimSpace(strings.TrimPrefix(t, "model"))
47
+ if !strings.HasPrefix(rest, "=") {
48
+ continue
49
+ }
50
+ v := strings.TrimSpace(strings.TrimPrefix(rest, "="))
51
+ if i := strings.Index(v, "#"); i >= 0 {
52
+ v = strings.TrimSpace(v[:i])
53
+ }
54
+ v = strings.Trim(v, `"'`)
55
+ if v != "" {
56
+ return v
57
+ }
58
+ }
59
+ return ""
60
+ }
61
+
62
+ // buildTraeExecArgs 构造 2.0 的 traex 参数。
63
+ func (e *TraeEngine) buildTraeExecArgs(req Request, prompt string) []string {
64
+ resuming := req.SessionID != "" || req.Continue
65
+ var args []string
66
+ switch {
67
+ case req.SessionID != "":
68
+ args = append(args, "exec", "resume", req.SessionID)
69
+ case req.Continue:
70
+ args = append(args, "exec", "resume", "--last")
71
+ default:
72
+ args = append(args, "exec")
73
+ }
74
+ args = append(args, "--json", "--skip-git-repo-check")
75
+ if m := traeModelFor(req, e); m != "" {
76
+ args = append(args, "-m", m)
77
+ }
78
+ // resume 子命令没有 -C;续接轮靠子进程 cwd(调用点已设置)。
79
+ if ws := strings.TrimSpace(req.Workspace); ws != "" && !resuming {
80
+ args = append(args, "-C", ws)
81
+ }
82
+ // 工具模式:off = 逐个 --disallowed-tool 减法;on / 白名单 = -y 全放行
83
+ //(trae 表达不了「只允许这几个」)。
84
+ tools := toolsOrDefault(req.Tools)
85
+ if tools.IsOff() {
86
+ for _, t := range traeOffDisableTools {
87
+ args = append(args, "--disallowed-tool", t)
88
+ }
89
+ } else {
90
+ args = append(args, "-y")
91
+ }
92
+ // MaxTokens / Temperature 仍不支持(与其余 CLI 同一矩阵②)。
93
+ _ = req.MaxTokens
94
+ _ = req.Temperature
95
+ args = append(args, prompt)
96
+ return args
97
+ }
98
+
99
+ // completeExec 非流式:跑一次 `exec --json`,把 JSONL 聚合为最终正文。
100
+ func (e *TraeEngine) completeExec(ctx context.Context, bin string, req Request, start time.Time) (Response, error) {
101
+ prompt, err := traePrompt(req)
102
+ if err != nil {
103
+ return Response{}, err
104
+ }
105
+ args := e.buildTraeExecArgs(req, prompt)
106
+ stdout, stderr, runErr := runCLIIn(ctx, req.Workspace, bin, args...)
107
+
108
+ acc := &traeExecAcc{}
109
+ for _, line := range strings.Split(stdout, "\n") {
110
+ _ = acc.handleLine(line)
111
+ }
112
+ if acc.turnErr != nil {
113
+ return Response{}, acc.turnErr
114
+ }
115
+ if runErr != nil {
116
+ return Response{}, wrapCliError("trae", stdout, stderr, runErr)
117
+ }
118
+ text := strings.TrimSpace(acc.Text.String())
119
+ if text == "" {
120
+ return Response{}, fmt.Errorf("trae CLI returned empty result")
121
+ }
122
+ sessionID := acc.SessionID
123
+ if sessionID == "" {
124
+ sessionID = req.SessionID
125
+ }
126
+ in, out := acc.tokenCounts()
127
+ return Response{
128
+ Engine: e.Name(),
129
+ Text: text,
130
+ Model: e.modelLabel(req),
131
+ SessionID: sessionID,
132
+ InputTokens: in,
133
+ OutputTokens: out,
134
+ TotalTokens: in + out,
135
+ Latency: time.Since(start),
136
+ }, nil
137
+ }
138
+
139
+ // streamExec 流式:逐行消费 `exec --json`,正文以增量事件转发。
140
+ func (e *TraeEngine) streamExec(ctx context.Context, bin string, req Request, start time.Time, onEvent func(StreamEvent)) (StreamResult, error) {
141
+ prompt, err := traePrompt(req)
142
+ if err != nil {
143
+ return StreamResult{}, err
144
+ }
145
+ args := e.buildTraeExecArgs(req, prompt)
146
+ acc := &traeExecAcc{OnEvent: onEvent, OnSessionID: req.OnSessionID}
147
+ err = runStreamCLIIn(ctx, req.Workspace, bin, args, acc.handleLine)
148
+ if acc.turnErr != nil {
149
+ acc.emitTurnFailed(acc.SessionID, acc.turnErr)
150
+ return StreamResult{}, acc.turnErr
151
+ }
152
+ if err != nil {
153
+ acc.emitTurnFailed(acc.SessionID, err)
154
+ return StreamResult{}, err
155
+ }
156
+ text := strings.TrimSpace(acc.Text.String())
157
+ if text == "" {
158
+ err := fmt.Errorf("trae CLI returned empty result")
159
+ acc.emitTurnFailed(acc.SessionID, err)
160
+ return StreamResult{}, err
161
+ }
162
+ if !acc.sawTurnEnd {
163
+ err := fmt.Errorf("trae CLI stream ended without turn.completed")
164
+ acc.emitTurnFailed(acc.SessionID, err)
165
+ return StreamResult{}, err
166
+ }
167
+ in, out := acc.tokenCounts()
168
+ return StreamResult{
169
+ Response: Response{
170
+ Engine: e.Name(),
171
+ Text: text,
172
+ Model: e.modelLabel(req),
173
+ SessionID: acc.SessionID,
174
+ InputTokens: in,
175
+ OutputTokens: out,
176
+ TotalTokens: in + out,
177
+ Latency: time.Since(start),
178
+ },
179
+ Thinking: strings.TrimSpace(acc.Thinking.String()),
180
+ }, nil
181
+ }
182
+
183
+ // traeExecAcc 把 2.0 的 JSONL 事件聚合为正文 / 思考 / 工具事件。
184
+ //
185
+ // 正文用「前缀差量」消费:item.updated 可能给**累积全文**,item.completed
186
+ // 给最终全文;同一条 item 的文本只要与已见部分前缀一致就只发差量,
187
+ // 对「增量形状」也天然兼容(不构成前缀时按追加处理)。
188
+ type traeExecAcc struct {
189
+ OnEvent func(StreamEvent)
190
+ OnSessionID func(string)
191
+
192
+ SessionID string
193
+ Text strings.Builder
194
+ Thinking strings.Builder
195
+ Usage codexTurnUsage
196
+
197
+ sawTurnEnd bool
198
+ turnErr error
199
+
200
+ curTextID string
201
+ curTextSeen string
202
+ curThinkID string
203
+ curThinkSeen string
204
+ }
205
+
206
+ func (a *traeExecAcc) emit(ev StreamEvent) {
207
+ if a.OnEvent != nil {
208
+ a.OnEvent(ev)
209
+ }
210
+ }
211
+
212
+ func (a *traeExecAcc) tokenCounts() (int, int) {
213
+ in := a.Usage.InputTokens + a.Usage.CachedInputTokens
214
+ out := a.Usage.OutputTokens + a.Usage.ReasoningOutputTokens
215
+ return in, out
216
+ }
217
+
218
+ func (a *traeExecAcc) emitTurnEnd(text string) {
219
+ if a.OnEvent == nil {
220
+ return
221
+ }
222
+ a.OnEvent(StreamEvent{Kind: KindTurnEnd, Text: text, SessionID: a.SessionID})
223
+ }
224
+
225
+ func (a *traeExecAcc) emitTurnFailed(sessionID string, err error) {
226
+ if a.OnEvent == nil || err == nil {
227
+ return
228
+ }
229
+ a.OnEvent(StreamEvent{
230
+ Kind: KindTurnFailed,
231
+ SessionID: sessionID,
232
+ Error: err.Error(),
233
+ Reason: ReasonOf(err),
234
+ Status: StatusFailed,
235
+ })
236
+ }
237
+
238
+ // handleLine 消费一行 JSONL;turn.failed / error 返回错误以立即中止进程。
239
+ func (a *traeExecAcc) handleLine(line string) error {
240
+ s := strings.TrimSpace(line)
241
+ if s == "" {
242
+ return nil
243
+ }
244
+ var head struct {
245
+ Type string `json:"type"`
246
+ ThreadID string `json:"thread_id"`
247
+ }
248
+ if json.Unmarshal([]byte(s), &head) != nil {
249
+ return nil // 非 JSON 行(警告 / 杂讯)跳过
250
+ }
251
+ // 任意带 thread_id 的事件都可以定下会话锚点(thread.started 必带)。
252
+ if head.ThreadID != "" && a.SessionID == "" {
253
+ a.SessionID = head.ThreadID
254
+ if a.OnSessionID != nil {
255
+ a.OnSessionID(head.ThreadID)
256
+ }
257
+ }
258
+ switch head.Type {
259
+ case "item.started", "item.updated", "item.completed":
260
+ a.handleItem(s, head.Type)
261
+ case "turn.completed":
262
+ var tc codexTurnCompleted
263
+ if json.Unmarshal([]byte(s), &tc) == nil {
264
+ a.Usage = tc.Usage
265
+ }
266
+ a.sawTurnEnd = true
267
+ a.emitTurnEnd(strings.TrimSpace(a.Text.String()))
268
+ case "turn.failed":
269
+ var tf struct {
270
+ Error *struct {
271
+ Message string `json:"message"`
272
+ } `json:"error"`
273
+ Message string `json:"message"`
274
+ }
275
+ _ = json.Unmarshal([]byte(s), &tf)
276
+ msg := tf.Message
277
+ if msg == "" && tf.Error != nil {
278
+ msg = tf.Error.Message
279
+ }
280
+ if msg == "" {
281
+ msg = "turn failed"
282
+ }
283
+ a.sawTurnEnd = true
284
+ a.turnErr = fmt.Errorf("trae CLI turn failed: %s", msg)
285
+ return a.turnErr
286
+ case "error":
287
+ var ev struct {
288
+ Message string `json:"message"`
289
+ }
290
+ _ = json.Unmarshal([]byte(s), &ev)
291
+ msg := ev.Message
292
+ if msg == "" {
293
+ msg = "unknown error"
294
+ }
295
+ a.sawTurnEnd = true
296
+ a.turnErr = fmt.Errorf("trae CLI error: %s", msg)
297
+ return a.turnErr
298
+ }
299
+ return nil
300
+ }
301
+
302
+ // handleItem 处理 item.started / item.updated / item.completed。
303
+ //
304
+ // agent_message 正文
305
+ // reasoning 思考
306
+ // command_execution 工具(started → tool_use,completed → tool_result)
307
+ // mcp_tool_call MCP 工具(同上)
308
+ // 其余(file_change / todo_list / web_search 等)暂不映射,忽略即可。
309
+ func (a *traeExecAcc) handleItem(raw, evType string) {
310
+ var ev struct {
311
+ Item struct {
312
+ ID string `json:"id"`
313
+ Type string `json:"type"`
314
+ Text string `json:"text"`
315
+ Command string `json:"command"`
316
+ AggregatedOutput string `json:"aggregated_output"`
317
+ Tool string `json:"tool"`
318
+ Name string `json:"name"`
319
+ Arguments json.RawMessage `json:"arguments"`
320
+ Result json.RawMessage `json:"result"`
321
+ } `json:"item"`
322
+ }
323
+ if json.Unmarshal([]byte(raw), &ev) != nil {
324
+ return
325
+ }
326
+ it := ev.Item
327
+ switch it.Type {
328
+ case "agent_message":
329
+ a.consumeText(&a.curTextID, &a.curTextSeen, it.ID, it.Text, &a.Text, KindText)
330
+ case "reasoning":
331
+ a.consumeText(&a.curThinkID, &a.curThinkSeen, it.ID, it.Text, &a.Thinking, KindThinking)
332
+ case "command_execution":
333
+ if evType == "item.started" {
334
+ a.emit(StreamEvent{Kind: KindToolUse, Name: "command", ID: it.ID, Text: it.Command})
335
+ return
336
+ }
337
+ if evType == "item.completed" {
338
+ out := it.AggregatedOutput
339
+ if out == "" {
340
+ out = it.Command
341
+ }
342
+ a.emit(StreamEvent{Kind: KindToolResult, Name: it.ID, ID: it.ID, Text: out})
343
+ }
344
+ case "mcp_tool_call":
345
+ name := it.Tool
346
+ if name == "" {
347
+ name = it.Name
348
+ }
349
+ if name == "" {
350
+ name = "mcp"
351
+ }
352
+ if evType == "item.started" {
353
+ a.emit(StreamEvent{Kind: KindToolUse, Name: name, ID: it.ID, Text: string(it.Arguments)})
354
+ return
355
+ }
356
+ if evType == "item.completed" {
357
+ a.emit(StreamEvent{Kind: KindToolResult, Name: it.ID, ID: it.ID, Text: rawText(it.Result)})
358
+ }
359
+ }
360
+ }
361
+
362
+ // consumeText 消费同一条 item 的文本更新,只把「新增部分」计入累积并发事件。
363
+ func (a *traeExecAcc) consumeText(curID, curSeen *string, id, text string, acc *strings.Builder, kind StreamEventKind) {
364
+ if text == "" {
365
+ return
366
+ }
367
+ if id == "" {
368
+ id = *curID
369
+ }
370
+ if id == "" {
371
+ id = "stream"
372
+ }
373
+ if id != *curID {
374
+ *curID = id
375
+ *curSeen = ""
376
+ }
377
+ var delta string
378
+ switch {
379
+ case strings.HasPrefix(text, *curSeen):
380
+ // 累积形状(item.updated / completed 给全文)
381
+ delta = text[len(*curSeen):]
382
+ *curSeen = text
383
+ case strings.HasPrefix(*curSeen, text):
384
+ // 迟到的旧版本,忽略
385
+ return
386
+ default:
387
+ // 增量形状
388
+ delta = text
389
+ *curSeen += text
390
+ }
391
+ if delta == "" {
392
+ return
393
+ }
394
+ acc.WriteString(delta)
395
+ if a.OnEvent != nil {
396
+ a.OnEvent(StreamEvent{Kind: kind, Text: delta})
397
+ }
398
+ }
399
+
400
+ // rawText 从工具结果字段里抽可读文本(字符串 / {content:[{text}]} / {content:"..."})。
401
+ func rawText(raw json.RawMessage) string {
402
+ if len(raw) == 0 {
403
+ return ""
404
+ }
405
+ var s string
406
+ if json.Unmarshal(raw, &s) == nil {
407
+ return s
408
+ }
409
+ var obj struct {
410
+ Content json.RawMessage `json:"content"`
411
+ }
412
+ if json.Unmarshal(raw, &obj) == nil && len(obj.Content) > 0 {
413
+ if out := extractToolResultText(obj.Content); out != "" {
414
+ return out
415
+ }
416
+ }
417
+ var blocks []struct {
418
+ Type string `json:"type"`
419
+ Text string `json:"text"`
420
+ }
421
+ if json.Unmarshal(raw, &blocks) == nil {
422
+ var sb strings.Builder
423
+ for _, b := range blocks {
424
+ if b.Text != "" {
425
+ sb.WriteString(b.Text)
426
+ }
427
+ }
428
+ if sb.Len() > 0 {
429
+ return sb.String()
430
+ }
431
+ }
432
+ return string(raw)
433
+ }
@@ -0,0 +1,181 @@
1
+ package agent
2
+
3
+ // trae_exec_test.go - `exec --json` 参数构造与 JSONL 事件解析的单测。
4
+ //
5
+ // 不落子进程:buildTraeExecArgs / traeExecAcc 都是纯函数式入口,
6
+ // 这样在 Windows 上也能跑(其它引擎的假 CLI 是 sh 脚本,只在 Unix 上可执行)。
7
+
8
+ import (
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ )
14
+
15
+ func TestTraeBuildExecArgs(t *testing.T) {
16
+ cases := []struct {
17
+ name string
18
+ req Request
19
+ engine *TraeEngine
20
+ want []string
21
+ forbid []string
22
+ }{
23
+ {
24
+ name: "新会话 + 工作目录 + 模型",
25
+ req: Request{
26
+ Model: "MiniMax-M3.1",
27
+ Workspace: `D:\work`,
28
+ Messages: []Message{{Role: "user", Content: "hi"}},
29
+ },
30
+ want: []string{"exec", "--json", "--skip-git-repo-check", "-m MiniMax-M3.1", "-C D:\\work", "-y"},
31
+ },
32
+ {
33
+ name: "续接指定会话:exec resume <id>,无 -C",
34
+ req: Request{
35
+ SessionID: "th-9",
36
+ Workspace: `D:\work`,
37
+ Messages: []Message{{Role: "user", Content: "hi"}},
38
+ },
39
+ want: []string{"exec resume th-9", "--json", "-y"},
40
+ forbid: []string{"-C "},
41
+ },
42
+ {
43
+ name: "Continue:exec resume --last",
44
+ req: Request{Continue: true, Messages: []Message{{Role: "user", Content: "hi"}}},
45
+ want: []string{"exec resume --last", "--json", "-y"},
46
+ },
47
+ {
48
+ name: "工具关闭:逐个 --disallowed-tool",
49
+ req: Request{Tools: ToolsOff, Messages: []Message{{Role: "user", Content: "hi"}}},
50
+ want: []string{
51
+ "exec", "--json", "--disallowed-tool Bash", "--disallowed-tool Read",
52
+ },
53
+ forbid: []string{"-y"},
54
+ },
55
+ {
56
+ name: "引擎注入模型",
57
+ req: Request{Messages: []Message{{Role: "user", Content: "hi"}}},
58
+ engine: &TraeEngine{Model: "trae/inj"},
59
+ want: []string{"-m inj"},
60
+ },
61
+ }
62
+ for _, c := range cases {
63
+ t.Run(c.name, func(t *testing.T) {
64
+ eng := &TraeEngine{}
65
+ if c.engine != nil {
66
+ eng = c.engine
67
+ }
68
+ args := eng.buildTraeExecArgs(c.req, "PROMPT")
69
+ joined := strings.Join(args, " ")
70
+ if !strings.HasSuffix(joined, " PROMPT") {
71
+ t.Errorf("prompt 必须在末尾: %q", joined)
72
+ }
73
+ for _, w := range c.want {
74
+ if !strings.Contains(joined, w) {
75
+ t.Errorf("缺少 %q: %q", w, joined)
76
+ }
77
+ }
78
+ for _, f := range c.forbid {
79
+ if strings.Contains(joined, f) {
80
+ t.Errorf("不应出现 %q: %q", f, joined)
81
+ }
82
+ }
83
+ // 1.x / 不支持的参数一个都不能出现。
84
+ for _, gone := range []string{"--query-timeout", "--output-format", "model.name", "--resume", "-p "} {
85
+ if strings.Contains(joined, gone) {
86
+ t.Errorf("不应出现 %q: %q", gone, joined)
87
+ }
88
+ }
89
+ })
90
+ }
91
+ }
92
+
93
+ func TestTraeExecAccStream(t *testing.T) {
94
+ var events []StreamEvent
95
+ acc := &traeExecAcc{OnEvent: func(ev StreamEvent) { events = append(events, ev) }}
96
+ lines := []string{
97
+ `{"type":"thread.started","thread_id":"th-1"}`,
98
+ `{"type":"turn.started"}`,
99
+ `{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"ls"}}`,
100
+ `{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"ls","aggregated_output":"a.txt","exit_code":0}}`,
101
+ `{"type":"item.updated","item":{"id":"item_1","type":"agent_message","text":"你"}}`,
102
+ `{"type":"item.updated","item":{"id":"item_1","type":"agent_message","text":"你好"}}`,
103
+ `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"你好!"}}`,
104
+ `{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"想一下"}}`,
105
+ `{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":7,"reasoning_output_tokens":3}}`,
106
+ }
107
+ for _, ln := range lines {
108
+ if err := acc.handleLine(ln); err != nil {
109
+ t.Fatalf("handleLine(%s): %v", ln, err)
110
+ }
111
+ }
112
+ if got := acc.Text.String(); got != "你好!" {
113
+ t.Errorf("Text = %q want 你好!", got)
114
+ }
115
+ if got := acc.Thinking.String(); got != "想一下" {
116
+ t.Errorf("Thinking = %q want 想一下", got)
117
+ }
118
+ if acc.SessionID != "th-1" {
119
+ t.Errorf("SessionID = %q want th-1", acc.SessionID)
120
+ }
121
+ in, out := acc.tokenCounts()
122
+ if in != 120 || out != 10 {
123
+ t.Errorf("tokens = (%d,%d) want (120,10)", in, out)
124
+ }
125
+ if !acc.sawTurnEnd {
126
+ t.Error("sawTurnEnd 应为 true")
127
+ }
128
+ var kinds []StreamEventKind
129
+ for _, ev := range events {
130
+ kinds = append(kinds, ev.Kind)
131
+ }
132
+ want := []StreamEventKind{KindToolUse, KindToolResult, KindText, KindText, KindText, KindThinking, KindTurnEnd}
133
+ if len(kinds) != len(want) {
134
+ t.Fatalf("事件序列 = %v want %v", kinds, want)
135
+ }
136
+ for i := range want {
137
+ if kinds[i] != want[i] {
138
+ t.Errorf("事件[%d] = %v want %v(全序列 %v)", i, kinds[i], want[i], kinds)
139
+ }
140
+ }
141
+ last := events[len(events)-1]
142
+ if last.Text != "你好!" || last.SessionID != "th-1" {
143
+ t.Errorf("turn_end = %+v", last)
144
+ }
145
+ if events[0].Name != "command" || events[0].Text != "ls" || events[0].ID != "item_0" {
146
+ t.Errorf("tool_use = %+v", events[0])
147
+ }
148
+ if events[1].Text != "a.txt" || events[1].ID != "item_0" {
149
+ t.Errorf("tool_result = %+v", events[1])
150
+ }
151
+ }
152
+
153
+ func TestTraeExecAccTurnFailed(t *testing.T) {
154
+ acc := &traeExecAcc{}
155
+ err := acc.handleLine(`{"type":"turn.failed","error":{"message":"quota exceeded"}}`)
156
+ if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
157
+ t.Fatalf("turn.failed 应返回错误,实际 %v", err)
158
+ }
159
+ if acc.turnErr == nil {
160
+ t.Error("turnErr 应被记录")
161
+ }
162
+ }
163
+
164
+ func TestTraeDefaultModelFromTOML(t *testing.T) {
165
+ dir := t.TempDir()
166
+ p := filepath.Join(dir, "traecli.toml")
167
+ body := "model = \"MiniMax-M3.1-Flash\"\nmodel_provider = \"minimax\"\n"
168
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ if got := traeDefaultModelFromTOML(p); got != "MiniMax-M3.1-Flash" {
172
+ t.Errorf("got %q want MiniMax-M3.1-Flash", got)
173
+ }
174
+ // model_provider 不能被当成 model
175
+ if err := os.WriteFile(p, []byte("model_provider = \"x\"\n"), 0o644); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ if got := traeDefaultModelFromTOML(p); got != "" {
179
+ t.Errorf("model_provider 误命中: %q", got)
180
+ }
181
+ }