Files
HomeAgent/internal/plugins/remotedevice/inline_media_test.go
JianFeeeee a7d048c922 feat(remotedevice): screensee 落盘化,回路径而非自动送模型
## 为什么

原行为把截图的 base64 直接送去视觉模型自动描述。三个问题:

1. **上下文成本**:96KB 截图 ≈ 13 万 token 的内联 base64
2. **源不可控**:生产实证 AUTO 把大图路由到不支持视觉的源,模型回
   「没有收到截图」——而我们无法区分「图没到」与「模型看不了」
3. **agent 无法决策**:不知道图存不存在,也就不知道该 describe 还是该 ocr

## 现在

截图落盘 → 回 {file,mime,size,sha256,width,height} →
agent 显式调 describe_image(path=…)/ocr_image(path=…)。
需要哪个能力由它自己判断,provider 参数也可控。

工具描述同步改写,明说「本工具不会自动描述任何内容,两步缺一不可」——
否则模型会以为拿到 file 就等于看到了画面。

## 实现

- `Registry.SaveInlineMedia(name, payload, mimeHint)`:
  解 data URL / 裸 base64 → 落盘 → 回可核对的元信息
- 文件名用**内容摘要**后缀(`<name>_<sha16>.<ext>`):
  截屏/录像会被反复抓取,用 reqID 命名会把同一张图存成多份,
  用固定名则不同内容互相覆盖 —— 摘要两个问题都避免
- `decodeInlineMedia` / `imageDimensions`:JPEG SOFn 与 PNG IHDR
  解尺寸,不解码全图;认不出的格式**省略字段而非填 0**
- 落盘失败当**工具失败**上抛,不静默退回内联:
  图已在手上却拿不到路径,agent 既没有描述也没有可查证产物
- 旧路径保留在 `auto_describe=true`(调试用)

顺带把 cmd_data_end 的媒体落盘逻辑也换成同一个方法(原来内联了
一份 MkdirAll+WriteFile 的副本)。

## 判据

新增 inline_media_test.go,六个判据经四个变异自证会红:

- 变异 A:写入截半的字节但元信息照填 → 红(只查「文件存在」会放过)
- 变异 B:尺寸写死 0 占位 → 红(元信息「看起来有」但不可信)
- 变异 C:sha256 换成假的 → 红
- 变异 D:未配置 mediaDir 时静默降级 → 红

★ 变异 C 第一次跑出「无输出」,查下来是**编译失败不是被杀**
(`sum`/`hex` 变成未使用)。这正是本项目记过的坑:
编译失败 ≠ 被杀。改写成能编译的注入后确认被杀。

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race remotedevice + multimodal 无数据竞争
- 旧判据按新契约更新时保留原意:
  TestScreenseE2E_SuccessReturnsDescription 现在同时验两条路径
  (默认不出 description 且不调 handler;auto_describe 才出)
- 部署后:39 插件加载、0 panic/fatal、plugin=multimodal
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-screensee-persist-20261006-091536/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 09:16:15 +08:00

195 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package remotedevice
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"strings"
"testing"
)
// screensee 落盘化(2026-10-06)
//
// ## 为什么要落盘
//
// 原行为是把 96KB 截图的 base64 直接送去视觉模型:
// · 约 13 万 token 的内联载荷
// · 自动挑源不可控(生产实证:AUTO 路由到不支持视觉的源,模型说「没收到图」)
// · agent 不知道图存不存在,无法决定该 describe 还是该 ocr
//
// 现在:截图落盘 → 回 {file,mime,size,sha256,width,height} →
// agent 显式调 describe_image(path=…) / ocr_image(path=…)
//
// 这些判据钉住「真的落盘了」与「元信息可信」——后者是 agent 决定
// 下一步的唯一依据,写错了它就会去读一个不存在的文件。
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 0x40, A: 0xff})
}
}
var buf strings.Builder
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return []byte(buf.String())
}
// ---- 落盘:文件存在且字节逐字节一致 ----
func TestSaveInlineMedia_DataURLWritesExactBytes(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 64, 48)
meta, err := reg.SaveInlineMedia("shot", "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw), "")
if err != nil {
t.Fatalf("落盘应成功: %v", err)
}
fp, _ := meta["file"].(string)
if fp == "" {
t.Fatalf("meta 必须含 file,实际: %v", meta)
}
got, err := os.ReadFile(fp)
if err != nil {
t.Fatalf("file 应可读: %v", err)
}
// ★ 逐字节比对:只查「文件存在」会放过内容被截断/转码的情况。
if string(got) != string(raw) {
t.Errorf("落盘字节与原图不一致: 写入 %d 字节, 原图 %d 字节", len(got), len(raw))
}
if meta["size"] != len(raw) {
t.Errorf("meta.size=%v,实际 %d", meta["size"], len(raw))
}
}
func TestSaveInlineMedia_BareBase64AlsoWorks(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 32, 32)
// 设备端有时只回裸 base64(无 data: 前缀)
meta, err := reg.SaveInlineMedia("bare", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatalf("裸 base64 应可落盘: %v", err)
}
got, _ := os.ReadFile(meta["file"].(string))
if string(got) != string(raw) {
t.Error("裸 base64 落盘字节不一致")
}
if meta["mime"] != "image/jpeg" {
t.Errorf("mimeHint 未生效: %v", meta["mime"])
}
}
// ---- 元信息可信:agent 全靠它决定下一步 ----
func TestSaveInlineMedia_MetaIsVerifiable(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 128, 96)
meta, err := reg.SaveInlineMedia("meta", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
// sha256 必须与文件内容对得上(不是随机的/占位的)
got, _ := os.ReadFile(meta["file"].(string))
sum := sha256Hex(got)
if meta["sha256"] != sum {
t.Errorf("sha256 与落盘内容不符: meta=%v 实际=%v", meta["sha256"], sum)
}
// 尺寸必须是真解出来的,不是 0 或缺省
if meta["width"] != 128 || meta["height"] != 96 {
t.Errorf("尺寸应解出 128x96,实际 %vx%v", meta["width"], meta["height"])
}
}
// TestSaveInlineMedia_ContentAddressedName 钉住文件名含内容摘要。
//
// ★ 为什么要摘要后缀:截屏/录像会被反复抓取。用 reqID 命名会把同一张图
//
// 存成多份;用固定名则不同内容互相覆盖。摘要两个问题都避免。
func TestSaveInlineMedia_ContentAddressedName(t *testing.T) {
reg := NewRegistry()
reg.SetMediaDir(t.TempDir())
raw := jpegBytes(t, 16, 16)
m1, err := reg.SaveInlineMedia("req-1", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
// 同内容不同 name ⇒ 摘要部分相同(内容寻址的语义)
m2, err := reg.SaveInlineMedia("req-2", base64.StdEncoding.EncodeToString(raw), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if filepath.Base(m1["file"].(string)) == filepath.Base(m2["file"].(string)) {
t.Error("不同 name 的同内容落盘,文件名不应完全相同(name 前缀不同)")
}
if !strings.HasPrefix(filepath.Base(m1["file"].(string)), "req-1_") {
t.Errorf("文件名应保留 name 前缀,实际 %s", filepath.Base(m1["file"].(string)))
}
if !strings.HasSuffix(m1["file"].(string), ".jpg") {
t.Errorf("应按 mime 推出 .jpg 后缀,实际 %s", m1["file"])
}
}
// ---- 失败必须报错,不得静默降级 ----
func TestSaveInlineMedia_FailuresAreExplicit(t *testing.T) {
reg := NewRegistry()
// 未配置落盘目录
if _, err := reg.SaveInlineMedia("x", "data:image/jpeg;base64,AAAA", ""); err == nil {
t.Error("未配置 mediaDir 时必须报错(静默降级会让 agent 拿不到路径也无从排查)")
}
reg.SetMediaDir(t.TempDir())
for name, payload := range map[string]string{
"空载荷": "",
"只有空白": " ",
"非法base64": "data:image/jpeg;base64,!!!not-base64!!!",
"缺逗号": "data:image/jpeg;base64",
"解出空内容": "data:image/jpeg;base64,",
} {
if _, err := reg.SaveInlineMedia(name, payload, ""); err == nil {
t.Errorf("%s 应报错,实际返回成功", name)
}
}
}
// ---- 尺寸解析:不认识的格式省略字段,而不是填 0 ----
func TestImageDimensions(t *testing.T) {
// JPEG
if w, h, ok := imageDimensions(jpegBytes(t, 200, 150)); !ok || w != 200 || h != 150 {
t.Errorf("JPEG 尺寸解析错: %dx%d ok=%v", w, h, ok)
}
// PNG(存一个真 PNG 头)
png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13,
'I', 'H', 'D', 'R', 0, 0, 1, 44, // 300
0, 0, 1, 0x90, // 400
8, 6, 0, 0, 0}
if w, h, ok := imageDimensions(png); !ok || w != 300 || h != 400 {
t.Errorf("PNG 尺寸解析错: %dx%d ok=%v", w, h, ok)
}
// 认不出的格式 ⇒ ok=false,**不得**返回 0,0,true
if _, _, ok := imageDimensions([]byte("not an image at all")); ok {
t.Error("非图片应返回 ok=false")
}
if _, _, ok := imageDimensions(nil); ok {
t.Error("空输入应返回 ok=false")
}
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}