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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"sync"
|
|
6
|
+
"sync/atomic"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// lineWriter 收集每次写入的行,供异步断言使用(控制通道是后台 goroutine 在写)。
|
|
12
|
+
type lineWriter struct {
|
|
13
|
+
mu sync.Mutex
|
|
14
|
+
lines []string
|
|
15
|
+
ch chan string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func newLineWriter() *lineWriter {
|
|
19
|
+
return &lineWriter{ch: make(chan string, 64)}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (w *lineWriter) Write(p []byte) (int, error) {
|
|
23
|
+
for _, ln := range strings.Split(strings.TrimSpace(string(p)), "\n") {
|
|
24
|
+
if ln == "" {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
w.mu.Lock()
|
|
28
|
+
w.lines = append(w.lines, ln)
|
|
29
|
+
w.mu.Unlock()
|
|
30
|
+
w.ch <- ln
|
|
31
|
+
}
|
|
32
|
+
return len(p), nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// wait 等够 n 行(超时即失败),返回收到的行。
|
|
36
|
+
func (w *lineWriter) wait(t *testing.T, n int) []string {
|
|
37
|
+
t.Helper()
|
|
38
|
+
got := make([]string, 0, n)
|
|
39
|
+
deadline := time.After(3 * time.Second)
|
|
40
|
+
for len(got) < n {
|
|
41
|
+
select {
|
|
42
|
+
case ln := <-w.ch:
|
|
43
|
+
got = append(got, ln)
|
|
44
|
+
case <-deadline:
|
|
45
|
+
t.Fatalf("等待第 %d 行超时;已收到 %v", n, got)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return got
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// TestControlPingAndUnknownOp 未知 op 只回一条 control_error,**不中断读取** ——
|
|
52
|
+
// 一个拼错的 op 不该让整条控制通道失效。
|
|
53
|
+
func TestControlPingAndUnknownOp(t *testing.T) {
|
|
54
|
+
w := newLineWriter()
|
|
55
|
+
ctl := startControlReader(
|
|
56
|
+
strings.NewReader(`{"op":"ping"}`+"\n"+`{"op":"nope"}`+"\n"+`{"op":"ping"}`+"\n"),
|
|
57
|
+
newEventSink(w, "claude", true), controlHooks{})
|
|
58
|
+
defer ctl.Close()
|
|
59
|
+
|
|
60
|
+
lines := w.wait(t, 3)
|
|
61
|
+
if !strings.Contains(lines[0], `"type":"pong"`) {
|
|
62
|
+
t.Errorf("第 1 行应是 pong,实际 %s", lines[0])
|
|
63
|
+
}
|
|
64
|
+
if !strings.Contains(lines[1], `"type":"control_error"`) {
|
|
65
|
+
t.Errorf("未知 op 应回 control_error,实际 %s", lines[1])
|
|
66
|
+
}
|
|
67
|
+
if !strings.Contains(lines[2], `"type":"pong"`) {
|
|
68
|
+
t.Errorf("未知 op 之后应继续读命令,实际 %s", lines[2])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// TestControlBadJSON 非 JSON 行回 control_error 并继续。
|
|
73
|
+
func TestControlBadJSON(t *testing.T) {
|
|
74
|
+
w := newLineWriter()
|
|
75
|
+
ctl := startControlReader(strings.NewReader("这不是 JSON\n"+`{"op":"ping"}`+"\n"),
|
|
76
|
+
newEventSink(w, "claude", true), controlHooks{})
|
|
77
|
+
defer ctl.Close()
|
|
78
|
+
|
|
79
|
+
lines := w.wait(t, 2)
|
|
80
|
+
if !strings.Contains(lines[0], "control_error") {
|
|
81
|
+
t.Errorf("非 JSON 行应回 control_error,实际 %s", lines[0])
|
|
82
|
+
}
|
|
83
|
+
if !strings.Contains(lines[1], "pong") {
|
|
84
|
+
t.Errorf("之后应继续读,实际 %s", lines[1])
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// TestControlAnswer 常驻不可用时 answer 要回**可读的** control_error(不静默丢);
|
|
89
|
+
// 可用时回 answered 且文本原样送达。
|
|
90
|
+
func TestControlAnswer(t *testing.T) {
|
|
91
|
+
t.Run("常驻不可用", func(t *testing.T) {
|
|
92
|
+
w := newLineWriter()
|
|
93
|
+
ctl := startControlReader(strings.NewReader(`{"op":"answer","text":"选 A"}`+"\n"),
|
|
94
|
+
newEventSink(w, "claude", true), controlHooks{})
|
|
95
|
+
defer ctl.Close()
|
|
96
|
+
ln := w.wait(t, 1)[0]
|
|
97
|
+
if !strings.Contains(ln, "control_error") {
|
|
98
|
+
t.Errorf("应回 control_error,实际 %s", ln)
|
|
99
|
+
}
|
|
100
|
+
if !strings.Contains(ln, "常驻") {
|
|
101
|
+
t.Errorf("错误文案应说明走的是常驻会话,实际 %s", ln)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
t.Run("空文本", func(t *testing.T) {
|
|
106
|
+
w := newLineWriter()
|
|
107
|
+
ctl := startControlReader(strings.NewReader(`{"op":"answer"}`+"\n"),
|
|
108
|
+
newEventSink(w, "claude", true), controlHooks{answer: func(string) bool { return true }})
|
|
109
|
+
defer ctl.Close()
|
|
110
|
+
if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") {
|
|
111
|
+
t.Errorf("空 text 应回 control_error,实际 %s", ln)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
t.Run("可用", func(t *testing.T) {
|
|
116
|
+
w := newLineWriter()
|
|
117
|
+
var got atomic.Value
|
|
118
|
+
ctl := startControlReader(strings.NewReader(`{"op":"answer","text":"选 A"}`+"\n"),
|
|
119
|
+
newEventSink(w, "claude", true), controlHooks{
|
|
120
|
+
answer: func(text string) bool { got.Store(text); return true },
|
|
121
|
+
})
|
|
122
|
+
defer ctl.Close()
|
|
123
|
+
if ln := w.wait(t, 1)[0]; !strings.Contains(ln, `"type":"answered"`) {
|
|
124
|
+
t.Errorf("应回 answered,实际 %s", ln)
|
|
125
|
+
}
|
|
126
|
+
if v, _ := got.Load().(string); v != "选 A" {
|
|
127
|
+
t.Errorf("答案文本 = %q want 选 A", v)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// TestControlInterruptAndStop interrupt 不立即回应答(应答在本轮真的结束时发),
|
|
133
|
+
// stop 立即回;两个标志位都要落。
|
|
134
|
+
func TestControlInterruptAndStop(t *testing.T) {
|
|
135
|
+
w := newLineWriter()
|
|
136
|
+
var gotReason atomic.Value
|
|
137
|
+
ctl := startControlReader(
|
|
138
|
+
strings.NewReader(`{"op":"interrupt","reason":"用户点了停止"}`+"\n"+`{"op":"stop"}`+"\n"),
|
|
139
|
+
newEventSink(w, "claude", true), controlHooks{
|
|
140
|
+
interrupt: func(reason string) { gotReason.Store(reason) },
|
|
141
|
+
})
|
|
142
|
+
defer ctl.Close()
|
|
143
|
+
|
|
144
|
+
// interrupt 不发应答,所以第一条应当是 stop 的 stopped。
|
|
145
|
+
if ln := w.wait(t, 1)[0]; !strings.Contains(ln, `"type":"stopped"`) {
|
|
146
|
+
t.Errorf("第一条应答应是 stopped,实际 %s", ln)
|
|
147
|
+
}
|
|
148
|
+
if v, _ := gotReason.Load().(string); v != "用户点了停止" {
|
|
149
|
+
t.Errorf("interrupt reason = %q want 用户点了停止", v)
|
|
150
|
+
}
|
|
151
|
+
if !ctl.WasInterrupted() {
|
|
152
|
+
t.Error("WasInterrupted 应为 true")
|
|
153
|
+
}
|
|
154
|
+
if !ctl.WasStopped() {
|
|
155
|
+
t.Error("WasStopped 应为 true")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// TestControlStopCallsHook stop 要真的触发收工动作。
|
|
160
|
+
func TestControlStopCallsHook(t *testing.T) {
|
|
161
|
+
w := newLineWriter()
|
|
162
|
+
var stopped atomic.Bool
|
|
163
|
+
ctl := startControlReader(strings.NewReader(`{"op":"stop"}`+"\n"),
|
|
164
|
+
newEventSink(w, "claude", true), controlHooks{stop: func() { stopped.Store(true) }})
|
|
165
|
+
defer ctl.Close()
|
|
166
|
+
w.wait(t, 1)
|
|
167
|
+
if !stopped.Load() {
|
|
168
|
+
t.Error("stop hook 未调用")
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// TestKeepAlivePushAfterShutdown Push 与 shutdown 的竞态防线:
|
|
173
|
+
// 收工之后必须返回 false,**不能**向已关闭通道发送(那会 panic)。
|
|
174
|
+
func TestKeepAlivePushAfterShutdown(t *testing.T) {
|
|
175
|
+
ka := &keepAlive{in: make(chan string, 1), appendCh: make(chan string, 1)}
|
|
176
|
+
|
|
177
|
+
if !ka.Push("hello") {
|
|
178
|
+
t.Fatal("收工前 Push 应成功")
|
|
179
|
+
}
|
|
180
|
+
if got := <-ka.in; got != "hello" {
|
|
181
|
+
t.Errorf("Push 内容 = %q want hello", got)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ka.shutdown()
|
|
185
|
+
if ka.Push("again") {
|
|
186
|
+
t.Error("收工后 Push 必须返回 false")
|
|
187
|
+
}
|
|
188
|
+
ka.shutdown() // 幂等:重复收工不该 panic
|
|
189
|
+
if ka.Push("") {
|
|
190
|
+
t.Error("空文本不该被接受")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// engines_test.go - `--engines` 的「一键安装 / 升级命令」(install 字段)与版本号(version)装配测试。
|
|
4
|
+
//
|
|
5
|
+
// 需求(2026-09-21):「引擎列表返回不可用的引擎时包括一键安装引擎的命令 这样检测到
|
|
6
|
+
// 无法安装的可以直接安装」;(2026-09-23)「引擎检测除了 a2a 的 其他也要支持有升级」。
|
|
7
|
+
// 约定(**当前契约**):
|
|
8
|
+
//
|
|
9
|
+
// 有官方安装方式的引擎 → **一律给 install**(不管 ok 是 true 还是 false):
|
|
10
|
+
// ok:false → 调用方当「安装」用;ok:true → 当「升级」用(重跑一遍 = 装最新版)
|
|
11
|
+
// 没有可执行安装路径的引擎(codebuddy 是 GUI 应用、arkclaw / 具名 A2A agent 是远端网关)
|
|
12
|
+
// → 不给该字段,原因在同行的 note 里(不编一条跑不通的命令)
|
|
13
|
+
// version → 可用引擎尽力而为地给(`<bin> --version` 的第一个 semver);`--no-models`
|
|
14
|
+
// 跳过探测 → 一个都不给(本文件据此断言「该档零探测」)
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ 注册假引擎的名字必须避开「别的测试行为上依赖」的名字:
|
|
17
|
+
// `agent.Lookup` 取注册表里**首个**同名匹配,而 Go 按文件名字母序跑测试
|
|
18
|
+
// (engines_test.go 在 keepalive_test.go / permission_test.go 之前)。两个坑:
|
|
19
|
+
// - name:"codebuddy" 会挡住 keepalive_test.go 需要的**流式** codebuddy 假引擎
|
|
20
|
+
// → TestKeepAliveIsDefaultForStreaming / TestKeepAliveAppendEndToEnd 稳定超时;
|
|
21
|
+
// - name:"dsh" 会被 attach_test.go(先跑)注册的可用假引擎挡住 → ok 报 true。
|
|
22
|
+
// 故这里只用 openclaw(install 用例)、codebuddy-ai(无 install 用例)与 llm(可用 + 有命令用例):
|
|
23
|
+
// 整包测试里没有别的文件在它们之前注册过这三个名字。
|
|
24
|
+
//
|
|
25
|
+
// 注:codebuddy / arkclaw 确实没有一键安装命令,但那个断言放在
|
|
26
|
+
// internal/agent/engine_base_test.go 的 TestInstallCommandOf 里做(纯函数、零副作用),
|
|
27
|
+
// 不必在这个会改全局注册表的测试里再注册一次它们的同名假引擎。
|
|
28
|
+
|
|
29
|
+
import (
|
|
30
|
+
"context"
|
|
31
|
+
"encoding/json"
|
|
32
|
+
"errors"
|
|
33
|
+
"strings"
|
|
34
|
+
"testing"
|
|
35
|
+
|
|
36
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// unavailableEngine 探测失败的假引擎(ok:false 路径)。
|
|
40
|
+
type unavailableEngine struct{ name string }
|
|
41
|
+
|
|
42
|
+
func (u *unavailableEngine) Name() string { return u.name }
|
|
43
|
+
|
|
44
|
+
func (u *unavailableEngine) Detect() (bool, string) {
|
|
45
|
+
return false, u.name + " CLI not found (install it or set the BIN env)"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (u *unavailableEngine) Complete(context.Context, agent.Request) (agent.Response, error) {
|
|
49
|
+
return agent.Response{}, errors.New("unavailable")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestEnginesInstallCommandByAvailability(t *testing.T) {
|
|
53
|
+
// 四个假引擎:
|
|
54
|
+
// 不可用 + 有一键安装命令(openclaw)
|
|
55
|
+
// 不可用 + 也有(codebuddy-ai:2026-09-28 起是**独立安装**的 CodeBuddy Code CLI,
|
|
56
|
+
// 不再是桌面 GUI 应用)
|
|
57
|
+
// 不可用 + 没有(codebuddy-gateway:它要的是「把网关跑起来」而不是安装)
|
|
58
|
+
// 可用 + 有一键安装命令(llm,用来钉「可用时也给 = 当升级用」)
|
|
59
|
+
registerFake(&unavailableEngine{name: "openclaw"})
|
|
60
|
+
registerFake(&unavailableEngine{name: "codebuddy-ai"})
|
|
61
|
+
registerFake(&unavailableEngine{name: "codebuddy-gateway"})
|
|
62
|
+
registerFake(&stringEngine{name: "llm", text: "x"})
|
|
63
|
+
|
|
64
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
65
|
+
if err != nil {
|
|
66
|
+
t.Fatalf("--engines: %v", err)
|
|
67
|
+
}
|
|
68
|
+
var rows []struct {
|
|
69
|
+
Engine string `json:"engine"`
|
|
70
|
+
OK bool `json:"ok"`
|
|
71
|
+
Note string `json:"note"`
|
|
72
|
+
Install string `json:"install"`
|
|
73
|
+
}
|
|
74
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
75
|
+
t.Fatalf("--engines 输出不是 JSON 数组: %v\n%s", err, stdout)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1) 不可用 + 有安装命令:install 非空、note 也保留(人读原因)。
|
|
79
|
+
const wantInstall = "npm install -g openclaw@latest"
|
|
80
|
+
foundWithInstall := false
|
|
81
|
+
for _, r := range rows {
|
|
82
|
+
if r.Engine != "openclaw" {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if r.OK {
|
|
86
|
+
t.Fatal("openclaw 假引擎应报 ok:false")
|
|
87
|
+
}
|
|
88
|
+
foundWithInstall = true
|
|
89
|
+
if r.Install != wantInstall {
|
|
90
|
+
t.Errorf("openclaw 的 install = %q want %q", r.Install, wantInstall)
|
|
91
|
+
}
|
|
92
|
+
if r.Note == "" {
|
|
93
|
+
t.Error("openclaw 不可用却没给 note")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if !foundWithInstall {
|
|
97
|
+
t.Fatal("--engines 缺 openclaw 行")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2) 不可用但没有可执行安装命令:install 必须为空(原因在 note)。
|
|
101
|
+
foundGateway := false
|
|
102
|
+
for _, r := range rows {
|
|
103
|
+
if r.Engine != "codebuddy-gateway" {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if r.OK {
|
|
107
|
+
t.Fatal("codebuddy-gateway 假引擎应报 ok:false")
|
|
108
|
+
}
|
|
109
|
+
foundGateway = true
|
|
110
|
+
if r.Install != "" {
|
|
111
|
+
t.Errorf("codebuddy-gateway(要的是「把网关跑起来」,不是安装)不该给 install,got %q", r.Install)
|
|
112
|
+
}
|
|
113
|
+
if r.Note == "" {
|
|
114
|
+
t.Error("codebuddy-gateway 不可用却没给 note(怎么才能用要靠它说明)")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if !foundGateway {
|
|
118
|
+
t.Fatal("--engines 缺 codebuddy-gateway 行")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2b) codebuddy-ai 改成独立安装的 CLI 之后,要给一键安装命令
|
|
122
|
+
// (2026-09-28:不再走桌面 App 内置 CLI,见 engine_base.go 的 codebuddyBase 注释)。
|
|
123
|
+
foundAI := false
|
|
124
|
+
for _, r := range rows {
|
|
125
|
+
if r.Engine != "codebuddy-ai" {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
foundAI = true
|
|
129
|
+
if want := agent.InstallCommandOf("codebuddy-ai"); r.Install != want {
|
|
130
|
+
t.Errorf("codebuddy-ai 的 install = %q want %q", r.Install, want)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if !foundAI {
|
|
134
|
+
t.Fatal("--engines 缺 codebuddy-ai 行")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 3) **可用**引擎只要该引擎有官方安装方式,照样给 install(2026-09-23 新契约:
|
|
138
|
+
// 同一条命令对已装好的引擎重跑一遍 = 升级到最新版,调用方据此把按钮写成「升级」)。
|
|
139
|
+
foundOKWithInstall := false
|
|
140
|
+
for _, r := range rows {
|
|
141
|
+
if r.Engine != "llm" || !r.OK {
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
foundOKWithInstall = true
|
|
145
|
+
if r.Install != agent.InstallCommandOf("llm") {
|
|
146
|
+
t.Errorf("llm(可用)的 install = %q want %q", r.Install, agent.InstallCommandOf("llm"))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if !foundOKWithInstall {
|
|
150
|
+
t.Fatal("--engines 缺可用的 llm 行(本用例要钉「可用时也给 install」)")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4) 全局不变式:install 非空 ⟺ 该引擎确实有官方安装命令(与 ok 无关)。
|
|
154
|
+
for _, r := range rows {
|
|
155
|
+
want := agent.InstallCommandOf(r.Engine)
|
|
156
|
+
if want == "" && r.Install != "" {
|
|
157
|
+
t.Errorf("%s 没有官方安装方式却带了 install = %q", r.Engine, r.Install)
|
|
158
|
+
}
|
|
159
|
+
if want != "" && r.Install != want {
|
|
160
|
+
t.Errorf("%s 的 install = %q want %q(不管 ok 是 %v)", r.Engine, r.Install, want, r.OK)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// `--no-models` 档跳过版本探测:一个 version 都不给(该档的卖点就是「不启动任何 CLI」)。
|
|
166
|
+
func TestEnginesVersionSkippedWithNoModels(t *testing.T) {
|
|
167
|
+
registerFake(&stringEngine{name: "fake-eng-ver", text: "x"})
|
|
168
|
+
|
|
169
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
170
|
+
if err != nil {
|
|
171
|
+
t.Fatalf("--engines: %v", err)
|
|
172
|
+
}
|
|
173
|
+
var rows []map[string]any
|
|
174
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
175
|
+
t.Fatalf("解析失败: %v\n%s", err, stdout)
|
|
176
|
+
}
|
|
177
|
+
for _, r := range rows {
|
|
178
|
+
if _, ok := r["version"]; ok {
|
|
179
|
+
t.Errorf("--no-models 不该给 version(零探测): %v", r)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// install 字段的 omitempty:没有官方安装方式的引擎,JSON 里**不应出现**该 key(不是空串)。
|
|
185
|
+
func TestEnginesInstallKeyOmittedWhenNoCommand(t *testing.T) {
|
|
186
|
+
registerFake(&stringEngine{name: "install-fake-ok-2", text: "x"})
|
|
187
|
+
|
|
188
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
189
|
+
if err != nil {
|
|
190
|
+
t.Fatalf("--engines: %v", err)
|
|
191
|
+
}
|
|
192
|
+
var rows []map[string]any
|
|
193
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
194
|
+
t.Fatalf("解析失败: %v\n%s", err, stdout)
|
|
195
|
+
}
|
|
196
|
+
for _, r := range rows {
|
|
197
|
+
if r["engine"] == "install-fake-ok-2" {
|
|
198
|
+
if _, ok := r["install"]; ok {
|
|
199
|
+
t.Errorf("没有官方安装方式的引擎不该有 install key: %v", r)
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
t.Fatal("--engines 输出里没找到 install-fake-ok-2")
|
|
205
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"sync"
|
|
8
|
+
|
|
9
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
/* 事件契约 v1(客户端消费形态):给流式事件加版本与游标。
|
|
13
|
+
*
|
|
14
|
+
* 为什么需要(2026-09-27):`--stream` 的 NDJSON 事件流此前没有版本号、也没有
|
|
15
|
+
* 序号,客户端(magic-client 桌面 / 移动插件)因此回答不了两个必须回答的问题:
|
|
16
|
+
* 1) 这份输出我能不能解析?—— 字段改名时老客户端会静默解析失败,界面上表现为空白;
|
|
17
|
+
* 2) 我收到的是否连续、有没有漏行?—— 断线重连要按游标续读。
|
|
18
|
+
* 于是 `--events` 在**不改老形状**的前提下补三样:
|
|
19
|
+
* v 契约版本,与 `--contract` 的 contractVersion 同一个数
|
|
20
|
+
* seq 单次进程内单调递增的行号(从 1 开始)
|
|
21
|
+
* session_id 一旦拿到就每行都带(老形状只在 turn_end / result 带)
|
|
22
|
+
* 沿用老形状已有的约定:**每轮一定以 result 或 error 收尾**。
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ `--events` 与 `--stream` 的输出**形状不同**,别混用:
|
|
25
|
+
* 老消费者继续用 `--stream`(逐字节不变);新客户端用 `--stream --events`。
|
|
26
|
+
* 两条路径共用 streamEventPayload(),靠测试钉住「字段集只差 v / seq」。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// eventSink 事件出口。enabled=false 时输出与历史形状逐字节一致。
|
|
30
|
+
type eventSink struct {
|
|
31
|
+
w io.Writer
|
|
32
|
+
engine string
|
|
33
|
+
enabled bool
|
|
34
|
+
|
|
35
|
+
mu sync.Mutex
|
|
36
|
+
seq int
|
|
37
|
+
session string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func newEventSink(w io.Writer, engine string, enabled bool) *eventSink {
|
|
41
|
+
return &eventSink{w: w, engine: engine, enabled: enabled}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Enabled 是否处于 `--events` 形态。控制通道的应答只在它下面输出(老形状不认新事件类型)。
|
|
45
|
+
func (s *eventSink) Enabled() bool { return s.enabled }
|
|
46
|
+
|
|
47
|
+
// setSession 记住会话 id(拿到之后后续每行都带)。
|
|
48
|
+
func (s *eventSink) setSession(id string) {
|
|
49
|
+
if id == "" {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
s.mu.Lock()
|
|
53
|
+
s.session = id
|
|
54
|
+
s.mu.Unlock()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// nextSeq 取下一个行号(调用方不要再持锁调它)。
|
|
58
|
+
func (s *eventSink) nextSeq() int {
|
|
59
|
+
s.mu.Lock()
|
|
60
|
+
defer s.mu.Unlock()
|
|
61
|
+
s.seq++
|
|
62
|
+
return s.seq
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// sessionID 当前已知的会话 id。
|
|
66
|
+
func (s *eventSink) sessionID() string {
|
|
67
|
+
s.mu.Lock()
|
|
68
|
+
defer s.mu.Unlock()
|
|
69
|
+
return s.session
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (s *eventSink) writeLine(b []byte) error {
|
|
73
|
+
s.mu.Lock()
|
|
74
|
+
defer s.mu.Unlock()
|
|
75
|
+
_, err := fmt.Fprintln(s.w, string(b))
|
|
76
|
+
return err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// event 输出一行事件。
|
|
80
|
+
//
|
|
81
|
+
// 老形态(未开 --events):payload 原样一行,与历史输出逐字节一致。
|
|
82
|
+
// 新形态:payload 的字段集不变,外层补 v / seq(以及已知的 session_id)。
|
|
83
|
+
// 只在新形态下走一次「marshal → 加字段 → marshal」的往返 —— 它是客户端路径,
|
|
84
|
+
// 每行多一次解析换「两个形状永不漂」是划算的。
|
|
85
|
+
func (s *eventSink) event(payload any) error {
|
|
86
|
+
b, err := json.Marshal(payload)
|
|
87
|
+
if err != nil {
|
|
88
|
+
return err
|
|
89
|
+
}
|
|
90
|
+
if !s.enabled {
|
|
91
|
+
return s.writeLine(b)
|
|
92
|
+
}
|
|
93
|
+
var m map[string]any
|
|
94
|
+
if err := json.Unmarshal(b, &m); err != nil {
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
return s.eventAttrs(m)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// eventAttrs 输出一行带 envelope 的新形态事件(attrs 至少要有 type)。
|
|
101
|
+
func (s *eventSink) eventAttrs(attrs map[string]any) error {
|
|
102
|
+
if !s.enabled {
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
seq := s.nextSeq()
|
|
106
|
+
env := make(map[string]any, len(attrs)+3)
|
|
107
|
+
for k, v := range attrs {
|
|
108
|
+
env[k] = v
|
|
109
|
+
}
|
|
110
|
+
env["v"] = agent.ContractVersion
|
|
111
|
+
env["seq"] = seq
|
|
112
|
+
if sess := s.sessionID(); sess != "" {
|
|
113
|
+
if _, ok := env["session_id"]; !ok {
|
|
114
|
+
env["session_id"] = sess
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
b, err := json.Marshal(env)
|
|
118
|
+
if err != nil {
|
|
119
|
+
return err
|
|
120
|
+
}
|
|
121
|
+
return s.writeLine(b)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// stream 输出一条引擎流式事件。
|
|
125
|
+
//
|
|
126
|
+
// 未开 --events 时直接走老写入函数(逐字节不变);开了才补 v / seq。
|
|
127
|
+
// 两条路径共用 streamEventPayload(),字段集因此不可能漂。
|
|
128
|
+
func (s *eventSink) stream(ev agent.StreamEvent) error {
|
|
129
|
+
s.setSession(ev.SessionID)
|
|
130
|
+
if !s.enabled {
|
|
131
|
+
return writeStreamEventJSON(s.w, ev)
|
|
132
|
+
}
|
|
133
|
+
return s.event(streamEventPayload(ev))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ready 握手行:新形态的第一行,告诉客户端「谁在跑、契约版本是多少」。
|
|
137
|
+
// 只在 `--events` 下输出 —— 老消费者不认识它。
|
|
138
|
+
func (s *eventSink) ready(model string) error {
|
|
139
|
+
if !s.enabled {
|
|
140
|
+
return nil
|
|
141
|
+
}
|
|
142
|
+
attrs := map[string]any{"type": "ready", "engine": s.engine}
|
|
143
|
+
if model != "" {
|
|
144
|
+
attrs["model"] = model
|
|
145
|
+
}
|
|
146
|
+
return s.eventAttrs(attrs)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// notice 输出一条 core 自己产生的事件(不是引擎增量):
|
|
150
|
+
// pong / interrupted / stopped / answered / answer_queued / control_error。
|
|
151
|
+
// 只在 `--events` 下输出,老形状不注入新类型。
|
|
152
|
+
func (s *eventSink) notice(kind string, attrs map[string]any) error {
|
|
153
|
+
if !s.enabled {
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
if attrs == nil {
|
|
157
|
+
attrs = map[string]any{}
|
|
158
|
+
}
|
|
159
|
+
attrs["type"] = kind
|
|
160
|
+
return s.eventAttrs(attrs)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// streamEventPayload 流式事件的 wire 形状 —— `--stream` 与 `--stream --events` 共用。
|
|
164
|
+
// 两边的字段集必须只差 v / seq,test 里有专门的防漂断言。
|
|
165
|
+
func streamEventPayload(ev agent.StreamEvent) any {
|
|
166
|
+
return struct {
|
|
167
|
+
Type string `json:"type"`
|
|
168
|
+
Text string `json:"text"`
|
|
169
|
+
Name string `json:"name,omitempty"`
|
|
170
|
+
ID string `json:"id,omitempty"`
|
|
171
|
+
ToolKind string `json:"tool_kind,omitempty"`
|
|
172
|
+
SessionID string `json:"session_id,omitempty"`
|
|
173
|
+
Ask *agent.AskRequest `json:"ask,omitempty"`
|
|
174
|
+
}{
|
|
175
|
+
Type: string(ev.Kind),
|
|
176
|
+
Text: ev.Text,
|
|
177
|
+
Name: ev.Name,
|
|
178
|
+
ID: ev.ID,
|
|
179
|
+
ToolKind: toolKindOfEvent(ev),
|
|
180
|
+
SessionID: ev.SessionID,
|
|
181
|
+
Ask: ev.Ask,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* toolKindOfEvent 事件携带的工具子形态(2026-09-29 新增,见 agent/toolkind.go)。
|
|
186
|
+
*
|
|
187
|
+
* 只有**工具调用**事件带它:渲染层据此选卡(命令 / 文件变更 / 网页检索 / MCP /
|
|
188
|
+
* 子代理 / 提问 / 通用),不再靠工具名猜 —— 一处判定、两个界面共用。
|
|
189
|
+
*
|
|
190
|
+
* ⚠️ 工具**结果**事件不给:它的 Name 字段放的是「关联的 tool_use.id」(见 StreamEvent
|
|
191
|
+
* 的注释),拿它去判形态只会把 id 当成工具名。结果的形态由它配上的那张卡承担。
|
|
192
|
+
* ⚠️ 非工具事件一律空串 → `omitempty` 把它从 wire 上省掉,老消费者的字段集不受影响。 */
|
|
193
|
+
func toolKindOfEvent(ev agent.StreamEvent) string {
|
|
194
|
+
if ev.Kind != agent.KindToolUse {
|
|
195
|
+
return ""
|
|
196
|
+
}
|
|
197
|
+
return agent.ToolKindFor(ev.Name, ev.Text)
|
|
198
|
+
}
|