fix(channelbridge): IM 渠道也走「回合必落可见回复」(与网关同口径)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因(agent-194,文件:行):
- inbound.go 的 deliverReply 对空回复直接 return(静默)⇒ 只剩 user 消息;
- runInboundTurn 报错直接 return ⇒ 失败回合连说明都没有。
修法:抽共享**纯函数**包 internal/runtime/turnreply(从网关逐字搬运、零语义改动)+
channelbridge/turnreply.go 的 turnVisibleReply(与网关 persistTurnReply 同构);
取消边界在 IM 用 context.Canceled / ctx 取消表达(渠道桥无代次概念,如实登记差异)。
测试 6 条:空回复/纯空白/轮次用尽/安全网/回合报错/取消不落库/正常逐字不变。
变异:还原旧静默行为 ⇒ 4 条红(含「出站 0 条,want 1」与英文内部诊断外泄)。
This commit is contained in:
AskaEth committed 2026-10-04 22:29:54 +08:00
1 parent be38651ba4
commit fbbbb4ea64
6 files changed
+380 -73

No files matched your search

+20 -9
View File
@@ -266,27 +266,38 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
return
}
b.deliverReply(ctx, msg, a, sess, target, res)
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
// 见 turnreply.go)。
b.deliverReply(ctx, msg, a, sess, target, res, err)
}
// deliverReply 持久化助手回复,并经接收该消息的适配器
// 送回去。不支持出站文本的适配器
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
// IM 回合因此绝不会「没声了」;被取消的回合刻意不交付。不支持出站文本的适配器
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result) {
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", target.sessionID)
sess session.Session, target inboundTarget, res *loop.Result, runErr error) {
reply, ok := turnVisibleReply(ctx, res, runErr)
if !ok {
b.log.Debug("turn reply canceled, nothing delivered",
"platform", msg.Platform, "session", target.sessionID)
return
}
// mood 只挂在助手回复上(design/16 §3.3),且只有真实结果才带得回来
// (回合报错时 res 为 nil)。
mood := ""
if res != nil {
mood = res.Mood
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, target.platform, deviceIDFor(msg), adapterName(a))
b.persistToMain(ctx, history.RoleAssistant, reply, mood, target.platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}