Files
HomeAgent/internal/plugins/remotedevice/outputch_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

288 lines
10 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 remotedevice
// 设备输出通道(outputch.go)的测试:
// - caps 映射词表
// - 上下线 → 通道登记/注销 + push 真能落到设备(走真 WS 帧)
// - 聚合通道 devicectl 的出站寻址
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"time"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestDeviceOutputCapsMapping(t *testing.T) {
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
cases := []struct {
name string
caps []string
kind string
want agentIO.OutputCapability
}{
{"未声明已知能力 ⇒ 全能力(旧设备兼容)", nil, "", full},
{"未知 caps ⇒ 全能力", []string{"whatever"}, "", full},
{"cmd ⇒ 历史全能力", []string{"cmd"}, "", full},
{"speaker ⇒ 文本+音频", []string{"speaker"}, "", agentIO.CapText | agentIO.CapAudio},
{"screen ⇒ 文本+图+文件", []string{"screen"}, "", agentIO.CapText | agentIO.CapImage | agentIO.CapFile},
{"clipboard ⇒ 文本+文件", []string{"clipboard"}, "", agentIO.CapText | agentIO.CapFile},
{"kind=computer 兜底(未声明 caps)", nil, "computer", agentIO.CapText | agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured},
{"kind=speaker 兜底", nil, "speaker", agentIO.CapText | agentIO.CapAudio},
}
for _, c := range cases {
if got := deviceOutputCaps(c.caps, c.kind); got != c.want {
t.Errorf("%s: deviceOutputCaps(%v,%q)=%s,期望 %s", c.name, c.caps, c.kind, got, c.want)
}
}
}
// channelRecorder 记录通道注册/注销。
//
// 必须加锁:注册/注销发生在设备 WS 的处理 goroutine(上下线回调)里,
// 而测试在主线读 —— 裸 map/slice 会被 -race 抓住(第一版就是这么被抓住的)。
type channelRecorder struct {
mu sync.Mutex
registered map[string]int
unregistered []string
}
func (r *channelRecorder) caps(name string) (int, bool) {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.registered[name]
return c, ok
}
func (r *channelRecorder) unregList() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.unregistered...)
}
// recordingSDK 造一个能记账的 PluginSDK:通道注册/注销都落到 recorder 里。
func recordingSDK(t *testing.T, iom *agentIO.IOManager, rec *channelRecorder) *sdk.PluginSDK {
t.Helper()
return sdk.New("remotedevice", sdk.SDKConfig{
IOManager: iom,
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
rec.mu.Lock()
rec.registered[name] = caps
rec.mu.Unlock()
// 通道 handler 也要真的可调用 —— 记进 iom 才能从外面触发。
return iom.RegisterDevice(&recordingDevice{name: name, handler: handler})
},
RegOutputUnreg: func(name string) error {
rec.mu.Lock()
rec.unregistered = append(rec.unregistered, name)
rec.mu.Unlock()
iom.UnregisterDevice(name)
return nil
},
RegInput: func(name string, def sdk.ChannelDef) error {
iom.RegisterInputChannel(name, agentIO.ChannelDef(def))
return nil
},
})
}
// recordingDevice 把插件注册的输出通道在 io 层落地,便于用 Execute("output") 触发。
type recordingDevice struct {
name string
handler sdk.ToolHandler
}
func (d *recordingDevice) Name() string { return d.name }
func (d *recordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *recordingDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *recordingDevice) Description() string { return "recording device" }
func (d *recordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *recordingDevice) Start() error { return nil }
func (d *recordingDevice) Stop() error { return nil }
func (d *recordingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *recordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return d.handler(args)
}
func TestDeviceChannelLifecycleAndPush(t *testing.T) {
reg := NewRegistry()
token := "tk"
reg.SetAcceptToken(func(p string) bool { return p == token })
iom := agentIO.NewIOManager()
rec := &channelRecorder{registered: map[string]int{}}
p := &Plugin{registry: reg}
p.sdk = recordingSDK(t, iom, rec)
p.wireDeviceChannels() // 走 Start 的同一条接线
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 设备上线(caps=speaker ⇒ 通道能力应为 文本+音频)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`))
cli.readHelloAckAndBind(t, token)
ch := p.deviceChannelName("spk-1")
deadline := time.Now().Add(3 * time.Second)
caps, ok := rec.caps(ch)
for !ok && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
caps, ok = rec.caps(ch)
}
if !ok {
t.Fatalf("设备上线后应注册输出通道 %s,实际: %v", ch, rec.unregList())
}
if want := int(agentIO.CapText | agentIO.CapAudio); caps != want {
t.Fatalf("通道能力位应为 文本+音频(%d),实际 %d", want, caps)
}
// 入站 inputch 同名登记(父 agent 才能把"这台设备"划给驻留子)
if _, ok := iom.LookupInputChannel(ch); !ok {
t.Fatalf("设备上线后应同时登记同名 inputch %s", ch)
}
// 触发一次出站:走 io 层的 output 分发(与 output_send__<通道> 同一条路)
dev := iom.GetDevice(ch)
if dev == nil {
t.Fatalf("输出通道 %s 未在 io 层注册", ch)
}
if _, err := dev.Execute("output", map[string]interface{}{"payload": "你好,设备", "type": "text"}); err != nil {
t.Fatalf("向设备发送失败: %v", err)
}
// 设备侧应收到 op=push 的帧
got := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var m map[string]interface{}
if json.Unmarshal(payload, &m) == nil {
got <- m
}
}()
select {
case m := <-got:
if m["op"] != "push" {
t.Fatalf("设备应收到 op=push,实际 %v", m)
}
if m["payload"] != "你好,设备" || m["type"] != "text" {
t.Fatalf("push 帧内容不符: %v", m)
}
case <-time.After(3 * time.Second):
t.Fatal("设备未收到 push 帧")
}
// 设备下线 ⇒ 注销通道(不留死通道)
cli.close()
deadline = time.Now().Add(3 * time.Second)
for len(rec.unregList()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if got := rec.unregList(); len(got) == 0 || got[0] != ch {
t.Fatalf("设备下线后应注销 %s,实际 %v", ch, got)
}
if iom.GetDevice(ch) != nil {
t.Fatalf("注销后 io 层不应还有 %s", ch)
}
}
func TestDevicectlAggregateOutputAddressing(t *testing.T) {
reg := NewRegistry()
token := "tk2"
reg.SetAcceptToken(func(p string) bool { return p == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
dev := &devicectlDevice{reg: reg}
// ① 没指定设备 ⇒ 报错要**可执行**(列出在线设备),而不是含糊失败
if _, err := dev.Execute("output", map[string]interface{}{"payload": "x", "type": "text"}); err == nil {
t.Fatal("无 device_id 时应报错")
}
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"pc-1","name":"PC","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
deadline := time.Now().Add(3 * time.Second)
for !reg.Online("pc-1") && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if !reg.Online("pc-1") {
t.Fatal("设备未上线")
}
// ② meta 是 JSON 且含 device_id ⇒ 投递到该设备
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "meta": `{"device_id":"pc-1"}`,
}); err != nil {
t.Fatalf("按 meta.device_id 投递失败: %v", err)
}
m := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var got map[string]interface{}
if json.Unmarshal(payload, &got) == nil {
m <- got
}
}()
select {
case got := <-m:
if got["op"] != "push" {
t.Fatalf("应为 push 帧,实际 %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("聚合通道未投递到设备")
}
// ③ 设备在线但指定了不存在的设备 ⇒ 报错(online 列表里有 pc-1)
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "device_id": "ghost",
}); err == nil {
t.Fatal("不存在的设备应报错")
}
}
// 通道名合规性:设备通道名会被内核拼进 LLM **函数名**(output_send__<通道名>),
// 而上游函数名规范是 ^[a-zA-Z0-9_-]{1,64}$ —— 违规会让**整条请求**被 400 拒绝
// (实测把生产打挂:device/<id> 里的 `/` 触发 Invalid 'tools[299].function.name',
// 网关 auto tier 全链条失败,整个 agent 不说话了)。
//
// 通道名是**插件自己的声明**,所以这条判据钉在插件侧。
func TestDeviceChannelNameIsLLMFunctionNameSafe(t *testing.T) {
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// 含**恶意/异常** id:空格、符号、非 ASCII、超长、以及会折成同一个名字的两个 id
ids := []string{"waiter-fnnas", "1", "a b!c", "中文设备", strings.Repeat("x", 120), "a b", "a-b"}
p := &Plugin{}
seen := map[string]string{}
for _, id := range ids {
ch := p.deviceChannelName(id)
if prev, dup := seen[ch]; dup {
t.Errorf("不同设备 id(%q 与 %q)派生出同一个通道名 %q", prev, id, ch)
}
seen[ch] = id
if !re.MatchString(ch) {
t.Errorf("设备通道名 %q 违反上游函数名规范 %s", ch, re)
}
toolName := "output_send__" + ch
if !re.MatchString(toolName) {
t.Errorf("派生出的工具名 %q 违反上游函数名规范 %s", toolName, re)
}
}
}