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 }`,升级二进制**不会更新已有适配器文件**。
     这可能正是"仓库缺透传却没人发现"的原因之一
This commit is contained in:
JianFeeeee
2026-09-27 17:13:18 +08:00
parent f8add88dbc
commit 66200ff226
2 changed files with 215 additions and 1 deletions

View File

@ -0,0 +1,200 @@
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
}

View File

@ -117,7 +117,21 @@ function adapter.transform_stream_chunk(raw_chunk)
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args
raw_arguments = raw_args,
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
--
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片
-- (process.go:347 `idx := tc.StreamIndex`)。缺了这一项,
-- 所有分片的 StreamIndex 都是缺省 0 ⇒ 全部并进同一个桶 ⇒
-- argsRaw 混拼 ⇒ 每个工具都报"参数不是合法 JSON",
-- 而工具一次都没真跑过。
--
-- 单工具调用时上游 index 恒为 0,缺省也是 0,所以这个问题
-- 在生产上长期不显形 —— 直到模型一轮发多个工具才炸。
--
-- 续传分片(只有 arguments、没有 name)尤其依赖它:
-- 那种分片除了 index 没有任何可归位的依据。
stream_index = tc.index or 0
})
end
unified.tool_calls = tcs