mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 一、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
272 lines
9.9 KiB
Go
272 lines
9.9 KiB
Go
//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]) + "…"
|
||
}
|