magic-agent 2.4.3 → 2.6.3
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 +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -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 +40 -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 +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -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 +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -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 +69 -10
- package/cmd/magic-client/main.go +210 -32
- 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_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- 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/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- 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/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- 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 +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- 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 +168 -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/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- 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/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -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/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- 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/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- 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/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -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/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -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/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- 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 +6 -3
- 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/npm/install.js +11 -0
- package/package.json +14 -3
|
@@ -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
|
+
}
|