Files
HomeAgent/internal/plugins/rtchild_reuse_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

272 lines
9.9 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 修复在**真实外部插件 + 真 RPC** 下成立。
//
// ## 为什么这一组必须单独存在(与 FullChain 的分工)
//
// `TestExternalRTChild_FullChain` 证明"全链路能跑通"——登记→划拨→注入→
// 输出回写→回收悬空。但它**一次都不测同名复用**,也不测批量停止:
// 它用的是全新的 id、且只在末尾 Cleanup 里 StopRTChildren。
//
// 而 2026-10-30 修的三处恰好全在"复用/清理"这条线上:
//
// ① RTTranscript 的读序(留档遮蔽在跑实例 ⇒ 两通电话串线)
// ② StopRTChildren 漏注销入站登记(child/rt-<id> 单调累积)
// ③ 留档跨代覆写语义
//
// 三者都是**跨通话生命周期**的缺陷 —— 单实例 e2e 天然测不到。这一组用
// 同一个插件产物、同一个通道对,反复"拨号—挂断",把清理路径跑成主路径。
//
// ## 前置(与 FullChain 同口径)
//
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
// go test ./internal/plugins/ -run TestExternalRTChild_Reuse -v
//
// 未提供产物时 **skip** 而非 fail(CI 不强制先构建插件)。
import (
"fmt"
"strings"
"testing"
"time"
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
)
// rtE2EHost 装配一个"插件面 + 内核 Agent"的现场,供复用类判据共用。
//
// 与 FullChain 内联装配的唯一差别是抽出来复用:三条判据各要一份干净现场,
// 内联复制会让"为什么这三条要各自 spawn 一套"变得不可读。
func rtE2EHost(t *testing.T, bin string) (*testPluginEnv, *agentCore.Agent, string, string) {
t.Helper()
env := setupIntegration(t)
plgDir := 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 未加载")
}
host := agentCore.New(agentCore.AgentConfig{
ID: "reuse-host",
IO: env.iom,
Provider: &e2eEchoProvider{},
})
return env, host, "rtvoice-e2e-in", "rtvoice-e2e"
}
// TestExternalRTChild_ReuseDoesNotLeakTranscript 钉住①在**真实链路**下成立:
// 同名 rtchild 回收后重建,新建实例的 transcript 不得含上一通的内容;
// 同时上一通的留档(在覆写之前)必须仍然可取 —— 那是交接契约。
//
// 变异方式:RTTranscript 改回 archive-first ⇒ 第二条断言必红(进程内判据
// 已实测;本判据保证同样的错误在跨进程装配下也拦得住)。
func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) {
bin := rtE2EPluginBin(t)
env, host, inCh, outCh := rtE2EHost(t, bin)
defer env.cleanup()
defer host.StopRTChildren()
const id = "e2e-reuse"
// ── 第一通:注入 → 跑一轮 → 回收 ──
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
t.Fatalf("第一通创建失败: %v", err)
}
if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say",
map[string]interface{}{"text": "第一通说的话"}); err != nil {
t.Fatalf("第一通注入失败: %v", err)
}
waitHistory(t, host, id, 2, "第一通(user+assistant)")
firstMsgs, ok := host.RTTranscript(id)
if !ok || len(firstMsgs) == 0 {
t.Fatalf("第一通 transcript 为空(ok=%v len=%d)", ok, len(firstMsgs))
}
firstText := agentCore.FormatRTTranscript(firstMsgs)
if n, err := host.CloseRTChild(id); err != nil || n == 0 {
t.Fatalf("第一通回收失败(n=%d err=%v)", n, err)
}
// ★ 回收后留档必须仍可取 —— 这是「推信号 + 拉状态」的另一半。
// 若这条红,说明有人为了"修串线"把留档删了(我本人曾写错过这一版)。
archived, ok := host.RTTranscript(id)
if !ok || len(archived) == 0 {
t.Fatalf("★ 回收后取不到上一通原文 —— 交接契约断链(推了信号却拉不到正文)")
}
if !strings.Contains(agentCore.FormatRTTranscript(archived), "第一通说的话") {
t.Fatalf("留档里没有第一通的内容:%s",
truncateE2E(agentCore.FormatRTTranscript(archived)))
}
// ── 第二通:同名重建,必须"忘掉"上一通 ──
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
t.Fatalf("同名重建失败: %v", err)
}
if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say",
map[string]interface{}{"text": "第二通说的话"}); err != nil {
t.Fatalf("第二通注入失败: %v", err)
}
waitHistory(t, host, id, 2, "第二通(user+assistant)")
second, ok := host.RTTranscript(id)
if !ok {
t.Fatal("第二通取不到 transcript")
}
secondText := agentCore.FormatRTTranscript(second)
if strings.Contains(secondText, "第一通说的话") {
t.Fatalf("★ 同名重建后 transcript 含**第一通**的内容 —— 两通电话串线:%s",
truncateE2E(secondText))
}
if !strings.Contains(secondText, "第二通说的话") {
t.Fatalf("第二通 transcript 里没有它自己的输入:%s", truncateE2E(secondText))
}
_ = firstText
}
// TestExternalRTChild_BulkStopUnregistersInbound 钉住②在真实链路下成立:
// 批量停止必须把 child/rt-<id> 从共享登记表里摘掉。
//
// 为什么要在跨进程层测:入站登记是**内核自己建的**,但登记表由插件
// 装配共用。进程内判据用的是裸 IOManager;这里用的是真实插件装好的那张表
// ——"在真实装配下也摘得干净"才算数。
func TestExternalRTChild_BulkStopUnregistersInbound(t *testing.T) {
bin := rtE2EPluginBin(t)
env, host, inCh, outCh := rtE2EHost(t, bin)
defer env.cleanup()
reg := env.iom.ChannelRegistry()
const id = "e2e-bulk"
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
t.Fatalf("创建失败: %v", err)
}
// 入站登记的名字由内核约定:child/rt-<id>。
// 这里不直接调用未导出的 inboundChannelName,而是按登记表现实探测:
// 批量停止前后必须有**恰好一条** child/rt- 前缀的登记消失。
const inboundPrefix = "child/rt/"
countInbound := func() int {
n := 0
for _, ch := range reg.List() {
if strings.HasPrefix(ch.Name, inboundPrefix) {
n++
}
}
return n
}
before := countInbound()
if before == 0 {
t.Fatalf("创建后没有任何 %q 前缀的入站登记 —— 判据前提不成立", inboundPrefix)
}
if n := host.StopRTChildren(); n != 1 {
t.Fatalf("StopRTChildren 返回 %d,期望 1", n)
}
after := countInbound()
if after != before-1 {
t.Fatalf("★ 批量停止后入站登记 %d → %d(期望减少 1)—— "+
"登记会随 create/destroy 次数单调累积,而外部无法单独注销",
before, after)
}
// 插件声明的 rt 通道必须**悬空**而非消失(用户裁定:主 agent 无 rt 能力)。
for _, n := range []string{inCh, outCh} {
ch, ok := reg.Lookup(n)
if !ok {
t.Fatalf("批量停止把插件声明的 rt 通道 %q 也删了 —— 应保持悬空", n)
}
if ch.Owner == "" {
t.Fatalf("rt 通道 %q 被归还为未分配(应悬空指向已销毁的 %q)", n, id)
}
}
}
// TestExternalRTChild_RepeatedCyclesDoNotAccumulate 是"长跑"形态的护栏:
// 连续多轮建—停,登记表与 rtChildren 都不得累积。
//
// 这条对应生产里的真实场景:模型被反复要求"打个电话问一句"。
// 单次回收正确、但多次之后残留堆积,是典型的"每次少清一点"型缺陷
// (②正是这种:单数路径对、复数路径漏)。
func TestExternalRTChild_RepeatedCyclesDoNotAccumulate(t *testing.T) {
bin := rtE2EPluginBin(t)
env, host, inCh, outCh := rtE2EHost(t, bin)
defer env.cleanup()
reg := env.iom.ChannelRegistry()
countInbound := func() int {
n := 0
for _, ch := range reg.List() {
if strings.HasPrefix(ch.Name, "child/rt/") {
n++
}
}
return n
}
const cycles = 3
base := countInbound()
for i := 0; i < cycles; i++ {
id := fmt.Sprintf("cycle-%d", i)
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{
ID: id, Input: inCh, Output: outCh,
}); err != nil {
t.Fatalf("第 %d 轮创建失败: %v", i, err)
}
if n := host.StopRTChildren(); n != 1 {
t.Fatalf("第 %d 轮 StopRTChildren 返回 %d,期望 1", i, n)
}
}
if got := countInbound(); got != base {
t.Fatalf("★ %d 轮建—停后入站登记从 %d 涨到 %d —— 每轮都有残留", cycles, base, got)
}
if ids := host.RTChildren(); len(ids) != 0 {
t.Fatalf("多轮建—停后 rtChildren 仍有 %v", ids)
}
}
// ── 共用小工具 ──
// waitHistory 等到 rtchild 的 history 里出现 want 条消息(超时即失败)。
//
// ## 为什么等 history 而不等 Rounds(本仓已有专门教训)
//
// `rtRunOnce` 先 `rc.round++`(Rounds 立刻 +1),**之后**才流式生成、
// 最后才 `rtAppendHistory`。所以"Rounds 到了"不等于"话说完了、历史入账了"——
// 此时 RTTranscript 可能仍是空的。
//
// 这正是 dff745c「rt_children stop 判据改为等历史入账,不等 Rounds」的
// 同一个坑,我第一版又踩了一遍(首轮判据报"transcript 为空(len=0)")。
// 等 history 才是可观测的正确条件。
func waitHistory(t *testing.T, host *agentCore.Agent, id string, want int, what string) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
var last int
for time.Now().Before(deadline) {
if msgs, ok := host.RTTranscript(id); ok {
if len(msgs) >= want {
return
}
last = len(msgs)
}
time.Sleep(20 * time.Millisecond)
}
st, ok := host.RTChildStats(id)
t.Fatalf("%s:history 只到 %d 条(期望 ≥%d,ok=%v stats=%+v)—— "+
"输入可能没到达 rtSlot,或话说完了但历史没入账", what, last, want, ok, st)
}
func truncateE2E(s string) string {
r := []rune(s)
if len(r) <= 240 {
return s
}
return string(r[:240]) + "…"
}