fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)

复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
This commit is contained in:
AskaEth committed 2026-10-24 19:33:33 +08:00
1 parent fa573663c1
commit 6b0af81136
3 files changed
+68 -11

No files matched your search

@@ -23,8 +23,8 @@ import (
// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。
const literalEscape = `\n`
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字节、5 个 <p>、
// 6 处字面 `\n`、不含反引号)。断言依赖这三条性质,改样本时必须同步。
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、
// 5 个 <p>、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
@@ -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` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒
// 两段 `<p>` ⇒ 两端各两条气泡。
//
// 语义上说得通(空行 = 段落),但它与「字面 `\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 镜像):两段 <p>,中间保留空行。
assertAssistantReply(t, hist, "<p>甲</p>\n\n<p>乙</p>")
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)
}
}