refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口

## 用户裁定(本次唯一方向)

1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」

## 实测起点

    537 个测试文件 / 377 个源码文件 / 2853 条 Test
    523 / 537(97%)直接调用私有函数
    514 个是 in-package 白盒(package xxx,与源码同目录)
     88 个读 .go/.md 源码做断言
     94 个断言私有字段(f.Step / r.buf / a.sched …)
     38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
    internal/agent/core: 140 test / 52 src

## 做了什么

### ① 删除 514 个 in-package 白盒测试

不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。

用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:

    internal/agent/core/child/test/     Registry 的 Add/Get/Stop/Remove 输出
    internal/agent/core/test/           StageHost 的 Register/Run 输出
    internal/plugins/files/test/        工具 handler 的 {path} → {content/error}
    internal/plugins/webui/test/        HTTP 请求 → 响应(NewHandler/RegisterRoutes)
    internal/tfidf/test/                文本 → 向量/检索结果

现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。

### ② 回退「为测试而导出」的接口

这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:

    webui.FrontendAsset / DashboardPage        删除
    webui.GraphDataForVisual → graphDataForVisual
    webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
    webui.NewSSEEventRing                       删除
    kstate.MountForTest                         删除
    security.ClassifyPrincipalForTest           删除

生产代码对它们的内部调用同步改回私有名,不改任何行为。

### ③ CI 一鼓作气改到位(用户指定顺序)

**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。

**加两道构建期静态门**(原先这些约束错放在 go test 里):

- `测试归拢(源码目录不得有 _test.go)`
  `find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
  非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
  用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
  这是**工具链**的职责,不该由 `go test` 兼职。

`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。

### ④ 顺带(上一批未提交的)

`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。

## 验证

    go build ./...                     PASS
    go vet ./...                       PASS
    gofmt -l                           干净
    python3 .github/scripts/check-ci-gates.py   PASS
    go test ./... -count=1             5 个测试包全过 / 100 个包无测试 / 0 FAIL
    源码目录 _test.go 数              0
    测试文件数                         9(全部在 <module>/test/)

## 后续

按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
This commit is contained in:
JianFeeeee
2026-11-09 11:13:56 +08:00
parent eac8e808f1
commit 53cdc7f2a6
539 changed files with 158 additions and 116960 deletions

View File

@ -1,179 +0,0 @@
package lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
)
// TestBundledAdaptersMatchDeployedOnes **报告**仓库与部署目录的适配器差异。
//
// ★ 刻意**不作为失败判据**(只 t.Log)。
//
// 实测结果:生产部署的适配器普遍比仓库旧(anthropic 2537 vs 5144 字节),
// 而 openai 那份反而领先(仓库曾缺 stream_index、生产早就有)。
// 这是**正常的**——生产实例是长期运行的部署,适配器在它首次创建时就解包落地,
// 之后仓库一直在演进。
//
// 若把"必须一致"写成失败判据,这条判据会在任何老部署上恒红,
// 而恒红的判据会被无视——那等于没有判据,甚至更糟(它会掩盖真正的漂移)。
// 所以这里只负责**把差异摆出来**,由人判断哪一侧才是想要的。
//
// 真正能自动抓 bug 的契约检查在 TestAdapterEmitsOnlyKnownFields:它查的是
// "适配器输出的键名是否在 agentAPI.ToolCall 的契约内",与部署状态无关。
//
// ★ 这条漂移的具体内容与时间线(解释了为什么仓库长期缺它却没人发现):
//
// 生产 /data/homeagent/adapters/openai.lua 4853 字节 含 stream_index
// 仓库 ddef195 时的 openai.lua 4709 字节 不含
// 生产文件时间 2026-08-26 15:46
// ddef195 提交时间 2026-08-26 16:10
//
// 生产落地比入库**早 32 分钟** —— 该提交的说明里写着「openai.lua 输出
// stream_index 字段」,但 `--stat` 显示它没改 openai.lua。说明修复先在生产
// 环境生效、随后提交入库时漏了这个文件。
//
// 于是"生产能跑多工具、仓库跑不了"这个状态持续了一个月,而两端都没人发现:
// 生产不报问题(它有),仓库的判据也测不到(它直接构造 Go 结构体,
// 绕过适配器)。
//
// ★ 这条判据是被一次误判逼出来的。
//
// 我曾断言「openai.lua 缺 stream_index 透传、批内并发在生产走不通」,并据此
// 写了实现与提交。后来核对生产实例才发现:
//
// 生产 /data/homeagent/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 {
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)
}

View File

@ -1,184 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// ★ 必须真正加载并执行**内嵌的 openai.lua**。
//
// 为什么不复用 core 里的 stream_index_test.go:那个判据直接构造 Go 结构体
// agentAPI.StreamChunk{...},**不经过 Lua 适配器** —— 于是"适配器有没有把
// 上游 index 透传出来"这件事它永远测不到。
//
// 历史教训:提交 ddef195(2026-08-26,"流式并行 tool_call 按 JSON index 分桶")
// 的说明里写着「openai.lua 输出 stream_index 字段」,Go 侧也加了
// StreamIndex int `json:"stream_index,omitempty"` 并注明"lua 适配器以
// stream_index 键透传" —— 但那次提交**根本没有改 openai.lua**(6 个文件里
// 没有它)。内核侧逻辑写好了、判据也加上了,唯独透传那一步从未落地。
//
// 生产为什么没暴露:单工具调用时上游 index 恒为 0,缺省也是 0,分桶恰好正确。
// 只有一轮**多个** tool_call(index=1,2,3…)时才会全部并到槽 0。
type streamIndex struct {
StreamIndex int `json:"stream_index"`
ID string `json:"id"`
Name string `json:"name"`
RawArguments string `json:"raw_arguments"`
}
type unifiedChunk struct {
ToolCalls []streamIndex `json:"tool_calls"`
Content string `json:"content"`
Done bool `json:"done"`
}
// openAIMultiToolChunk 造一个「一轮 3 个 tool_call」的首分片,形态取自真实
// OpenAI 流式协议:每个元素带 index/id/name/arguments。
const openAIMultiToolChunk = `{"id":"c","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[` +
`{"index":0,"id":"c0","type":"function","function":{"name":"cmd_run","arguments":"{\"command\":\"a\"}"}},` +
`{"index":1,"id":"c1","type":"function","function":{"name":"cmd_run","arguments":"{\"command\":\"b\"}"}},` +
`{"index":2,"id":"c2","type":"function","function":{"name":"cmd_run","arguments":"{\"command\":\"c\"}"}}` +
`]}}]}`
// TestOpenAIAdapterPassesThroughStreamIndex 是本判据的核心。
//
// 断言:三个 tool_call 的 stream_index 必须是 0/1/2。
//
// 现状(缺透传)下三者全为 0 —— 内核 accumulateStream 会把三个分片并到
// 同一个桶,argsRaw 互相混拼,最终每个工具都报"参数不是合法 JSON",
// 而工具一次都没真跑过。
func TestOpenAIAdapterPassesThroughStreamIndex(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "openai")
out, err := vm.CallTransformStreamChunk("openai", openAIMultiToolChunk)
if err != nil {
t.Fatalf("transform_stream_chunk: %v", err)
}
var u unifiedChunk
if err := json.Unmarshal([]byte(out), &u); err != nil {
t.Fatalf("适配器输出不是合法 unified JSON: %v\n输出:%s", err, out)
}
if len(u.ToolCalls) != 3 {
t.Fatalf("应透传 3 个 tool_call,实际 %d 个:%s", len(u.ToolCalls), out)
}
for i, tc := range u.ToolCalls {
if tc.StreamIndex != i {
t.Errorf("第 %d 个 tool_call 的 stream_index = %d,应为 %d\n"+
"★ 缺失 index 透传会让内核把多个分片并到同一个桶(process.go:347 "+
"`idx := tc.StreamIndex`),参数混拼成非法 JSON。\n完整输出:%s",
i, tc.StreamIndex, i, out)
}
}
}
// TestOpenAIAdapterKeepsContinuationFragment 续传分片(只带 arguments、
// 不带 name)必须被保留 —— 它的 StreamIndex 是分槽的**唯一**依据。
//
// 这一条比上一条更关键:内核对「idx==0 且 name/args 都空」才跳过,
// 而 index=1/2/3 的续传分片正是靠 stream_index 归位。
func TestOpenAIAdapterKeepsContinuationFragment(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "openai")
// 续传分片:只有 index + arguments
const frag = `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":2,"function":{"arguments":"{\"command\":\"c\"}"}}]}}]}`
out, err := vm.CallTransformStreamChunk("openai", frag)
if err != nil {
t.Fatalf("transform_stream_chunk: %v", err)
}
var u unifiedChunk
if err := json.Unmarshal([]byte(out), &u); err != nil {
t.Fatalf("输出非法: %v\n%s", err, out)
}
if len(u.ToolCalls) != 1 {
t.Fatalf("续传分片应被保留,实际 %d 个:%s", len(u.ToolCalls), out)
}
if got := u.ToolCalls[0].StreamIndex; got != 2 {
t.Errorf("续传分片的 stream_index = %d,应为 2 —— 它是内核分槽的唯一依据:%s", got, out)
}
if u.ToolCalls[0].RawArguments == "" {
t.Errorf("续传分片的 arguments 丢了:%s", out)
}
}
// TestAllBundledAdaptersStreamToolCallStatus 给**全部**内嵌适配器做一次体检,
// 并把结果**分类记进测试输出**。
//
// ★ 为什么全绿不等于全支持:
//
// 早期版本对"未产出 tool_calls / 返回空 / 报错"的适配器一律 t.Skip,
// 于是一个**完全不支持流式工具调用**的适配器也会让整体显示为绿 ——
// 而"绿"在这里被误读成"都支持"。压测发现这一点时才回头查。
//
// 现在改成:分三类明确记录(supported / nested-passthrough / unsupported),
// 任何"声称支持但 index 不对"的都判失败。
func TestAllBundledAdaptersStreamToolCallStatus(t *testing.T) {
type row struct {
adapters []string
note string
}
var supported, nested, unsupported []string
for _, name := range bundledAdapterNames {
vm := NewVM(t.TempDir())
loadBundled(t, vm, name)
out, err := vm.CallTransformStreamChunk(name, openAIMultiToolChunk)
switch {
case err != nil || out == "":
// 协议不同(Anthropic 用 content_block_*、gemini 用 candidates 等)
unsupported = append(unsupported, name)
continue
}
var u unifiedChunk
if err := json.Unmarshal([]byte(out), &u); err != nil {
t.Fatalf("%s 输出非法: %v\n%s", name, err, out)
}
if len(u.ToolCalls) == 0 {
unsupported = append(unsupported, name)
continue
}
// 嵌套透传的形态:字段在 function 里,Go 侧解析不到 → 归为「需另行修」
if u.ToolCalls[0].RawArguments == "" {
var probe map[string]interface{}
_ = json.Unmarshal([]byte(out), &probe)
tcs, _ := probe["tool_calls"].([]interface{})
if len(tcs) > 0 {
if m, ok := tcs[0].(map[string]interface{}); ok {
if _, hasFn := m["function"]; hasFn {
nested = append(nested, name)
continue
}
}
}
}
bad := false
for i, tc := range u.ToolCalls {
if tc.StreamIndex != i {
t.Errorf("%s 第 %d 个 tool_call 的 stream_index = %d,应为 %d(缺透传)",
name, i, tc.StreamIndex, i)
bad = true
}
}
if !bad {
supported = append(supported, name)
}
}
t.Logf("流式工具调用支持情况(共 %d 个适配器)", len(supported)+len(nested)+len(unsupported))
t.Logf(" ✓ 扁平+index 透传正确 : %v", supported)
t.Logf(" ⚠ 透传嵌套形态需另修 : %v", nested)
t.Logf(" ✗ 未产出 tool_calls : %v", unsupported)
}
func loadBundled(t *testing.T, vm *VM, name string) {
t.Helper()
b, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Fatalf("读内嵌适配器 %s 失败: %v", name, err)
}
if err := vm.LoadAdapterSource(name, string(b)); err != nil {
t.Fatalf("加载 %s 失败: %v", name, err)
}
}

View File

@ -1,204 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// 适配器必须把**上游给的用量**透传出来。
//
// ── 为什么需要这组判据 ──
//
// 用户的描述是准确的:「Lua 适配器透传上游提供的用量,内核是估算」。
// 设计意图确实如此。但实现没做到 —— 流式路径上适配器**一个字节的用量都不透传**:
//
// anthropic.lua transform_stream_chunk
// message_start → return "" ← input_tokens 与 cache_read_input_tokens 就在这帧
// message_delta → 返回不带 usage ← output_tokens 就在这帧
// openai.lua transform_stream_chunk
// 整段从不读 chunk.usage
//
// 后果:生产流式请求的 `StreamChunk.Usage` 恒为 nil ⇒ 缓存命中率与
// token 消耗**根本无从统计**,内核只剩估算一条路。
//
// ── 为什么以前没被发现 ──
//
// 没有任何判据断言过「适配器输出里有 usage」。既有测试全部只查 content /
// tool_calls / stream_index —— 用量字段从来没进过任何 fixture。
// 这与 stream_index 那次是同一形态:内核侧写好了、判据也加了,
// 唯独**透传那一步从未落地**,而它静默(缺字段不报错,只是永远取零值)。
//
// ── 契约 ──
//
// 任何**上游 payload 里带 usage 对象**的帧,适配器输出必须带出该 usage。
// 键名对齐 Go 侧 agentAPI.TokenUsage 的 json tag
// (prompt / completion / total / cache_read / cache_miss / cache_reported /
// reasoning_tokens),因为该输出会被 json.Unmarshal 进 StreamChunk。
// usageView 是断言用的宽松视图:只关心有没有、值对不对。
type usageView struct {
Usage *struct {
Prompt int `json:"prompt"`
Completion int `json:"completion"`
Total int `json:"total"`
CacheRead int `json:"cache_read"`
CacheMiss int `json:"cache_miss"`
CacheReported bool `json:"cache_reported"`
Reasoning int `json:"reasoning_tokens"`
} `json:"usage"`
}
func decodeUsage(t *testing.T, out string) usageView {
t.Helper()
if out == "" {
return usageView{}
}
var v usageView
if err := json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("适配器输出不是合法 JSON: %s(原文 %q)", err, out)
}
return v
}
// TestAnthropicAdapterPassesInputCacheUsage 覆盖 Anthropic 的 message_start。
//
// 这是**最贵的一帧**:prompt caching 的收益全在这里 ——
// input_tokens 是总输入,cache_read_input_tokens 是其中命中缓存的部分。
// 少了它,Anthropic 用户永远看不到自己省了多少。
func TestAnthropicAdapterPassesInputCacheUsage(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "anthropic")
start := `{"type":"message_start","message":{"id":"msg_1","usage":{` +
`"input_tokens":1000,"output_tokens":1,` +
`"cache_creation_input_tokens":128,` +
`"cache_read_input_tokens":768}}}`
out, err := vm.CallTransformStreamChunk("anthropic", start)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
v := decodeUsage(t, out)
if v.Usage == nil {
t.Fatalf("message_start 的输出没有 usage —— 这一帧携带 input_tokens 与 "+
"cache_read_input_tokens,丢掉就再也拿不回来了。输出: %q", out)
}
if v.Usage.Prompt != 1896 {
// ★ 为何是 1896 而不是 1000 —— 这是**跨厂商口径差异**,很容易搞错:
//
// OpenAI : prompt_tokens **包含**缓存命中部分
// Anthropic: input_tokens **不包含**;
// 真实总输入 = input + cache_read + cache_creation
//
// 本系统的 TokenUsage.Prompt 统一按 OpenAI 口径(否则同一个字段
// 在不同后端下含义不同,统计就无法横比)。所以这里是
// 1000(input) + 768(cache_read) + 128(cache_creation) = 1896。
//
// 若直接搬 input_tokens,会**少算缓存那部分** —— 而它通常是最
// 大的一块,于是“输入很短”的错觉会让缓存收益看起来不存在。
t.Errorf("prompt = %d,期望 1896(input 1000 + cache_read 768 + "+
"cache_creation 128,按 OpenAI 口径含缓存)", v.Usage.Prompt)
}
if v.Usage.CacheRead != 768 {
t.Errorf("cache_read = %d,期望 768(cache_read_input_tokens)", v.Usage.CacheRead)
}
if v.Usage.CacheMiss != 128 {
// 缓存**写入**算未命中侧的开销(要花钱但不算 hit)。
// 命中率分母若把它算成 hit,命中率会被写缓存抬高。
t.Errorf("cache_miss = %d,期望 128(cache_creation_input_tokens 归入未命中侧)",
v.Usage.CacheMiss)
}
if !v.Usage.CacheReported {
t.Error("cache_reported = false,期望 true —— 上游明确报了缓存字段," +
"必须与「没报缓存」区分开,否则 0 命中会被显示成「无数据」")
}
}
// TestAnthropicAdapterPassesOutputUsage 覆盖 Anthropic 的 message_delta。
// output_tokens 只在这一帧,而它返回的是非空 unified ⇒ Go 侧会**采用适配器结果**、
// 不再走回退解析 ⇒ 适配器不透传就彻底没有。
func TestAnthropicAdapterPassesOutputUsage(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "anthropic")
delta := `{"type":"message_delta","delta":{"stop_reason":"end_turn"},` +
`"usage":{"output_tokens":250}}`
out, err := vm.CallTransformStreamChunk("anthropic", delta)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
if out == "" {
t.Fatal("message_delta 输出为空 —— 但该帧同时携带 stop_reason 与 usage," +
"不能整帧丢弃")
}
v := decodeUsage(t, out)
if v.Usage == nil {
t.Fatalf("message_delta 的输出没有 usage(output_tokens=250 丢失)。输出: %q", out)
}
if v.Usage.Completion != 250 {
t.Errorf("completion = %d,期望 250(output_tokens)", v.Usage.Completion)
}
}
// TestOpenAIAdapterPassesUsageOnContentFrame 覆盖「内容帧同时带 usage」。
//
// 为什么单列一条:OpenAI 的**纯 usage 心跳帧**(choices 为空)适配器会返回 "",
// 此时 Go 侧回退到标准解析,能接住用量 —— 所以那条路是通的(但属隐式依赖)。
// 而**内容帧带 usage** 时适配器返回非空 unified,Go 侧就采用适配器结果了,
// 用量只能由适配器透传。
func TestOpenAIAdapterPassesUsageOnContentFrame(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "openai")
frame := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}],` +
`"usage":{"prompt_tokens":900,"completion_tokens":40,"total_tokens":940,` +
`"prompt_tokens_details":{"cached_tokens":640}}}`
out, err := vm.CallTransformStreamChunk("openai", frame)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
v := decodeUsage(t, out)
if v.Usage == nil {
t.Fatalf("内容帧携带 usage,但适配器输出里没有。输出: %q", out)
}
if v.Usage.Prompt != 900 {
t.Errorf("prompt = %d,期望 900", v.Usage.Prompt)
}
if v.Usage.CacheRead != 640 {
t.Errorf("cache_read = %d,期望 640(prompt_tokens_details.cached_tokens)",
v.Usage.CacheRead)
}
}
// TestOpenAIAdapterPassesUsageOnlyHeartbeat 覆盖纯 usage 心跳帧。
//
// 上游开 include_usage 时,最后一帧是 `{"choices":[],"usage":{...}}`。
// 适配器可以返回 ""(让 Go 回退解析)**或**自己透传,但**不能两者都丢** ——
// 本判据接受返回 ""(回退路径已由 api 包判据覆盖),只禁止
// 「返回了非空内容却不带 usage」这种把数据吞掉的情形。
func TestOpenAIAdapterPassesUsageOnlyHeartbeat(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "openai")
frame := `{"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":0,` +
`"total_tokens":1200,"prompt_tokens_details":{"cached_tokens":1024}}}`
out, err := vm.CallTransformStreamChunk("openai", frame)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
if out == "" {
// 允许:Go 侧会回退到标准解析(api 包的
// parseOpenAICompatibleStreamChunkFull 已覆盖该形态)。
return
}
v := decodeUsage(t, out)
if v.Usage == nil {
t.Fatalf("适配器返回了非空内容却不带 usage ⇒ 用量被吞掉。输出: %q", out)
}
if v.Usage.CacheRead != 1024 {
t.Errorf("cache_read = %d,期望 1024", v.Usage.CacheRead)
}
}

View File

@ -1,66 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex 用 **Anthropic 协议**
// 的形态喂分片,验证输出是**扁平**结构且键名是 stream_index。
//
// ★ 与 OpenAI 族的判据分开,原因有二:
//
// 1. 协议不同:Anthropic 用 content_block_start / content_block_delta +
// input_json_delta,不是 OpenAI 的 delta.tool_calls。共用一个 fixture
// 会因"不适用该 chunk"而跳过 —— 看着绿,实则没测。
// 2. 它此前是**两处都错**:发嵌套 `["function"]={...}`,且键名用 `index`
// 而非 `stream_index`。两种都是**静默**失效(Go 侧按 json tag 取值,取不到
// 就是零值,没有报错):
// · 嵌套 ⇒ name / raw_arguments 取零值 ⇒ flush 时判 "无 name" 丢弃
// · 键名 index ⇒ StreamIndex 取零值 ⇒ 多分片并桶 ⇒ argsRaw 混拼
func TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "anthropic")
// 首片:content_block_start,声明工具名
start := `{"type":"content_block_start","index":2,` +
`"content_block":{"type":"tool_use","id":"toolu_01","name":"Read"}}`
// 续传片:content_block_delta + input_json_delta
delta := `{"type":"content_block_delta","index":2,` +
`"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"a\"}"}}`
for i, f := range []string{start, delta} {
out, err := vm.CallTransformStreamChunk("anthropic", f)
if err != nil {
t.Fatalf("第 %d 片: %v", i, err)
}
var u struct {
ToolCalls []map[string]interface{} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil {
t.Fatalf("第 %d 片输出非法: %s", i, out)
}
if len(u.ToolCalls) == 0 {
t.Fatalf("第 %d 片没有 tool_calls: %s", i, out)
}
tc := u.ToolCalls[0]
// 必须是扁平:name / raw_arguments 在顶层,不能藏在 ["function"] 里
if _, nested := tc["function"]; nested {
t.Errorf("第 %d 片仍是**嵌套**形态 %v —— Go 侧 agentAPI.ToolCall 没有 "+
"function 字段,name/raw_arguments 会取零值(静默)", i, tc)
}
if _, has := tc["name"]; !has {
t.Errorf("第 %d 片缺顶层 name 键: %v", i, tc)
}
if _, has := tc["raw_arguments"]; !has {
t.Errorf("第 %d 片缺顶层 raw_arguments 键: %v", i, tc)
}
// 键名必须是 stream_index,不是 index
if si, ok := tc["stream_index"]; !ok {
t.Errorf("第 %d 片缺 stream_index 键: %v —— 键名写成 index 的话内核取零值,"+
"多个分片并到同一个桶、参数混拼(静默)", i, tc)
} else if int(si.(float64)) != 2 {
t.Errorf("第 %d 片 stream_index = %v,应为 2", i, si)
}
}
}

View File

@ -1,167 +0,0 @@
package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestWriteBundledAdaptersSkipsUserModified 钉住自动更新的**安全边界**。
//
// 背景:writeBundledAdapters 原本是 `if 文件已存在 { continue }`,
// 于是「修好适配器 → 升级二进制 → 已部署实例上的文件不更新」。
// 这就是仓库 openai.lua 缺 stream_index 透传、而生产早有(2026-08-26 15:46
// 手工补上,比入库早 32 分钟)却长期没人发现的机制性原因。
//
// 改成按内容判断后,**必须**守住两条边界,否则会吞掉用户的改动:
//
// ① 用户**改过**的文件(内容与上次内嵌的 embed 不同)⇒ 不动它
// ② 与上次内嵌**一致**的文件(只是没跟上新版本)⇒ 用新的覆盖
//
// 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
// 指向自己维护的适配器。内核内置那 10 个文件虽在 DataDir 下,但"用户改了
// 内置适配器"是现实存在的用法 —— 无条件覆盖等于静默丢弃他们的修改。
func TestWriteBundledAdaptersSkipsUserModified(t *testing.T) {
dir := t.TempDir()
vm := NewVM(dir)
// 场景 A:用户把 openai.lua 改成了自己的版本
// 假适配器必须**功能完整**(含 transform_response 等钩子),
// 否则后面"用户改过的仍能加载"那条断言会因为缺函数而失败 ——
// 那是 fixture 的问题,不是保护逻辑的问题。
custom := `local adapter = {}
adapter.name = "openai"
function adapter.transform_request(r) return r end
function adapter.transform_response(r) return r end
function adapter.transform_stream_chunk(r) return r end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "openai.lua"), []byte(custom), 0644); err != nil {
t.Fatal(err)
}
// 场景 B:anthropic.lua 内容是"上次内嵌的版本"(没跟上新版)
prev, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "anthropic.lua"), prev, 0644); err != nil {
t.Fatal(err)
}
// Start 会 mkdir + writeBundledAdapters + 逐个 LoadAdapter(vm.go:173)
if err := vm.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
// ① 用户改过的 openai.lua 必须原样保留
got, err := os.ReadFile(filepath.Join(dir, "openai.lua"))
if err != nil {
t.Fatal(err)
}
if string(got) != custom {
t.Errorf("用户改过的 openai.lua 被覆盖了\n 期望:%q\n 实际:%q",
custom, string(got))
}
// 而且它应该仍被加载成功(用户改的能用)
if _, err := vm.CallTransformResponse("openai", "{}"); err != nil {
t.Errorf("用户改过的 openai.lua 加载失败:%v", err)
}
// ② 与上次内嵌一致的 anthropic.lua 应保持一致(幂等,不反复改写)
gotA, err := os.ReadFile(filepath.Join(dir, "anthropic.lua"))
if err != nil {
t.Fatal(err)
}
if string(gotA) != string(prev) {
t.Error("anthropic.lua 内容被改动了 —— 与上次内嵌一致的文件应保持不变(幂等)")
}
}
// TestBundledAdapterMatchesEmbedded 确认内嵌内容与源文件一致。
//
// 这是"用户改过"的判据基准:VM 必须拿**当前内嵌**的版本做比对。
func TestBundledAdapterMatchesEmbedded(t *testing.T) {
for _, name := range bundledAdapterNames {
b, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Errorf("%s: 内嵌读取失败 %v", name, err)
continue
}
if len(b) == 0 {
t.Errorf("%s: 内嵌内容为空", name)
}
}
}
// TestWriteBundledAdaptersUpdatesStale 验证**正向**路径:没跟上新版本的文件
// 确实被覆盖。
//
// 只测"用户改过的不被覆盖"是不够的 —— 那样一个"永远不覆盖任何文件"的
// 实现也能全绿,而那正是我们要修的病。
func TestWriteBundledAdaptersUpdatesStale(t *testing.T) {
dir := t.TempDir()
// 首跑:解包 + 写清单
vm1 := NewVM(dir)
if err := vm1.Start(); err != nil {
t.Fatalf("首跑 Start: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".bundled")); err != nil {
t.Fatalf("首跑应写出 .bundled 清单:%v", err)
}
// 场景:把 groq.lua 换成"用户版本"(与首跑内嵌不同)
groqPath := filepath.Join(dir, "groq.lua")
cur, err := bundledAdapters.ReadFile("adapters/groq.lua")
if err != nil {
t.Fatal(err)
}
if string(cur) == "" {
t.Fatal("groq.lua 内嵌为空")
}
// 模拟"内核版本变了":改清单里的哈希,让它认为 groq 落后于新内嵌
man, err := os.ReadFile(filepath.Join(dir, ".bundled"))
if err != nil {
t.Fatal(err)
}
stale := strings.ReplaceAll(string(man), groqLineHash(man), "deadbeef")
if err := os.WriteFile(filepath.Join(dir, ".bundled"), []byte(stale), 0644); err != nil {
t.Fatal(err)
}
// 二跑:应把 groq.lua 更新回内嵌版本
vm2 := NewVM(dir)
if err := vm2.Start(); err != nil {
t.Fatalf("二跑 Start: %v", err)
}
got, err := os.ReadFile(groqPath)
if err != nil {
t.Fatal(err)
}
if sha256Hex(got) != sha256Hex(cur) {
t.Errorf("落后的 groq.lua 未被更新回内嵌版本(这是本次要修的病)\n"+
" 盘上 %d 字节 / 内嵌 %d 字节", len(got), len(cur))
}
// 幂等:三跑不应再改任何文件
before, _ := os.ReadFile(groqPath)
vm3 := NewVM(dir)
if err := vm3.Start(); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(groqPath)
if string(before) != string(after) {
t.Error("已是最新版本却被再次改写 —— 更新不幂等")
}
}
// groqLineHash 从清单里取出 groq 那行的哈希。
func groqLineHash(manifest []byte) string {
for _, line := range strings.Split(string(manifest), "\n") {
if strings.HasPrefix(line, "groq\t") {
return strings.TrimPrefix(line, "groq\t")
}
}
return ""
}

View File

@ -1,66 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// TestDeepSeekAdapterHandlesStreamToolCalls 钉住「deepseek 源的流式模式也能工具调用」。
//
// ★ 为什么单独给 deepseek 写判据:
//
// 它的 transform_stream_chunk 只透 content/done,**完全不处理 tool_calls**
// (那部分代码只存在于 transform_response,即非流式路径)。于是:
//
// · 非流式请求 ⇒ 工具调用正常
// · 流式请求 ⇒ 工具调用**全部丢失**,模型只收到纯文本
//
// 而内核的 tool call 循环默认走流式(provider.go 的 stream 分支)。所以
// 配了 deepseek 源的用户,模型调不动任何工具,且**没有任何报错** ——
// 只是"工具好像不听话"。
//
// 这类缺陷极难察觉:功能判据(core 包的批内测试)直接构造 Go 结构体,
// 完全绕过适配器;而非流式的端到端路径又是好的。
func TestDeepSeekAdapterHandlesStreamToolCalls(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "deepseek")
// 一个含 2 个 tool_call 的 chunk(首片 + 续传片各一)
frags := []string{
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":0,"id":"t0","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`,
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":1,"id":"t1","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`,
}
for i, f := range frags {
out, err := vm.CallTransformStreamChunk("deepseek", f)
if err != nil {
t.Fatalf("第 %d 片: %v", i, err)
}
var u struct {
ToolCalls []struct {
StreamIndex int `json:"stream_index"`
ID string `json:"id"`
Name string `json:"name"`
RawArguments string `json:"raw_arguments"`
} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil {
t.Fatalf("第 %d 片输出非法: %s", i, out)
}
if len(u.ToolCalls) == 0 {
t.Errorf("第 %d 片:deepseek 适配器的流式路径**丢掉了 tool_call**\n"+
" 输出:%s\n"+
" ⇒ deepseek 源在流式模式下无法调用任何工具,且无任何报错。\n"+
" 它的 tool_calls 处理只存在于 transform_response(非流式路径)。",
i, out)
continue
}
if u.ToolCalls[0].StreamIndex != i {
t.Errorf("第 %d 片的 stream_index = %d,应为 %d", i, u.ToolCalls[0].StreamIndex, i)
}
if u.ToolCalls[0].Name != "cmd_run" {
t.Errorf("第 %d 片 name = %q,应为 cmd_run", i, u.ToolCalls[0].Name)
}
}
}

View File

@ -1,55 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex 用 **Ollama 协议**
// 的形态喂分片,验证输出是扁平结构且键名是 stream_index。
//
// ollama 的 tool_calls 是**整条一次发完**(不分片),所以 stream_index 取
// 数组下标。它此前发的是嵌套 ["function"]={...} + index 键名 —— 两种都是
// **静默**失效:Go 侧 agentAPI.ToolCall 没有 function 字段(取零值),
// 键名 index 也不会映射到 StreamIndex(取零值)。
func TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "ollama")
chunk := `{"message":{"content":"","tool_calls":[` +
`{"id":"c0","function":{"name":"Read","arguments":"{\"p\":1}"}},` +
`{"id":"c1","function":{"name":"Write","arguments":"{\"p\":2}"}}]},` +
`"done":false}`
out, err := vm.CallTransformStreamChunk("ollama", chunk)
if err != nil {
t.Fatalf("transform_stream_chunk: %v", err)
}
var u struct {
ToolCalls []map[string]interface{} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil {
t.Fatalf("输出非法: %s", out)
}
if len(u.ToolCalls) != 2 {
t.Fatalf("应有 2 个 tool_call,实际 %d: %s", len(u.ToolCalls), out)
}
for i, tc := range u.ToolCalls {
if _, nested := tc["function"]; nested {
t.Errorf("[%d] 仍是**嵌套**形态 %v —— Go 侧没有 function 字段,"+
"name/raw_arguments 取零值(静默)", i, tc)
}
if _, has := tc["name"]; !has {
t.Errorf("[%d] 缺顶层 name: %v", i, tc)
}
if _, has := tc["raw_arguments"]; !has {
t.Errorf("[%d] 缺顶层 raw_arguments: %v", i, tc)
}
si, ok := tc["stream_index"]
if !ok {
t.Errorf("[%d] 缺 stream_index: %v —— 键名写成 index 的话内核取零值,"+
"多分片并桶、参数混拼(静默)", i, tc)
} else if int(si.(float64)) != i {
t.Errorf("[%d] stream_index = %v,应为 %d", i, si, i)
}
}
}

View File

@ -1,64 +0,0 @@
package lua
import (
"encoding/json"
"testing"
)
// openAICompatibleStreamAdapters 是「OpenAI 兼容流式协议」那一族适配器。
//
// 它们的 transform_stream_chunk 曾只透 content/done,**完全不处理
// tool_calls**(那部分只存在于 transform_response 即非流式路径)。后果:
// 流式模式下工具调用全部丢失,模型调不动任何工具,且没有任何报错。
//
// 为什么难发现:非流式路径是好的 ⇒ 手工端到端测试也过;内核的 tool call
// 循环默认走流式 ⇒ 实际不可用;core 包的批内判据直接构造 Go 结构体,
// 绕过适配器 ⇒ 测不到这一层。
//
// ★ 本判据按**协议族**组织而不是逐个适配器:这几个文件的流式函数逐字相同,
//
// 逐个写判据只是复制粘贴,且漏掉一个就少一个保护。
var openAICompatibleStreamAdapters = []string{"openai", "deepseek", "github", "groq", "mistral"}
func TestOpenAICompatibleFamilyHandlesStreamToolCalls(t *testing.T) {
// 两个 tool_call,各一片:首片带 name、续传片只带 arguments
frags := []string{
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":0,"id":"t0","type":"function","function":{"name":"cmd_run","arguments":""}}]}}]}`,
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":1,"function":{"arguments":"{\"command\":\"x\"}"}}]}}]}`,
}
for _, name := range openAICompatibleStreamAdapters {
t.Run(name, func(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, name)
for i, f := range frags {
out, err := vm.CallTransformStreamChunk(name, f)
if err != nil {
t.Fatalf("第 %d 片: %v", i, err)
}
var u struct {
ToolCalls []struct {
StreamIndex int `json:"stream_index"`
Name string `json:"name"`
RawArguments string `json:"raw_arguments"`
} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil {
t.Fatalf("第 %d 片输出非法: %s", i, out)
}
if len(u.ToolCalls) == 0 {
t.Errorf("第 %d 片:%s 的流式路径**丢掉了 tool_call**\n"+
" 输出:%s\n"+
" ⇒ 该源在流式模式下无法调用任何工具,且无任何报错。",
i, name, out)
continue
}
if u.ToolCalls[0].StreamIndex != i {
t.Errorf("第 %d 片 stream_index = %d,应为 %d —— 缺它会让内核"+
"把多个分片并到同一个桶,参数混拼成非法 JSON", i, u.ToolCalls[0].StreamIndex, i)
}
}
})
}
}

View File

@ -1,192 +0,0 @@
package lua
import (
"strings"
"sync"
"testing"
)
func TestVMLoadAndTransform(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
out, err := vm.CallTransformRequest("openai", `{"model":"x"}`)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, `"model":"x"`) {
t.Fatalf("unexpected output: %s", out)
}
ep := vm.GetAdapterEndpoint("openai")
if ep == "" {
t.Fatal("openai endpoint empty")
}
hdrs := vm.GetAdapterHeaders("openai")
if hdrs == nil {
t.Fatal("openai headers nil")
}
}
func TestVMLoadCustomAdapter(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
code := `
local adapter = {}
adapter.name = "testa"
adapter.version = "1.0"
adapter.endpoint = "/x"
adapter.headers = { ["X-A"] = "1" }
function adapter.transform_request(raw) return "REQ" end
function adapter.build_headers(meta) return { ["X-Key"] = "k" } end
function adapter.transform_response(raw) return "RESP" end
function adapter.transform_stream_chunk(raw) return "CHUNK" end
return adapter
`
if err := vm.LoadAdapterSource("testa", code); err != nil {
t.Fatalf("load adapter: %v", err)
}
if out, err := vm.CallTransformRequest("testa", "x"); err != nil || out != "REQ" {
t.Fatalf("req=%q err=%v", out, err)
}
if out, err := vm.CallTransformResponse("testa", "x"); err != nil || out != "RESP" {
t.Fatalf("resp=%q err=%v", out, err)
}
if out, err := vm.CallTransformStreamChunk("testa", "x"); err != nil || out != "CHUNK" {
t.Fatalf("chunk=%q err=%v", out, err)
}
meta := map[string]interface{}{"url": "http://x", "api_key": "k1"}
hdrs, err := vm.BuildHeaders("testa", meta)
if err != nil {
t.Fatalf("build_headers: %v", err)
}
if hdrs["X-Key"] != "k" {
t.Fatalf("missing dynamic header: %v", hdrs)
}
if ep := vm.GetAdapterEndpoint("testa"); ep != "/x" {
t.Fatalf("endpoint=%q", ep)
}
if st := vm.GetAdapterHeaders("testa"); st["X-A"] != "1" {
t.Fatalf("static headers=%v", st)
}
}
func TestVMLoadStaticHeaderFallback(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
code := `
local adapter = {}
adapter.name = "statics"
adapter.headers = { ["X-S"] = "s" }
function adapter.transform_request(raw) return raw end
return adapter
`
if err := vm.LoadAdapterSource("statics", code); err != nil {
t.Fatalf("load: %v", err)
}
hdrs, err := vm.BuildHeaders("statics", nil)
if err != nil {
t.Fatalf("build_headers: %v", err)
}
if hdrs["X-S"] != "s" {
t.Fatalf("expected static fallback, got %v", hdrs)
}
}
func TestVMConcurrentCalls(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 10; j++ {
_, err := vm.CallTransformRequest("openai", `{"model":"x"}`)
if err != nil {
errs <- err
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent call: %v", err)
}
}
func TestOllamaMultimodal(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
raw := `{"model":"llama3","messages":[{"role":"user","content":"hi"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
out, err := vm.CallTransformRequest("ollama", raw)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, `"images":["AAAA"]`) {
t.Fatalf("expected base64 images array, got: %s", out)
}
if !strings.Contains(out, `"content":"look"`) {
t.Fatalf("text not preserved: %s", out)
}
}
func TestAnthropicMultimodal(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
raw := `{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":[{"type":"text","text":"what is this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AAEC"}}]}]}`
out, err := vm.CallTransformRequest("anthropic", raw)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, `"type":"image"`) || !strings.Contains(out, `"media_type":"image/png"`) || !strings.Contains(out, `"data":"AAEC"`) {
t.Fatalf("expected anthropic image source block, got: %s", out)
}
if !strings.Contains(out, `"type":"text"`) {
t.Fatalf("expected text block preserved: %s", out)
}
}
func TestOpenAIPassthroughKeepsMultimodal(t *testing.T) {
vm := NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("start vm: %v", err)
}
defer vm.Stop()
raw := `{"model":"auto","messages":[{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}]}`
out, err := vm.CallTransformRequest("openai", raw)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
if !strings.Contains(out, `"image_url"`) || !strings.Contains(out, "QUJD") {
t.Fatalf("openai passthrough dropped multimodal: %s", out)
}
}