package channelbridge import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点, // 两件必须同时成立的事: // 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进 // Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据); // 2. 🔴 **`
` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成 // 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。 // // 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行, // 6 处字面 `\n` 全挤在同一个

里)。 // literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 const literalEscape = `\n` // realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、 // 5 个

、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。 const realLiteralBreakReply = `

看着那颗 CPU,忍不住叹了口气R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪

坐直身子,认真起来乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:

伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭

那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。

不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪

` // TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条 // 回复的第三个

是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库 // 镜像与出站气泡是同一份文本。 func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}} b, ad, hist := replyTurnBridge(t, chat) b.bubbleGapFn = func() time.Duration { return 0 } b.process(context.Background(), directMessage()) // ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。 want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n") rec := assertAssistantReply(t, hist, want) if got := strings.Count(rec.Content, literalEscape); got != 0 { t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got) } // 段落结构一字不变:仍是 5 个

(字面 `\n` 绝不升级成段落或
)。 if got := strings.Count(rec.Content, "

"); got != 5 { t.Fatalf("落库文本

块 = %d, want 5(段落口径不得被改动)", got) } wantLines := strings.Count(realLiteralBreakReply, "\n") + 6 if got := strings.Count(rec.Content, "\n"); got != wantLines { t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines) } // ② 出站:5 条气泡(

一级、
二级的既有语义不变),第三条是七行。 if len(ad.sends) != 5 { t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends) } scale := ad.sends[2].Content lines := strings.Split(scale, "\n") if len(lines) != 7 { t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale) } if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") { t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6]) } for i, sent := range ad.sends { if strings.Contains(sent.Content, literalEscape) { t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content) } if strings.Contains(sent.Content, " —— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content) } } // ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。 if !strings.Contains(rec.Content, scale) { t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content) } } // TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`
` 仍 // 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的 // 判据一个字节都没动。 func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙
丙" + literalEscape + "丁"}} b, ad, hist := replyTurnBridge(t, chat) b.bubbleGapFn = func() time.Duration { return 0 } b.process(context.Background(), directMessage()) assertAssistantReply(t, hist, "

甲\n乙
丙\n丁

") if len(ad.sends) != 2 { t.Fatalf("出站气泡 = %d 条, want 2(
仍是消息分隔符): %+v", len(ad.sends), ad.sends) } if got, want := ad.sends[0].Content, "甲\n乙"; got != want { t.Fatalf("气泡[0] = %q, want %q(气泡内换行)", got, want) } if got, want := ad.sends[1].Content, "丙\n丁"; got != want { t.Fatalf("气泡[1] = %q, want %q(气泡内换行)", got, want) } } // TestSendProactive_LiteralBreakRenderedForBothSides 覆盖 **IM 侧**的第三个助手文本 // 产出点:渠道桥的主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此 // 这条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。 // // ⚠️ 范围边界:**Web 活跃客户端**那条主动推送(gateway 的 proactive → // proactive_persist)**不经** NormalizeParagraphs,字面 `\n` 在那条路由上仍原样进库; // 那是既有取舍、不属本片 scope(已由塔登记为后续切片)。本测试只断言 IM 这一侧。 func TestSendProactive_LiteralBreakRenderedForBothSides(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 } push := "第一行" + literalEscape + "第二行" if err := b.SendProactive(context.Background(), proactiveMsg(push)); err != nil { t.Fatalf("SendProactive: %v", err) } if len(weixin.sends) != 1 { t.Fatalf("weixin 气泡 = %d 条, want 1(字面 `\\n` 不切气泡): %+v", len(weixin.sends), weixin.sends) } if got, want := weixin.sends[0].Content, "第一行\n第二行"; got != want { t.Fatalf("推送气泡 = %q, want %q", got, want) } recs := hist.records() if len(recs) != 1 { t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs) } if got, want := recs[0].Content, "

第一行\n第二行

"; got != want { t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want) } } // TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak 钉住一个**口径边界**(复审观察 // ②):两个连续的字面 `\n` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒ // 两段 `

` ⇒ 两端各两条气泡。 // // 语义上说得通(空行 = 段落),但它与「字面 `\n` 只做气泡内换行」不是同一句话 —— // 这里显式钉住,让日后任何改动**有意**改这个气泡数,而不是悄悄变。 func TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + literalEscape + "乙"}} b, ad, hist := replyTurnBridge(t, chat) b.bubbleGapFn = func() time.Duration { return 0 } b.process(context.Background(), directMessage()) // 落库(Web 镜像):两段

,中间保留空行。 assertAssistantReply(t, hist, "

甲

\n\n

乙

") if len(ad.sends) != 2 { t.Fatalf("出站气泡 = %d 条, want 2(`\\n\\n` ⇒ 空行 ⇒ 段落分隔): %+v", len(ad.sends), ad.sends) } if got, want := ad.sends[0].Content, "甲"; got != want { t.Fatalf("气泡[0] = %q, want %q", got, want) } if got, want := ad.sends[1].Content, "乙"; got != want { t.Fatalf("气泡[1] = %q, want %q", got, want) } }