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 的判据与客户端**逐字相同**(只要存在完整

块就 // 只切

块),半包文本在 IM 侧同样只会得到一个气泡、前面几段凭空消失。 // // 两侧共用同一份样本字面量(网关一侧见 gateway/session_turn_reply_paragraph_test.go, // 两处的 halfWrappedTurnReply / wrappedTurnReply 必须逐字相同 —— 那正是「Web 与 IM // 吃到同一份文本」的断言方式)。 // halfWrappedTurnReply 是上报的半包形状:4 段裸文本(第 4 段内联一个 )+ // 只有第 5 段带

。 const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" + "第二段,轻轻合上纸条。\n\n" + "第三段,纸张有点卷边了。\n\n" + "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。\n\n" + "

第五段,我把它们按日子排好啦。

" // wrappedTurnReply 是 halfWrappedTurnReply 规整后的规范形态(每段一个

)。 const wrappedTurnReply = "

第一段,读到头也读到尾啦♪

\n\n" + "

第二段,轻轻合上纸条。

\n\n" + "

第三段,纸张有点卷边了。

\n\n" + "

把整卷纸条摊在小桌上第四段,你看这里还有个小记号。

\n\n" + "

第五段,我把它们按日子排好啦。

" // TestTurnReply_HalfWrappedReplyRegularizedForBothSides 是必测①/⑤(IM 侧):半包 // 回复经真流水线后,**落库文本**与**出站气泡**都基于同一份规整文本 —— 落库逐字 // 等于网关侧断言的那份规范文本,出站是 5 个气泡(而不是过去那 1 个), // 按渠道既有行为被剥掉。 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 侧):完全没有任何

的 // 裸文本也会被规整后交付 —— 过去它拿到的是「一个含换行的气泡」,现在是三段气泡。 func TestTurnReply_FullyBareReplyIsRegularized(t *testing.T) { t.Parallel() const bare = "第一段。\n\n第二段。\n\n拍拍你" const want = "

第一段。

\n\n

第二段。

\n\n

拍拍你

" 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, "

") { 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) } }