Files
HomeAgent/internal/lua/adapter_streamindex_test.go
JianFeeeee d3eaff46f1 fix(lua): 内置适配器按内容自动更新,替代「文件已存在就跳过」
## 原机制是这次全部误判的根源

    if _, err := os.Stat(dstPath); err == nil { continue }

**升级二进制永远不更新已部署的适配器文件。** 于是"改了仓库 ≠ 生产生效",
而这个机制让同类问题可以长期潜伏:

    2026-08-26 15:46  生产 openai.lua 手工补上 stream_index 透传
    2026-08-26 16:10  ddef195 提交,说明里写了但代码没改这个文件

修复当天先在生产落地、32 分钟后才提交入库(漏了这个文件),此后一个月里
两端都没人发现 —— 生产不报问题(它有),仓库的判据也测不到(直接构造 Go
结构体,绕过适配器)。而"升级不覆盖"意味着即使仓库补上修复,已部署的
老实例也不会拿到。

## 新判据(按内容,不按存在)

    文件不存在                     ⇒ 写
    有历史清单且盘上 == 上次内嵌   ⇒ 覆盖(只是没跟上新版本)
    有历史清单但盘上 != 上次内嵌   ⇒ 不动 + 日志(用户改过)
    无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失文件(与旧行为一致)

"上次内嵌的版本"记在 `DataDir/adapters/.bundled`(`<name>\t<sha256>`)。

⚠️ 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
指向自己维护的适配器。静默覆盖等于丢弃他们的修改,而且**没有报错**。

## 判据(两个方向都要测)

- TestWriteBundledAdaptersSkipsUserModified  用户改过的**必须保留**,
  且改完仍能正常加载(fixture 必须功能完整,否则会因为缺钩子函数而失败 ——
  那是 fixture 问题,不是保护逻辑问题)
- TestWriteBundledAdaptersUpdatesStale       落后于新内嵌的**必须被更新**,
  且**幂等**(三跑不再改写任何文件)

★ 第二条是必要的:只测保护的话,**一个"永远不覆盖任何文件"的实现也能全绿**
  —— 而那正是要修的病。

## 代价(必须知道)

**升级到本版本的这一次,已部署实例的适配器不会更新**(没有历史清单可比)。
从第二次升级起自动生效。要立刻生效就删掉 DataDir/adapters 让内核重新解包。

对本次修的 8 个适配器而言:生产此刻用不到(三个源 llmsproxy/visionllm/
justworker 全是 openai.lua),所以不影响运行;将来启用 deepseek 等源时,
自然就是修复版。

## 顺带

`bundledAdapterNames` 从 writeBundledAdapters 里提出来成包级变量 ——
writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉某个
(漏掉的后果是该适配器永远不会被更新)。
2026-09-27 18:46:51 +08:00

185 lines
7.1 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"
"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)
}
}