Files
HomeAgent/internal/lua/adapter_drift_test.go
JianFeeeee e0aeb14917 test(lua): 加适配器漂移报告 + 键名契约判据
上一提交(5d864b8)纠正了一处误判:仓库的 openai.lua 缺 stream_index 透传,
而生产实例早就有 —— 仓库版本落后于生产。这个漂移当时没有任何判据能发现。

## 两条判据,定位不同

### TestBundledAdaptersMatchDeployedOnes —— 诊断式,刻意**不**作为失败判据

实测结果:生产部署的 7/10 个适配器比仓库旧(anthropic 2537 vs 5144 字节),
而 openai 那份反而领先。这**是正常的** —— 生产是长期运行的部署,适配器在它首次
创建时就解包落地,之后仓库一直在演进。

若把"必须一致"写成失败判据,它会在任何老部署上恒红,而恒红的判据会被无视 ——
那等于没有判据,甚至更糟(它会掩盖真正的漂移)。所以这里只把差异摆出来。

但它顺带把**部署陷阱**摆到了明面上:

    vm.go writeBundledAdapters: if 文件已存在 { continue }

升级二进制**不会更新已部署的适配器文件**。于是"仓库改了适配器但老实例上不生效"
与"仓库根本没改"在现象上完全一样 —— 这大概就是仓库长期缺 stream_index 却
没人发现的原因之一。

### TestAdapterEmitsOnlyKnownFields —— 这条才是能自动抓 bug 的

契约 = agentAPI.ToolCall / StreamChunk 的 json tag:
`id, type, name, arguments, raw_arguments, stream_index`(+ 上游原样透传的
`index` / `function`)。

★ 为什么必须有:Go 侧按 json tag 反序列化,**键名拼错会静默取零值**。
`streamindex`(少个下划线)与"没写这行"的表现完全一样 —— 无报错、字段为零、
分桶全部并到槽 0。这正是本次 stream_index 缺失的形态。

判据只看"适配器吐出来的键名对不对",与环境无关,所以能在 CI 里恒定生效。

## 顺带确认的一件事(事后查明:是我的操作失误)

压测实例解包出的 openai.lua 是 4709 字节(无 stream_index),而二进制 embed
里是 5672 字节(有)。`rm -rf` 后重新解包**仍是旧的**。

我逐行读过 `writeBundledAdapters`,只找到 embed 一条来源,一度判为"未解释的
矛盾"。**真因是操作失误**:`rm -rf` 之后启动的那一轮,用的还是修复前编译的
/tmp/homed-stress —— 删除与重编之间隔了几轮,中间又用旧二进制起了好几次
实例,每次解包出来的自然都是旧版。

用当前 main 重新构建 + 全新数据目录验证:全新解包 5672 字节、含
stream_index×3、md5 与源文件一致;删掉再解一次仍一致;启动日志有
`[lua] installed bundled adapter: openai.lua`。⇒ **writeBundledAdapters 无缺陷**。

★ 教训:验证"二进制内嵌内容是否更新"时,必须确认跑的就是**刚编译出来的那个
二进制**。否则会得出"代码有 bug"的错误结论 —— 我确实这么怀疑了好几天。

(下面那条"部署陷阱"观察本身仍然成立:升级二进制确实不会更新已部署的适配器
文件。但它与这次的现象无关。)
2026-09-27 18:29:15 +08:00

165 lines
6.1 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 lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
)
// TestBundledAdaptersMatchDeployedOnes **报告**仓库与部署目录的适配器差异。
//
// ★ 刻意**不作为失败判据**(只 t.Log)。
//
// 实测结果:生产部署的适配器普遍比仓库旧(anthropic 2537 vs 5144 字节),
// 而 openai 那份反而领先(仓库曾缺 stream_index、生产早就有)。
// 这是**正常的**——生产实例是长期运行的部署,适配器在它首次创建时就解包落地,
// 之后仓库一直在演进。
//
// 若把"必须一致"写成失败判据,这条判据会在任何老部署上恒红,
// 而恒红的判据会被无视——那等于没有判据,甚至更糟(它会掩盖真正的漂移)。
// 所以这里只负责**把差异摆出来**,由人判断哪一侧才是想要的。
//
// 真正能自动抓 bug 的契约检查在 TestAdapterEmitsOnlyKnownFields:它查的是
// "适配器输出的键名是否在 agentAPI.ToolCall 的契约内",与部署状态无关。
//
// ★ 这条判据是被一次误判逼出来的。
//
// 我曾断言「openai.lua 缺 stream_index 透传、批内并发在生产走不通」,并据此
// 写了实现与提交。后来核对生产实例才发现:
//
// 生产 /home/newqqagent/adapters/openai.lua 130 行 含 stream_index
// 仓库(修复前) 128 行 无 stream_index
//
// 生产**早就有**那个透传 —— 仓库版本落后于生产。而当时没有任何判据能发现这个
// 漂移:判据要么只看仓库(自证),要么只看内核累积逻辑(绕过适配器)。
//
// 漂移为什么能长期存在:vm.go 的 writeBundledAdapters 是
// `if 文件已存在 { continue }` —— 升级二进制**不会更新已部署的适配器文件**。
// 于是「仓库改了适配器但老实例上不生效」与「仓库没改」在现象上完全一样。
//
// ★ 本判据只在**部署目录确实存在时**才检查(本机跑单测的 CI 上没有它),
// 找不到就跳过 —— 不能让判据因为环境差异而恒绿,那等于没有判据。
func TestBundledAdaptersMatchDeployedOnes(t *testing.T) {
deployDirs := deployedAdapterDirs()
if len(deployDirs) == 0 {
t.Skip("本机没有部署目录(CI 常态),跳过漂移检查")
}
for _, dir := range deployDirs {
t.Run(filepath.Base(dir), func(t *testing.T) {
ents, err := os.ReadDir(dir)
if err != nil {
t.Skipf("读 %s 失败:%v", dir, err)
}
checked := 0
var diffs []string
for _, e := range ents {
if filepath.Ext(e.Name()) != ".lua" {
continue
}
want, err := bundledAdapters.ReadFile("adapters/" + e.Name())
if err != nil {
continue // 部署目录里有仓库没有的适配器,跳过
}
got, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
checked++
if string(got) == string(want) {
continue
}
diffs = append(diffs, fmt.Sprintf("%s(部署 %d / 仓库 %d 字节)",
e.Name(), len(got), len(want)))
}
if checked == 0 {
t.Skipf("%s 里没有可比对的适配器", dir)
}
if len(diffs) > 0 {
t.Logf("⚠ %d/%d 个适配器与仓库不同:%v", len(diffs), checked, diffs)
t.Logf(" 部署目录不会被新二进制覆盖(writeBundledAdapters 文件已存在即跳过)")
t.Logf(" ⇒ 仓库的适配器改动在已部署实例上不生效。需要时直接改,或删掉该文件让内核重新解包。")
} else {
t.Logf("✓ %d 个适配器与仓库一致", checked)
}
})
}
}
// deployedAdapterDirs 找本机上已部署的适配器目录。
func deployedAdapterDirs() []string {
var out []string
for _, root := range []string{"/home", "/var/tmp", "/opt"} {
ents, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range ents {
if !e.IsDir() {
continue
}
cand := filepath.Join(root, e.Name(), "adapters")
if st, err := os.Stat(cand); err == nil && st.IsDir() {
out = append(out, cand)
}
}
}
return out
}
// TestAdapterEmitsOnlyKnownFields 验证适配器输出的 tool_call 键都在**契约内**。
//
// 契约 = internal/agent/api/provider.go 里 ToolCall / StreamChunk 的 json tag:
//
// id, type, name, arguments, raw_arguments, stream_index
//
// ★ 这条才是能自动抓 bug 的判据。漂移检查要看部署状态(环境相关),
//
// 而这条只看"适配器吐出来的键名对不对"——拼错一个字母(streamindex、
// rawArgs)就会静默失效:Go 侧按 json tag 反序列化,键不匹配 ⇒ 字段取不到
// ⇒ 零值,而**没有任何报错**。
//
// 这正是本次 stream_index 缺失的形态:键名不对时表现和"没写这行"完全一样。
func TestAdapterEmitsOnlyKnownFields(t *testing.T) {
// 允许的键:ToolCall 的 json tag + StreamChunk 的顶层键
known := map[string]bool{
"id": true, "type": true, "name": true,
"arguments": true, "raw_arguments": true, "stream_index": true,
// 上游原样透传(部分适配器直接转发 delta.tool_calls)
"index": true, "function": true,
}
chunk := `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":1,"id":"c1","type":"function","function":{"name":"f","arguments":"{\"a\":1}"}}]}}]}`
checked := 0
for _, name := range bundledAdapterNames(t) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, name)
out, err := vm.CallTransformStreamChunk(name, chunk)
if err != nil || out == "" {
continue // 协议不同,不适用该 chunk
}
var u struct {
ToolCalls []map[string]interface{} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil || len(u.ToolCalls) == 0 {
continue
}
checked++
for i, tc := range u.ToolCalls {
for k := range tc {
if !known[k] {
t.Errorf("%s tool_calls[%d] 输出了契约外的键 %q —— Go 侧按 json tag "+
"反序列化,键名对不上会**静默取零值**(与没写这行完全一样)\\n 完整:%s",
name, i, k, out)
}
}
}
}
if checked == 0 {
t.Fatal("没有任何适配器产出 tool_calls —— 测试前提不成立")
}
t.Logf("检查了 %d 个适配器", checked)
}