Files
Cyrene_v2/internal/services/channelbridge/turnreply_paragraph_test.go
T
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

166 lines
7.2 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 channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定「助手回复落库/出站前做段落包裹规整」这条写入侧修复(IM 一侧)。
//
// 同一根缺陷在 IM 侧有两个出口,本文件两条都钉住:
// - **落库**:主人私聊回复会镜像进 Web 主会话,半包文本落到 Web 历史里同样会
// 被客户端渲染成一整坨;
// - **出站**:splitBubbles 的判据与客户端**逐字相同**(只要存在完整 <p> 块就
// 只切 <p> 块),半包文本在 IM 侧同样只会得到一个气泡、前面几段凭空消失。
//
// 两侧共用同一份样本字面量(网关一侧见 gateway/session_turn_reply_paragraph_test.go,
// 两处的 halfWrappedTurnReply / wrappedTurnReply 必须逐字相同 —— 那正是「Web 与 IM
// 吃到同一份文本」的断言方式)。
// halfWrappedTurnReply 是上报的半包形状:4 段裸文本(第 4 段内联一个 <action>)+
// 只有第 5 段带 <p>。
const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" +
"第二段,轻轻合上纸条。\n\n" +
"第三段,纸张有点卷边了。\n\n" +
"<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// wrappedTurnReply 是 halfWrappedTurnReply 规整后的规范形态(每段一个 <p>)。
const wrappedTurnReply = "<p>第一段,读到头也读到尾啦♪</p>\n\n" +
"<p>第二段,轻轻合上纸条。</p>\n\n" +
"<p>第三段,纸张有点卷边了。</p>\n\n" +
"<p><action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。</p>\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// TestTurnReply_HalfWrappedReplyRegularizedForBothSides 是必测①/⑤(IM 侧):半包
// 回复经真流水线后,**落库文本**与**出站气泡**都基于同一份规整文本 —— 落库逐字
// 等于网关侧断言的那份规范文本,出站是 5 个气泡(而不是过去那 1 个),<action>
// 按渠道既有行为被剥掉。
func TestTurnReply_HalfWrappedReplyRegularizedForBothSides(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: halfWrappedTurnReply, Mood: "平静"}}
b, ad, hist := replyTurnBridge(t, chat)
// 5 个气泡 ⇒ 4 次气泡间延时;测试里归零,否则真的会 sleep 0.5-2s。
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
// 落库(Web 主会话镜像)吃的是规整后的同一份文本。
rec := assertAssistantReply(t, hist, wrappedTurnReply)
if rec.Mood != "平静" {
t.Fatalf("assistant mood = %q, want 平静", rec.Mood)
}
// 出站:不再塌成一条 —— 每段一个气泡。
want := []string{
"第一段,读到头也读到尾啦♪",
"第二段,轻轻合上纸条。",
"第三段,纸张有点卷边了。",
"第四段,你看这里还有个小记号。",
"第五段,我把它们按日子排好啦。",
}
if len(ad.sends) != len(want) {
t.Fatalf("出站气泡 = %d 条, want %d(半包曾整条塌成一个气泡): %+v", len(ad.sends), len(want), ad.sends)
}
for i, w := range want {
if got := ad.sends[i].Content; got != w {
t.Fatalf("气泡[%d] = %q, want %q", i, got, w)
}
}
}
// TestTurnReply_CanonicalReplyStaysByteIdentical 是必测②(IM 侧):已规范的回复
// 落库与出站都不被二次加工 —— 落库逐字不变。
func TestTurnReply_CanonicalReplyStaysByteIdentical(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: wrappedTurnReply}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, wrappedTurnReply)
if len(ad.sends) != 5 {
t.Fatalf("出站气泡 = %d 条, want 5", len(ad.sends))
}
}
// TestTurnReply_FullyBareReplyIsRegularized 是必测③(IM 侧):完全没有任何 <p> 的
// 裸文本也会被规整后交付 —— 过去它拿到的是「一个含换行的气泡」,现在是三段气泡。
func TestTurnReply_FullyBareReplyIsRegularized(t *testing.T) {
t.Parallel()
const bare = "第一段。\n\n第二段。\n\n<action>拍拍你</action>"
const want = "<p>第一段。</p>\n\n<p>第二段。</p>\n\n<p><action>拍拍你</action></p>"
chat := &stubChatter{res: &loop.Result{Text: bare}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, want)
// 纯动作段被剥成空 ⇒ 只发两个气泡(既有行为,不受本改动影响)。
if len(ad.sends) != 2 {
t.Fatalf("出站气泡 = %d 条, want 2: %+v", len(ad.sends), ad.sends)
}
if ad.sends[0].Content != "第一段。" || ad.sends[1].Content != "第二段。" {
t.Fatalf("气泡 = %+v, want [第一段。 第二段。]", ad.sends)
}
}
// TestTurnReply_BlankReplyStaysUntouchedByRegularization 是必测④(IM 侧):空回复
// 走的是另一条分支(补诚实说明),规整不介入 —— 文案与共享包逐字相同。
func TestTurnReply_BlankReplyStaysUntouchedByRegularization(t *testing.T) {
t.Parallel()
for _, blank := range []string{"", " "} {
chat := &stubChatter{res: &loop.Result{Text: blank}}
b, ad, hist := replyTurnBridge(t, chat)
b.process(context.Background(), directMessage())
if len(ad.sends) != 1 {
t.Fatalf("出站消息 = %d 条, want 1(空回复也要有可见回复)", len(ad.sends))
}
if got := ad.sends[0].Content; strings.Contains(got, "<p>") {
t.Fatalf("兜底文案被包上了段落标记: %q", got)
}
assertAssistantReply(t, hist, turnreply.FallbackEmptyReply)
}
}
// TestSendProactive_HalfWrappedPushRegularized 覆盖**第三个**助手文本产出点:主动
// 推送(backthink)。它与回合回复同一类缺陷、同一份判据,因此走同一份规整 ——
// IM 侧不再塌成一个气泡,镜像进 Web 主会话的也是规整后的同一份文本。
func TestSendProactive_HalfWrappedPushRegularized(t *testing.T) {
t.Parallel()
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
hist := &captureHistory{}
b := newProactiveBridgeHist(t, owner, hist, weixin)
b.bubbleGapFn = func() time.Duration { return 0 }
if err := b.SendProactive(context.Background(), proactiveMsg(halfWrappedTurnReply)); err != nil {
t.Fatalf("SendProactive: %v", err)
}
if len(weixin.sends) != 5 {
t.Fatalf("weixin 气泡 = %d 条, want 5(半包推送曾整条塌成一个气泡): %+v", len(weixin.sends), weixin.sends)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
}
if recs[0].Content != wrappedTurnReply {
t.Fatalf("镜像内容 =\n%q\nwant 与 IM 出站同一份(网关侧同一字面量)\n%q", recs[0].Content, wrappedTurnReply)
}
}