mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
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:
200
internal/lua/adapter_streamindex_test.go
Normal file
200
internal/lua/adapter_streamindex_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user