Files
HomeAgent/internal/lua/adapter_streamindex_test.go
JianFeeeee 66200ff226 test(lua): 补适配器 stream_index 透传判据 + 全适配器体检
## 先纠正一件事:这个修复在生产上早就存在

最初我判断"`openai.lua` 缺 stream_index 透传、批内并发在生产走不通",并据此
写了实现。**核对生产实例后,这个判断是错的**:

    生产 /home/newqqagent/adapters/openai.lua   130 行  含 stream_index
    仓库 950b21b^                                128 行  无 stream_index

生产那份的注释是「透传上游分片 index:并行多工具调用时内核按它区分归属桶」——
简洁,与本提交新增的长注释不同。**也就是说仓库版本落后于生产,生产一直没这个
问题。** 我修的是"仓库与生产的差距",不是"生产正在发生的故障"。

## 真正缺的是判据

`internal/agent/core/stream_index_test.go` 的
TestAccumulateStreamParallelToolCallsByIndex 直接构造 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 个文件里没有它)。
说明与实现不符,而没有任何判据能发现。

## 本提交做的事

① 让 openai.lua 与生产一致(补 stream_index 透传),并说明为何缺它会静默失效:
   多个分片全部并到槽 0 → argsRaw 混拼 → 每个工具报"参数不是合法 JSON",
   而**工具一次都没真跑过**。单工具时上游 index 恒为 0,缺省也是 0,
   所以问题只在"一轮多个 tool_call"时显形。

② 新增 internal/lua/adapter_streamindex_test.go,**真正加载并执行内嵌的
   openai.lua**(复用 VM 的真实路径),三条判据:
   - TestOpenAIAdapterPassesThroughStreamIndex  3 个 tool_call 的
     stream_index 必须是 0/1/2
   - TestOpenAIAdapterKeepsContinuationFragment 续传分片(只有 arguments、
     没有 name)的 stream_index 必须正确 —— 它是分桶的**唯一**依据
   - TestAllBundledAdaptersStreamToolCallStatus  全 10 个适配器体检

★ 第三条刻意**不**用 t.Skip 掩盖不支持的适配器 —— 早期版本一律 Skip,结果
"完全不支持流式工具调用"也会让整体显示为绿,而绿会被误读成"都支持"。
现在分类记录:openai ✓ / kimicode+server 透传嵌套形态需另修 /
其余 7 个未产出 tool_calls。

③ 体检顺带暴露的、与本提交无关但已记录的问题:
   - **仓库 vs 生产漂移无判据**:仓库适配器落后于生产时,只有靠人工对比才发现
   - **部署陷阱**:vm.go writeBundledAdapters 是
     `if 文件已存在 { continue }`,升级二进制**不会更新已有适配器文件**。
     这可能正是"仓库缺透传却没人发现"的原因之一
2026-09-27 17:48:16 +08:00

201 lines
7.4 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"
"path/filepath"
"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(t) {
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)
}
}
func bundledAdapterNames(t *testing.T) []string {
t.Helper()
ents, err := bundledAdapters.ReadDir("adapters")
if err != nil {
t.Fatalf("读 adapters 目录失败: %v", err)
}
var out []string
for _, e := range ents {
if filepath.Ext(e.Name()) == ".lua" {
out = append(out, e.Name()[:len(e.Name())-4])
}
}
return out
}