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,200 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// attachments.go - 「文件+提示词」的公共件:附件类型、MIME 嗅探、能力表、兜底文案。
|
|
4
|
+
//
|
|
5
|
+
// 用户需求原文(2026-09-17):
|
|
6
|
+
//
|
|
7
|
+
// 「传了提示词需要支持传入文件路径」→ 更正为「理解不对 是文件+提示词 比如截图加提示词」
|
|
8
|
+
//
|
|
9
|
+
// 也就是:提示词**照旧是提示词**,附件(截图等)作为并列的第二份输入一起送进去。
|
|
10
|
+
// 本文件只放公共逻辑;各引擎怎么把附件交给 CLI 见 engine.go 的 Request.Attachments 注释。
|
|
11
|
+
//
|
|
12
|
+
// 能力表(AttachmentSupportOf)是**机器可读**的,`--engines` 每行会带上它,
|
|
13
|
+
// 调用方(如观物台)据此决定「要不要发附件 / 要不要提示用户」:
|
|
14
|
+
//
|
|
15
|
+
// flag:-i codex 原生 --image
|
|
16
|
+
// stdin:stream-json claude/codebuddy 原生 stream-json input(图片走 content block)
|
|
17
|
+
// flag:-a llm CLI 原生 --attachment
|
|
18
|
+
// part:file arkclaw 走 A2A 原生 file part(base64 inline)
|
|
19
|
+
// prompt 无原生输入通道:把绝对路径写进提示词,靠引擎的读文件工具看
|
|
20
|
+
// (trae / openclaw / dsh —— 需要工具可用,CLI 层会提示)
|
|
21
|
+
// codebuddy-gateway 也归这一档:它走 payload.attachments 原生字段,
|
|
22
|
+
// 但网关把它渲染成路径清单塞进 prompt(图不进模型上下文)
|
|
23
|
+
|
|
24
|
+
import (
|
|
25
|
+
"encoding/base64"
|
|
26
|
+
"fmt"
|
|
27
|
+
"os"
|
|
28
|
+
"path/filepath"
|
|
29
|
+
"strings"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Attachment 一个随提示词一起发送的附件。
|
|
33
|
+
type Attachment struct {
|
|
34
|
+
// Path 附件绝对路径(CLI 层校验过「存在 + 是普通文件」并转成绝对路径)。
|
|
35
|
+
Path string `json:"path"`
|
|
36
|
+
// MIME 嗅探到的媒体类型(按魔数优先,其次扩展名;识别不出为空串)。
|
|
37
|
+
MIME string `json:"mime,omitempty"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Name 附件文件名(提示词里展示用)。
|
|
41
|
+
func (a Attachment) Name() string { return filepath.Base(a.Path) }
|
|
42
|
+
|
|
43
|
+
// IsImage 是否是图片(MIME 前缀判定)。
|
|
44
|
+
func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MIME, "image/") }
|
|
45
|
+
|
|
46
|
+
// attachmentMagic 按魔数嗅探:只认图片这几类(与 magic-test 的 media-store 同源判据)。
|
|
47
|
+
// 不信任扩展名 —— 它由调用方随便填。
|
|
48
|
+
var attachmentMagic = []struct {
|
|
49
|
+
mime string
|
|
50
|
+
test func(b []byte) bool
|
|
51
|
+
}{
|
|
52
|
+
{"image/png", func(b []byte) bool { return len(b) > 8 && b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' }},
|
|
53
|
+
{"image/jpeg", func(b []byte) bool { return len(b) > 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF }},
|
|
54
|
+
{"image/gif", func(b []byte) bool { return len(b) > 6 && string(b[:4]) == "GIF8" }},
|
|
55
|
+
{"image/webp", func(b []byte) bool {
|
|
56
|
+
return len(b) > 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP"
|
|
57
|
+
}},
|
|
58
|
+
{"image/bmp", func(b []byte) bool { return len(b) > 2 && b[0] == 'B' && b[1] == 'M' }},
|
|
59
|
+
// PDF 也认一下:部分引擎(A2A file part / llm 附件)对它有明确支持。
|
|
60
|
+
{"application/pdf", func(b []byte) bool { return len(b) > 4 && string(b[:4]) == "%PDF" }},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// sniffAttachment 读文件头判类型;魔数不认时按扩展名兜底,最后给
|
|
64
|
+
// application/octet-stream(不返回错误 —— 附件类型识别不了不该阻断调用)。
|
|
65
|
+
func sniffAttachment(path string) string {
|
|
66
|
+
if f, err := os.Open(path); err == nil {
|
|
67
|
+
head := make([]byte, 512)
|
|
68
|
+
n, _ := f.Read(head)
|
|
69
|
+
f.Close()
|
|
70
|
+
for _, m := range attachmentMagic {
|
|
71
|
+
if m.test(head[:n]) {
|
|
72
|
+
return m.mime
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
switch strings.ToLower(filepath.Ext(path)) {
|
|
77
|
+
case ".png":
|
|
78
|
+
return "image/png"
|
|
79
|
+
case ".jpg", ".jpeg":
|
|
80
|
+
return "image/jpeg"
|
|
81
|
+
case ".gif":
|
|
82
|
+
return "image/gif"
|
|
83
|
+
case ".webp":
|
|
84
|
+
return "image/webp"
|
|
85
|
+
case ".bmp":
|
|
86
|
+
return "image/bmp"
|
|
87
|
+
case ".pdf":
|
|
88
|
+
return "application/pdf"
|
|
89
|
+
case ".txt", ".md", ".json", ".csv", ".log", ".yaml", ".yml", ".xml", ".html":
|
|
90
|
+
return "text/plain"
|
|
91
|
+
}
|
|
92
|
+
return "application/octet-stream"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// NewAttachment 构造附件:嗅探 MIME(路径不存在也不报错 —— 校验在 CLI 层做)。
|
|
96
|
+
func NewAttachment(path string) Attachment {
|
|
97
|
+
return Attachment{Path: path, MIME: sniffAttachment(path)}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// AttachmentSupportOf 返回该引擎「收附件」的落地方式(见文件头的能力表)。
|
|
101
|
+
// 未知引擎一律 "prompt"(最保守的兜底:把路径写进提示词)。
|
|
102
|
+
func AttachmentSupportOf(engine string) string {
|
|
103
|
+
// 具名 agent(如 MagicAI)先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
|
|
104
|
+
engine = CapabilityFamilyOf(engine)
|
|
105
|
+
switch engine {
|
|
106
|
+
case "codex":
|
|
107
|
+
return "flag:-i"
|
|
108
|
+
case "claude", "codebuddy", "codebuddy-ai":
|
|
109
|
+
return "stdin:stream-json"
|
|
110
|
+
case "llm":
|
|
111
|
+
return "flag:-a"
|
|
112
|
+
case "arkclaw":
|
|
113
|
+
return "part:file"
|
|
114
|
+
case "trae", "openclaw", "dsh", "codebuddy-gateway":
|
|
115
|
+
// codebuddy-gateway 走 payload.attachments 原生字段(urlType=local-path),
|
|
116
|
+
// 但网关把它渲染成「附件清单 + 路径」塞进 prompt —— 图**不会**以图片形式
|
|
117
|
+
// 进模型上下文,故如实报 prompt 而不是 part:file。
|
|
118
|
+
return "prompt"
|
|
119
|
+
}
|
|
120
|
+
return "prompt"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// AppendSupportOf 报告该引擎是否支持「常驻会话 + 追加消息」(Request.Append)。
|
|
124
|
+
//
|
|
125
|
+
// claude / codebuddy / codebuddy-ai --input-format stream-json 能持续从 stdin 收 user 消息
|
|
126
|
+
// dsh SDK 通道下对同一 sessionId 连续 session/prompt 即可续接
|
|
127
|
+
// (官方 Python SDK 文档:「reuse a harness, home, and id」)
|
|
128
|
+
//
|
|
129
|
+
// 其余引擎要么一次性(trae/llm),要么走协议级 follow-up 但不在本次范围(codex queue /
|
|
130
|
+
// openclaw agent --session-id / arkclaw A2A taskId)。
|
|
131
|
+
//
|
|
132
|
+
// ⚠️ dsh 的这条能力**依赖 SDK 通道**:显式 Profile/环境变量把通道切成 headless 后,
|
|
133
|
+
// 追加没有落地通道 —— 引擎会明确报错而不是静默丢消息(见 dsh.go 的回退分支)。
|
|
134
|
+
func AppendSupportOf(engine string) bool {
|
|
135
|
+
// 具名 agent(如 MagicAI)先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
|
|
136
|
+
engine = CapabilityFamilyOf(engine)
|
|
137
|
+
switch engine {
|
|
138
|
+
case "claude", "codebuddy", "codebuddy-ai", "dsh":
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// AppendDefaultOn 报告「常驻会话」在该引擎上是否**默认开**(--keep-alive 的默认值语义)。
|
|
145
|
+
//
|
|
146
|
+
// claude / codebuddy 默认开(用户 2026-09-18 的要求:「keep-alive 要是默认的」)。
|
|
147
|
+
// dsh 默认**关**、需要显式 `--keep-alive`:它的 SDK 通道本来就是「一次调用一轮」的形态,
|
|
148
|
+
// 默认挂一个 5 分钟空闲窗口会让既有调用方(脚本 / 观物台)以为命令卡住了。
|
|
149
|
+
func AppendDefaultOn(engine string) bool {
|
|
150
|
+
// 具名 agent 先归到它协议的家族名,再查表 —— 见 CapabilityFamilyOf。
|
|
151
|
+
engine = CapabilityFamilyOf(engine)
|
|
152
|
+
switch engine {
|
|
153
|
+
case "claude", "codebuddy", "codebuddy-ai":
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// AttachmentPromptSection 把附件以「路径清单」的形式拼进提示词正文。
|
|
160
|
+
//
|
|
161
|
+
// 这是**没有原生附件输入通道**的引擎(trae / openclaw / dsh)的兜底:把绝对路径说给模型,
|
|
162
|
+
// 由它自己的读文件工具去看。因此只有在工具可用时才有意义 —— CLI 层会在
|
|
163
|
+
// --tools off 时给出明确提示(不静默丢附件)。
|
|
164
|
+
func AttachmentPromptSection(atts []Attachment) string {
|
|
165
|
+
if len(atts) == 0 {
|
|
166
|
+
return ""
|
|
167
|
+
}
|
|
168
|
+
var sb strings.Builder
|
|
169
|
+
sb.WriteString("【附件】以下文件已随本次请求提供,请用你的文件读取工具查看后再回答:\n")
|
|
170
|
+
for _, a := range atts {
|
|
171
|
+
fmt.Fprintf(&sb, "- %s", a.Path)
|
|
172
|
+
if a.MIME != "" {
|
|
173
|
+
fmt.Fprintf(&sb, "(%s)", a.MIME)
|
|
174
|
+
}
|
|
175
|
+
sb.WriteString("\n")
|
|
176
|
+
}
|
|
177
|
+
return sb.String()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// appendAttachmentSection 把附件清单追加到 prompt 末尾(空附件原样返回)。
|
|
181
|
+
func appendAttachmentSection(prompt string, atts []Attachment) string {
|
|
182
|
+
section := AttachmentPromptSection(atts)
|
|
183
|
+
if section == "" {
|
|
184
|
+
return prompt
|
|
185
|
+
}
|
|
186
|
+
if strings.TrimSpace(prompt) == "" {
|
|
187
|
+
return strings.TrimSpace(section)
|
|
188
|
+
}
|
|
189
|
+
return strings.TrimSpace(prompt) + "\n\n" + section
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// readAttachmentBase64 读附件并做 base64(llm 直连 / A2A file part 共用)。
|
|
193
|
+
// 读不动直接报错 —— 附件是调用方点名要发的东西,静默丢掉比失败更糟。
|
|
194
|
+
func readAttachmentBase64(a Attachment) (string, error) {
|
|
195
|
+
data, err := os.ReadFile(a.Path)
|
|
196
|
+
if err != nil {
|
|
197
|
+
return "", fmt.Errorf("读取附件 %s: %w", a.Path, err)
|
|
198
|
+
}
|
|
199
|
+
return base64.StdEncoding.EncodeToString(data), nil
|
|
200
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// attachments_test.go - 「文件+提示词」(附件)链路的单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 用户需求原文(2026-09-17):「传了提示词需要支持传入文件路径」→
|
|
6
|
+
// 「理解不对 是文件+提示词 比如截图加提示词」。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖:MIME 嗅探 / 能力表 / 兜底路径清单 / stream-json 输入构造(claude、codebuddy)
|
|
9
|
+
// / codex -i(含 resume 退化为路径)/ llm 直连 content parts / llm CLI -a /
|
|
10
|
+
// arkclaw A2A file part / trae 路径兜底。
|
|
11
|
+
//
|
|
12
|
+
// 图片用**真 PNG 字节**(Go 现造),不靠扩展名 —— 嗅探走的是魔数,测试要覆盖这一点。
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"bytes"
|
|
16
|
+
"compress/zlib"
|
|
17
|
+
"context"
|
|
18
|
+
"encoding/base64"
|
|
19
|
+
"encoding/json"
|
|
20
|
+
"hash/crc32"
|
|
21
|
+
"net/http"
|
|
22
|
+
"os"
|
|
23
|
+
"path/filepath"
|
|
24
|
+
"strings"
|
|
25
|
+
"testing"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// pngChunk 组一个 PNG 块(长度 + 类型 + 数据 + CRC)。
|
|
29
|
+
func pngChunk(tag string, data []byte) []byte {
|
|
30
|
+
var b bytes.Buffer
|
|
31
|
+
b.Write([]byte{byte(len(data) >> 24), byte(len(data) >> 16), byte(len(data) >> 8), byte(len(data))})
|
|
32
|
+
b.WriteString(tag)
|
|
33
|
+
b.Write(data)
|
|
34
|
+
crc := crc32.ChecksumIEEE(append([]byte(tag), data...))
|
|
35
|
+
b.Write([]byte{byte(crc >> 24), byte(crc >> 16), byte(crc >> 8), byte(crc)})
|
|
36
|
+
return b.Bytes()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// writeTestPNG 现造一张纯红 PNG(8x8 RGB),返回路径。
|
|
40
|
+
func writeTestPNG(t *testing.T, name string) string {
|
|
41
|
+
t.Helper()
|
|
42
|
+
w, h := 8, 8
|
|
43
|
+
raw := make([]byte, 0, h*(1+w*3))
|
|
44
|
+
for y := 0; y < h; y++ {
|
|
45
|
+
raw = append(raw, 0) // filter type 0
|
|
46
|
+
for x := 0; x < w; x++ {
|
|
47
|
+
raw = append(raw, 0xff, 0x00, 0x00)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
var idat bytes.Buffer
|
|
51
|
+
zw := zlib.NewWriter(&idat)
|
|
52
|
+
if _, err := zw.Write(raw); err != nil {
|
|
53
|
+
t.Fatal(err)
|
|
54
|
+
}
|
|
55
|
+
if err := zw.Close(); err != nil {
|
|
56
|
+
t.Fatal(err)
|
|
57
|
+
}
|
|
58
|
+
ihdr := []byte{0, 0, 0, byte(w), 0, 0, 0, byte(h), 8, 2, 0, 0, 0}
|
|
59
|
+
png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, pngChunk("IHDR", ihdr)...)
|
|
60
|
+
png = append(png, pngChunk("IDAT", idat.Bytes())...)
|
|
61
|
+
png = append(png, pngChunk("IEND", nil)...)
|
|
62
|
+
|
|
63
|
+
p := filepath.Join(t.TempDir(), name)
|
|
64
|
+
if err := os.WriteFile(p, png, 0o600); err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
return p
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// writeTestFile 写一个任意内容的小文件(非图片附件用)。
|
|
71
|
+
func writeTestFile(t *testing.T, name, content string) string {
|
|
72
|
+
t.Helper()
|
|
73
|
+
p := filepath.Join(t.TempDir(), name)
|
|
74
|
+
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
return p
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── 嗅探与能力表 ──────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
func TestNewAttachmentSniffsByMagicBytes(t *testing.T) {
|
|
83
|
+
// 故意用 .dat 后缀:魔数优先,不能被扩展名带偏。
|
|
84
|
+
p := writeTestPNG(t, "shot.dat")
|
|
85
|
+
a := NewAttachment(p)
|
|
86
|
+
if a.MIME != "image/png" {
|
|
87
|
+
t.Errorf("MIME = %q want image/png(魔数优先于扩展名)", a.MIME)
|
|
88
|
+
}
|
|
89
|
+
if !a.IsImage() {
|
|
90
|
+
t.Error("IsImage() 应为 true")
|
|
91
|
+
}
|
|
92
|
+
if a.Name() != "shot.dat" {
|
|
93
|
+
t.Errorf("Name() = %q", a.Name())
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestNewAttachmentFallsBackToExtension(t *testing.T) {
|
|
98
|
+
// 非图片内容 + 认识的扩展名 → 按扩展名兜底;完全不认识 → octet-stream。
|
|
99
|
+
txt := writeTestFile(t, "notes.txt", "hello")
|
|
100
|
+
if got := NewAttachment(txt).MIME; got != "text/plain" {
|
|
101
|
+
t.Errorf("MIME = %q want text/plain", got)
|
|
102
|
+
}
|
|
103
|
+
bin := writeTestFile(t, "blob.xyz", "\x00\x01\x02")
|
|
104
|
+
if got := NewAttachment(bin).MIME; got != "application/octet-stream" {
|
|
105
|
+
t.Errorf("MIME = %q want application/octet-stream", got)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestAttachmentSupportOf(t *testing.T) {
|
|
110
|
+
want := map[string]string{
|
|
111
|
+
"codex": "flag:-i",
|
|
112
|
+
"claude": "stdin:stream-json",
|
|
113
|
+
"codebuddy": "stdin:stream-json",
|
|
114
|
+
"codebuddy-ai": "stdin:stream-json",
|
|
115
|
+
"llm": "flag:-a",
|
|
116
|
+
"arkclaw": "part:file",
|
|
117
|
+
"trae": "prompt",
|
|
118
|
+
"openclaw": "prompt",
|
|
119
|
+
"dsh": "prompt",
|
|
120
|
+
}
|
|
121
|
+
for engine, w := range want {
|
|
122
|
+
if got := AttachmentSupportOf(engine); got != w {
|
|
123
|
+
t.Errorf("AttachmentSupportOf(%q) = %q want %q", engine, got, w)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 未知引擎保守兜底
|
|
127
|
+
if got := AttachmentSupportOf("nope"); got != "prompt" {
|
|
128
|
+
t.Errorf("未知引擎应为 prompt, got %q", got)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestAppendAttachmentSectionFallback(t *testing.T) {
|
|
133
|
+
atts := []Attachment{{Path: "/tmp/a.png", MIME: "image/png"}}
|
|
134
|
+
|
|
135
|
+
got := appendAttachmentSection("看看这张图", atts)
|
|
136
|
+
if !strings.Contains(got, "看看这张图") || !strings.Contains(got, "/tmp/a.png") || !strings.Contains(got, "image/png") {
|
|
137
|
+
t.Errorf("兜底文案应含提示词与路径: %q", got)
|
|
138
|
+
}
|
|
139
|
+
// 空附件不动提示词
|
|
140
|
+
if got := appendAttachmentSection("原样", nil); got != "原样" {
|
|
141
|
+
t.Errorf("无附件时应原样返回, got %q", got)
|
|
142
|
+
}
|
|
143
|
+
// 提示词为空时只剩附件清单(不能拼出前导空行)
|
|
144
|
+
if got := appendAttachmentSection("", atts); strings.HasPrefix(got, "\n") {
|
|
145
|
+
t.Errorf("空提示词时不应有前导空行: %q", got)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── stream-json 输入(claude / codebuddy)──────────────────────
|
|
150
|
+
|
|
151
|
+
func TestStreamJSONUserLineCarriesImageBlock(t *testing.T) {
|
|
152
|
+
img := writeTestPNG(t, "shot.png")
|
|
153
|
+
txt := writeTestFile(t, "readme.md", "文件里的说明")
|
|
154
|
+
stdin, err := streamJSONUserLine("这张图什么颜色?", []Attachment{
|
|
155
|
+
NewAttachment(img), NewAttachment(txt),
|
|
156
|
+
})
|
|
157
|
+
if err != nil {
|
|
158
|
+
t.Fatalf("streamJSONUserLine: %v", err)
|
|
159
|
+
}
|
|
160
|
+
if !strings.HasSuffix(stdin, "\n") {
|
|
161
|
+
t.Error("应是一整行(尾部换行)")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var msg struct {
|
|
165
|
+
Type string `json:"type"`
|
|
166
|
+
Message struct {
|
|
167
|
+
Role string `json:"role"`
|
|
168
|
+
Content []struct {
|
|
169
|
+
Type string `json:"type"`
|
|
170
|
+
Text string `json:"text"`
|
|
171
|
+
Source struct {
|
|
172
|
+
Type string `json:"type"`
|
|
173
|
+
MediaType string `json:"media_type"`
|
|
174
|
+
Data string `json:"data"`
|
|
175
|
+
} `json:"source"`
|
|
176
|
+
} `json:"content"`
|
|
177
|
+
} `json:"message"`
|
|
178
|
+
}
|
|
179
|
+
if err := json.Unmarshal([]byte(stdin), &msg); err != nil {
|
|
180
|
+
t.Fatalf("stdin 不是合法 JSON: %v\n%s", err, stdin)
|
|
181
|
+
}
|
|
182
|
+
if msg.Type != "user" || msg.Message.Role != "user" {
|
|
183
|
+
t.Errorf("消息形状不对: type=%q role=%q", msg.Type, msg.Message.Role)
|
|
184
|
+
}
|
|
185
|
+
if len(msg.Message.Content) != 2 {
|
|
186
|
+
t.Fatalf("content 块数 = %d want 2(图 + 文本)", len(msg.Message.Content))
|
|
187
|
+
}
|
|
188
|
+
// 图在前、文本在后
|
|
189
|
+
if msg.Message.Content[0].Type != "image" || msg.Message.Content[0].Source.Type != "base64" {
|
|
190
|
+
t.Errorf("第一块应是 image/base64: %+v", msg.Message.Content[0])
|
|
191
|
+
}
|
|
192
|
+
if msg.Message.Content[0].Source.MediaType != "image/png" {
|
|
193
|
+
t.Errorf("media_type = %q", msg.Message.Content[0].Source.MediaType)
|
|
194
|
+
}
|
|
195
|
+
raw, _ := base64.StdEncoding.DecodeString(msg.Message.Content[0].Source.Data)
|
|
196
|
+
if len(raw) == 0 || !bytes.HasPrefix(raw, []byte{0x89, 'P', 'N', 'G'}) {
|
|
197
|
+
t.Error("image block 的 base64 解回来不是 PNG —— 附件内容没真带上")
|
|
198
|
+
}
|
|
199
|
+
if msg.Message.Content[1].Type != "text" || !strings.Contains(msg.Message.Content[1].Text, "这张图什么颜色?") {
|
|
200
|
+
t.Errorf("第二块应是带提示词的 text: %+v", msg.Message.Content[1])
|
|
201
|
+
}
|
|
202
|
+
// 非图片附件退化为路径文本
|
|
203
|
+
if !strings.Contains(msg.Message.Content[1].Text, "readme.md") {
|
|
204
|
+
t.Errorf("非图片附件应写进文本: %q", msg.Message.Content[1].Text)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func TestStreamJSONArgsSwapsPromptForStdin(t *testing.T) {
|
|
209
|
+
base := []string{"-p", "--output-format", "json", "--tools", "", "原始提示词"}
|
|
210
|
+
got := streamJSONArgs(base, "原始提示词", false)
|
|
211
|
+
|
|
212
|
+
if strings.Contains(strings.Join(got, " "), "原始提示词") {
|
|
213
|
+
t.Errorf("提示词不能再作为位置参数(改走 stdin): %v", got)
|
|
214
|
+
}
|
|
215
|
+
if !hasFlagValue(got, "--output-format", "stream-json") {
|
|
216
|
+
t.Errorf("--output-format 应改成 stream-json: %v", got)
|
|
217
|
+
}
|
|
218
|
+
if !hasFlagValue(got, "--input-format", "stream-json") {
|
|
219
|
+
t.Errorf("应追加 --input-format stream-json: %v", got)
|
|
220
|
+
}
|
|
221
|
+
streaming := streamJSONArgs(base, "原始提示词", true)
|
|
222
|
+
if !contains(streaming, "--include-partial-messages") {
|
|
223
|
+
t.Errorf("流式模式应带 --include-partial-messages: %v", streaming)
|
|
224
|
+
}
|
|
225
|
+
if contains(got, "--include-partial-messages") {
|
|
226
|
+
t.Errorf("非流式模式不该带 --include-partial-messages: %v", got)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// hasFlagValue 断言 flag 的下一个值等于 want。
|
|
231
|
+
func hasFlagValue(args []string, flag, want string) bool {
|
|
232
|
+
for i := 0; i < len(args)-1; i++ {
|
|
233
|
+
if args[i] == flag && args[i+1] == want {
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func contains(list []string, want string) bool {
|
|
241
|
+
for _, s := range list {
|
|
242
|
+
if s == want {
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// countFlag 数某个 flag 在 args 里出现的次数。
|
|
250
|
+
func countFlag(args []string, flag string) int {
|
|
251
|
+
n := 0
|
|
252
|
+
for _, a := range args {
|
|
253
|
+
if a == flag {
|
|
254
|
+
n++
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return n
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// claude 带附件:Complete 也必须走 stream-json 输入(提示词 + 图走 stdin),
|
|
261
|
+
// 且不向调用方发增量事件。
|
|
262
|
+
func TestClaudeCompleteWithAttachmentUsesStdin(t *testing.T) {
|
|
263
|
+
img := writeTestPNG(t, "shot.png")
|
|
264
|
+
// 假 CLI:把 stdin 长度与收到的参数回显进 result 行。
|
|
265
|
+
cli := writeFakeCLI(t, "claude", `#!/bin/sh
|
|
266
|
+
n=$(wc -c | tr -d ' ')
|
|
267
|
+
echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"stdin=$n args=$*\",\"session_id\":\"s-att\",\"model\":\"m\"}"
|
|
268
|
+
`)
|
|
269
|
+
e := &ClaudeEngine{BinPath: cli}
|
|
270
|
+
events, onEvent := collectEvents()
|
|
271
|
+
|
|
272
|
+
res, err := e.Complete(context.Background(), Request{
|
|
273
|
+
Messages: []Message{{Role: "user", Content: "这张图什么颜色?"}},
|
|
274
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
275
|
+
})
|
|
276
|
+
if err != nil {
|
|
277
|
+
t.Fatalf("Complete: %v", err)
|
|
278
|
+
}
|
|
279
|
+
if len(*events) != 0 {
|
|
280
|
+
t.Errorf("Complete 不该发事件, got %d", len(*events))
|
|
281
|
+
}
|
|
282
|
+
_ = onEvent
|
|
283
|
+
if !strings.Contains(res.Text, "--input-format stream-json") {
|
|
284
|
+
t.Errorf("参数里应有 --input-format stream-json: %q", res.Text)
|
|
285
|
+
}
|
|
286
|
+
if !strings.Contains(res.Text, "--output-format stream-json") {
|
|
287
|
+
t.Errorf("参数里应有 --output-format stream-json: %q", res.Text)
|
|
288
|
+
}
|
|
289
|
+
if strings.Contains(res.Text, "这张图什么颜色?") {
|
|
290
|
+
t.Errorf("提示词不该再出现在命令行参数里: %q", res.Text)
|
|
291
|
+
}
|
|
292
|
+
// stdin 至少要有图片 base64 的量级(几 KB 级);为 0 说明附件没喂进去
|
|
293
|
+
if strings.Contains(res.Text, "stdin=0 ") {
|
|
294
|
+
t.Errorf("stdin 为空 —— 附件没经 stdin 发送: %q", res.Text)
|
|
295
|
+
}
|
|
296
|
+
if res.SessionID != "s-att" {
|
|
297
|
+
t.Errorf("SessionID = %q want s-att", res.SessionID)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// claude 流式带附件:增量照常转发,正文以 result 行为准。
|
|
302
|
+
func TestClaudeStreamWithAttachment(t *testing.T) {
|
|
303
|
+
img := writeTestPNG(t, "shot.png")
|
|
304
|
+
cli := writeFakeCLI(t, "claude", `#!/bin/sh
|
|
305
|
+
echo '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"s-1"}'
|
|
306
|
+
echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"红色"}},"session_id":"s-1"}'
|
|
307
|
+
echo '{"type":"result","subtype":"success","is_error":false,"result":"红色","session_id":"s-1","model":"m"}'
|
|
308
|
+
`)
|
|
309
|
+
e := &ClaudeEngine{BinPath: cli}
|
|
310
|
+
events, onEvent := collectEvents()
|
|
311
|
+
|
|
312
|
+
res, err := e.Stream(context.Background(), Request{
|
|
313
|
+
Messages: []Message{{Role: "user", Content: "什么颜色"}},
|
|
314
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
315
|
+
}, onEvent)
|
|
316
|
+
if err != nil {
|
|
317
|
+
t.Fatalf("Stream: %v", err)
|
|
318
|
+
}
|
|
319
|
+
if res.Text != "红色" {
|
|
320
|
+
t.Errorf("Text = %q want 红色", res.Text)
|
|
321
|
+
}
|
|
322
|
+
if len(*events) == 0 {
|
|
323
|
+
t.Error("流式应转发增量事件")
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── codex / llm / trae / arkclaw ─────────────────────────────
|
|
328
|
+
|
|
329
|
+
func TestCodexArgsCarryImages(t *testing.T) {
|
|
330
|
+
e := &CodexEngine{}
|
|
331
|
+
img := writeTestPNG(t, "shot.png")
|
|
332
|
+
txt := writeTestFile(t, "notes.txt", "hi")
|
|
333
|
+
req := Request{
|
|
334
|
+
Messages: []Message{{Role: "user", Content: "看图说话"}},
|
|
335
|
+
Attachments: []Attachment{NewAttachment(img), NewAttachment(txt)},
|
|
336
|
+
}
|
|
337
|
+
// 调用顺序与 Complete/Stream 一致:先把「没走 -i 的附件」补进提示词,再拼参数。
|
|
338
|
+
prompt := codexPromptWithAttachments("看图说话", req)
|
|
339
|
+
args := e.buildArgs(req, prompt, "", "")
|
|
340
|
+
|
|
341
|
+
if !hasFlagValue(args, "-i", img) {
|
|
342
|
+
t.Errorf("图片附件应走原生 -i: %v", args)
|
|
343
|
+
}
|
|
344
|
+
if hasFlagValue(args, "-i", txt) {
|
|
345
|
+
t.Errorf("非图片附件不该给 -i(-i 只收图片): %v", args)
|
|
346
|
+
}
|
|
347
|
+
if n := countFlag(args, "-i"); n != 1 {
|
|
348
|
+
t.Errorf("-i 出现 %d 次 want 1(只有那张图): %v", n, args)
|
|
349
|
+
}
|
|
350
|
+
if !strings.Contains(args[len(args)-1], txt) {
|
|
351
|
+
t.Errorf("非图片附件应写进提示词: %q", args[len(args)-1])
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// resume 轮:codex exec resume 不接受 -i → 退化为路径写进提示词
|
|
355
|
+
resumeReq := Request{
|
|
356
|
+
Messages: []Message{{Role: "user", Content: "接着看"}},
|
|
357
|
+
SessionID: "sid-1",
|
|
358
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
359
|
+
}
|
|
360
|
+
resumePrompt := codexPromptWithAttachments("接着看", resumeReq)
|
|
361
|
+
argsResume := e.buildArgs(resumeReq, resumePrompt, "", "")
|
|
362
|
+
if contains(argsResume, "-i") {
|
|
363
|
+
t.Errorf("resume 轮不该注入 -i: %v", argsResume)
|
|
364
|
+
}
|
|
365
|
+
if !strings.Contains(argsResume[len(argsResume)-1], img) {
|
|
366
|
+
t.Errorf("resume 轮附件应退化为提示词里的路径: %q", argsResume[len(argsResume)-1])
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func TestLLMConfiguredBodyAttachments(t *testing.T) {
|
|
371
|
+
img := writeTestPNG(t, "shot.png")
|
|
372
|
+
body, err := buildConfiguredBody(LLMConfigModel{ID: "m1", URL: "https://x/v1"},
|
|
373
|
+
Request{
|
|
374
|
+
Messages: []Message{{Role: "user", Content: "什么颜色"}},
|
|
375
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
376
|
+
}, false)
|
|
377
|
+
if err != nil {
|
|
378
|
+
t.Fatalf("buildConfiguredBody: %v", err)
|
|
379
|
+
}
|
|
380
|
+
msgs, _ := body["messages"].([]map[string]any)
|
|
381
|
+
if len(msgs) != 1 {
|
|
382
|
+
t.Fatalf("messages 数 = %d want 1", len(msgs))
|
|
383
|
+
}
|
|
384
|
+
parts, ok := msgs[0]["content"].([]map[string]any)
|
|
385
|
+
if !ok {
|
|
386
|
+
t.Fatalf("带附件时 content 应是 content parts 数组, got %T", msgs[0]["content"])
|
|
387
|
+
}
|
|
388
|
+
if len(parts) != 2 {
|
|
389
|
+
t.Fatalf("parts 数 = %d want 2(图 + 文本)", len(parts))
|
|
390
|
+
}
|
|
391
|
+
if parts[0]["type"] != "image_url" {
|
|
392
|
+
t.Errorf("第一块应是 image_url: %+v", parts[0])
|
|
393
|
+
}
|
|
394
|
+
u, _ := parts[0]["image_url"].(map[string]any)
|
|
395
|
+
urlStr, _ := u["url"].(string)
|
|
396
|
+
if !strings.HasPrefix(urlStr, "data:image/png;base64,") {
|
|
397
|
+
t.Errorf("应是 data URL, got %q", urlStr[:min(40, len(urlStr))])
|
|
398
|
+
}
|
|
399
|
+
if parts[1]["type"] != "text" || parts[1]["text"] != "什么颜色" {
|
|
400
|
+
t.Errorf("第二块应是原提示词: %+v", parts[1])
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
func TestLLMCLIArgsUseAttachmentFlag(t *testing.T) {
|
|
405
|
+
img := writeTestPNG(t, "shot.png")
|
|
406
|
+
e := &LLMEngine{}
|
|
407
|
+
args := e.buildArgs(Request{
|
|
408
|
+
Messages: []Message{{Role: "user", Content: "看图"}},
|
|
409
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
410
|
+
}, "看图")
|
|
411
|
+
if !hasFlagValue(args, "-a", img) {
|
|
412
|
+
t.Errorf("图片附件应走 llm CLI 原生 -a: %v", args)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func TestTraePromptCarriesAttachmentPath(t *testing.T) {
|
|
417
|
+
img := writeTestPNG(t, "shot.png")
|
|
418
|
+
// 假 trae-cli:把收到的参数原样回显(纯文本输出)。
|
|
419
|
+
cli := writeFakeCLI(t, "trae-cli", "#!/bin/sh\necho \"$*\"\n")
|
|
420
|
+
e := &TraeEngine{BinPath: cli}
|
|
421
|
+
res, err := e.Complete(context.Background(), Request{
|
|
422
|
+
Messages: []Message{{Role: "user", Content: "看图"}},
|
|
423
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
424
|
+
})
|
|
425
|
+
if err != nil {
|
|
426
|
+
t.Fatalf("Complete: %v", err)
|
|
427
|
+
}
|
|
428
|
+
if !strings.Contains(res.Text, img) {
|
|
429
|
+
t.Errorf("trae 无原生附件通道 → 应把路径写进提示词: %q", res.Text)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func TestArkClawSendsFilePart(t *testing.T) {
|
|
434
|
+
img := writeTestPNG(t, "shot.png")
|
|
435
|
+
srv, rec := arkClawTestServer(t, func(w http.ResponseWriter, _ *http.Request, _ string) {
|
|
436
|
+
_, _ = w.Write([]byte(arkClawTextEnvelope("看到了红色", "ctx-1")))
|
|
437
|
+
})
|
|
438
|
+
e := &ArkClawEngine{URL: srv.URL, Key: "k", ClawID: "c"}
|
|
439
|
+
|
|
440
|
+
res, err := e.Complete(context.Background(), Request{
|
|
441
|
+
Messages: []Message{{Role: "user", Content: "什么颜色"}},
|
|
442
|
+
Attachments: []Attachment{NewAttachment(img)},
|
|
443
|
+
})
|
|
444
|
+
if err != nil {
|
|
445
|
+
t.Fatalf("Complete: %v", err)
|
|
446
|
+
}
|
|
447
|
+
if res.Text != "看到了红色" {
|
|
448
|
+
t.Errorf("Text = %q", res.Text)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 请求体里应有 A2A 原生 file part(inline base64)
|
|
452
|
+
var reqBody struct {
|
|
453
|
+
Params struct {
|
|
454
|
+
Message struct {
|
|
455
|
+
Parts []struct {
|
|
456
|
+
Kind string `json:"kind"`
|
|
457
|
+
Text string `json:"text"`
|
|
458
|
+
File *struct {
|
|
459
|
+
Name string `json:"name"`
|
|
460
|
+
MimeType string `json:"mimeType"`
|
|
461
|
+
Bytes string `json:"bytes"`
|
|
462
|
+
} `json:"file"`
|
|
463
|
+
} `json:"parts"`
|
|
464
|
+
} `json:"message"`
|
|
465
|
+
} `json:"params"`
|
|
466
|
+
}
|
|
467
|
+
if err := json.Unmarshal([]byte(rec.Body), &reqBody); err != nil {
|
|
468
|
+
t.Fatalf("请求体不是 JSON: %v\n%s", err, rec.Body)
|
|
469
|
+
}
|
|
470
|
+
parts := reqBody.Params.Message.Parts
|
|
471
|
+
if len(parts) != 2 || parts[0].Kind != "text" || parts[1].Kind != "file" {
|
|
472
|
+
t.Fatalf("parts = %+v want [text file]", parts)
|
|
473
|
+
}
|
|
474
|
+
if parts[1].File == nil || parts[1].File.MimeType != "image/png" {
|
|
475
|
+
t.Fatalf("file part 形状不对: %+v", parts[1].File)
|
|
476
|
+
}
|
|
477
|
+
raw, derr := base64.StdEncoding.DecodeString(parts[1].File.Bytes)
|
|
478
|
+
if derr != nil || !bytes.HasPrefix(raw, []byte{0x89, 'P', 'N', 'G'}) {
|
|
479
|
+
t.Error("file part 的 bytes 不是 PNG base64 —— 附件没真带上")
|
|
480
|
+
}
|
|
481
|
+
}
|