Files
HomeAgent/internal/agent/core/outputbatch_test.go
JianFeeeee f850d754c1 feat(outputch): 一 call 多发 + 多类型 —— 内核级通用能力(非某插件专属)
用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义
字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。

## 内核原有形态的限制

output_send__<ch> 每次只能发一条单类型:
  payload: string / type: string / meta: string
带附件或多段内容的通道只能额外注册独立 tool(如 QQ 的 qq_send_file),
而这不该是插件的职责 —— "发一条消息"本来就是输出门的语义。

## 改法

### ① 工具 schema:oneOf [string, array]

payload / type / meta 三者的 schema 改为同时接受字符串与数组。
payload 是字符串时走单条路径,**行为与改动前逐字相同**(向后兼容,用户拍板)。

### ② 解析规则(parseOutputItems)

- payload 与 type 必须等长;type 省略或单值 ⇒ 广播
- meta 允许**单值广播**(用户拍板)
- 长度不符 ⇒ **整 call 拒绝,一条都不发**
- 含空条目 ⇒ 同样整 call 拒绝(不能静默发空消息)

### ③ 能力校验下放插件(用户拍板)

删掉内核里的 `switch rawType { case text/voice/image/file: 查 caps }`:

  · type 是**插件自定义枚举**,内核不认识它。写死四个值 = 内核在理解插件语义;
  · 且插件的自定义类型(如 QQ 的 sticker)会被内核无端拒绝。

内核只保留两件它确实该管的校验:**授权闸**(IsOutputAllowed)与
**通道存在性**。type 与能力的匹配由插件在 handler 里判断并**返回 error**。

### ④ 部分失败:继续发剩余的,如实逐项汇报

用户要求"部分失败向 agent 返回失败项"。实现选择"继续发完"而不是"中止":
第 2 条图片失败不该把第 3 条文字也丢掉,否则"一次发多条"失去意义。

返回形态:
  全部成功(多条)  已通过 [qq] 通道发送 3 条
  部分失败          已通过 [qq] 通道发送 2/3 条:
                      [1] text ok
                      [2] image 失败:<插件返回的错误原文>
                      [3] text ok
                    失败的条目请单独重发。

单条成功仍返回 "ok"(与旧版完全一致,避免富回执驱动回声效应 ——
output.go 里有实测依据)。

### ⑤ 拆出 sendOneOutput

before_output / 安全回填 / after_output 这套顺序对**每一条**都必须完整走一遍。
混在批量循环里极易漏环(例如只给第一条跑 stage),所以把单条路径独立成函数,
批量只是循环调用它。

## 判据(8 条,全部有变异自证)

| 判据 | 覆盖 |
|---|---|
| SingleValueUnchanged | 单值 payload 行为与旧版逐字相同(返回 "ok") |
| ArraySendsInOrder | 数组按顺序发 N 条,type 一一对应 |
| MetaBroadcast | meta 单值广播给所有条 |
| LengthMismatchRejectsWholeCall | 长度不符 ⇒ 一条都不发 |
| PartialFailureReportsAndContinues | 第 2 条失败仍尝试第 3 条 + 如实列出失败项与插件错误原文 |
| KernelDoesNotInterpretType | 自定义 type "sticker" 原样透传,内核不拦 |
| RejectsEmptyEntries | 含空条目整 call 拒绝 |
| UnauthorizedStillBlocked | 授权闸不被批量绕过 |

变异全部被抓:
- 第 2 条失败即中止 → 「实际只调用 2 次」
- 恢复内核 type 能力校验 → 「内核不该对 type 做能力判断」
- meta 不广播 → 「meta 未广播到」

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 现有 output 相关测试(output_grant / output_rules / resident_output)全绿

## 未做(下一步)

- example 插件(qq)收回 qq_send_file / qq_send_sticker,改走 outputch
- outputch 说明迁入各插件 skill(保留 _help 机制)
- 插件侧补 type 能力自校验(校验已下放,需要它们真的做)
2026-10-22 21:05:17 +08:00

295 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 core
// outputch 一 call 多发的判据。
//
// ## 用户拍板的五条(2026-10-22)
//
// 1. 独立 tool(如 qq_send_file)收回 outputch —— 能力统一走输出门
// 2. meta 允许单值广播
// 3. 能力校验放在插件,插件向内核返回错误
// 4. 支持向后兼容(单值 payload 老写法照旧)
// 5. outputch 说明移进 skill 一份,同时保留 _help 机制
//
// ## 内核的职责边界(这是本组判据的核心)
//
// 内核只做三件事:**透传**(payload/type/meta 原样给插件)、**对齐校验**、
// **逐条调用 + 如实汇报**。它不解释 type 的语义 —— type 是插件自定义字段。
import (
"fmt"
"strings"
"sync"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// recordingChannel 记录每次发送收到的参数,并可按条注入失败。
type recordingChannel struct {
mu sync.Mutex
calls []map[string]interface{}
// failAt 是"第 N 条(0 基)失败";-1 表示全成功
failAt int
}
func newRecordingChannel() *recordingChannel { return &recordingChannel{failAt: -1} }
func (c *recordingChannel) run(args map[string]interface{}) (interface{}, error) {
c.mu.Lock()
defer c.mu.Unlock()
idx := len(c.calls)
c.calls = append(c.calls, args)
if idx == c.failAt {
return nil, fmt.Errorf("模拟失败:第 %d 条", idx+1)
}
return map[string]interface{}{"status": "sent"}, nil
}
func (c *recordingChannel) payloads() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, len(c.calls))
for i, a := range c.calls {
out[i], _ = a["payload"].(string)
}
return out
}
// batchDevice 是一个可记录、可按条注入失败的输出设备。
type batchDevice struct {
name string
caps agentIO.OutputCapability
ch *recordingChannel
}
func (d *batchDevice) Name() string { return d.name }
func (d *batchDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *batchDevice) Description() string { return "batch test device" }
func (d *batchDevice) Tools() []agentIO.ToolDef { return nil }
func (d *batchDevice) Execute(_ string, args map[string]interface{}) (interface{}, error) {
return d.ch.run(args)
}
func (d *batchDevice) Start() error { return nil }
func (d *batchDevice) Stop() error { return nil }
func (d *batchDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *batchDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
// newOutputTestAgent 造一个带可控通道的 Agent。
func newOutputTestAgent(t *testing.T, ch *recordingChannel, caps agentIO.OutputCapability) *Agent {
t.Helper()
a := newToolTestAgent(t)
a.io = agentIO.NewIOManager()
if err := a.io.RegisterDevice(&batchDevice{
name: "testch", caps: caps, ch: ch,
}); err != nil {
t.Fatalf("注册测试设备失败: %v", err)
}
return a
}
func sendArgs(args map[string]interface{}) agentAPI.ToolCall {
return agentAPI.ToolCall{Name: "output_send__testch", Arguments: args}
}
// ① 向后兼容:payload 为字符串时,行为与改动前逐字相同。
//
// 变异:把单值路径也走批量(返回"已发送 1 条"而不是 "ok")⇒ 红。
func TestOutputSend_SingleValueUnchanged(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "你好",
"meta": `{"user_id":1}`,
}))
if out != "ok" {
t.Errorf("单条成功应返回 \"ok\"(与改动前一致),实际: %q", out)
}
if got := ch.payloads(); len(got) != 1 || got[0] != "你好" {
t.Errorf("应发送 1 条「你好」,实际: %v", got)
}
// type 缺省应为 text
if ty, _ := ch.calls[0]["type"].(string); ty != "text" {
t.Errorf("type 缺省应为 text,实际 %q", ty)
}
}
// ② 批量:payload 数组 ⇒ 按顺序发 N 条。
//
// 变异:乱序发送 / 只发第一条 ⇒ 红。
func TestOutputSend_ArraySendsInOrder(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage|agentIO.CapFile)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "https://x/a.png", "/tmp/r.pdf"},
"type": []interface{}{"text", "image", "file"},
}))
if !strings.Contains(out, "3") {
t.Errorf("返回值应报告发送了 3 条,实际: %q", out)
}
want := []string{"第一条", "https://x/a.png", "/tmp/r.pdf"}
got := ch.payloads()
if len(got) != 3 {
t.Fatalf("应发送 3 条,实际 %d: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("第 %d 条顺序错:got %q want %q", i+1, got[i], want[i])
}
}
// type 一一对应
for i, wantTy := range []string{"text", "image", "file"} {
if ty, _ := ch.calls[i]["type"].(string); ty != wantTy {
t.Errorf("第 %d 条 type: got %q want %q", i+1, ty, wantTy)
}
}
}
// ③ meta 单值广播(用户明确要求)。
//
// 变异:广播只给第一条 ⇒ 红。
func TestOutputSend_MetaBroadcast(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b", "c"},
"meta": `{"user_id":42}`,
}))
if len(ch.calls) != 3 {
t.Fatalf("应发送 3 条,实际 %d", len(ch.calls))
}
for i, call := range ch.calls {
if m, _ := call["meta"].(string); m != `{"user_id":42}` {
t.Errorf("第 %d 条的 meta 未广播到:got %q", i+1, m)
}
}
}
// ④ 长度不符 ⇒ 整 call 拒绝,一条都不发。
//
// 变异:发前 N 条再报错 ⇒ 红。
func TestOutputSend_LengthMismatchRejectsWholeCall(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text"}, // 1 个 type 会被当成广播,这里用 3 个才错
}))
// 1 个 type + 2 个 payload ⇒ 广播,合法。用 3 个 meta 制造真错。
if len(ch.calls) != 2 {
t.Errorf("单 type 应广播给 2 条,实际发送 %d 条(%q)", len(ch.calls), out)
}
ch2 := newRecordingChannel()
a2 := newOutputTestAgent(t, ch2, agentIO.CapText)
out2 := a2.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text", "text", "text"}, // 3 != 2
}))
if len(ch2.calls) != 0 {
t.Errorf("长度不符必须一条都不发,实际发了 %d 条", len(ch2.calls))
}
if !strings.Contains(out2, "数量必须一致") {
t.Errorf("返回值应说明数量不一致,实际: %q", out2)
}
}
// ⑤ 部分失败:继续发剩余的,并如实列出失败项。
//
// 变异:第 2 条失败即中止 ⇒ 第 3 条不会被尝试 ⇒ 红。
// 变异:静默吞掉失败 ⇒ 返回值不含"失败" ⇒ 红。
func TestOutputSend_PartialFailureReportsAndContinues(t *testing.T) {
ch := newRecordingChannel()
ch.failAt = 1 // 第 2 条失败
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "第二条", "第三条"},
}))
if len(ch.calls) != 3 {
t.Errorf("第 2 条失败后仍应尝试第 3 条,实际只调用 %d 次", len(ch.calls))
}
if !strings.Contains(out, "2/3") {
t.Errorf("返回值应报告 2/3 条成功,实际: %q", out)
}
if !strings.Contains(out, "失败") {
t.Errorf("返回值必须如实列出失败项,实际: %q", out)
}
if !strings.Contains(out, "模拟失败:第 2 条") {
t.Errorf("返回值应带插件的错误原文,实际: %q", out)
}
if !strings.Contains(out, "重发") {
t.Errorf("返回值应告知可单独重发失败项,实际: %q", out)
}
}
// ⑥ 内核不再解释 type 语义:任意插件自定义 type 都原样透传。
//
// ★ 这是"能力校验下放插件"的核心判据。旧实现里内核 switch 了
// text/voice/image/file 四个值,其它值直接跳过校验、且四个已知值会被
// 内核用 caps 拦掉 —— 那等于内核在理解插件语义。
//
// 变异:恢复 switch rawType 的能力校验 ⇒ 自定义 type "sticker" 若插件
// 不支持会被内核拦截(本判据用 caps 只声明 CapText 来触发)。
func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) {
ch := newRecordingChannel()
// 只声明 CapText:若内核还按 type 校验,"sticker" 会解析成未知类型
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "这是插件自定义类型的载荷",
"type": "sticker",
}))
if len(ch.calls) != 1 {
t.Fatalf("自定义 type 应被原样透传给插件,实际调用 %d 次(返回 %q)", len(ch.calls), out)
}
if ty, _ := ch.calls[0]["type"].(string); ty != "sticker" {
t.Errorf("type 被内核改写:got %q want \"sticker\"", ty)
}
if strings.Contains(out, "不支持") {
t.Errorf("内核不该对 type 做能力判断,实际返回: %q", out)
}
}
// ⑦ 空 payload 与非字符串元素必须被拒(不能静默发空消息)。
func TestOutputSend_RejectsEmptyEntries(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"正常", " "},
}))
if len(ch.calls) != 0 {
t.Errorf("含空条目的批量必须整 call 拒绝,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "为空") {
t.Errorf("返回值应指出空条目,实际: %q", out)
}
}
// ⑧ 未授权通道不得发送(授权闸不被批量绕过)。
func TestOutputSend_UnauthorizedStillBlocked(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.allowedOutputs = []string{"other"}
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
}))
if len(ch.calls) != 0 {
t.Errorf("未授权通道不得发送,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "未授权") {
t.Errorf("返回值应说明未授权,实际: %q", out)
}
}