mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 20:27:30 +00:00
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:
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user