Files
HomeAgent/internal/agent/core/output_meta_test.go
JianFeeeee 6b74862118 fix(agent): output_send 缺收件人时从输入事件自动补
## 现象(线上 2026-09-26 17:57)

用户从 QQ 私聊发来消息,agent 生成了回复也调了 output_send__qq,
但**没填 meta**:

  17:57:45  qq_get_message → {user_id: 2198972886, message_type: private}
  17:57:46  output_send__qq → 失败:meta 中需要 group_id 或 user_id 字段
  17:58:14  output_send__qq_help → 查格式
  17:58:14  output_send__qq → ok      ← 靠重试成功,整轮耗时 74s

信息内核**本来就有**(输入事件里带着 user_id/group_id),却要模型从
qq_get_message 的返回里手抄进 meta。抄错就失败,失败才去查 _help。
而"回复"这件事的收件人是确定的(= 消息来源),本不该由模型负责。

运气差就不重试:同日 17:15 / 17:21 两次 `tools=[]` —— 模型压根没调
output_send,回复生成了但没发出去,日志连一行告警都没有。

## 改法

meta 缺收件人时,内核从**本轮输入事件**推导后补上。模型只需给内容。

## 边界(都刻意收窄:宁可不补,也不能补错)

- 显式传了 meta ⇒ 原样返回。主动 DM 别人等场景必须保持原行为。
- meta 里已有 group_id/user_id ⇒ 不覆盖。
- meta 是坏 JSON ⇒ 原样返回。让下游报"格式错",而不是被静默替换成
  一个模型没要求过的收件人 —— 那比报错更坏:消息会发给错的人。
- 非 qq 通道(如 webui)⇒ 不补。webui 走 ResponseCh,不过 output_send。
  其余异步通道(wechat 等)不猜:猜错等于发错人。
- 输入事件里没有收件人信息 ⇒ 留空,让下游按原逻辑报"需要 user_id"。
  宁可报错让模型重试,也不要编一个收件人。
- 群消息里 user_id 是**发送者**不是收件人 ⇒ group_id 非 "0" 时优先用
  group_id,否则会把消息发回给群成员本人。

数字型 user_id 要按整数格式化:JSON 反序列化成 float64 时
fmt.Sprint 会得到 "2.198972886e+09"。

## 判据:7 条

私聊补 user_id / 群聊补 group_id 且不补 user_id / 显式 meta 不改写 /
已有收件人不覆盖 / 坏 JSON 不静默替换 / webui 不补 / 无信息留空(含 nil evt)。

★ 实现时我先自己写了个 payloadString,编译报错才发现包内已有更完整的
  版本(memorypass.go:176,含 float64/int64/int/json.Number 分支),
  直接复用 —— 不重复造轮子。

全量 42 包绿。
2026-09-26 18:29:23 +08:00

132 lines
4.8 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
import (
"encoding/json"
"strings"
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// ===== output_send 自动补收件人 =====
//
// 线上现象(2026-09-26 17:57):用户从 QQ 私聊发来消息,agent 生成了回复、
// 也调了 output_send__qq,但**没填 meta**:
//
// 17:57:45 qq_get_message → {user_id: 2198972886, message_type: private, ...}
// 17:57:46 output_send__qq → 失败:meta 中需要 group_id 或 user_id 字段
// 17:58:14 output_send__qq_help → 查格式
// 17:58:14 output_send__qq → ok ← 28 秒后靠重试成功
//
// 即:信息内核**本来就有**(输入事件里带着 user_id),却要模型从
// qq_get_message 的返回里手抄进 meta。抄错就失败,失败才去查 _help,
// 一次本该 5 秒的回复花了 74 秒;运气差就不重试(17:15 / 17:21 那两次
// tools=[] 压根没调发送,回复静默丢失)。
//
// 修法:meta 缺收件人时,内核从**本轮输入事件**自动补。显式传 meta 时
// 不干预(主动 DM 别人等场景行为不变)。
// ---- 判据:meta 补全逻辑 ----
// 私聊:补 user_id。
func TestOutputSendAutoFillsUserIDForPrivateChat(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "qq/qq",
Payload: map[string]interface{}{"user_id": "2198972886", "message_type": "private"},
}
meta := autoFillOutputMeta("", "qq", evt)
if !strings.Contains(meta, "2198972886") {
t.Errorf("私聊应自动补 user_id,实际 meta=%q", meta)
}
// 必须是真的 JSON,不能是拼字符串
var m map[string]interface{}
if err := json.Unmarshal([]byte(meta), &m); err != nil {
t.Fatalf("补出的 meta 不是合法 JSON: %v (meta=%q)", err, meta)
}
if m["user_id"] != "2198972886" {
t.Errorf("user_id 不对: %v", m["user_id"])
}
}
// 群聊:补 group_id(且不该同时补 user_id —— 群消息有 user_id 是发送者,
// 不是收件人)。
func TestOutputSendAutoFillsGroupIDForGroupChat(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "qq/qq",
Payload: map[string]interface{}{"user_id": "111", "group_id": "999", "message_type": "group"},
}
meta := autoFillOutputMeta("", "qq", evt)
var m map[string]interface{}
if err := json.Unmarshal([]byte(meta), &m); err != nil {
t.Fatalf("不是合法 JSON: %v (meta=%q)", err, meta)
}
if m["group_id"] != "999" {
t.Errorf("群聊应补 group_id,实际 %v (meta=%q)", m["group_id"], meta)
}
if _, ok := m["user_id"]; ok {
t.Errorf("群聊不该补 user_id(那是发送者不是收件人): %q", meta)
}
}
// 显式传了 meta 就不动它(主动 DM 等场景必须保持原行为)。
func TestOutputSendKeepsExplicitMeta(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "qq/qq",
Payload: map[string]interface{}{"user_id": "2198972886"},
}
explicit := `{"user_id":"someone_else"}`
if got := autoFillOutputMeta(explicit, "qq", evt); got != explicit {
t.Errorf("显式 meta 不该被改写: got=%q want=%q", got, explicit)
}
}
// meta 里已有 group_id(群聊)时也不该覆盖。
func TestOutputSendDoesNotOverwriteExistingRecipient(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "qq/qq",
Payload: map[string]interface{}{"user_id": "111", "group_id": "999"},
}
explicit := `{"group_id":"777"}`
if got := autoFillOutputMeta(explicit, "qq", evt); got != explicit {
t.Errorf("已有收件人时不该覆盖: got=%q want=%q", got, explicit)
}
}
// meta 是坏 JSON(模型写了错的)时不该崩,也不该当成"没传"而静默覆盖。
func TestOutputSendBadJSONMetaIsNotSilentlyReplaced(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "qq/qq",
Payload: map[string]interface{}{"user_id": "2198972886"},
}
broken := `{"user_id": ` // 截断的 JSON
got := autoFillOutputMeta(broken, "qq", evt)
if got != broken {
t.Errorf("坏 JSON 应原样保留(让下游报格式错,而不是被静默替换成别的东西): got=%q", got)
}
}
// 非 qq 通道(如 webui)不该被补 —— webui 回复走 ResponseCh,
// 不经过 output_send 的 meta。
func TestOutputSendNoAutoFillForWebUI(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "webui",
Payload: map[string]interface{}{"user_id": "123"},
}
if got := autoFillOutputMeta("", "webui", evt); got != "" {
t.Errorf("webui 通道不该补 meta: %q", got)
}
}
// 输入事件里没有收件人信息时,保持空(让下游按原逻辑报"需要 user_id",
// 而不是编一个错的收件人)。
func TestOutputSendNoRecipientInfoStaysEmpty(t *testing.T) {
evt := &agentIO.InputEvent{Source: "qq/qq", Payload: map[string]interface{}{}}
if got := autoFillOutputMeta("", "qq", evt); got != "" {
t.Errorf("无收件人信息时应留空让下游报错,不能编: %q", got)
}
// evt 为 nil 也不能崩
if got := autoFillOutputMeta("", "qq", nil); got != "" {
t.Errorf("nil evt 应留空: %q", got)
}
}