From d3590192e7d94d0d70e184aa7428c14439cd6129 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 22:42:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(outputch):=20=E5=86=85=E6=A0=B8=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=9B=BF=E6=8F=92=E4=BB=B6=E5=88=A4=E6=96=AD=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E8=AF=AD=E4=B9=89=20=E2=80=94=E2=80=94=20=E7=A9=BA=20?= =?UTF-8?q?payload=20=E6=98=AF=E5=90=88=E6=B3=95=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测踩到的真 bug(生产环境) QQ 插件重装后首次实测 `sticker_index` 用法,直接失败: output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 而插件侧明明有这个放行分支: if payload == "" && !(rawType == "sticker" && stickerIndex > 0) { return nil, fmt.Errorf("payload 不能为空") } 分支永远走不到 —— 内核先拒了。 ## 根因:两处内核越权判断 | 位置 | 旧判断 | 问题 | |---|---|---| | `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 | | `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 | **正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。 这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件): **内核只透传与对齐,不解释插件自定义字段的含义**。 ## 连带修正:`stringOrArray("")` 不再返回 nil 原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。 现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件), 后者才是真的没提供(由 `len(payloads) == 0` 拦下)。 ## 判据调整(原有判据与正确设计冲突) `TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是 按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界: | 判据 | 断言 | |---|---| | `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) | | `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 | | `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) | 变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红: - 「内核不该拦空条目,应交插件判断,实际只发 0 条」 - 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」 ## 验证 - `go test ./internal/agent/core/` 全绿(含新 3 条) - 生产实测修复前后对照(journalctl 原文): 21:54:35 output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 ← 修复前 21:56:34 output_send__qq result: ok ← 修复后 - webui / agent-core 全绿 --- internal/agent/core/output.go | 31 ++++++++++---- internal/agent/core/outputbatch_test.go | 56 ++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index a0f720f4..3dcdf629 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -68,11 +68,17 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) { if len(payloads) == 0 { return nil, fmt.Errorf("payload 不能为空") } - for i, p := range payloads { - if strings.TrimSpace(p) == "" { - return nil, fmt.Errorf("payload 第 %d 条为空", i+1) - } - } + // ★ 空字符串元素**不在这里拒绝**(2026-10-22 实测踩到)。 + // + // 内核不知道 payload 的语义:它可能是"要发的内容",也可能是占位 —— + // 例如 QQ 的 `type=sticker` + `meta.sticker_index=1`,真实 URL 由插件从 + // 收藏表情里取,模型根本不知道该往 payload 填什么。内核在这里判空, + // 等于用一个它不理解的字段把插件的合法用法挡掉(实测报 + // "payload 不能为空",而插件侧的放行分支永远走不到)。 + // + // 正确边界:**参数是否存在归内核管,参数值是否合法归插件管**。 + // 所以只拒绝"整条数组里没有任何一项"这种结构性错误(上面那条), + // 逐项内容交给插件判断并返回 error。 // type 对齐:省略 / 单值 ⇒ 广播;否则必须与 payload 等长 if len(types) == 0 { @@ -125,9 +131,12 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) { func stringOrArray(v interface{}) []string { switch t := v.(type) { case string: - if t == "" { - return nil - } + // 空字符串**保留**为一个元素,不当"未提供"处理。 + // + // 区别很重要:`payload: ""` 是"模型明确给了一个空载荷占位" + // (插件可能从 meta 里取真实内容,如 QQ 的 sticker_index), + // 而"没传 payload 这个键"才是真的没提供。前者归插件判断合法性, + // 后者由 parseOutputItems 的 len==0 拦下。 return []string{t} case []string: return t @@ -246,7 +255,11 @@ func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) { if stageCtx.Response != nil { return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false } - if stageCtx.FinalText == "" { + // 只有"原本非空、被插件清空"才是异常(防插件静默吞掉内容却仍算成功)。 + // + // 原本就空 ⇒ 合法占位(如 type=sticker + meta.sticker_index,真实 URL + // 由插件自己取),不能报"被清空"——那是拿内核不理解的字段做判断。 + if item.payload != "" && stageCtx.FinalText == "" { return "输出被插件清空", false } args["payload"] = stageCtx.FinalText diff --git a/internal/agent/core/outputbatch_test.go b/internal/agent/core/outputbatch_test.go index 9bba758c..d1fbfb5e 100644 --- a/internal/agent/core/outputbatch_test.go +++ b/internal/agent/core/outputbatch_test.go @@ -260,19 +260,65 @@ func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) { } } -// ⑦ 空 payload 与非字符串元素必须被拒(不能静默发空消息)。 -func TestOutputSend_RejectsEmptyEntries(t *testing.T) { +// ⑦ 空 payload 交给插件判断,内核不拦(★ 实测踩到的真实缺陷)。 +// +// 原先这里是"含空条目必须整 call 拒绝"。但内核**不知道 payload 的语义**: +// QQ 的 `type=sticker` + `meta.sticker_index=1` 需要 payload 留空占位, +// 真实 URL 由插件从收藏表情里取。旧实现在内核判空 ⇒ 插件侧的放行分支 +// 永远走不到 ⇒ 生产实测报 "payload 不能为空",功能完全不可用。 +// +// 正确边界:**参数是否存在归内核,参数值是否合法归插件**。 +// +// 变异:把 trim 判空加回去 ⇒ 红(内核又替插件做决定了)。 +func TestOutputSend_EmptyPayloadDeferredToPlugin(t *testing.T) { ch := newRecordingChannel() a := newOutputTestAgent(t, ch, agentIO.CapText) out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ "payload": []interface{}{"正常", " "}, + "meta": `{"sticker_index":1}`, + })) + // 内核应把两条都交给插件(内容合法性由插件判) + if len(ch.calls) != 2 { + t.Errorf("内核不该拦空条目,应交插件判断,实际只发 %d 条(返回 %q)", len(ch.calls), out) + } +} + +// ⑦b 单个空 payload 是"占位",不是"没传"。 +// +// 区别:`payload: ""` 说明模型明确给了空占位;"没传 payload 键"才是真没提供。 +// 前者必须走到插件(否则 sticker_index 用法永远不可用)。 +func TestOutputSend_EmptyStringIsNotMissing(t *testing.T) { + ch := newRecordingChannel() + a := newOutputTestAgent(t, ch, agentIO.CapText) + + out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ + "payload": "", + "type": "sticker", + "meta": `{"user_id":1,"sticker_index":1}`, + })) + if len(ch.calls) != 1 { + t.Fatalf("空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 %d 次调用(返回 %q)", + len(ch.calls), out) + } + if p, _ := ch.calls[0]["payload"].(string); p != "" { + t.Errorf("payload 应原样透传空串,实际 %q", p) + } +} + +// ⑦c 但"完全没传 payload 键"仍必须拒绝(结构性错误,内核能判)。 +func TestOutputSend_MissingPayloadStillRejected(t *testing.T) { + ch := newRecordingChannel() + a := newOutputTestAgent(t, ch, agentIO.CapText) + + out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ + "type": "text", })) if len(ch.calls) != 0 { - t.Errorf("含空条目的批量必须整 call 拒绝,实际发了 %d 条", len(ch.calls)) + t.Errorf("完全没传 payload 必须拒绝,实际发了 %d 条", len(ch.calls)) } - if !strings.Contains(out, "为空") { - t.Errorf("返回值应指出空条目,实际: %q", out) + if !strings.Contains(out, "payload 不能为空") { + t.Errorf("返回值应说明 payload 不能为空,实际 %q", out) } }