magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,673 @@
1
+ package agent
2
+
3
+ // codebuddy_gateway_test.go - codebuddy-gateway(CodeBuddy Code HTTP 网关,webhook + SSE)
4
+ // 引擎单元测试。
5
+ //
6
+ // 覆盖:注册表与接口契约、webhook 投递请求形状(路径 / 认证头 / 安全头 / body 字段)、
7
+ // SSE 逐帧解析(accepted / streaming / completed / error)、增量去重、终帧权威正文、
8
+ // JSONSchema 后处理、会话锚点续接、附件映射、非 SSE 回退、投递失败、能力表接线、
9
+ // 配置文件读取。
10
+ //
11
+ // 全部走 httptest,不触网。
12
+
13
+ import (
14
+ "context"
15
+ "encoding/json"
16
+ "fmt"
17
+ "io"
18
+ "net/http"
19
+ "net/http/httptest"
20
+ "os"
21
+ "path/filepath"
22
+ "strings"
23
+ "testing"
24
+ "time"
25
+
26
+ "github.com/darren/magic-agent/internal/config"
27
+ )
28
+
29
+ // cbGatewayRec 记录假网关收到的一次请求。
30
+ type cbGatewayRec struct {
31
+ Method string
32
+ Path string
33
+ Header http.Header
34
+ Body string
35
+ }
36
+
37
+ // cbGatewayTestServer 起一个假网关:POST 投递按 runID 回 202,GET 流按帧脚本回 SSE。
38
+ //
39
+ // frames 里每个元素是一行 data 的载荷(不含 "data: " 前缀)。
40
+ func cbGatewayTestServer(t *testing.T, runID string, frames []string) (*httptest.Server, *[]cbGatewayRec) {
41
+ t.Helper()
42
+ recs := &[]cbGatewayRec{}
43
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44
+ raw, _ := io.ReadAll(r.Body)
45
+ *recs = append(*recs, cbGatewayRec{Method: r.Method, Path: r.URL.Path, Header: r.Header.Clone(), Body: string(raw)})
46
+
47
+ switch {
48
+ case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/webhooks/"):
49
+ w.Header().Set("content-type", "application/json;charset=UTF-8")
50
+ w.WriteHeader(http.StatusAccepted)
51
+ fmt.Fprintf(w, `{"data":{"runId":%q,"status":"accepted"}}`, runID)
52
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/runs/"+runID+"/stream":
53
+ w.Header().Set("content-type", "text/event-stream")
54
+ w.WriteHeader(http.StatusOK)
55
+ fl, _ := w.(http.Flusher)
56
+ for _, f := range frames {
57
+ fmt.Fprintf(w, "data: %s\n\n", f)
58
+ if fl != nil {
59
+ fl.Flush()
60
+ }
61
+ }
62
+ default:
63
+ http.NotFound(w, r)
64
+ }
65
+ }))
66
+ t.Cleanup(srv.Close)
67
+ return srv, recs
68
+ }
69
+
70
+ // cbGatewayFrameJSON 组一帧出站消息 JSON。
71
+ func cbGatewayFrameJSON(status, extra string) string {
72
+ base := fmt.Sprintf(`{"version":"1.0","replyTo":"msg","status":%q`, status)
73
+ if extra != "" {
74
+ base += "," + extra
75
+ }
76
+ return base + "}"
77
+ }
78
+
79
+ // cbGatewayServerEngine 返回一个指向假网关的引擎(全部字段显式给,不读用户配置文件)。
80
+ func cbGatewayServerEngine(srv *httptest.Server) *CodeBuddyGatewayEngine {
81
+ return &CodeBuddyGatewayEngine{
82
+ URL: srv.URL,
83
+ Password: "test-token",
84
+ Platform: "generic",
85
+ Sender: "tester",
86
+ // ConfigPath 指向一个不存在的文件:保证不会读到开发机上的真实配置。
87
+ ConfigPath: filepath.Join(os.TempDir(), "magic-agent-cbgw-absent.json"),
88
+ }
89
+ }
90
+
91
+ // ---------- 注册表与接口契约 ----------
92
+
93
+ func TestCodeBuddyGatewayLookup(t *testing.T) {
94
+ e := Lookup("codebuddy-gateway")
95
+ if e == nil {
96
+ t.Fatal(`Lookup("codebuddy-gateway") = nil`)
97
+ }
98
+ if e.Name() != "codebuddy-gateway" {
99
+ t.Errorf("Name() = %q want codebuddy-gateway", e.Name())
100
+ }
101
+ if Lookup("CodeBuddy-Gateway") == nil {
102
+ t.Error("Lookup 应大小写不敏感")
103
+ }
104
+ // 与 codebuddy / codebuddy-ai 是两个引擎,不能互相遮住。
105
+ if Lookup("codebuddy").Name() != "codebuddy" {
106
+ t.Error(`Lookup("codebuddy") 被 codebuddy-gateway 遮住了`)
107
+ }
108
+ }
109
+
110
+ func TestCodeBuddyGatewayImplementsInterfaces(t *testing.T) {
111
+ var e Engine = &CodeBuddyGatewayEngine{}
112
+ if e.Name() != cbGatewayEngineName {
113
+ t.Errorf("Name() = %q", e.Name())
114
+ }
115
+ if AsStreamer(e) == nil {
116
+ t.Error("codebuddy-gateway 应实现 Streamer")
117
+ }
118
+ if !SupportsStream(e) {
119
+ t.Error("SupportsStream(codebuddy-gateway) 应为 true")
120
+ }
121
+ if e.(CapabilityFamily).CapabilityFamily() != "codebuddy-gateway" {
122
+ t.Errorf("CapabilityFamily() = %q", e.(CapabilityFamily).CapabilityFamily())
123
+ }
124
+ }
125
+
126
+ // ---------- 非流式(Complete):投递 + 收流 ----------
127
+
128
+ func TestCodeBuddyGatewayComplete(t *testing.T) {
129
+ frames := []string{
130
+ cbGatewayFrameJSON("accepted", ""),
131
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":"你好"}`),
132
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":",世界"}`),
133
+ cbGatewayFrameJSON("completed", `"content":{"text":"你好,世界","markdown":"你好,世界"},"agent":{"sessionId":"sess-1"}`),
134
+ }
135
+ srv, recs := cbGatewayTestServer(t, "run-abc", frames)
136
+ e := cbGatewayServerEngine(srv)
137
+
138
+ res, err := e.Complete(context.Background(), Request{
139
+ Messages: []Message{{Role: "user", Content: "打个招呼"}},
140
+ })
141
+ if err != nil {
142
+ t.Fatalf("Complete 失败: %v", err)
143
+ }
144
+ if res.Text != "你好,世界" {
145
+ t.Errorf("Text = %q want 你好,世界", res.Text)
146
+ }
147
+ if res.Engine != "codebuddy-gateway" {
148
+ t.Errorf("Engine = %q", res.Engine)
149
+ }
150
+ // ⚠️ SessionID 必须是**会话锚点**(conversation id),不是终帧里的 agent.sessionId ——
151
+ // 后者是网关内部 UUID,拿它当 --session 会静默新开会话(见 result 的注释)。
152
+ if res.SessionID == "sess-1" {
153
+ t.Error("SessionID 取了终帧的 agent.sessionId(内部 UUID),应为会话锚点")
154
+ }
155
+ if res.SessionID == "" || !strings.HasPrefix(res.SessionID, "magic-agent-") {
156
+ t.Errorf("SessionID = %q want 自动生成的会话锚点", res.SessionID)
157
+ }
158
+ if res.Attempts != 1 {
159
+ t.Errorf("Attempts = %d want 1", res.Attempts)
160
+ }
161
+
162
+ if len(*recs) != 2 {
163
+ t.Fatalf("假网关收到 %d 次请求 want 2(投递 + 收流)", len(*recs))
164
+ }
165
+ post, get := (*recs)[0], (*recs)[1]
166
+
167
+ // 投递:路径 / 方法 / 两个头 / body 形状。
168
+ if post.Method != http.MethodPost {
169
+ t.Errorf("投递方法 = %s", post.Method)
170
+ }
171
+ if post.Path != "/api/v1/webhooks/generic" {
172
+ t.Errorf("投递路径 = %s want /api/v1/webhooks/generic", post.Path)
173
+ }
174
+ if got := post.Header.Get("authorization"); got != "Bearer test-token" {
175
+ t.Errorf("authorization = %q want Bearer test-token", got)
176
+ }
177
+ if got := post.Header.Get(cbGatewaySecurityHeader); got != "1" {
178
+ t.Errorf("%s = %q want 1(网关请求校验中间件要求)", cbGatewaySecurityHeader, got)
179
+ }
180
+ var inbound cbGatewayInbound
181
+ if err := json.Unmarshal([]byte(post.Body), &inbound); err != nil {
182
+ t.Fatalf("投递 body 不是合法 JSON: %v", err)
183
+ }
184
+ if inbound.Type != "message" || inbound.Version != "1.0" {
185
+ t.Errorf("version/type = %q/%q want 1.0/message", inbound.Version, inbound.Type)
186
+ }
187
+ if inbound.Source.Platform != "generic" || inbound.Source.Sender.ID != "tester" {
188
+ t.Errorf("source = %+v", inbound.Source)
189
+ }
190
+ if inbound.Source.Conversation.ID == "" {
191
+ t.Error("conversation.id 不能为空(它是网关的会话锚点)")
192
+ }
193
+ if !strings.Contains(inbound.Payload.Text, "打个招呼") {
194
+ t.Errorf("payload.text = %q,应含展平后的用户消息", inbound.Payload.Text)
195
+ }
196
+
197
+ // 收流:路径 + accept。
198
+ if get.Path != "/api/v1/runs/run-abc/stream" {
199
+ t.Errorf("收流路径 = %s", get.Path)
200
+ }
201
+ if got := get.Header.Get("accept"); got != "text/event-stream" {
202
+ t.Errorf("accept = %q want text/event-stream", got)
203
+ }
204
+ }
205
+
206
+ func TestCodeBuddyGatewayStreamEmitsIncrements(t *testing.T) {
207
+ frames := []string{
208
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":"第一"}`),
209
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":"第二"}`),
210
+ cbGatewayFrameJSON("completed", `"content":{"markdown":"第一第二"}`),
211
+ }
212
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
213
+ e := cbGatewayServerEngine(srv)
214
+
215
+ var got []string
216
+ res, err := e.Stream(context.Background(), Request{
217
+ Messages: []Message{{Role: "user", Content: "x"}},
218
+ }, func(ev StreamEvent) {
219
+ if ev.Kind == KindText {
220
+ got = append(got, ev.Text)
221
+ }
222
+ })
223
+ if err != nil {
224
+ t.Fatalf("Stream 失败: %v", err)
225
+ }
226
+ // 增量必须是**逐块**(不是整段一次性),终帧只补差量(此处无差量)。
227
+ want := []string{"第一", "第二"}
228
+ if strings.Join(got, "|") != strings.Join(want, "|") {
229
+ t.Errorf("增量 = %v want %v", got, want)
230
+ }
231
+ if res.Text != "第一第二" {
232
+ t.Errorf("Text = %q", res.Text)
233
+ }
234
+ }
235
+
236
+ // 终帧正文比已发增量长时,只补差量(不重复发整段)。
237
+ func TestCodeBuddyGatewayCompletedEmitsRemainderOnly(t *testing.T) {
238
+ frames := []string{
239
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":"你好"}`),
240
+ cbGatewayFrameJSON("completed", `"content":{"markdown":"你好,世界"}`),
241
+ }
242
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
243
+ e := cbGatewayServerEngine(srv)
244
+
245
+ var got []string
246
+ if _, err := e.Stream(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}},
247
+ func(ev StreamEvent) { got = append(got, ev.Text) }); err != nil {
248
+ t.Fatalf("Stream 失败: %v", err)
249
+ }
250
+ if len(got) != 2 || got[0] != "你好" || got[1] != ",世界" {
251
+ t.Errorf("增量 = %v want [你好 ,世界]", got)
252
+ }
253
+ }
254
+
255
+ func TestCodeBuddyGatewayErrorFrame(t *testing.T) {
256
+ frames := []string{
257
+ cbGatewayFrameJSON("streaming", `"content":{"chunk":"半句"}`),
258
+ cbGatewayFrameJSON("error", `"error":{"code":"EXECUTION_ERROR","message":"模型调用失败"}`),
259
+ }
260
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
261
+ e := cbGatewayServerEngine(srv)
262
+
263
+ _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}})
264
+ if err == nil {
265
+ t.Fatal("error 帧应报错")
266
+ }
267
+ if !strings.Contains(err.Error(), "EXECUTION_ERROR") || !strings.Contains(err.Error(), "模型调用失败") {
268
+ t.Errorf("错误信息 = %q,应带上 code 与 message", err.Error())
269
+ }
270
+ }
271
+
272
+ func TestCodeBuddyGatewayEmptyBody(t *testing.T) {
273
+ frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":" "}`)}
274
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
275
+ e := cbGatewayServerEngine(srv)
276
+
277
+ _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}})
278
+ if err == nil || !strings.Contains(err.Error(), "空正文") {
279
+ t.Errorf("err = %v want 空正文", err)
280
+ }
281
+ }
282
+
283
+ // 流结束但没见到终帧 → 如实报错,不把半句当结果。
284
+ func TestCodeBuddyGatewayMissingTerminalFrame(t *testing.T) {
285
+ frames := []string{cbGatewayFrameJSON("streaming", `"content":{"chunk":"半句"}`)}
286
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
287
+ e := cbGatewayServerEngine(srv)
288
+
289
+ _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}})
290
+ if err == nil || !strings.Contains(err.Error(), "没有终帧") {
291
+ t.Errorf("err = %v want 没有终帧", err)
292
+ }
293
+ }
294
+
295
+ // 网关没按 SSE 回(老版本 / 路由被改)→ 如实报错,不假装有增量。
296
+ func TestCodeBuddyGatewayNonSSEFallback(t *testing.T) {
297
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298
+ if r.Method == http.MethodPost {
299
+ w.WriteHeader(http.StatusAccepted)
300
+ fmt.Fprint(w, `{"data":{"runId":"run-1","status":"accepted"}}`)
301
+ return
302
+ }
303
+ w.Header().Set("content-type", "application/json")
304
+ w.WriteHeader(http.StatusOK)
305
+ fmt.Fprint(w, `{"data":{"runId":"run-1","active":false}}`)
306
+ }))
307
+ t.Cleanup(srv.Close)
308
+ e := cbGatewayServerEngine(srv)
309
+
310
+ _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}})
311
+ if err == nil || !strings.Contains(err.Error(), "未按 SSE 回") {
312
+ t.Errorf("err = %v want 未按 SSE 回", err)
313
+ }
314
+ }
315
+
316
+ // 投递被拒(401 纯文本)→ 报错里带上状态码与正文,不能只说 "invalid character"。
317
+ func TestCodeBuddyGatewayDeliverRejected(t *testing.T) {
318
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
319
+ w.Header().Set("content-type", "text/plain")
320
+ w.WriteHeader(http.StatusUnauthorized)
321
+ fmt.Fprint(w, "Authentication required")
322
+ }))
323
+ t.Cleanup(srv.Close)
324
+ e := cbGatewayServerEngine(srv)
325
+
326
+ _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}})
327
+ if err == nil {
328
+ t.Fatal("401 应报错")
329
+ }
330
+ if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "Authentication required") {
331
+ t.Errorf("err = %q,应带状态码与正文", err.Error())
332
+ }
333
+ }
334
+
335
+ // ---------- 会话续接 / 附件 / 后处理 ----------
336
+
337
+ func TestCodeBuddyGatewaySessionContinuation(t *testing.T) {
338
+ frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":"ok"}`)}
339
+ srv, recs := cbGatewayTestServer(t, "run-1", frames)
340
+ e := cbGatewayServerEngine(srv)
341
+
342
+ res, err := e.Complete(context.Background(), Request{
343
+ Messages: []Message{{Role: "user", Content: "接着说"}},
344
+ SessionID: "conv-42",
345
+ })
346
+ if err != nil {
347
+ t.Fatalf("Complete 失败: %v", err)
348
+ }
349
+ var inbound cbGatewayInbound
350
+ if err := json.Unmarshal([]byte((*recs)[0].Body), &inbound); err != nil {
351
+ t.Fatalf("body: %v", err)
352
+ }
353
+ if inbound.Source.Conversation.ID != "conv-42" {
354
+ t.Errorf("conversation.id = %q want conv-42(--session 应落到会话锚点)", inbound.Source.Conversation.ID)
355
+ }
356
+ if res.SessionID != "conv-42" {
357
+ t.Errorf("SessionID = %q want conv-42(终帧没给 sessionId 时沿用请求里的)", res.SessionID)
358
+ }
359
+ }
360
+
361
+ // 终帧带 agent.sessionId 时,也不能拿它当 session_id 返回(那是网关内部 UUID)。
362
+ func TestCodeBuddyGatewaySessionIDIsConversationAnchor(t *testing.T) {
363
+ frames := []string{
364
+ cbGatewayFrameJSON("completed", `"content":{"markdown":"ok"},"agent":{"sessionId":"6a58b80e-2750-432f-9cea-a37424ea9533"}`),
365
+ }
366
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
367
+ e := cbGatewayServerEngine(srv)
368
+
369
+ res, err := e.Complete(context.Background(), Request{
370
+ Messages: []Message{{Role: "user", Content: "接着说"}},
371
+ SessionID: "conv-42",
372
+ })
373
+ if err != nil {
374
+ t.Fatalf("Complete 失败: %v", err)
375
+ }
376
+ if res.SessionID != "conv-42" {
377
+ t.Errorf("SessionID = %q want conv-42(会话锚点,不是终帧的 agent.sessionId)", res.SessionID)
378
+ }
379
+ }
380
+
381
+ // 每次调用都不给 --session 时,会话锚点必须各不相同(否则会串上下文)。
382
+ func TestCodeBuddyGatewayFreshConversationPerCall(t *testing.T) {
383
+ frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":"ok"}`)}
384
+ srv, recs := cbGatewayTestServer(t, "run-1", frames)
385
+ e := cbGatewayServerEngine(srv)
386
+
387
+ seen := map[string]bool{}
388
+ for i := 0; i < 3; i++ {
389
+ if _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}}); err != nil {
390
+ t.Fatalf("第 %d 次失败: %v", i, err)
391
+ }
392
+ }
393
+ for _, r := range *recs {
394
+ if r.Method != http.MethodPost {
395
+ continue
396
+ }
397
+ var inbound cbGatewayInbound
398
+ if err := json.Unmarshal([]byte(r.Body), &inbound); err != nil {
399
+ t.Fatalf("body: %v", err)
400
+ }
401
+ if seen[inbound.Source.Conversation.ID] {
402
+ t.Errorf("conversation.id %q 重复了(会串上下文)", inbound.Source.Conversation.ID)
403
+ }
404
+ seen[inbound.Source.Conversation.ID] = true
405
+ }
406
+ if len(seen) != 3 {
407
+ t.Errorf("收到 %d 个不同的会话锚点 want 3", len(seen))
408
+ }
409
+ }
410
+
411
+ func TestCodeBuddyGatewayContinueRejected(t *testing.T) {
412
+ srv, _ := cbGatewayTestServer(t, "run-1", nil)
413
+ e := cbGatewayServerEngine(srv)
414
+
415
+ _, err := e.Complete(context.Background(), Request{
416
+ Messages: []Message{{Role: "user", Content: "x"}},
417
+ Continue: true,
418
+ })
419
+ if err == nil || !strings.Contains(err.Error(), "continue") {
420
+ t.Errorf("err = %v want 明确拒绝 continue", err)
421
+ }
422
+ }
423
+
424
+ func TestCodeBuddyGatewayAttachmentsMapped(t *testing.T) {
425
+ frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":"ok"}`)}
426
+ srv, recs := cbGatewayTestServer(t, "run-1", frames)
427
+ e := cbGatewayServerEngine(srv)
428
+
429
+ _, err := e.Complete(context.Background(), Request{
430
+ Messages: []Message{{Role: "user", Content: "看这张图"}},
431
+ Attachments: []Attachment{
432
+ {Path: "/tmp/a.png", MIME: "image/png"},
433
+ {Path: "/tmp/b.pdf", MIME: "application/pdf"},
434
+ },
435
+ })
436
+ if err != nil {
437
+ t.Fatalf("Complete 失败: %v", err)
438
+ }
439
+ var inbound cbGatewayInbound
440
+ if err := json.Unmarshal([]byte((*recs)[0].Body), &inbound); err != nil {
441
+ t.Fatalf("body: %v", err)
442
+ }
443
+ if len(inbound.Payload.Attachments) != 2 {
444
+ t.Fatalf("attachments = %d want 2", len(inbound.Payload.Attachments))
445
+ }
446
+ a := inbound.Payload.Attachments[0]
447
+ if a.Type != "image" || a.URL != "/tmp/a.png" || a.URLType != "local-path" {
448
+ t.Errorf("附件[0] = %+v", a)
449
+ }
450
+ if inbound.Payload.Attachments[1].Type != "file" {
451
+ t.Errorf("附件[1].Type = %q want file", inbound.Payload.Attachments[1].Type)
452
+ }
453
+ }
454
+
455
+ func TestCodeBuddyGatewayJSONSchemaPostProcess(t *testing.T) {
456
+ // 模型把 JSON 裹在 markdown 围栏里(最常见形态),后处理要抽得出来。
457
+ markdown := "```json\n{\"name\":\"n\",\"age\":1}\n```"
458
+ quoted, err := json.Marshal(markdown)
459
+ if err != nil {
460
+ t.Fatal(err)
461
+ }
462
+ frames := []string{
463
+ fmt.Sprintf(`{"version":"1.0","replyTo":"msg","status":"completed","content":{"markdown":%s}}`, quoted),
464
+ }
465
+ srv, _ := cbGatewayTestServer(t, "run-1", frames)
466
+ e := cbGatewayServerEngine(srv)
467
+
468
+ schema := &JSONSchema{Type: "object", Required: []string{"name", "age"}}
469
+ res, err := e.Complete(context.Background(), Request{
470
+ Messages: []Message{{Role: "user", Content: "x"}},
471
+ JSONSchema: schema,
472
+ })
473
+ if err != nil {
474
+ t.Fatalf("Complete 失败: %v", err)
475
+ }
476
+ if !strings.HasPrefix(res.Text, "{") {
477
+ t.Errorf("Text = %q,JSONSchema 模式下应抽出纯 JSON", res.Text)
478
+ }
479
+ }
480
+
481
+ // ---------- Detect / 配置 ----------
482
+
483
+ func TestCodeBuddyGatewayDetect(t *testing.T) {
484
+ absent := filepath.Join(t.TempDir(), "absent.json")
485
+
486
+ // 未配置 url → 不可用,提示里带上配置文件位置与环境变量名。
487
+ e := &CodeBuddyGatewayEngine{ConfigPath: absent}
488
+ ok, note := e.Detect()
489
+ if ok {
490
+ t.Error("未配置 url 时 Detect 应为 false")
491
+ }
492
+ if !strings.Contains(note, "codebuddyGateway") || !strings.Contains(note, config.EnvCBGWURL) {
493
+ t.Errorf("note = %q,应点明配置节与环境变量", note)
494
+ }
495
+
496
+ // 配了 url → 可用,说明信息是 URL(与 CLI 引擎返回二进制路径同构)。
497
+ e2 := &CodeBuddyGatewayEngine{URL: "http://127.0.0.1:8399", ConfigPath: absent}
498
+ ok2, note2 := e2.Detect()
499
+ if !ok2 {
500
+ t.Fatalf("配了 url 时 Detect 应为 true,note=%q", note2)
501
+ }
502
+ if note2 != "http://127.0.0.1:8399" {
503
+ t.Errorf("note = %q want 端点 URL", note2)
504
+ }
505
+ }
506
+
507
+ func TestCodeBuddyGatewayConfigFromFile(t *testing.T) {
508
+ dir := t.TempDir()
509
+ p := filepath.Join(dir, "config.json")
510
+ body := `{"codebuddyGateway":{"endpoint":"http://127.0.0.1:9000/","token":"tok","platform":"generic","sender":"s1","conversation":"c1"}}`
511
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
512
+ t.Fatal(err)
513
+ }
514
+ cfg, err := config.LoadFrom(p)
515
+ if err != nil {
516
+ t.Fatalf("LoadFrom: %v", err)
517
+ }
518
+ g := cfg.CodeBuddyGateway
519
+ // endpoint → url、token → password 的宽松别名要生效。
520
+ if g.URL != "http://127.0.0.1:9000/" || g.Password != "tok" {
521
+ t.Errorf("url/password = %q/%q(别名未生效)", g.URL, g.Password)
522
+ }
523
+ if g.Platform != "generic" || g.Sender != "s1" || g.Conversation != "c1" {
524
+ t.Errorf("其余字段 = %+v", g)
525
+ }
526
+ if !g.Ready() {
527
+ t.Errorf("Ready() = false,Missing=%v", g.Missing())
528
+ }
529
+
530
+ // 节名别名(codebuddy_gateway)也要认。
531
+ cfg2, err := config.LoadFrom(writeCBGWConfig(t, `{"codebuddy_gateway":{"url":"http://h:1"}}`))
532
+ if err != nil {
533
+ t.Fatalf("LoadFrom(alias): %v", err)
534
+ }
535
+ if cfg2.CodeBuddyGateway.URL != "http://h:1" {
536
+ t.Errorf("节名别名未生效: %+v", cfg2.CodeBuddyGateway)
537
+ }
538
+ }
539
+
540
+ // writeCBGWConfig 写一份临时配置文件并返回路径。
541
+ func writeCBGWConfig(t *testing.T, body string) string {
542
+ t.Helper()
543
+ p := filepath.Join(t.TempDir(), "config.json")
544
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
545
+ t.Fatal(err)
546
+ }
547
+ return p
548
+ }
549
+
550
+ // 引擎从配置文件读连接参数(显式字段留空时)。
551
+ func TestCodeBuddyGatewayEngineReadsConfig(t *testing.T) {
552
+ frames := []string{cbGatewayFrameJSON("completed", `"content":{"markdown":"ok"}`)}
553
+ srv, recs := cbGatewayTestServer(t, "run-1", frames)
554
+ p := writeCBGWConfig(t, fmt.Sprintf(`{"codebuddyGateway":{"url":%q,"password":"cfg-tok"}}`, srv.URL))
555
+
556
+ e := &CodeBuddyGatewayEngine{ConfigPath: p}
557
+ if _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "x"}}}); err != nil {
558
+ t.Fatalf("Complete 失败: %v", err)
559
+ }
560
+ if got := (*recs)[0].Header.Get("authorization"); got != "Bearer cfg-tok" {
561
+ t.Errorf("authorization = %q want Bearer cfg-tok(口令应来自配置文件)", got)
562
+ }
563
+ }
564
+
565
+ // ---------- 能力表接线 ----------
566
+
567
+ func TestCodeBuddyGatewayCapabilities(t *testing.T) {
568
+ const name = "codebuddy-gateway"
569
+
570
+ // workspace:网关在它自己的 cwd 里跑 → per-call 改不动。
571
+ if got := WorkspaceSupportOf(name); got != "none" {
572
+ t.Errorf("WorkspaceSupportOf = %q want none", got)
573
+ }
574
+ // attachments:走原生字段但网关把它渲染成路径清单 → 如实报 prompt。
575
+ if got := AttachmentSupportOf(name); got != "prompt" {
576
+ t.Errorf("AttachmentSupportOf = %q want prompt", got)
577
+ }
578
+ // permission:档位在网关进程启动参数上,且网关强制 bypass → 未接线。
579
+ if got := PermissionSupportOf(name); got != "none" {
580
+ t.Errorf("PermissionSupportOf = %q want none", got)
581
+ }
582
+ if PermissionSupported(name) {
583
+ t.Error("PermissionSupported 应为 false(传 --permission 要在 CLI 层报错)")
584
+ }
585
+ // ask:协议里没有作答通道。
586
+ if got := AskSupportOf(name); got != "none" {
587
+ t.Errorf("AskSupportOf = %q want none", got)
588
+ }
589
+ // append:webhook 是「一次投递一个 run」,没有持续喂消息的通道。
590
+ if AppendSupportOf(name) {
591
+ t.Error("AppendSupportOf 应为 false")
592
+ }
593
+ if AppendDefaultOn(name) {
594
+ t.Error("AppendDefaultOn 应为 false")
595
+ }
596
+ // tools:工具集由网关进程的 --agent 决定,命令行层无逐工具开关。
597
+ if ToolsSwitchableOf(name) {
598
+ t.Error("ToolsSwitchableOf 应为 false")
599
+ }
600
+ // install:要的是「把网关跑起来」,不是安装命令 → 如实留空。
601
+ if got := InstallCommandOf(name); got != "" {
602
+ t.Errorf("InstallCommandOf = %q want 空串", got)
603
+ }
604
+ }
605
+
606
+ // ---------- SSE 行解析 ----------
607
+
608
+ func TestCodeBuddyGatewaySSEPayload(t *testing.T) {
609
+ cases := []struct {
610
+ line string
611
+ want string
612
+ ok bool
613
+ }{
614
+ {`data: {"status":"completed"}`, `{"status":"completed"}`, true},
615
+ {`data:{"a":1}`, `{"a":1}`, true},
616
+ {``, "", false},
617
+ {`: keep-alive`, "", false},
618
+ {`event: message`, "", false},
619
+ {`id: 3`, "", false},
620
+ {`data: [DONE]`, "", false},
621
+ }
622
+ for _, c := range cases {
623
+ got, ok := cbGatewaySSEPayload(c.line)
624
+ if ok != c.ok || got != c.want {
625
+ t.Errorf("cbGatewaySSEPayload(%q) = (%q,%v) want (%q,%v)", c.line, got, ok, c.want, c.ok)
626
+ }
627
+ }
628
+ }
629
+
630
+ func TestCodeBuddyGatewayIsEventStream(t *testing.T) {
631
+ for _, ct := range []string{"text/event-stream", "text/event-stream; charset=utf-8", "TEXT/EVENT-STREAM"} {
632
+ if !cbGatewayIsEventStream(ct) {
633
+ t.Errorf("cbGatewayIsEventStream(%q) = false want true", ct)
634
+ }
635
+ }
636
+ for _, ct := range []string{"application/json", "", "text/plain"} {
637
+ if cbGatewayIsEventStream(ct) {
638
+ t.Errorf("cbGatewayIsEventStream(%q) = true want false", ct)
639
+ }
640
+ }
641
+ }
642
+
643
+ // 超时/取消要在读流时如实报出来(不能静默返回半句)。
644
+ func TestCodeBuddyGatewayStreamTimeout(t *testing.T) {
645
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
646
+ if r.Method == http.MethodPost {
647
+ w.WriteHeader(http.StatusAccepted)
648
+ fmt.Fprint(w, `{"data":{"runId":"run-1","status":"accepted"}}`)
649
+ return
650
+ }
651
+ w.Header().Set("content-type", "text/event-stream")
652
+ w.WriteHeader(http.StatusOK)
653
+ fl, _ := w.(http.Flusher)
654
+ fmt.Fprintf(w, "data: %s\n\n", cbGatewayFrameJSON("streaming", `"content":{"chunk":"半句"}`))
655
+ if fl != nil {
656
+ fl.Flush()
657
+ }
658
+ time.Sleep(3 * time.Second) // 迟迟不给终帧
659
+ }))
660
+ t.Cleanup(srv.Close)
661
+ e := cbGatewayServerEngine(srv)
662
+
663
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
664
+ defer cancel()
665
+ start := time.Now()
666
+ _, err := e.Complete(ctx, Request{Messages: []Message{{Role: "user", Content: "x"}}})
667
+ if err == nil {
668
+ t.Fatal("超时应报错")
669
+ }
670
+ if time.Since(start) > 2*time.Second {
671
+ t.Errorf("超时没生效,耗时 %v", time.Since(start))
672
+ }
673
+ }