feat(channel): N1a —— inputch 一等化(归属插件/归属 agent/容量/共享登记表)+ 单工具多视图总览

设计:docs/zh/resident-subagent-design.md §4.6(里程碑 N1a)。

## 用户要求

「父 agent 可以看到所有已注册的 inputch 以及 inputch 的划分情况,用**单工具多视图**方式构筑」

## 登记层(internal/agent/io/inputch.go)

inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个),
所以登记表以 inputch 为键,每条记录:

    名字 / 归属插件 / 归属 agent(被划给谁) / 容量 / 默认回程 outputch / 记忆策略(ChannelDef)

- 新增 `ChannelRegistry`,设计成**可共享对象**(`*ChannelRegistry`):
  根 agent 与驻留子共用同一份,"划入/授权"才有意义;`SetChannelRegistry` 注入。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
  保留已有 Owner/Capacity/Output(否则一次 reload 就把父做的划分清空)。
- `Assign` 语义按设计 R6 默认:**读写授权,不转移所有权**(Plugin 与 Owner 分别记录)。
- 原 `inputChannels map[string]ChannelDef` 被登记表取代;`GetInputChannelDef` 保持兼容。
- `plugin.Registry` 注册时带上**归属插件名**(此前完全无归属信息)。

## 总览工具(internal/agent/core/inputch.go)

单工具 **`input_channels`** + `view` 参数(不是一堆小工具):

    all(默认)= 全部已注册(带归属插件)
    mine       = 划给本 agent 的
    unassigned = 尚未划出的
    by_agent   = 划分情况总览(按归属分组)
    detail     = 单个 inputch 全字段(需 name)

未知 view **报错并列出可用值**(拼错不得被静默当成默认视图);登记表为空时明确说明。

## 验收

- `internal/agent/io/inputch_test.go`:归属记录、重载保划分、Assign/视图数据面、
  **跨 manager 共享登记表**、策略查询向后兼容 —— 5 项
- `internal/agent/core/inputch_test.go`:单工具多视图逐视图断言(含未知 view 与空表)—— 2 项
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/... ./internal/plugin/...` 干净
This commit is contained in:
JianFeeeee
2026-09-13 09:06:50 +08:00
parent 7545f5869c
commit 63b6f852aa
10 changed files with 787 additions and 27 deletions

View File

@ -0,0 +1,134 @@
package io
// inputch 登记表与划分(单工具多视图背后的数据面)。
//
// 设计依据 docs/zh/resident-subagent-design.md §4:
// inputch 是**最基本的输入路由单位**,由插件注册,一个插件可注册多个。
import "testing"
func TestChannelRegistry_RegisterKeepsAttribution(t *testing.T) {
r := NewChannelRegistry()
// 一个插件注册多个 inputch(最基本的输入路由单位)。
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
if err := r.Register(InputChannel{Name: "qq/device-2", Plugin: "qq"}); err != nil {
t.Fatal(err)
}
if n := r.Count(); n != 2 {
t.Fatalf("注册数=%d,期望 2(同一插件的多个 inputch 彼此独立)", n)
}
ch, ok := r.Lookup("qq/device-2")
if !ok || ch.Plugin != "qq" {
t.Fatalf("归属插件未记录:%+v ok=%v", ch, ok)
}
if _, ok := r.Lookup("nope"); ok {
t.Fatal("未注册的 inputch 不应查得到")
}
if err := r.Register(InputChannel{Name: ""}); err != ErrInputChannelNameEmpty {
t.Fatalf("空名应报错,实际 %v", err)
}
}
// 插件重载(重复登记)**不得抹掉划分**:Owner/Capacity/Output 必须保留。
func TestChannelRegistry_ReRegisterKeepsAllocation(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
if err := r.Assign("qq", "agent-1", 128); err != nil {
t.Fatal(err)
}
// 插件重载:只带 Plugin/Def,不带 Owner/Capacity。
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
ch, _ := r.Lookup("qq")
if ch.Owner != "agent-1" || ch.Capacity != 128 {
t.Fatalf("重载后划分被抹掉:owner=%q capacity=%d", ch.Owner, ch.Capacity)
}
if !ch.Def.NoMemory {
t.Fatal("重载应更新 Def")
}
}
func TestChannelRegistry_AssignAndViews(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
_ = r.Register(InputChannel{Name: "cli", Plugin: "cli"})
_ = r.Register(InputChannel{Name: "sub/in", Plugin: "sub"})
if err := r.Assign("qq", "root", 0); err != nil {
t.Fatal(err)
}
if err := r.Assign("sub/in", "child-1", 32); err != nil {
t.Fatal(err)
}
if err := r.Assign("nope", "x", 0); err != ErrInputChannelUnknown {
t.Fatalf("划分未注册的 inputch 应报错,实际 %v", err)
}
if got := len(r.ListByOwner("root")); got != 1 {
t.Fatalf("root 的 inputch 数=%d,期望 1", got)
}
child := r.ListByOwner("child-1")
if len(child) != 1 || child[0].Name != "sub/in" || child[0].Capacity != 32 {
t.Fatalf("child-1 的划分=%+v", child)
}
// 未分配的:cli。
un := r.ListByOwner("")
if len(un) != 1 || un[0].Name != "cli" {
t.Fatalf("未分配的 inputch=%+v,期望只有 cli", un)
}
// List 按名排序(视图输出稳定)。
all := r.List()
if len(all) != 3 || all[0].Name != "cli" || all[2].Name != "sub/in" {
t.Fatalf("List 未按名排序:%+v", all)
}
}
// 共享登记表:根 agent 与驻留子共用同一份,划分才有意义。
func TestChannelRegistry_SharedBetweenManagers(t *testing.T) {
shared := NewChannelRegistry()
root := NewIOManager()
child := NewIOManager()
root.SetChannelRegistry(shared)
child.SetChannelRegistry(shared)
if err := root.RegisterInputChannelFrom("sub", "sub/in", ChannelDef{}); err != nil {
t.Fatal(err)
}
if err := root.AssignInputChannel("sub/in", "child-1", 8); err != nil {
t.Fatal(err)
}
// 子在**自己**的 io 上就能看到这份划分。
ch, ok := child.LookupInputChannel("sub/in")
if !ok {
t.Fatal("共享登记表后,子应看得到根注册的 inputch")
}
if ch.Owner != "child-1" || ch.Capacity != 8 {
t.Fatalf("子看到的划分=%+v", ch)
}
// 注销也要跨 manager 生效。
root.UnregisterInputChannel("sub/in")
if _, ok := child.LookupInputChannel("sub/in"); ok {
t.Fatal("注销应跨 manager 生效")
}
}
// 记忆策略查询保持向后兼容(原 GetInputChannelDef 的语义)。
func TestChannelRegistry_DefLookupCompat(t *testing.T) {
m := NewIOManager()
m.RegisterInputChannel("qq", ChannelDef{NoMemory: true, ContextPolicy: "prune"})
def, ok := m.GetInputChannelDef("qq")
if !ok || !def.NoMemory || def.ContextPolicy != "prune" {
t.Fatalf("策略查询=%+v ok=%v", def, ok)
}
if _, ok := m.GetInputChannelDef("nope"); ok {
t.Fatal("未注册的 inputch 不应有策略")
}
}