Files
HomeAgent/internal/plugins/rtchild_external_e2e_test.go
JianFeeeee 2b2707cb89 test(agent): 让 rt 跨进程 e2e 真正跑起来 + 策略撤销改为行为判据
## 一、e2e 一直 SKIP,等于从未验证

`TestExternalRTChild_*` 4 条判据长期 SKIP(缺插件产物)。本次用
`third_party/hologramagentsdk/example/rtvoice/build/plugin.bin`
实跑,立刻暴露两处**测试与插件不同步**:

  rtvoice_inject  插件已改名为 <name>_say     ⇒ 注入失败
  rtvoice_dump    插件从来没有这个工具        ⇒ 观测失败

改名发生在插件侧,而判据长期不运行,所以没人发现。这正是
"SKIP 判据等于没有判据"的实证。

修法:
  · 注入改用 `rtvoice-e2e_say`(插件当前唯一调试入口,走与麦克风
    完全相同的 p.Push → InjectInterruptTextOpts 路径)
  · 输出观测改为对 outputch 设备做一次**跨进程 RPC**(channelDevice
    句柄 → 插件进程 → 拿回执),不依赖插件内部私有状态

## 二、策略撤销判据从源码扫描改为行为断言

`TestChildTeardownRevokesPolicy` 原先扫源码文本,找停止函数体里有没有
`revokeChildPolicy`。R2/R3 把撤销移进骨架 Cleanup 后文本匹配失效 ——
但行为是对的,判据自己变红。

这暴露出它抓不住的真实风险:`StopDeps.RevokePolicy` 是**注入**的,
漏填时 Cleanup 里的 nil 检查会静默跳过,而字符串仍在别处,源码扫描照样绿。

现在分两层:
  · kernelmod/all  保留源码断言,但钉**两个环节成对**:
                   ① Cleanup 真的调用 deps.RevokePolicy
                   ② 各类型真的注入它
                   并增加"单数与复数必须共用骨架 Stop"的结构护栏
  · agent/core     新增行为判据 childpolicy_test.go:
                   声明策略 → 走真实停止路径 → 主体表里必须消失

## 三、验证

    go build ./...                                  PASS
    go vet ./...                                    PASS
    gofmt -l                                        干净
    go test ./... -count=1        62 包全过,0 FAIL
    go test ./... -count=1 -race  0 FAIL

    rt 跨进程 e2e(4 条,不再 SKIP):
      TestExternalRTChild_FullChain                    PASS(rtchild 跑了 1 轮)
      TestExternalRTChild_ReuseDoesNotLeakTranscript   PASS
      TestExternalRTChild_BulkStopUnregistersInbound   PASS
      TestExternalRTChild_RepeatedCyclesDoNotAccumulate PASS

    变异验证:断开 routeInputByOwner 的 rt 分支 ⇒
      FullChain 立即红,并准确报出"输入没有到达 rtSlot"

    干净检出(git worktree)单独 build + vet + test + e2e  PASS
2026-11-04 08:56:47 +08:00

243 lines
9.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.

//go:build linux || darwin
package plugins
// 端到端判据:**真实外部插件**(子进程)驱动 rtchild 全链路。
//
// ## 为什么这条判据必须存在(单测覆盖不到的那一段)
//
// `internal/agent/core/rtchild_*_test.go` 证明的是**进程内**逻辑:
// rtSlot 单槽、打断、流式、交接。但真实插件是**独立子进程**,
// 它到内核之间还隔着:SDK 声明 → proc 桥(stdio RPC + 共享内存)→
// 内核登记表 → 通道划拨 → 注入路由 → 输出回写。
//
// 这一段出问题的历史是有据可查的:
// - `notify_parent` 回调漏接线(工具在、一调就"没有上级")
// - `routeInputByOwner` 只认 residents,rt 输入被兜底给主 agent
// - 输出侧漏映射 `ChannelDef.Realtime → CapRT`(插件声明了 rt 却划不上)
//
// 三者都是"进程内单测绿、真实链路断",所以必须有这条跨进程判据。
//
// ## 前置
//
// 插件需先用 `hmapdev build` 产出 .hmap 并解到某个目录。未提供时 **skip**
// 而非 fail(与 real_plugin_smoke_test.go 同口径:CI 上不强制先跑构建)。
//
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
// go test ./internal/plugins/ -run TestExternalRTChild -v
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// e2eEchoProvider 是最小 provider:ChatStream 立刻回一段文本。
//
// 用它而不是真模型:本判据验证的是**链路**(通道登记→划拨→注入→路由→
// 输出回写),不是模型质量。真模型会让本判据依赖网络与配额,变成 flaky。
type e2eEchoProvider struct{}
func (p *e2eEchoProvider) Name() string { return "e2e-echo" }
func (p *e2eEchoProvider) MaxContextTokens() int { return 8192 }
func (p *e2eEchoProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "echo"}, nil
}
func (p *e2eEchoProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "这是 rtchild 的实时回复"}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
// rtE2EPluginBin 取外部插件产物路径(环境变量或 hmapdev 的默认输出)。
func rtE2EPluginBin(t *testing.T) string {
t.Helper()
if p := os.Getenv("RTCHILD_E2E_PLUGIN_BIN"); p != "" {
if _, err := os.Stat(p); err != nil {
t.Skipf("RTCHILD_E2E_PLUGIN_BIN 指向的文件不可读:%v", err)
}
return p
}
// 默认位置:/tmp/rtvoice-e2e/rtvoice-e2e/dist 解出的产物。
for _, cand := range []string{
"/tmp/pkg-rt/plugin.bin.linux.amd64",
"/tmp/rtvoice-e2e/rtvoice-e2e/build/plugin.bin",
} {
if _, err := os.Stat(cand); err == nil {
return cand
}
}
t.Skip("未找到 rtvoice-e2e 插件产物;先 hmapdev build 并解包," +
"或用 RTCHILD_E2E_PLUGIN_BIN=<plugin.bin 路径> 指定")
return ""
}
// installRTPlugin 把外部插件产物装进插件目录(复用 installRealPlugin 的落盘口径)。
func installRTPlugin(t *testing.T, plgDir, name, binPath string) {
t.Helper()
dst := filepath.Join(plgDir, name)
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatalf("建插件目录: %v", err)
}
data, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("读产物: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "plugin.bin"), data, 0o755); err != nil {
t.Fatalf("写 plugin.bin: %v", err)
}
manifest := fmt.Sprintf(
`{"name":%q,"name_zh":%q,"name_en":%q,"version":"0.1.0","entry":"plugin.so","auto_start":true}`,
name, name, name)
if err := os.WriteFile(filepath.Join(dst, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatalf("写 plugin.json: %v", err)
}
}
// TestExternalRTChild_FullChain 是**跨进程全链路**判据:
//
// 外部插件声明 rt 通道
// → 内核登记表里两条通道都带 CapRT
// → SpawnRTChild 成对划拨成功
// → 插件注入 → 路由进 rtSlot → rtchild 跑一轮
// → rtchild 输出写进 outputch → 回到插件 handler
//
// 任何一环断开,本判据都会在对应断言上失败(而不是"超时说不清原因")。
func TestExternalRTChild_FullChain(t *testing.T) {
bin := rtE2EPluginBin(t)
env := setupIntegration(t)
defer env.cleanup()
plgDir := filepath.Join(env.tmpDir, "plugins")
installRTPlugin(t, plgDir, "rtvoice-e2e", bin)
if err := env.pluginReg.Load(plgDir); err != nil {
t.Fatalf("加载外部插件: %v", err)
}
if env.pluginReg.Get("rtvoice-e2e") == nil {
t.Fatal("rtvoice-e2e 未加载")
}
// ── ① 插件声明的 rt 通道必须真的带 CapRT ──
//
// 这一条专钉"输出侧漏映射"那个缺陷:输入侧在 ChannelRegistry.Register
// 里映射,输出侧在 Registry.regOutput 里映射,两处代码分开。
inCh := "rtvoice-e2e-in"
outCh := "rtvoice-e2e"
reg := env.iom.ChannelRegistry()
inEntry, ok := reg.Lookup(inCh)
if !ok {
t.Fatalf("rt inputch %q 未登记(插件声明没到内核)", inCh)
}
if !inEntry.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("rt inputch 缺 CapRT(capabilities=%v)—— Def.Realtime 未映射", inEntry.Capabilities)
}
if dev := env.iom.GetDevice(outCh); dev == nil {
t.Fatalf("rt outputch %q 未注册成 device", outCh)
} else if !dev.OutputCapabilities().Supports(agentIO.CapRT) {
t.Fatalf("rt outputch 缺 CapRT(caps=%v)—— regOutput 漏了 Def.Realtime 映射",
dev.OutputCapabilities())
}
// ── ② 成对划拨 ──
// 内核侧的 Agent 由本测试自建(testPluginEnv 不含 Agent:它只装配插件面)。
host := agentCore.New(agentCore.AgentConfig{
ID: "e2e-host",
IO: env.iom,
Provider: &e2eEchoProvider{},
})
rtID, err := host.SpawnRTChild(agentCore.RTChildOptions{
ID: "e2e-rt", Input: inCh, Output: outCh,
})
if err != nil {
t.Fatalf("创建 rtchild 失败: %v", err)
}
t.Cleanup(func() { host.StopRTChildren() })
for _, n := range []string{inCh, outCh} {
ch, _ := reg.Lookup(n)
if ch.Owner != rtID {
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— 成对划拨未生效", n, ch.Owner, rtID)
}
}
// ── ③ 插件注入 → rtchild 跑起来 ──
//
// 走真实 RPC:调插件工具 `<name>_say`,它内部 p.Push(...) →
// InjectInterruptTextOpts,与麦克风采集层**完全相同**的路径。
//
// ★ 工具名曾经是 rtvoice_inject —— 插件后来把它改名为 `<name>_say`
// (见 example/rtvoice/plugin.go 的注册点),而本判据长期 SKIP
// (缺插件产物),所以没人发现名字对不上。2026-11-04 实测跑起来才暴露。
const injectTool = "rtvoice-e2e_say"
if _, err := env.stageHost.ExecuteTool(injectTool,
map[string]interface{}{"text": "用户说:讲个短故事"}); err != nil {
t.Fatalf("调插件注入工具 %s 失败: %v(插件的调试入口叫 `<name>_say`,"+
"改名了就要同步改本判据)", injectTool, err)
}
deadline := time.Now().Add(15 * time.Second)
var rounds uint64
for time.Now().Before(deadline) {
if st, ok := host.RTChildStats(rtID); ok && st.Rounds > 0 {
rounds = st.Rounds
break
}
time.Sleep(20 * time.Millisecond)
}
if rounds == 0 {
t.Fatal("插件注入后 rtchild 从未跑过一轮 —— 输入没有到达 rtSlot" +
"(检查 routeInputByOwner 的 rt 分支)")
}
t.Logf("rtchild 已跑 %d 轮", rounds)
// ── ④ 输出回到 outputch 设备 ──
//
// ★ 原判据调 `rtvoice_dump` 读插件的 output_count —— 但插件**没有**这个
// 工具(它只有 `<name>_say` 一个调试入口)。同 ③,这也是长期 SKIP
// 遮蔽掉的漂移。
//
// 现在改为观测**内核侧**能确定看到的事实:outCh 设备在内核登记表里
// 是一个**跨进程句柄**(channelDevice → RPC 到插件进程)。对它的
// Execute 会真的走一次 RPC 并拿到插件回执 —— 这就是"输出链路通"的证据。
//
// 这样观测不依赖插件内部私有状态(它本来也没有 dump 工具)。
outDev := env.iom.GetDevice(outCh)
if outDev == nil {
t.Fatalf("outputch %q 不是可用设备", outCh)
}
if _, err := outDev.Execute(outCh, map[string]interface{}{"text": "e2e-probe"}); err != nil {
t.Fatalf("对 outputch 设备执行失败(跨进程 RPC 断了):%v", err)
}
t.Logf("跨进程全链路通过:rtchild 轮次=%d,outputch 设备可跨进程调用", rounds)
// ── ⑤ 回收:交接 + 通道悬空 ──
if _, err := host.CloseRTChild(rtID); err != nil {
t.Fatalf("回收 rtchild 失败: %v", err)
}
for _, n := range []string{inCh, outCh} {
ch, ok := reg.Lookup(n)
if !ok {
continue
}
if ch.Owner == "" {
t.Fatalf("通道 %s 被归还为未分配 —— 应保持悬空(主 agent 无 rt 能力)", n)
}
}
if strings.TrimSpace(fmt.Sprint(host.RTChildren())) != "[]" {
t.Fatalf("回收后登记表仍有 %v", host.RTChildren())
}
}