Files
HomeAgent/internal/plugins/seemedia/plugin_test.go
JianFeeeee b15994d964 fix(seemedia): hear_audio 不再承诺「你会听到」,如实说明可能不支持 (#7)
## 问题(e2e 抓出,非猜测)

端到端跑 hear_audio 时模型答「老实说,这次我没法真的听到」,转而去数 WAV
字节。查日志:

    tool hear_audio result: {"note":"音频已投给你——请在下一步直接聆听..."}
    modal fallback: 0 block(s) transcribed, 0 skipped
                    (provider=llmsproxy vision=true audio=false)

真因是**配置缺口 + 描述说谎**叠加:
- 当前部署所有 LLM 源都是 audio=false(visionllm/llmsproxy 均如此)
- core.input_processing.audio.fallback_provider 为空 ⇒ 无回退源
- 于是内核既不能直视、也无法转写,只发一条「拿不到音频」的 notice

而工具描述写死「你会直接听到它」、note 写「请在下一步直接聆听」——
模型同时看到「你会听到」与「拿不到」,两句自相矛盾(这就是它转去数
WAV 字节的原因)。

## 为何不改代码改行为

插件在调用时**无法**得知当前模型是否支持音频,SDK 未暴露能力查询接口
(只有 LLMAPI 的 Chat/ReloadFromConfig)。所以插件没有能力「先判断再决定
要不要注块」。正确做法是:内核继续做它该做的诚实降级(已在 modalfallback
里实现),插件这边**不许替内核承诺**。

## 改了什么

- hear_audio 描述:从无条件承诺改为条件式(「若当前模型支持音频或配了回退源,
  你会在下一步听到」),并明说:若都不支持,内核会明确告知拿不到(不会假装
  听到),那时改用 transcribe_audio 或配置回退源
- handler 的 note:从「请直接聆听」改为「请确认是否真的收到;没收到就改用
  transcribe_audio」

## 判据(可杀变异,3/3 实测)

- TestHearAudioDoesNotPromiseAudibility:描述不得含无条件承诺;必须指明
  transcribe_audio 出路;必须如实说「不支持」
- TestHearAudioNoteIsHonest:note 不得说「直接聆听」;必须给替代方案

★ 判据自身修了一个 bug(变异测试抓出):原先用「下一个 RegisterTool」做段
终点,但 hear_audio 是**最后一个**注册 ⇒ 一路截到文件尾,把 handler 的 note
也吞进来,于是 note 里残留的 transcribe_audio 让「删掉描述出路」的变异逃逸。
改用 "p.handleHearAudio)" 作终点后该变异被杀。

变异实测:
  F 描述改回无条件承诺      → FAIL ✓
  G 描述删掉 transcribe_audio 出路 → FAIL ✓(修判据前曾逃逸)
  H note 改回「直接聆听」     → FAIL ✓
  I 描述删掉「不支持」说明    → FAIL ✓

18/18 通过;全量 homed 构建通过;二进制内已无旧假承诺、含新的诚实措辞。

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 13:22:15 +08:00

256 lines
9.2 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 seemedia
import (
"os"
"path/filepath"
"strings"
"testing"
)
// seemedia 让模型**一手**看图/看视频/听音频(SetToolBlocks 直投主模型),
// 与 multimodal(二手文字描述)并存。
//
// 这些判据钉住的是**不变量**,不是某次实现:
//
// ① 来源解析三条路(url 透传 / path 读盘转 data URL / digest 取 CAS)与优先级
// ② 没有任何来源时报错,且错误**可操作**(说清传什么)
// ③ 抽帧数永远被 clamp 到 [1, maxVideoFrames]——对齐内核 modalfallback 的约束
// ④ 直投工具必须声明 ParallelSafe:false(SetToolBlocks 写的是"当前 call"的块,
// 并发多工具会串台)
// ---- ① / ② 来源解析 ----
func TestResolveMediaURL_URLPassthrough(t *testing.T) {
p := &Plugin{}
got, err := p.resolveMediaURL(map[string]interface{}{"url": "https://x/y.png"}, "image")
if err != nil {
t.Fatalf("http 直链应原样透传: %v", err)
}
if got != "https://x/y.png" {
t.Errorf("url 应原样返回(不下载、不转 data URL),实际: %q", got)
}
// 非 http 的 url 要被拒(避免 file:// 之类绕过 path 分支的本地读)
if _, err := p.resolveMediaURL(map[string]interface{}{"url": "file:///etc/passwd"}, "image"); err == nil {
t.Error("非 http(s) 的 url 必须被拒绝")
}
}
func TestResolveMediaURL_PathWinsOverDigest(t *testing.T) {
p := &Plugin{}
dir := t.TempDir()
fp := filepath.Join(dir, "shot.jpg")
if err := os.WriteFile(fp, []byte{0xFF, 0xD8, 0xFF, 1, 2}, 0o644); err != nil {
t.Fatal(err)
}
// path 与 digest 同时给:path 优先,且**不碰 CAS**(p.sdk 为 nil 也不该 panic)
got, err := p.resolveMediaURL(map[string]interface{}{"path": fp, "digest": "deadbeef"}, "image")
if err != nil {
t.Fatalf("path 优先时应成功: %v", err)
}
if !strings.HasPrefix(got, "data:image/jpeg;base64,") {
t.Errorf("应按扩展名给 image/jpeg data URL,实际前缀: %.40s", got)
}
// url 又比 path 优先
got2, err := p.resolveMediaURL(map[string]interface{}{"url": "http://a/b", "path": fp}, "image")
if err != nil || got2 != "http://a/b" {
t.Errorf("url 应比 path 优先,实际: %q err=%v", got2, err)
}
}
func TestResolveMediaURL_NoSourceIsActionable(t *testing.T) {
p := &Plugin{}
_, err := p.resolveMediaURL(map[string]interface{}{}, "image")
if err == nil {
t.Fatal("无任何来源时必须报错")
}
msg := err.Error()
for _, want := range []string{"path", "digest", "url"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息要指明可传 %s,实际: %v", want, err)
}
}
}
func TestResolveMediaURL_FileErrorsSpecific(t *testing.T) {
p := &Plugin{}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": "/nope/x.jpg"}, "image"); err == nil ||
!strings.Contains(err.Error(), "/nope/x.jpg") {
t.Errorf("读取失败应带路径,实际: %v", err)
}
dir := t.TempDir()
empty := filepath.Join(dir, "e.png")
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": empty}, "image"); err == nil ||
!strings.Contains(err.Error(), "空") {
t.Errorf("空文件应明说「空」,实际: %v", err)
}
}
func TestFallbackMIME(t *testing.T) {
if fallbackMIME("audio") != "audio/wav" {
t.Error("audio 兜底应为 audio/wav")
}
if fallbackMIME("image") != "image/png" {
t.Error("image 兜底应为 image/png")
}
// 未知 kind 落到 image(图是最常见的一手媒体)
if fallbackMIME("??") != "image/png" {
t.Error("未知 kind 应兜底 image/png")
}
}
// ---- ③ 抽帧数 clamp ----
func TestClampFrames(t *testing.T) {
cases := map[int]int{
-5: defaultVideoFrames, // 非正数回默认,而不是 0 帧(0 帧=白调一次)
0: defaultVideoFrames,
1: 1,
6: 6,
10: 10,
11: maxVideoFrames, // 超限夹到上限,不放任上游超体积
999: maxVideoFrames,
}
for in, want := range cases {
if got := clampFrames(in); got != want {
t.Errorf("clampFrames(%d)=%d,期望 %d", in, got, want)
}
}
// 不变量:上限必须 ≥ 默认,否则默认值会被自己夹掉
if maxVideoFrames < defaultVideoFrames {
t.Fatal("maxVideoFrames 不能小于 defaultVideoFrames")
}
}
func TestMimeByExt(t *testing.T) {
for p, want := range map[string]string{
"a.jpg": "image/jpeg", "a.JPEG": "image/jpeg", "a.png": "image/png",
"a.webp": "image/webp", "a.gif": "image/gif", "a.bmp": "image/bmp",
"a.wav": "audio/wav", "a.mp3": "audio/mpeg", "a.flac": "audio/flac",
"a.bin": "", "noext": "",
} {
if got := mimeByExt(p); got != want {
t.Errorf("mimeByExt(%q)=%q,期望 %q", p, got, want)
}
}
}
func TestIsHTTPURL(t *testing.T) {
for s, want := range map[string]bool{
"http://a": true,
"https://a": true,
"ftp://a": false,
"file:///x": false,
"/local/x": false,
"data:image": false,
} {
if got := isHTTPURL(s); got != want {
t.Errorf("isHTTPURL(%q)=%v,期望 %v", s, got, want)
}
}
}
// ---- ④ 直投工具的并发契约 ----
//
// SetToolBlocks 写的是"当前 call"的媒体块(IOManager.SetToolBlocks 无 call_id
// 时的兼容入口),并发执行多个直投工具会把彼此的块串到一起。所以三个工具都
// 必须 ParallelSafe:false。这条判据跨实现有效:谁把它改回 true,谁就引入串台。
func TestSeeToolsAreNotParallelSafe(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
// 不得出现 ParallelSafe:true(本插件所有工具都直投,必须串行)
if strings.Contains(strings.ReplaceAll(s, " ", ""), "ParallelSafe:true") {
t.Error("seemedia 的直投工具不得声明 ParallelSafe:true —— " +
"SetToolBlocks 写的是当前 call 的块,并发会串台")
}
}
// ---- ⑤ 不得承诺「你会听到」 ----
//
// 2026-10-11 e2e 抓出:hear_audio 的注册描述写死「你会直接听到它」,而当前
// 部署里所有 LLM 源都是 audio=false、且音频回退源为空 ⇒ 内核既不能直视也无法
// 回退,只发一条「拿不到音频」的 notice。模型看到「你会听到」+「拿不到」两句
// 自相矛盾的话,转而去数 WAV 字节(实测)。
//
// 插件在调用时**无法**得知当前模型是否支持音频(SDK 未暴露能力查询),
// 所以描述必须如实说「可能拿不到」,并把出路(transcribe_audio / 配置回退源)
// 写清楚。这条判据跨实现有效:谁把承诺改回去,谁就重建了那个自相矛盾。
func TestHearAudioDoesNotPromiseAudibility(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
// 钉住 RegisterTool("hear_audio" 这一段(到该注册调用结束为止)。
// ★ 不能用「下一个 RegisterTool」做终点:hear_audio 是**最后一个**注册,
// 那样会一直截到文件末尾,把 handler 的 note 也吞进来 —— 于是
// note 里残留的 transcribe_audio 会让本判据误判为「描述合格」。
// (这个 bug 是变异测试抓出来的:改掉描述里的出路却仍 PASS。)
i := strings.Index(s, `RegisterTool("hear_audio"`)
if i < 0 {
t.Fatal("找不到 hear_audio 注册")
}
seg := s[i:]
// 终点:该 RegisterTool 调用对应的 "}, p.handleHearAudio)" 之后。
en := strings.Index(seg, "p.handleHearAudio)")
if en < 0 {
t.Fatal("找不到 hear_audio 注册调用的结尾")
}
seg = seg[:en]
// ★ 剔除注释行:本文件的注释里会**引用旧文案**说明为何不得这么写,
// 那不是承诺。判据只应检查真正会送到模型面前的字符串(Description/note)。
var code []string
for _, line := range strings.Split(seg, "\n") {
t2 := strings.TrimSpace(line)
if strings.HasPrefix(t2, "//") {
continue
}
code = append(code, line)
}
seg = strings.Join(code, "\n")
// ① 不得出现无条件承诺
for _, bad := range []string{"你会直接听到它", "你会直接听到"} {
if strings.Contains(seg, bad) {
t.Errorf("hear_audio 描述不得无条件承诺可听性(当前配置下会与内核 notice 矛盾): 含 %q", bad)
}
}
// ② 必须说明「拿不到时怎么办」——即指向 transcribe_audio
if !strings.Contains(seg, "transcribe_audio") {
t.Error("hear_audio 描述必须指明拿不到音频时的出路(transcribe_audio)")
}
// ③ 必须点明可能不支持(诚实降级),不能只说好话
if !strings.Contains(seg, "不支持") {
t.Error("hear_audio 描述必须如实说明「当前模型/回退源可能不支持音频」")
}
}
// TestHearAudioNoteIsHonest 钉住 handler 返回的 note 同样不做虚假承诺。
func TestHearAudioNoteIsHonest(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleHearAudio(")
if i < 0 {
t.Fatal("找不到 handleHearAudio")
}
seg := s[i:]
if j := strings.Index(seg, "\nfunc "); j > 0 {
seg = seg[:j]
}
if strings.Contains(seg, "请在下一步直接聆听") {
t.Error("handleHearAudio 的 note 不得说「直接聆听」——插件无法保证当前模型支持音频")
}
if !strings.Contains(seg, "transcribe_audio") {
t.Error("handleHearAudio 的 note 必须指明听不到时的替代(transcribe_audio)")
}
}