diff --git a/internal/runtime/turnreply/literal_breaks.go b/internal/runtime/turnreply/literal_breaks.go new file mode 100644 index 0000000..90b8f8b --- /dev/null +++ b/internal/runtime/turnreply/literal_breaks.go @@ -0,0 +1,140 @@ +package turnreply + +import ( + "math" + "strings" +) + +// 本文件承载助手文本里**字面转义**的渲染:模型偶尔把换行写成**两个字符** +// `\n`(反斜杠 + n),而不是一个真换行。 +// +// 缺陷形状(主人 2026-10-24 报障,真机原文):她列了七个中古调式,用字面 `\n` +// 当分隔,全挤在**同一个 `
`** 里 —— +// +//
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:…\n弗里吉亚:…
+// +// 清洗链不认这两个字符(全仓没有任何一处把字面 `\n` 当换行),于是原样发给 QQ, +// 用户看到的是「F\n多利亚」,Web 气泡里也一样(`whitespace-pre-wrap` 只对**真** +// 换行生效)。⇒ 这一层是**写入侧规整**:把字面 `\n` 换成真换行,落库文本、WS +// `message_persisted`、REST reply、IM 出站气泡就是**同一份**(与 +// NormalizeParagraphs 同一条口径,见 paragraph.go 的调用点)。 +// +// 判据与边界: +// - 只认**恰好一个**反斜杠紧跟 `n`。`\\n`(双反斜杠 + n,模型在讲转义规则或 +// 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠 +// 加字母 n」,把它当换行等于篡改内容。 +// - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论 +// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。 +// - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层 +// 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。 +// - **不制造结构标记**:本函数只产出换行字符,绝不产出 `` ——
+// `
` 在 chat 口径里是**气泡/消息分隔符**(把一条列表升级成七条消息反而更吵),
+// 字面 `\n` 只应是**气泡内部的换行**。
+// - 幂等:渲染后的文本里不再有可被本函数改写的字面 `\n`(代码里的那些本来就不动),
+// 因此二次调用逐字相同。
+
+const (
+ // literalBreakEscape 是模型实际写出的「字面换行」:反斜杠 + n 两个字符。
+ literalBreakEscape = `\n`
+ // backtickCh 是代码围栏与行内代码的定界符。
+ backtickCh = '`'
+ // backslashCh 是转义引导符。
+ backslashCh = '\\'
+ // fenceRunLen 是围栏代码块的定界符长度(paragraph.go 的 codeFenceMarker 是
+ // 三个反引号;它在 var 块里,len() 不是常量,故这里写死同一个 3)。
+ fenceRunLen = 3
+ // unboundedRun 表示「反引号串长度不设上限」(围栏的闭合串可以更长,
+ // 如 ````> 也能闭合 ``` 开的围栏)。
+ unboundedRun = math.MaxInt
+)
+
+// RenderLiteralLineBreaks 返回把字面 `\n` 渲染成真换行后的文本:代码围栏与行内
+// 代码内原样保留,`\\n` 与其余转义不动,不含字面 `\n` 的文本逐字返回(零分配路径)。
+func RenderLiteralLineBreaks(text string) string {
+ if !strings.Contains(text, literalBreakEscape) {
+ return text
+ }
+ var b strings.Builder
+ b.Grow(len(text) + 8)
+ for i := 0; i < len(text); {
+ switch text[i] {
+ case backtickCh:
+ // 围栏/行内代码:整段逐字复制,内部的 `\n` 一个字不动。
+ end := codeProtectedEnd(text, i)
+ b.WriteString(text[i:end])
+ i = end
+ case backslashCh:
+ switch {
+ case i+1 >= len(text):
+ // 行尾孤立的 `\`:原文保留。
+ b.WriteByte(text[i])
+ i++
+ case text[i+1] == 'n':
+ b.WriteByte('\n')
+ i += 2
+ case text[i+1] == backslashCh:
+ // `\\` 成对吞掉 ⇒ 紧随其后的 `n` 是普通字母,`\\n` 因此原样保留。
+ b.WriteString(text[i : i+2])
+ i += 2
+ default:
+ // `\t` / `\r` / `\u0041` 等:本层不处理,原样保留。
+ b.WriteByte(text[i])
+ i++
+ }
+ default:
+ b.WriteByte(text[i])
+ i++
+ }
+ }
+ return b.String()
+}
+
+// codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开):
+// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于下一个 ``` 起的反引号串
+// (含该串本身)。没有闭合围栏时**保护到文本末尾** —— 宁可少渲染一处换行,
+// 也不把可能仍是代码的正文改坏。
+// - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串
+// (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符,
+// 区间就是它们自身(不吞后面的正文)。
+func codeProtectedEnd(text string, start int) int {
+ n := backtickRunLen(text, start)
+ body := start + n
+ if n >= fenceRunLen {
+ if close := findBacktickRun(text, body, fenceRunLen, unboundedRun); close >= 0 {
+ return close + backtickRunLen(text, close)
+ }
+ return len(text)
+ }
+ if close := findBacktickRun(text, body, n, n); close >= 0 {
+ return close + n
+ }
+ return body
+}
+
+// findBacktickRun 在 text[from:] 里找**下一个**长度落在
+// [atLeast, atMost] 区间内的连续反引号串的起始下标,找不到返回 -1。
+// 围栏用 atLeast=fenceRunLen / atMost=unboundedRun(更长的围栏也能闭合),
+// 行内代码用 atLeast==atMost==定界符长度(等长才算闭合)。
+func findBacktickRun(text string, from, atLeast, atMost int) int {
+ for i := from; i < len(text); {
+ if text[i] != backtickCh {
+ i++
+ continue
+ }
+ r := backtickRunLen(text, i)
+ if r >= atLeast && r <= atMost {
+ return i
+ }
+ i += r
+ }
+ return -1
+}
+
+// backtickRunLen 返回 text[at:] 起连续反引号的个数(at 越界时为 0)。
+func backtickRunLen(text string, at int) int {
+ n := 0
+ for at+n < len(text) && text[at+n] == backtickCh {
+ n++
+ }
+ return n
+}
diff --git a/internal/runtime/turnreply/literal_breaks_test.go b/internal/runtime/turnreply/literal_breaks_test.go
new file mode 100644
index 0000000..705066f
--- /dev/null
+++ b/internal/runtime/turnreply/literal_breaks_test.go
@@ -0,0 +1,211 @@
+package turnreply
+
+import (
+ "strings"
+ "testing"
+)
+
+// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」(literal_breaks.go)的判据。
+//
+// 重点三条:
+// - **代码内容不许被改**:围栏(```)与行内代码(`…`)里的 `\n` 逐字不动 ——
+// 模型讨论代码时写 `print("a\nb")`,全局替换会把代码改坏;
+// - **`\\n` 不动**:它表示「一个反斜杠 + 字母 n」,当换行等于篡改内容;
+// - **幂等**:它跑在每一条助手回复的写入路径上,二次调用必须逐字相同。
+
+// literalEscapeSample 是模型实际写出的那两个字符:反斜杠 + n(不是换行)。
+const literalEscapeSample = "\\n"
+
+// realLiteralBreakReply 是真机原文(主人 2026-10-24 报障那条乐理回复,6 处字面
+// `\n` 全挤在同一个
里)。它与 channelbridge 侧 +// sanitize_literal_breaks_test.go 的同名字面量**逐字相同** —— 那正是「网关/落库 +// 与 IM 出站吃同一份文本」的断言方式(同一份样本,两侧各自断言)。 +const realLiteralBreakReply = `
伊奥尼亚: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 这一组音,只是主音各站各的位置♪
` + +// TestRenderLiteralLineBreaks_RealMachineMessage 是必测①的**修前红/修后绿**判据: +// 真机原文的 6 处字面 `\n` 全部渲染成真换行,段落结构(5 个)一字不变,且
+// 绝不引入 `
`(它不是气泡分隔符,只是气泡内的换行)。
+func TestRenderLiteralLineBreaks_RealMachineMessage(t *testing.T) {
+ t.Parallel()
+
+ got := RenderLiteralLineBreaks(realLiteralBreakReply)
+ if strings.Contains(got, literalEscapeSample) {
+ t.Fatalf("仍剩字面 `\\n` —— QQ 上还会看到「F\\n多利亚」:\n%q", got)
+ }
+ if wantGain := strings.Count(realLiteralBreakReply, literalEscapeSample); strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n") != wantGain {
+ t.Fatalf("新增真换行 = %d, want %d", strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n"), wantGain)
+ }
+ if n := strings.Count(got, "
"); n != 5 { + t.Fatalf("
块 = %d, want 5(段落口径不得被改动)", n)
+ }
+ if strings.Contains(got, "
—— 字面 `\\n` 只应是气泡内换行,不是消息分隔符:\n%q", got)
+ }
+ // 逐字校验第三段:七个调式各占一行。
+ const scaleHead = "
伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:" + if !strings.Contains(got, scaleHead) { + t.Fatalf("调式段没有被换行渲染:\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_CodeFenceProtected 是必测②:围栏代码块里的 `\n` +// 逐字保留(她解释 `print("a\nb")` 时那两下是**代码内容**),围栏前后的正文照常渲染。 +func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) { + t.Parallel() + + in := "先说明一句" + literalEscapeSample + "这里要换行。\n" + + "```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```\n" + + "围栏之后这句" + literalEscapeSample + "也换行。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, `fmt.Print("a\nb")`) { + t.Fatalf("围栏内的 `\\n` 被改坏了(代码内容必须逐字保留):\n%q", got) + } + if got := strings.Count(RenderLiteralLineBreaks(in), literalEscapeSample); got != 1 { + t.Fatalf("剩余字面 `\\n` = %d, want 1(只该剩围栏里那一处)", got) + } + for _, want := range []string{"先说明一句\n这里要换行。", "围栏之后这句\n也换行。"} { + if !strings.Contains(got, want) { + t.Fatalf("围栏外正文没有被渲染,want 含 %q:\n%q", want, got) + } + } +} + +// TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码 +// (“ `…` “ 与等长的 “ “…“ “)里的 `\n` 也不动。 +func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) { + t.Parallel() + + in := "行内代码 `a" + literalEscapeSample + "b` 里那两下不动;真的换行这样写:" + literalEscapeSample + "下一句。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, "行内代码 `a"+literalEscapeSample+"b` 里那两下不动") { + t.Fatalf("行内代码里的 `\\n` 被改坏了:\n%q", got) + } + if !strings.HasSuffix(got, "真的换行这样写:\n下一句。") { + t.Fatalf("行内代码之外的 `\\n` 没被渲染:\n%q", got) + } + + // 双反引号定界符同样受保护。 + double := "``a" + literalEscapeSample + "b`` 不动,外面" + literalEscapeSample + "要换行" + if got := RenderLiteralLineBreaks(double); !strings.Contains(got, "``a"+literalEscapeSample+"b``") || + !strings.HasSuffix(got, "外面\n要换行") { + t.Fatalf("双反引号行内代码处理错:\n%q", got) + } + + // 落单反引号(没有等长闭合串)只是普通字符,不吞掉后面的正文。 + stray := "一个孤零零的反引号 `,然后" + literalEscapeSample + "这里照常换行" + if got := RenderLiteralLineBreaks(stray); !strings.HasSuffix(got, "然后\n这里照常换行") { + t.Fatalf("落单反引号把后面的正文一并保护了(应为普通字符):\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_DoubleBackslashUntouched 是必测③:`\\n`(双反斜杠 + +// n,模型讲转义规则或写路径时会写)**原样保留** —— 它的语义是一个反斜杠加字母 n。 +// 顺带钉住「`\\` 之后紧跟的字面 `\n` 仍要渲染」这条配对口径。 +func TestRenderLiteralLineBreaks_DoubleBackslashUntouched(t *testing.T) { + t.Parallel() + + const doubleBackslash = "\\\\n" // \\n:两个反斜杠 + n + unchanged := "字面反斜杠加 n 要写成 " + doubleBackslash + " 这样" + if got := RenderLiteralLineBreaks(unchanged); got != unchanged { + t.Fatalf("`\\\\n` 被改动了:\n got %q\nwant %q", got, unchanged) + } + + paired := "先一个转义的反斜杠 \\\\ 然后" + literalEscapeSample + "这里换行" + if got := RenderLiteralLineBreaks(paired); !strings.HasSuffix(got, "然后\n这里换行") { + t.Fatalf("`\\\\` 之后的字面 `\\n` 没有被渲染:\n%q", got) + } +} + +// TestRenderLiteralLineBreaks_OtherEscapesUntouched 记录本层的**范围边界**:只认 +// `\n` 这一个形状,`\t` / `\r` / 行尾孤立的 `\` 一律不动(其余字面转义见切片报告, +// 本片不扩大语义)。 +func TestRenderLiteralLineBreaks_OtherEscapesUntouched(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ name, in string }{ + {"tab", "缩进" + "\\t" + "不处理"}, + {"carriage", "回车" + "\\r" + "不处理"}, + {"trailing-backslash", "行尾孤立的反斜杠\\"}, + {"unicode", "转义" + "\\u00e9" + "不处理"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := RenderLiteralLineBreaks(tc.in); got != tc.in { + t.Fatalf("%s:被本层改动了:\n got %q\nwant %q", tc.name, got, tc.in) + } + }) + } +} + +// TestRenderLiteralLineBreaks_UnclosedFenceProtected 是保守兜底:围栏没闭合时, +// 后面的文本**一律原样保留**(宁可少渲染一处换行,也不把可能仍是代码的正文改坏)。 +func TestRenderLiteralLineBreaks_UnclosedFenceProtected(t *testing.T) { + t.Parallel() + + in := "```go\n代码里的" + literalEscapeSample + "不动" + if got := RenderLiteralLineBreaks(in); got != in { + t.Fatalf("未闭合围栏之后的正文被改了:\n got %q\nwant %q", got, in) + } +} + +// TestRenderLiteralLineBreaks_IdempotentAndFastPath 是守门断言:没有字面 `\n` 的 +// 文本逐字返回;有的话二次调用逐字相同(它跑在每条回复的写入路径上,破掉幂等就会 +// 每读改写一次叠一层)。 +func TestRenderLiteralLineBreaks_IdempotentAndFastPath(t *testing.T) { + t.Parallel() + + for _, plain := range []string{"", "普通一句话,没有字面转义。", "
已经规整好的段落。
"} { + if got := RenderLiteralLineBreaks(plain); got != plain { + t.Fatalf("无字面 `\\n` 的文本被改动:\n got %q\nwant %q", got, plain) + } + } + samples := []string{ + realLiteralBreakReply, + "甲" + literalEscapeSample + "乙", + "围栏外" + literalEscapeSample + "换行\n```\n代码" + literalEscapeSample + "\n```", + } + for _, s := range samples { + once := RenderLiteralLineBreaks(s) + if twice := RenderLiteralLineBreaks(once); twice != once { + t.Fatalf("不幂等:\n once %q\ntwice %q", once, twice) + } + } +} + +// TestNormalizeParagraphs_CarriesLiteralBreaks 是必测①的**落点**断言:字面 `\n` +// 的渲染挂在 NormalizeParagraphs 上 —— 网关(Web)与渠道桥(IM)都调这一个函数, +// 因此落库、WS/REST 与 IM 出站拿到的是同一份真换行文本。 +// +// 含围栏时段落规整整体 skip,但**围栏外的**字面 `\n` 仍必须被渲染(渲染刻意放在 +// 围栏 skip 之前),围栏内的不动。 +func TestNormalizeParagraphs_CarriesLiteralBreaks(t *testing.T) { + t.Parallel() + + got := NormalizeParagraphs(realLiteralBreakReply) + if strings.Contains(got, literalEscapeSample) { + t.Fatalf("NormalizeParagraphs 没有渲染字面 `\\n`:\n%q", got) + } + if n := strings.Count(got, ""); n != 5 { + t.Fatalf("
块 = %d, want 5", n) + } + + mixed := "
甲" + literalEscapeSample + "乙
\n\n" + + "```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```" + got = NormalizeParagraphs(mixed) + if !strings.Contains(got, "甲\n乙
") { + t.Fatalf("含围栏的消息里,围栏外那段没有被渲染:\n%q", got) + } + if !strings.Contains(got, `fmt.Print("a\nb")`) { + t.Fatalf("围栏内的 `\\n` 被改坏了:\n%q", got) + } +} diff --git a/internal/runtime/turnreply/paragraph.go b/internal/runtime/turnreply/paragraph.go index ce3a06e..2b18aa7 100644 --- a/internal/runtime/turnreply/paragraph.go +++ b/internal/runtime/turnreply/paragraph.go @@ -56,8 +56,14 @@ var ( // paragraph_repair.go)。 // // 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) == -// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。 +// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本按段落口径原样返回。 +// +// 开头先跑 `RenderLiteralLineBreaks`(literal_breaks.go):模型把换行写成字面 `\n` +// 时,落库/WS/REST/IM 出站要拿到**同一份**真换行文本。这一步刻意放在**围栏 skip +// 之前** —— 否则含代码围栏的消息里,围栏外那些字面 `\n` 就一律不渲染了;围栏与 +// 行内代码内部由那个函数自己保护,段落口径的 skip 不受影响。 func NormalizeParagraphs(text string) string { + text = RenderLiteralLineBreaks(text) if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) { return text } diff --git a/internal/services/channelbridge/sanitize_literal_breaks_test.go b/internal/services/channelbridge/sanitize_literal_breaks_test.go new file mode 100644 index 0000000..d99549e --- /dev/null +++ b/internal/services/channelbridge/sanitize_literal_breaks_test.go @@ -0,0 +1,146 @@ +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. 🔴 **`里)。 + +// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 +const literalEscape = `\n` + +// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个
、 +// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。 +const realLiteralBreakReply = `
伊奥尼亚: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丁
第一行\n第二行
"; got != want { + t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want) + } +}