magic-agent 2.4.3 → 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.
- package/CLIENT-CONTRACT.md +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- 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
|
+
}
|