Files
HomeAgent/internal/agent/core/rtchild_route_test.go
JianFeeeee a02feaaff7 refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。

## 1. 抽出 child 包(类型抽象 + 单一登记表)

新增 internal/agent/core/child/:
  child.go      Kind / Child(嵌入用)/ State / 残余与通道处置
  registry.go   单一 Registry + Entry 接口 + Cleanup 骨架
  kind.go       ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
  kinds.go      四种子 agent 的清单与迁移状态

此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。

现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。

## 2. rt 的输出改为异步(用户的原始诉求)

rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。

新增 outputpump.go(输出通道的异步壳):
  · Push 立即返回,交付由独立消费 goroutine 完成
  · 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
  · 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
  · Close 排空后退出,不吞最后半句
  · 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)

## 3. 工具钩子统一(本轮最大发现)

实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。

⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。

新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。

## 判据(均经变异验证)

  TestRTChild_OutputIsAsync              改回同步 ⇒ 红(实测确认)
  TestOutputPump_DropsOldestOnFull       改阻塞/无界 ⇒ 红
  TestOutputPump_CloseDrains             丢弃队列 ⇒ 红
  TestOutputPump_ResolvesDeviceEachTime  缓存指针 ⇒ 红
  TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
  TestRTChild_StageCanDenyTool           同上
  TestWorkerToolsGoThroughPluginStages   同上(变异实测确认)
  TestWorkerToolsStaySerial              改成并发 ⇒ 红
  TestNoChildTypeTablesInCore            源码扫描,禁类型专属表回潮
  TestRTChild_HasGraphMemory 等 5 条     rt 接入图记忆

## 顺带修复

  · rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
    memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
    默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
    闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
  · rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。

## 保留的语义(逐项确认仍在)

  驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
  三种通道处置:ChannelRelease / Suspend / Takeover
  残余 keep/drop + harvestResidentMemory
  child.Start() 的 7 个 goroutine

验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
2026-11-04 08:21:15 +08:00

541 lines
20 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 core
// 判据:**插件 → rtchild 的通路**。
//
// 这是本轮发现的最后一个真实缺口(比 R10 的 notify_parent 更关键):
//
// 插件只能调 Inject* 系列(SDK 里没有任何 rtchild 接口),
// 它们全部汇到 IOManager.deliverInput → routeInputByOwner。
// 而初版 routeInputByOwner 只认 a.residents(驻留子)——
// rt 通道的 Owner 是 rtchild id,查不到就**兜底给主 agent 处理**。
//
// 后果:插件的语音/画面输入永远打在父身上,rtchild 的轮次永远是 0。
// 这正是 resident-subagent-design.md 记的那个现场故障的同型形态
// (「子挂着 inputch=[timer],timer 的输入却打在父身上」)。
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// registerRTChannelWithOwner 注册一个 rt 通道并划给指定 rtchild。
func registerRTChannelWithOwner(t *testing.T, host *Agent, name, ownerID string) {
t.Helper()
reg := host.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{
Name: name, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道: %v", err)
}
if err := reg.Assign(name, ownerID, 0); err != nil {
t.Fatalf("划给 %s: %v", ownerID, err)
}
}
// TestRTChannel_PluginInputReachesRTSlot 是这条通路的**核心判据**:
// 插件往 rt 通道注入输入,必须进 rtSlot 并真的跑起一轮。
//
// 若 routeInputByOwner 不识别 rtchild,输入会被兜底给主 agent 处理
// (宿主没有调度器,于是什么都不会发生)—— 本判据会超时变红。
func TestRTChannel_PluginInputReachesRTSlot(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 先注册一对 rt 通道;**划拨由 SpawnRTChild 自己做**(成对划拨)。
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "voice-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
// 插件注入:完全走真实路径(IOManager → deliverInput → router)。
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "用户说话了"})
waitFor(t, "rtchild 被插件输入唤起", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.Rounds >= 1
})
calls, _ := p.stats()
if calls == 0 {
t.Fatal("插件输入到达了 rtSlot 但没跑模型")
}
// 内容必须真的进入 history(不是空跑)。
tr, _ := host.RTTranscript(id)
if len(tr) == 0 {
t.Fatal("跑了一轮但 history 为空")
}
}
// TestRTChannel_PluginInputIsLatestWins 钉住:插件连发时也走**单槽覆盖**语义,
// 不会因为"走了路由"就退化成排队。
func TestRTChannel_PluginInputIsLatestWins(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "burst-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
time.Sleep(30 * time.Millisecond)
const burst = 40
for i := 0; i < burst; i++ {
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "连发语音"})
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, _ := host.RTChildStats(id)
// ★ 判据取**不变式**而不是"跑了多少轮"。
//
// 初版断言 rounds <= 3,在 -race 下稳定失败(实测跑了 13 轮):
// 竞态插桩让 emit 变慢,40 条在 rtLoop 起轮之前全部到达,于是
// 每完成一轮就立刻被下一条唤起 —— 那不是"队列退化",而是**正常的
// 最新者胜**(每一轮用的都是当时最新的一条,没有一条在等前一条跑完)。
//
// 真正该钉的是:**每条输入要么被处理过、要么被覆盖丢弃**,
// 不存在"既没处理也没记录丢弃"的第三条路。
if st.Dropped == 0 && st.Rounds < burst {
t.Fatalf("连发 %d 条:dropped=0 且只跑了 %d 轮 —— "+
"有输入凭空消失了(既没被处理,也没计入覆盖丢弃)", burst, st.Rounds)
}
// 槽最终必须被清空(没有积压 —— 这是"不排队"的直接证据)。
if st.Pending {
t.Fatal("连发结束后槽里仍有待处理输入 —— rt 通道在排队")
}
}
// TestRTChannel_NoOwnerStillFallsBackToMain 钉住边界:
// rt 通道**未划拨**时输入仍归父处理(不静默丢)。
//
// 这条防"为了让 rt 通路工作而把所有 rt 输入都截走"——那会让未分配的
// rt 通道输入凭空消失。
func TestRTChannel_NoOwnerStillFallsBackToMain(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 注册但**不划拨**(Owner == "")—— 这条判据测的是路由的兜底分支,
// 与 SpawnRTChild 的成对划拨无关,所以直接登记一条裸通道。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "unassigned", Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册: %v", err)
}
// 路由必须返回 false(不接管),让父的队列收下它。
if handled := host.routeInputByOwner(&agentIO.InputEvent{
Source: "voice-plugin", Type: "text", OutputChannel: "unassigned",
Payload: map[string]interface{}{"content": "无人认领"},
}, false); handled {
t.Fatal("未划拨的 rt 通道输入被截走了 —— 会静默丢输入")
}
}
// TestRTChannel_SDKRealtimeBecomesCapRT 钉住 SDK → 内核的**唯一一次**能力位转换:
// 插件在 ChannelDef 里声明 Realtime=true,登记后通道必须带上 CapRT,
// 从而**能**被 AssignRealtime 划拨。
//
// 若去掉 Register 里的映射,插件就无法登记 rt 通道 ——
// rtchild 永远拿不到插件的语音输入(整条链路断在第一步)。
func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 插件侧声明(走 SDK 的 ChannelDef.Realtime)。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "sdk-rt", Plugin: "voice",
Def: agentIO.ChannelDef{Realtime: true},
}); err != nil {
t.Fatalf("注册: %v", err)
}
ch, ok := host.io.ChannelRegistry().Lookup("sdk-rt")
if !ok {
t.Fatal("通道未登记")
}
if !ch.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("Def.Realtime=true 但 Capabilities 没有 CapRT(= %v)—— "+
"插件声明的 rt 通道无法被划拨", ch.Capabilities)
}
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
rc := host.rtChildOf(id)
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
}
}
// ── 成对划拨 / 主 agent 无 rt 能力 / 销毁后悬空 ──
//
// 用户裁定(2026-10-30):
// 「主 agent 创建 rtchild,将 rt 通道划给 rtchild,开始工作
// (rt 通道必须**成对**划给子 agent)」
// 「主 agent 没有 rt 能力,rt 通道在 rtchild 销毁后应当**悬空**」
// registerRTPair 注册一对 rt 通道(input + output),供 Spawn 划拨。
func registerRTPair(t *testing.T, host *Agent, in, out string) {
t.Helper()
reg := host.io.ChannelRegistry()
for _, n := range []string{in, out} {
if err := reg.Register(agentIO.InputChannel{
Name: n, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道 %s: %v", n, err)
}
}
}
// TestSpawnRTChild_AssignsPairedChannels 钉住"成对划拨":
// SpawnRTChild 必须把 input 与 output **两条**都划给 rtchild,而不是只划一条。
//
// 变红条件:去掉 SpawnRTChild 里的 AssignRealtime 调用 ⇒ Owner 仍是 "" ⇒ 红。
func TestSpawnRTChild_AssignsPairedChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "paired", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
ch, ok := reg.Lookup(n)
if !ok {
t.Fatalf("通道 %s 未登记", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— "+
"rt 通道没有成对划给 rtchild", n, ch.Owner, id)
}
}
}
// TestSpawnRTChild_RejectsMissingHalf 钉住成对的**强制性**:
// 只给 input 或只给 output 都必须被拒(否则 rtchild 半残且不报错)。
func TestSpawnRTChild_RejectsMissingHalf(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in2", "voice2")
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-in", Output: "voice2"}); err == nil {
t.Fatal("只给 outputch 竟然创建成功 —— rtchild 会永远听不到用户")
}
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-out", Input: "voice-in2"}); err == nil {
t.Fatal("只给 inputch 竟然创建成功 —— rtchild 的输出无处可去")
}
// 被拒后**不得**在登记表里留下半残实例。
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("创建被拒但登记表里有 %v", got)
}
}
// TestSpawnRTChild_RejectsChannelOwnedByMainAgent 钉住「主 agent 没有 rt 能力」:
// 一条归属于主 agent 的通道不能被拿来当 rt 通道。
func TestSpawnRTChild_RejectsChannelOwnedByMainAgent(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in3", "voice3")
// 把 input 划给主 agent 自己(模拟"主 agent 占着这条通道")。
if err := host.io.ChannelRegistry().Assign("voice-in3", string(host.id), 0); err != nil {
t.Fatalf("划给主 agent: %v", err)
}
_, err := host.SpawnRTChild(RTChildOptions{ID: "steal", Input: "voice-in3", Output: "voice3"})
if err == nil {
t.Fatal("主 agent 自己占着的通道被划成 rt 通道 —— 主 agent 不持有 rt 能力")
}
}
// TestStopRTChild_ChannelsBecomeDangling 钉住用户裁定:
// rtchild 销毁后通道**悬空**(Owner 仍指向已销毁的 id),**不归还为未分配**。
//
// 为什么必须悬空而不是归还:
// - 归还为 "" ⇒ routeInputByOwner 会把它兜底给主 agent,而主 agent
// 没有 rt 能力 ⇒ 一条 rt 通道静默变成普通通道(不可查的坏状态)
// - 悬空 ⇒ 路由查不到该 owner,走既有"owner 无匹配 agent"兜底**并留日志**
// (可查的坏状态)
func TestStopRTChild_ChannelsBecomeDangling(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in4", "voice4")
id, err := host.SpawnRTChild(RTChildOptions{ID: "dangling", Input: "voice-in4", Output: "voice4"})
if err != nil {
t.Fatalf("创建: %v", err)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in4", "voice4"} {
ch, ok := reg.Lookup(n)
if !ok {
continue // 注销也算一种处理;但插件声明的 rt 通道不该被注销
}
if ch.Owner == "" {
t.Fatalf("通道 %s 被归还为未分配 —— 主 agent 没有 rt 能力,"+
"归还会让它静默兜底到主 agent(应保持悬空)", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望悬空保留 %q", n, ch.Owner, id)
}
}
}
// ── rt_children 工具面(主 agent 创建 rtchild 的唯一入口) ──
// TestRTChildrenTool_ListEmpty 钉住空态提示可读。
func TestRTChildrenTool_ListEmpty(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(got, "没有实时子 agent") {
t.Fatalf("空态提示不对:%q", got)
}
}
// TestRTChildrenTool_CreateRequiresBothChannels 钉住成对要求,
// 且失败提示必须**列出可用的 rt 通道**(否则模型只能瞎猜名字反复重试)。
func TestRTChildrenTool_CreateRequiresBothChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// 只给 input:必须拒,并列出可用通道。
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "c1", "inputch": "voice-in",
},
})
if !strings.Contains(got, "必须成对") {
t.Fatalf("只给 inputch 未被拒:%q", got)
}
if !strings.Contains(got, "voice-in") || !strings.Contains(got, "voice") {
t.Fatalf("失败提示没有列出可用 rt 通道(模型无法自我修正):%q", got)
}
}
// TestRTChildrenTool_FullLifecycle 是**端到端**:模型视角走一遍
// create → list → send → stop(这正是"主 agent 创建 rtchild 并开始工作")。
func TestRTChildrenTool_FullLifecycle(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// ① create(成对)
out := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "call-1",
"inputch": "voice-in", "outputch": "voice",
},
})
if !strings.Contains(out, "已创建") {
t.Fatalf("create 失败:%q", out)
}
// 通道必须已成对划给它。
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
if ch, _ := reg.Lookup(n); ch.Owner != "call-1" {
t.Fatalf("通道 %s 的 Owner = %q,期望 call-1", n, ch.Owner)
}
}
// ② list 能看到它
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(out, "call-1") {
t.Fatalf("list 里看不到刚创建的实例:%q", out)
}
if !strings.Contains(out, "打断") {
t.Fatalf("list 提示没有说明 rt 的打断语义:%q", out)
}
// ③ send:打断式注入,真的跑起来
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "send", "id": "call-1", "text": "用户说:你好",
},
})
if !strings.Contains(out, "打断") {
t.Fatalf("send 没说明打断语义:%q", out)
}
// ★ 必须等**历史入账**,不能等 Rounds。
//
// Rounds 在 ChatStream **被调用时**就自增(请求刚构建完),而 assistant
// 那条消息是**流结束后**才追加进 history 的。等 Rounds 的话,随后的
// stop 可能读到 n==0,于是走"本次通话没有上下文需要交接"分支 ——
// 判据就会怪 stop 没提示 rt_transcript,而其实是它自己等早了。
// (同 rtchild_r7b_test 的坑,实测约 1/12 概率。)
waitFor(t, "rtchild 跑了一轮并写入历史", func() bool {
tr, ok := host.RTTranscript("call-1")
return ok && len(tr) > 0
})
// ④ stop:回收并交接
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "stop", "id": "call-1"},
})
if !strings.Contains(out, "已回收") {
t.Fatalf("stop 失败:%q", out)
}
if !strings.Contains(out, "rt_transcript") {
t.Fatalf("stop 没有告诉模型用 rt_transcript 取回原文:%q", out)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("回收后登记表仍有 %v", got)
}
}
// TestRTChildrenTool_UnknownAction 钉住未知 action 响亮报错并列出可用值。
func TestRTChildrenTool_UnknownAction(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "destroy"},
})
if !strings.Contains(got, "未知 action") || !strings.Contains(got, "list") {
t.Fatalf("未知 action 提示不可操作:%q", got)
}
}
// TestRTChildrenTool_NotVisibleToChild 钉住工具是**父专属**的:
// rtchild 自己不该看到 rt_children(否则它能无限自我复制)。
func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
})
id := rtSpawn(t, host, "no-recurse")
rc := host.rtChildOf(id)
if rc == nil {
t.Fatal("实例不在登记表")
}
if _, ok := rtToolNames(rc.agent.buildToolDefs())["rt_children"]; ok {
t.Fatal("rtchild 自己能看到 rt_children —— 会形成无界自我复制")
}
}
// TestRTChildrenTool_WrongParamNameIsRejectedNotSilent 钉住一次**真实事故**。
//
// ## 事故现场(2026-10-30,生产实例 e2e)
//
// 我用 `rt_children action="create" input="rtvoice-in" output="rtvoice"`
// 创建 rtchild —— 参数名写成了 `input`/`output`,而工具声明的是
// `inputch`/`outputch`。
//
// 结果:`strArg(tc,"inputch")` 拿到空串,本应报"必须成对给出",
// **但它成功了**,而且 `rtvoice`(输出通道)被划给了新 rtchild。
// 随后 `rtvoice_say` 往 `rtvoice-in` 灌语音,rtchild 轮次永远是 0 ——
// 症状是"通道注册了、插件活着、推送 ok,但子没反应",
// 与真因(参数名拼错)隔了三层。
//
// ## 本条判据钉什么
//
// **参数名写错必须失败,且错误信息要能让人改对**:
// 指出缺哪个参数名,并列出当前可用的 rt 通道。
//
// 只断言"返回里有错"不够 —— 那可能只是碰巧提到"创建失败"。
// 所以同时断言:
//
// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么
// ② **没有**创建出 rtchild(不能半成功后失败)
// ③ 通道归属没被改动(不能"创建失败但通道已经被占了")
func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// 参数名照抄我当时的错误写法:input/output 而不是 inputch/outputch。
out := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create",
"id": "typo-child",
"input": "voice-in", // ← 错的参数名
"output": "voice", // ← 错的参数名
},
})
// ① 必须报错,且指名正确参数名
if !strings.Contains(out, "inputch") || !strings.Contains(out, "outputch") {
t.Fatalf("参数名写错时必须指出正确的参数名(inputch/outputch),实际:%q", out)
}
// ② 不能创建出来
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("参数名写错却创建成功了:%v —— 这会得到一个收不到输入的哑子", got)
}
// ③ 通道归属不能被改动("失败了但通道已被占"是最难查的那种)
reg := host.io.ChannelRegistry()
for _, name := range []string{"voice-in", "voice"} {
ch, ok := reg.Lookup(name)
if !ok {
t.Fatalf("通道 %s 不见了", name)
}
if ch.Owner != "" {
t.Fatalf("创建失败后通道 %s 的归属被改成了 %q —— 失败必须无副作用",
name, ch.Owner)
}
}
}
// TestRTChildrenTool_PairAssignmentIsAtomic 钉住成对分配的原子性:
// 第二个通道分配失败时,第一个必须回滚。
//
// 依据是 rtchild.go 里那句 `rollback() // 把已经划上的 Input 也恢复回去,别留半残`。
// 没有本条判据的话,那个 rollback 是死代码 —— 改坏了也没人知道。
func TestRTChildrenTool_PairAssignmentIsAtomic(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 只注册 rt 输入通道,**故意不给** outputch —— 第二个分配必定失败。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "only-in", Plugin: "test",
Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatal(err)
}
out := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "half",
"inputch": "only-in", "outputch": "no-such-channel",
},
})
if !strings.Contains(out, "创建 rtchild 失败") {
t.Fatalf("应当创建失败,实际:%q", out)
}
// ★ 关键:第一个通道必须被回滚,不能留半残。
ch, ok := host.io.ChannelRegistry().Lookup("only-in")
if !ok {
t.Fatal("only-in 不见了")
}
if ch.Owner != "" {
t.Fatalf("成对分配失败后 only-in 仍归 %q —— 回滚没生效,留下半残绑定",
ch.Owner)
}
}