mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
## 原机制是这次全部误判的根源
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 与体检判据共用,避免两处各写一份而漏掉某个
(漏掉的后果是该适配器永远不会被更新)。
185 lines
7.1 KiB
Go
185 lines
7.1 KiB
Go
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)
|
||
}
|
||
}
|