diff --git a/internal/runtime/turnreply/literal_breaks.go b/internal/runtime/turnreply/literal_breaks.go index 90b8f8b..0e9fd8b 100644 --- a/internal/runtime/turnreply/literal_breaks.go +++ b/internal/runtime/turnreply/literal_breaks.go @@ -24,7 +24,9 @@ import ( // 写 Windows 路径时 legitimately 会写)**原样保留** —— 它的语义是「一个反斜杠 // 加字母 n」,把它当换行等于篡改内容。 // - **代码围栏(```)与行内代码(`…`、``…``)里的 `\n` 逐字不动**:模型讨论 -// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。 +// 代码时写 `print("a\nb")` 是**代码内容**,全局替换会把代码改坏。围栏的闭合串 +// 按 CommonMark 取「不短于开启串」(```` 开的围栏不能被内层 ``` 关闭),行内 +// 代码取「等长」,因此嵌套围栏示例(```go 套在 ````markdown 里)同样受保护。 // - 不认识的转义(`\t` / `\r` / `\uXXXX` / 行尾单独的 `\`)**一律不动**:这一层 // 只修主人报了的那一个形状,不顺手扩大语义(其余字面转义见切片报告)。 // - **不制造结构标记**:本函数只产出换行字符,绝不产出 `
` / `

` —— @@ -90,9 +92,10 @@ func RenderLiteralLineBreaks(text string) string { } // codeProtectedEnd 返回从 start 起的**受保护区间**的结束下标(半开): -// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于下一个 ``` 起的反引号串 -// (含该串本身)。没有闭合围栏时**保护到文本末尾** —— 宁可少渲染一处换行, -// 也不把可能仍是代码的正文改坏。 +// - `start` 处是 ``` 起(含更长):一个围栏代码块,止于**不短于开启串**的反引号串 +// (CommonMark 口径:```` 开的围栏不能被内层的 ``` 关闭 —— 否则嵌套围栏示例里的 +// `\n` 会被当成正文渲染,而这段文本紧接着就落库)。没有闭合围栏时**保护到文本 +// 末尾** —— 宁可少渲染一处换行,也不把可能仍是代码的正文改坏。 // - `start` 处是一或两个反引号:一个行内代码 span,止于**等长**的反引号串 // (CommonMark 口径)。找不到等长闭合串时,这几个反引号只是普通字符, // 区间就是它们自身(不吞后面的正文)。 @@ -100,7 +103,7 @@ 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 { + if close := findBacktickRun(text, body, n, unboundedRun); close >= 0 { return close + backtickRunLen(text, close) } return len(text) diff --git a/internal/runtime/turnreply/literal_breaks_test.go b/internal/runtime/turnreply/literal_breaks_test.go index 705066f..97da72c 100644 --- a/internal/runtime/turnreply/literal_breaks_test.go +++ b/internal/runtime/turnreply/literal_breaks_test.go @@ -79,8 +79,30 @@ func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) { } } +// TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun 钉住 CommonMark 的围栏闭合 +// 口径:**闭合串不得短于开启串**。四个反引号开启的围栏里嵌着三反引号的示例时,内层 +// ``` 只是**代码内容**,不能把外层围栏闭合 —— 否则示例里的 `print("a\nb")` 会被渲染成 +// 真换行,而这段文本紧接着就落库(改坏的代码会永久进历史)。 +func TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun(t *testing.T) { + t.Parallel() + + const nested = "````markdown\n```go\nfmt.Println(\"a" + literalEscapeSample + "b\")\n```\n````" + in := "教你怎么写围栏代码块:\n\n" + nested + "\n\n围栏之后这句" + literalEscapeSample + "也换行。" + got := RenderLiteralLineBreaks(in) + + if !strings.Contains(got, nested) { + t.Fatalf("嵌套围栏没有被逐字保留(内层 ``` 提前闭合了外层 ````):\n got %q\nwant 含 %q", got, nested) + } + if strings.Contains(got, "fmt.Println(\"a\nb\")") { + t.Fatalf("嵌套围栏里的字面 `\\n` 被渲染成真换行 —— 代码内容被改坏:\n%q", got) + } + if !strings.HasSuffix(got, "围栏之后这句\n也换行。") { + t.Fatalf("围栏之外的正文没被渲染(保护不得溢出):\n%q", got) + } +} + // TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码 -// (“ `…` “ 与等长的 “ “…“ “)里的 `\n` 也不动。 +// (单个反引号与两个反引号两种定界符)里的 `\n` 也不动。 func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) { t.Parallel() diff --git a/internal/services/channelbridge/sanitize_literal_breaks_test.go b/internal/services/channelbridge/sanitize_literal_breaks_test.go index d99549e..582c4be 100644 --- a/internal/services/channelbridge/sanitize_literal_breaks_test.go +++ b/internal/services/channelbridge/sanitize_literal_breaks_test.go @@ -23,8 +23,8 @@ import ( // literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 const literalEscape = `\n` -// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个

、 -// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。 +// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、 +// 5 个

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

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

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

@@ -113,9 +113,13 @@ func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) { } } -// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖**第三个**助手文本产出点: -// 主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此同一条缺陷在那 -// 条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。 +// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖 **IM 侧**的第三个助手文本 +// 产出点:渠道桥的主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此 +// 这条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。 +// +// ⚠️ 范围边界:**Web 活跃客户端**那条主动推送(gateway 的 proactive → +// proactive_persist)**不经** NormalizeParagraphs,字面 `\n` 在那条路由上仍原样进库; +// 那是既有取舍、不属本片 scope(已由塔登记为后续切片)。本测试只断言 IM 这一侧。 func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) { t.Parallel() @@ -144,3 +148,31 @@ func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) { 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) + } +}