Files
HomeAgent/internal/agent/core/rtchild_route_test.go
JianFeeeee e78a38b3d8 fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪

上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。

那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。

区别是实质的:

	错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
	对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大

驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。

## 这不是新机制,是没接线的旧机制

`security.Runtime` 里早就有:

	CheckToolFor(p Principal, tool)              // 按主体判定
	PrincipalPolicy{AllowTools, DenyTools,       // 主体策略
	                HardPrivateTools}
	DeclarePrincipalPolicy(scope, key, policy)   // 声明入口

而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。

## 改动

① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
   **撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
   子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
   这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
   全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
   等于任何执行体销毁时都能抹掉全站策略。

② `agent/core`:`securityDecisionFor` 分两条路径

	父(parentID == "")    → CheckTool(tool)                          // 内核自身
	子(parentID != "")    → CheckToolFor(Principal{self, "resident"},
	                                        tool)                      // 叠加父的声明

   CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
   主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
   (那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。

③ 创建点声明、销毁点撤销(生命周期对称)

	SpawnResident      → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
	SpawnRTChild       → 同上
	teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy

   声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
   「子已跑工具、策略还没进内核」的窗口。
   声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。

④ 工具面:`resident_agents create` 与 `rt_children create` 新增
   `allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
   因为模型很容易把白名单误读成额外授予。

## 判据(4 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |

`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。

`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。

## 实测(真实整机装配 + 真执行)

	① 父 ExecuteTool(cmd_run)            → 走到分派(未被拦)
	② 父声明 deny=[cmd_run] 后,子的判定  → Allowed=false
	   reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
	③ 父自己的判定                        → 仍 Allowed(主体隔离)
	④ 撤销后子的判定                      → 恢复 Allowed

build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。

## 仍未接线

- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
2026-10-31 06:35:32 +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.rtChildren[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.rtChildren[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)
}
}