diff --git a/internal/plugins/remotedevice/binary_test.go b/internal/plugins/remotedevice/binary_test.go index 19cdeb29..db0d9010 100644 --- a/internal/plugins/remotedevice/binary_test.go +++ b/internal/plugins/remotedevice/binary_test.go @@ -298,8 +298,15 @@ func TestWSBinaryMediaToFile(t *testing.T) { if _, hasB64 := res["data_base64"]; hasB64 { t.Fatal("data_base64 should be absent in file mode") } - if want := filepath.Join(mediaDir, "req-file-1.mp4"); fp != want { - t.Fatalf("file path = %s, want %s", fp, want) + // ★ 2026-10-06:文件名含内容摘要(SaveInlineMedia)。 + // 截屏/录像这类会被反复抓取的媒体,用 reqID 命名会把同内容存成多份、 + // 不同内容却可能覆盖 —— 摘要后缀两���都避免。 + if filepath.Dir(fp) != mediaDir { + t.Fatalf("file 应落在媒体目录 %s,实际 %s", mediaDir, fp) + } + base := filepath.Base(fp) + if !strings.HasPrefix(base, "req-file-1_") || !strings.HasSuffix(base, ".mp4") { + t.Fatalf("文件名应为 req-file-1_.mp4,实际 %s", base) } got, err := os.ReadFile(fp) if err != nil { @@ -402,6 +409,7 @@ func TestPushDataOfflineDevice(t *testing.T) { func TestScreenseeEndToEnd(t *testing.T) { reg := NewRegistry() + reg.SetMediaDir(t.TempDir()) token := "test-token-see" reg.SetAcceptToken(func(provided string) bool { return provided == token }) @@ -449,7 +457,10 @@ func TestScreenseeEndToEnd(t *testing.T) { }() // agent 调用 screensee - res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"}) + // ★ 2026-10-06:默认走落盘 + 回路径;旧的自���描述路径用 auto_describe=true。 + res, err := dev.Execute("screensee", map[string]interface{}{ + "device_id": "see-dev", "auto_describe": true, + }) if err != nil { t.Fatalf("screensee: %v", err) } diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go index c9760151..178b3f5a 100644 --- a/internal/plugins/remotedevice/device.go +++ b/internal/plugins/remotedevice/device.go @@ -102,15 +102,22 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef { }, { Name: "screensee", - Description: "查看一台已授权设备的屏幕当前画面(截屏回传)。" + - "与 screensue(向用户屏幕显示内容)配对:screensue 是给用户看,screensee 是你看。" + - "返回屏幕截图的自动视觉描述;如需读取屏上文字可接着用 ocr_image。" + + Description: "截取一台已授权设备的当前屏幕画面。\n" + + "与 screensue(向用户屏幕显示内容)配对:screensue 是给用户看,screensee 是你看。\n" + + "★★ 返回的是截图的**落盘路径**与元信息(file/mime/size/sha256/width/height)," + + "**不含画面内容**。要看画面或读屏上文字,必须接着调用:" + + "describe_image(path=) 看画面,或 ocr_image(path=) 读文字。" + + "两步缺一不可——本工具不会自动描述任何东西。\n" + "需要 device_id(来自 devicedetect)。设备必须已授权且在线。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, - "provider": map[string]interface{}{"type": "string", "description": "可选:用于视觉描述的 LLM 源名称,不填则使用默认模型"}, + "auto_describe": map[string]interface{}{ + "type": "boolean", + "description": "可选(默认 false):true 时跳过落盘、直接用视觉模型描述" + + "(仅调试用:大截图约 13 万 token,且视觉源不可控)", + }, }, "required": []interface{}{"device_id"}, }, @@ -372,7 +379,11 @@ func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) } // screensee 实现 screensee:向设备下发 homeagent-screensee 截屏命令, -// 等待回传 jpeg base64,交给 seeHandler(agent 核心)做视觉描述。 +// screensee 截取设备屏幕。 +// +// ★★ 2026-10-06:不再自动送视觉模型,改为**落盘 + 回路径**, +// 由 agent 显式调 describe_image(path=…)/ocr_image(path=…)。见函数体注释。 +// args: device_id(必填)、provider(仅 auto_describe 时用)、auto_describe(调试用,默认 false)。 func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, error) { id, _ := args["device_id"].(string) provider, _ := args["provider"].(string) @@ -420,27 +431,66 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e // · 前若干字节的 base64 片段 + SHA256 前 16 hex // ⇒ 「回传的是不是同一张图」「是不是被截断了」一眼可判。 logScreenseePayload(id, output) - // 设备端回传 data URL(data:image/jpeg;base64,...)或裸 base64 - if !strings.HasPrefix(output, "data:") { - output = "data:image/jpeg;base64," + output + // ★★ 2026-10-06:截图先**落盘**,再把路径交给 agent。 + // + // 原行为:把 base64 直接送去视觉模型自动描述。三个问题: + // ① 96KB 截图 ≈ 13 万 token 的内联 base64; + // ② 自动挑源不可控 —— 生产实证:AUTO 把大图路由到不支持视觉的源, + // 模型回「没有收到截图」,而我们无法区分「图没到」与「模型看不了」; + // ③ agent 不知道图存不存在,也就无法决定用 describe 还是 ocr。 + // + // 现在:落盘 + 回元信息,agent 显式调 describe_image(path=…)/ocr_image(path=…) + // —— 需要哪个能力由它自己判断,且 provider 参数可控。 + // + // 落盘失败当**工具失败**上抛(不静默退回内联):图已经在手上却拿不到路径, + // agent 会既没有描述也没有可查证产物,只剩一句「失败」。 + // + // auto_describe=true 保留旧路径(调试用)。 + if auto, _ := args["auto_describe"].(bool); auto && d.seeHandler != nil { + if !strings.HasPrefix(output, "data:") { + output = "data:image/jpeg;base64," + output + } + desc, err := d.seeHandler(output, provider) + if err != nil { + // ★ 视觉描述失败 ⇒ 工具失败(不是「返回一段说明」)。 + // 生产实证:403 被塞进 description 且 status=ok, + // agent 以为拿到了结果,于是换 provider 又试一次(再失败一次)。 + d.reg.SaveResult(reqID, map[string]interface{}{ + "error": err.Error(), + "device": id, + "has_image": true, // 截图确实拿到了:区分「设备没回传」与「模型看不了」 + }) + return nil, fmt.Errorf("设备已回传截图,但视觉描述失败: %w", err) + } + return map[string]interface{}{"description": desc}, nil } - d.reg.SaveResult(reqID, res) - if d.seeHandler == nil { - return map[string]interface{}{"image_data_url": output, "note": "无视觉描述处理器,仅返回原始图像数据"}, nil - } - desc, err := d.seeHandler(output, provider) + + meta, err := d.reg.SaveInlineMedia("screensee_"+reqID, output, "image/jpeg") if err != nil { - // ★ 视觉描述失败 ⇒ 工具失败(不是「返回一段说明」)。 - // 生产实证:403 被塞进 description 且 status=ok, - // agent 以为拿到了结果,于是换 provider 又试一次(再失败一次)。 d.reg.SaveResult(reqID, map[string]interface{}{ "error": err.Error(), "device": id, - "has_image": true, // 截图确实拿到了:区分「设备没回传」与「模型看不了」 + "has_image": true, // 图确实拿到了,只是没落盘 }) - return nil, fmt.Errorf("设备已回传截图,但视觉描述失败: %w", err) + return nil, fmt.Errorf("截图已回传但落盘失败(无法交付给多模态工具): %w", err) } - return map[string]interface{}{"description": desc}, nil + d.reg.SaveResult(reqID, meta) + + out := map[string]interface{}{ + "file": meta["file"], + "mime": meta["mime"], + "size": meta["size"], + "sha256": meta["sha256"], + "device": id, + "next_step": "如需查看画面,请调用 describe_image 并传入 path 参数;需读图中的文字请用 ocr_image。", + } + if w, ok := meta["width"]; ok { + out["width"] = w + } + if h, ok := meta["height"]; ok { + out["height"] = h + } + return out, nil } // computeruse 实现 computeruse:向设备下发鼠标/键盘控制命令。 diff --git a/internal/plugins/remotedevice/inline_media_test.go b/internal/plugins/remotedevice/inline_media_test.go new file mode 100644 index 00000000..f35fa461 --- /dev/null +++ b/internal/plugins/remotedevice/inline_media_test.go @@ -0,0 +1,194 @@ +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[:]) +} diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index 28a46f76..e2e6421d 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -2,9 +2,12 @@ package remotedevice import ( "bufio" + "bytes" "crypto/sha1" // RFC6455 规定 Sec-WebSocket-Accept 必须用 SHA-1,勿改 + "crypto/sha256" "encoding/base64" "encoding/binary" + "encoding/hex" "encoding/json" "fmt" "io" @@ -155,6 +158,145 @@ func (r *Registry) SetMediaDir(dir string) { r.mu.Unlock() } +// SaveInlineMedia 把一段内联媒体(data URL 或裸 base64)落盘,返回文件元信息。 +// +// ★ 为什么抽出来(2026-10-06):screensee 之前只把 base64 塞给视觉模型, +// 带来两个问题—— +// +// ① **上下文成本**:96KB 的截图 = 约 13 万 token 的内联 base64; +// ② **诊断不了**:模型说「没收到图片」时,服务端无法证明图到底在不在 +// (只能靠日志里手写的元信息)。 +// +// 落盘 + 回路径后,agent 可以**显式**调 describe_image(path=…), +// 且「图存在」这件事有可查证的落盘产物。 +// +// 返回 meta(含 file/mime/size/sha256/width/height);mediaDir 为空或 +// 写入失败时返回 error —— **不静默降级成内联**:调用方要据此决定是否 +// 退回旧行为,而静默回退正是「失败伪装成成功」的源头。 +func (r *Registry) SaveInlineMedia(name, payload, mimeHint string) (map[string]interface{}, error) { + r.mu.RLock() + dir := r.mediaDir + r.mu.RUnlock() + if dir == "" { + return nil, fmt.Errorf("未配置媒体落盘目录(SetMediaDir 未调用)") + } + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("创建媒体目录 %s 失败: %w", dir, err) + } + + raw, mt, err := decodeInlineMedia(payload, mimeHint) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, fmt.Errorf("媒体内容为空(%s)", name) + } + + // 文件名用内容摘要:同一张图重复截屏不堆文件,不同图不覆盖。 + sum := sha256.Sum256(raw) + digest := hex.EncodeToString(sum[:]) + ext := mediaExt(mt, "") + if ext == "" { + ext = ".bin" + } + fp := filepath.Join(dir, name+"_"+digest[:16]+ext) + if err := os.WriteFile(fp, raw, 0o644); err != nil { + return nil, fmt.Errorf("写媒体文件 %s 失败: %w", fp, err) + } + + meta := map[string]interface{}{ + "file": fp, + "mime": mt, + "size": len(raw), + "sha256": digest, + } + if w, h, ok := imageDimensions(raw); ok { + meta["width"] = w + meta["height"] = h + } + return meta, nil +} + +// decodeInlineMedia 把 data URL 或裸 base64 解成字节 + mime。 +func decodeInlineMedia(payload, mimeHint string) ([]byte, string, error) { + p := strings.TrimSpace(payload) + if p == "" { + return nil, "", fmt.Errorf("媒体载荷为空") + } + mime := mimeHint + if strings.HasPrefix(p, "data:") { + // data:[][;base64], + comma := strings.IndexByte(p, ',') + if comma < 0 { + return nil, "", fmt.Errorf("data URL 缺少逗号分隔符") + } + metaPart := p[len("data:"):comma] + if strings.Contains(metaPart, "base64") { + if i := strings.IndexByte(metaPart, ';'); i >= 0 { + mime = metaPart[:i] + } else { + mime = metaPart + } + } else if mime == "" { + mime = metaPart + } + p = p[comma+1:] + } + if mime == "" { + mime = "image/jpeg" + } + raw, err := base64.StdEncoding.DecodeString(p) + if err != nil { + // 有些设备会塞换行/URL-safe 变体 + raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(strings.NewReplacer("\n", "", "\r", "").Replace(p))) + if err2 != nil { + return nil, "", fmt.Errorf("base64 解码失败: %w", err) + } + raw = raw2 + } + return raw, mime, nil +} + +// imageDimensions 从图片头部解出像素尺寸,不解码全图。 +// 不认识的格式返回 ok=false —— 调用方据此省略字段,而不是填 0。 +func imageDimensions(raw []byte) (int, int, bool) { + if len(raw) < 24 { + return 0, 0, false + } + // PNG: \x89PNG\r\n\x1a\n + IHDR(width,height BE32 @16,20) + if bytes.HasPrefix(raw, []byte{0x89, 'P', 'N', 'G'}) { + return int(raw[16])<<24 | int(raw[17])<<16 | int(raw[18])<<8 | int(raw[19]), + int(raw[20])<<24 | int(raw[21])<<16 | int(raw[22])<<8 | int(raw[23]), true + } + // JPEG: 扫 SOFn 段 + if bytes.HasPrefix(raw, []byte{0xFF, 0xD8}) { + for i := 2; i+9 < len(raw); { + if raw[i] != 0xFF { + i++ + continue + } + marker := raw[i+1] + // SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 + if marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + h := int(raw[i+5])<<8 | int(raw[i+6]) + w := int(raw[i+7])<<8 | int(raw[i+8]) + if w > 0 && h > 0 { + return w, h, true + } + } + if i+3 >= len(raw) { + break + } + segLen := int(raw[i+2])<<8 | int(raw[i+3]) + if segLen < 2 { + break + } + i += 2 + segLen + } + } + return 0, 0, false +} + // mediaExt 按 mime/kind 推断扩展名。 func mediaExt(mime, kind string) string { m := strings.ToLower(mime) @@ -913,22 +1055,17 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthor // 媒体落盘模式:写入 /.,cmd_result 返回 file 路径。 // 大体积 base64 内联会撑爆 LLM 上下文(一段 10s 录像即数 MB), // agent 应拿路径后用 files/describe_image/ocr 等工具消费。 - r.mu.RLock() - mediaDir := r.mediaDir - r.mu.RUnlock() - if mediaDir != "" { - if err := os.MkdirAll(mediaDir, 0755); err == nil { - fp := filepath.Join(mediaDir, reqID+mediaExt(acc.mime, acc.kind)) - if werr := os.WriteFile(fp, data, 0644); werr == nil { - res["file"] = fp - } else { - log.Printf("[remotedevice] media write %s: %v", fp, werr) - } - } else { - log.Printf("[remotedevice] media dir %s: %v", mediaDir, err) + if meta, err := r.SaveInlineMedia(reqID, base64.StdEncoding.EncodeToString(data), acc.mime); err == nil { + for k, v := range meta { + res[k] = v } + } else { + log.Printf("[remotedevice] media save %s: %v", reqID, err) } - // 未配置落盘目录时保持旧行为:base64 内联返回(小体积数据仍可用) + // 未落盘时保持旧行为:base64 内联返回(小体积数据仍可用)。 + // ★ 这里仍内联是可接受的退化——上面的 log 已记录原因。 + // screensee 那边不同:它把落盘失败当**工具失败**上抛, + // 因为「拿到了图却没落盘」会让 agent 拿不到路径也无从排查。 if _, hasFile := res["file"]; !hasFile { res["data_base64"] = base64.StdEncoding.EncodeToString(data) } diff --git a/internal/plugins/remotedevice/screensee_truth_test.go b/internal/plugins/remotedevice/screensee_truth_test.go index b280d281..1109f692 100644 --- a/internal/plugins/remotedevice/screensee_truth_test.go +++ b/internal/plugins/remotedevice/screensee_truth_test.go @@ -11,6 +11,7 @@ import ( "image/png" "net/http" "net/http/httptest" + "os" "strings" "testing" ) @@ -187,6 +188,8 @@ func TestScreenseE2E_VisualFailureIsErrorNotDescription(t *testing.T) { reg := NewRegistry() token := "tk-visual-fail" reg.SetAcceptToken(func(p string) bool { return p == token }) + // auto_describe 需要落盘目录先存在(默认路径也会落盘) + reg.SetMediaDir(t.TempDir()) dev := &devicectlDevice{reg: reg} // 视觉源返回 403(MCP/screensee 生产上真实发生过) @@ -224,7 +227,7 @@ func TestScreenseE2E_VisualFailureIsErrorNotDescription(t *testing.T) { } }() - res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev"}) + res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vf-dev", "auto_describe": true}) // ★ 必须报错 if err == nil { @@ -285,6 +288,9 @@ func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) { } }() + // ★★ 2026-10-06:默认路径**不再自动描述**,改为落盘 + 回路径。 + // 旧行为保留在 auto_describe=true 下,另有一处判据覆盖。 + reg.SetMediaDir(t.TempDir()) res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "vo-dev"}) if err != nil { t.Fatalf("成功路径被破坏: %v", err) @@ -293,11 +299,33 @@ func TestScreenseE2E_SuccessReturnsDescription(t *testing.T) { if !ok { t.Fatalf("返回类型变了: %T", res) } - if m["description"] != "屏幕上是一个终端窗口" { - t.Errorf("description 不对: %v", m["description"]) + fp, _ := m["file"].(string) + if fp == "" { + t.Fatalf("默认路径必须回 file,实际返回: %v", m) + } + if got, err := os.ReadFile(fp); err != nil || len(got) == 0 { + t.Errorf("file 应指向落盘的截图: err=%v", err) + } + if _, hasDesc := m["description"]; hasDesc { + t.Error("默认路径不应再返回 description(内容由 describe_image 显式取)") + } + if gotLen != 0 { + t.Error("默认路径不应调用视觉描述 handler(模型必须显式调 describe_image)") + } + + // ★ auto_describe=true 走旧路径(调试用):handler 被调,description 回来。 + res2, err := dev.Execute("screensee", map[string]interface{}{ + "device_id": "vo-dev", "auto_describe": true, + }) + if err != nil { + t.Fatalf("auto_describe 路径被破坏: %v", err) + } + m2, _ := res2.(map[string]interface{}) + if m2["description"] != "屏幕上是一个终端窗口" { + t.Errorf("auto_describe 应返回 description,实际: %v", m2["description"]) } if gotLen == 0 { - t.Error("handler 没收到 dataURL") + t.Error("auto_describe 下 handler 应收到 dataURL") } }