From 5de708a5604a43a082ee7c05d636edd7650a86ea Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Mon, 5 Oct 2026 10:56:39 +0800
Subject: [PATCH] =?UTF-8?q?fix(gateway,channelbridge,turnreply):=20p=20?=
=?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=8D=8A=E5=8C=85=E5=86=99=E5=85=A5=E4=BE=A7?=
=?UTF-8?q?=E8=A7=84=E6=95=B4=EF=BC=88=E4=BF=AE=E3=80=8C=E6=95=B4=E6=9D=A1?=
=?UTF-8?q?=E5=A1=8C=E6=88=90=E4=B8=80=E6=AE=B5=E3=80=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
把未被
包裹的段落补上
(按空行分段;/ 段也包进 ,
与正确形态
……
一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
---
internal/runtime/turnreply/paragraph.go | 142 ++++++++++++++
internal/runtime/turnreply/paragraph_test.go | 180 ++++++++++++++++++
.../channelbridge/channelbridge_test.go | 8 +-
internal/services/channelbridge/proactive.go | 14 +-
internal/services/channelbridge/turnreply.go | 8 +-
.../channelbridge/turnreply_paragraph_test.go | 165 ++++++++++++++++
.../services/channelbridge/turnreply_test.go | 16 +-
.../services/gateway/history_turnid_test.go | 4 +-
.../gateway/session_parallel_frames_test.go | 4 +-
.../session_parallel_turn_interrupt_test.go | 6 +-
.../services/gateway/session_turn_reply.go | 21 +-
.../session_turn_reply_paragraph_test.go | 153 +++++++++++++++
.../gateway/session_turn_reply_test.go | 10 +-
.../gateway/session_turnid_wiring_test.go | 28 +--
internal/services/gateway/voice_turn_test.go | 2 +-
.../services/gateway/ws_role_gate_test.go | 4 +-
.../services/gateway/ws_route_chat_test.go | 2 +-
17 files changed, 721 insertions(+), 46 deletions(-)
create mode 100644 internal/runtime/turnreply/paragraph.go
create mode 100644 internal/runtime/turnreply/paragraph_test.go
create mode 100644 internal/services/channelbridge/turnreply_paragraph_test.go
create mode 100644 internal/services/gateway/session_turn_reply_paragraph_test.go
diff --git a/internal/runtime/turnreply/paragraph.go b/internal/runtime/turnreply/paragraph.go
new file mode 100644
index 0000000..eaa6031
--- /dev/null
+++ b/internal/runtime/turnreply/paragraph.go
@@ -0,0 +1,142 @@
+package turnreply
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// 本文件承载助手文本的**段落包裹规整**:把「只包了最后一段」的半包回复,在写入
+// 侧规整成「每个段落一个 …
」的规范形态。
+//
+// 缺陷形状(安卓侧实测):模型的多数回复按段产出 `…
`,但它偶尔**只包最后
+// 一段**,前面几段是裸文本用空行分隔。客户端的解析规则是「**只要存在完整 块,
+// 就不再按空行回退切分**」—— 于是半包恰好落在唯一会塌的那一侧:整条消息被渲染成
+// 一整坨,而不是多个气泡。反过来,全都不包反而正常(客户端会按空行拆开)。
+//
+// 为什么放在这里(而不是各写一套):网关(Web/REST/WS 回合,见
+// internal/services/gateway/session_turn_reply.go)与渠道桥(IM 回合、主动推送,
+// 见 internal/services/channelbridge/turnreply.go、proactive.go)都必须吃到**同一份**
+// 规整后的文本 —— 落库内容、WS `message_persisted`、REST reply、续跑尾部、IM 出站
+// 气泡切分(splitBubbles 按
切)全部以它为准。两份实现迟早漂移,而这一份正是
+// 客户端「看到一段还是一坨」的唯一判据。
+//
+// 判据与边界(逐条对应下面的实现):
+// - **幂等**(最重要的守门断言):已完全包裹的文本**逐字不变**。规整只做加法,
+// 绝不二次加工好文本;段落之间的分隔空白(几个空行、缩进)原样保留。
+// - 空文本 / 纯空白:原样返回(绝不制造空段落)。
+// - 含 markdown 代码围栏(```):**整条跳过**。围栏内部按空行拆开会把一段代码
+// 切成互不相连的段落(比不规整更糟),无法安全判定时宁可不碰。
+// - `` / `` 块:**永不跨空行拆开**(被拆开的标签对会让 Web 与
+// IM 都渲染出野生标签),它们随所在段落一起被包进**同一个** ``。
+// - `
` 块内部是原子的:块内的空行不参与分段,块本身不被二次包裹。
+var (
+ // atomRe 匹配文本里**不可再拆**的原子块:完整的
…
(含自闭合 )与
+ // 完整的 / 块。它们内部的空行不算段落分隔。
+ atomRe = regexp.MustCompile(`(?is)]*?/>|
]*?>.*?
` +
+ `|]*?>.*?|]*?>.*?`)
+ // paragraphBlockRe 只匹配完整的 …
块(含自闭合 ),用于判断一段是否
+ // 已经被包裹。
+ paragraphBlockRe = regexp.MustCompile(`(?is)]*?/>|
]*?>.*?
`)
+ // blankLineRe 匹配分隔段落的**空行组**:两个及以上换行,中间只允许水平空白。
+ blankLineRe = regexp.MustCompile(`\n[ \t\r]*\n(?:[ \t\r]*\n)*`)
+ // codeFenceMarker 是 markdown 围栏代码块的起止标记(见到即整条跳过)。
+ codeFenceMarker = "```"
+ // trimCutset 是段落外缘的空白字符集,与 blankLineRe 的口径保持一致(空行组
+ // 只由这些字符构成),因此「外缘空白」与「段间空行」不会互相切错。
+ trimCutset = " \t\r\n\v\f"
+)
+
+// NormalizeParagraphs 返回规整后的助手文本:未被 `` 包裹的段落补上 `
…
`,
+// 已完整包裹的段落逐字保留,``/`` 段也包进它所在的 ``。
+//
+// 它是纯函数且**幂等**(`NormalizeParagraphs(NormalizeParagraphs(s)) ==
+// NormalizeParagraphs(s)`),空文本、纯空白、含代码围栏的文本原样返回。
+func NormalizeParagraphs(text string) string {
+ if strings.TrimSpace(text) == "" || strings.Contains(text, codeFenceMarker) {
+ return text
+ }
+ cuts := paragraphCuts(text)
+ if len(cuts) == 0 {
+ return normalizePiece(text)
+ }
+ var b strings.Builder
+ b.Grow(len(text) + 3*len(cuts))
+ prev := 0
+ for _, cut := range cuts {
+ b.WriteString(normalizePiece(text[prev:cut]))
+ prev = cut
+ }
+ b.WriteString(normalizePiece(text[prev:]))
+ return b.String()
+}
+
+// paragraphCuts 返回文本里全部的**分段边界**(切点,升序去重):
+// - 空行组的末尾(段落分隔);
+// - 每个完整 `
` 块的起点与终点(`
` 块自成一段,绝不被包进另一个 `
`)。
+//
+// 落在原子块(`
` 块 / `` / ``)内部的空行**不是**边界 ——
+// 这正是「不把标签对切开」的实现方式。
+func paragraphCuts(text string) []int {
+ atoms := atomRe.FindAllStringIndex(text, -1)
+ cuts := make([]int, 0, 2*len(atoms)+4)
+ for _, loc := range blankLineRe.FindAllStringIndex(text, -1) {
+ if insideAtoms(atoms, loc) {
+ continue
+ }
+ cuts = append(cuts, loc[1])
+ }
+ for _, loc := range atoms {
+ if isParagraphBlock(text[loc[0]:loc[1]]) {
+ cuts = append(cuts, loc[0], loc[1])
+ }
+ }
+ sort.Ints(cuts)
+ out := make([]int, 0, len(cuts))
+ for _, c := range cuts {
+ if len(out) > 0 && c == out[len(out)-1] {
+ continue
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+// insideAtoms 报告 loc 是否完全落在某个原子块内部(原子块之间互不重叠,因此只需
+// 逐个判断包含关系)。
+func insideAtoms(atoms [][]int, loc []int) bool {
+ for _, a := range atoms {
+ if loc[0] >= a[0] && loc[1] <= a[1] {
+ return true
+ }
+ }
+ return false
+}
+
+// isParagraphBlock 报告 s 整体就是**一个**完整的 `` 块(即该段已被包裹)。
+func isParagraphBlock(s string) bool {
+ t := strings.TrimSpace(s)
+ if t == "" {
+ return false
+ }
+ loc := paragraphBlockRe.FindStringIndex(t)
+ return loc != nil && loc[0] == 0 && loc[1] == len(t)
+}
+
+// normalizePiece 规整相邻两个切点之间的一段原文。切点已经含**所有**位于原子块
+// 之外的空行组,因此这一段**恰好就是一段**(它内部若还有空行,那只可能落在
+// ``/`` 块里,必须原样保留)。已是 `` 块或纯空白时逐字返回;
+// 否则整段包上 `
`,这一段自己的首尾空白(它同时是相邻 `
` 块之间的分隔)
+// 原样保留。
+func normalizePiece(piece string) string {
+ if isParagraphBlock(piece) || strings.TrimSpace(piece) == "" {
+ return piece
+ }
+ lead := len(piece) - len(strings.TrimLeft(piece, trimCutset))
+ trail := len(piece) - len(strings.TrimRight(piece, trimCutset))
+ core := piece[lead : len(piece)-trail]
+ if strings.TrimSpace(core) == "" {
+ return piece
+ }
+ return piece[:lead] + "
" + core + "
" + piece[len(piece)-trail:]
+}
diff --git a/internal/runtime/turnreply/paragraph_test.go b/internal/runtime/turnreply/paragraph_test.go
new file mode 100644
index 0000000..7255524
--- /dev/null
+++ b/internal/runtime/turnreply/paragraph_test.go
@@ -0,0 +1,180 @@
+package turnreply
+
+import (
+ "strings"
+ "testing"
+)
+
+// 本文件锁定「段落包裹规整」(paragraph.go)的判据,重点是**幂等**这一条守门断言:
+// 已规范的文本再跑一次必须逐字相同 —— 否则这套规整就会把好文本二次加工坏,而它
+// 跑在每一条助手回复的写入路径上。
+//
+// 半包样本照抄上报形状:多段裸文本 + 一段 ,只有最后一段带 。
+
+// halfWrappedSample 是上报的半包形状:4 段裸文本(含内联 的那段)+
+// 只有最后一段带 。它正是会塌成一坨的那一侧。
+const halfWrappedSample = "第一段,读到头也读到尾啦♪\n\n" +
+ "第二段,轻轻合上纸条。\n\n" +
+ "第三段,纸张有点卷边了。\n\n" +
+ "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。\n\n" +
+ "
第五段,我把它们按日子排好啦。
"
+
+// wrappedSample 是半包样本**规整后**的规范形态(每段一个 , 段也包
+// 在它所在的 里,只有最后一段原本就是这样)。
+const wrappedSample = "
第一段,读到头也读到尾啦♪
\n\n" +
+ "第二段,轻轻合上纸条。
\n\n" +
+ "第三段,纸张有点卷边了。
\n\n" +
+ "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。
\n\n" +
+ "第五段,我把它们按日子排好啦。
"
+
+// TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped 是必测①:半包样本
+// 规整后**每一段**都被 包裹 —— 包括 那一段(它必须与同段的正文一起
+// 落在同一个 里,而不是被拆出去或漏在外面)。
+func TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped(t *testing.T) {
+ t.Parallel()
+
+ got := NormalizeParagraphs(halfWrappedSample)
+ if got != wrappedSample {
+ t.Fatalf("规整结果 =\n%q\nwant\n%q", got, wrappedSample)
+ }
+ // 唯一的判据是「
块之外有没有非空白文本」—— 客户端的塌陷规则正是按它生效。
+ if outside := bareTextOutsideParagraphs(got); outside != "" {
+ t.Fatalf("
块之外仍有裸文本 %q —— 客户端仍会塌成一坨", outside)
+ }
+ // 段没有被拆开(开闭标签仍在同一段里,且与同段正文同在一个 )。
+ const action = "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。"
+ if !strings.Contains(got, "
"+action+"
") {
+ t.Fatalf(" 段没有被原样包进同一个 :\n%q", got)
+ }
+}
+
+// TestNormalizeParagraphs_Idempotent 是必测②(守门断言):已规范的文本再跑一次
+// 必须**逐字相同**。它是这套规整能跑在写入路径上的前提 —— 幂等一旦破掉,每次
+// 读改写都会往好文本上再叠一层标签。
+func TestNormalizeParagraphs_Idempotent(t *testing.T) {
+ t.Parallel()
+
+ canonical := []struct {
+ name string
+ text string
+ }{
+ {"半包样本的规范形态", wrappedSample},
+ {"上报的正确形态", "
把整卷纸条…读到头也读到尾啦♪
"},
+ {"多段规范文本", "第一段。
\n\n第二段。
"},
+ {"规范文本但空行更多", "第一段。
\n\n\n\n第二段。
"},
+ {"规范文本但段间有缩进空白", "第一段。
\n \n第二段。
"},
+ {"单段规范文本(带外缘空白)", " 就一句。
\n"},
+ {"段内多行保持不动", "第一行\n第二行
\n\n下一段。
"},
+ {"动作段内的空行不被切开", "前半;\n\n后半。说完啦。
"},
+ {"自闭合段落", "正文。
\n\n"},
+ {"空文本", ""},
+ {"纯空白", " \n\t "},
+ }
+ for _, c := range canonical {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ once := NormalizeParagraphs(c.text)
+ if twice := NormalizeParagraphs(once); twice != once {
+ t.Fatalf("不幂等:第二次运行改动了文本\n once=%q\ntwice=%q", once, twice)
+ }
+ })
+ }
+}
+
+// TestNormalizeParagraphs_CanonicalTextIsByteIdentical 是必测②的另一半:**已完全
+// 包裹**的文本必须逐字不变(不只是「第二次运行相同」,而是「一次都不许改」)。
+func TestNormalizeParagraphs_CanonicalTextIsByteIdentical(t *testing.T) {
+ t.Parallel()
+
+ canonical := []string{
+ wrappedSample,
+ "把整卷纸条…读到头也读到尾啦♪
",
+ "第一段。
\n\n第二段。
",
+ "第一段。
\n\n\n\n第二段。
",
+ "第一段。
\n \n第二段。
",
+ " 就一句。
\n",
+ "第一行\n第二行
\n\n下一段。
",
+ "前半;\n\n后半。说完啦。
",
+ "正文。
\n\n",
+ }
+ for _, text := range canonical {
+ if got := NormalizeParagraphs(text); got != text {
+ t.Fatalf("已规范的文本被改动:\n in=%q\nout=%q", text, got)
+ }
+ }
+}
+
+// TestNormalizeParagraphs_BareTextGetsWrapped 是必测③:完全没有任何 的裸文本
+// 也要被规整(它现在靠客户端的空行回退才拆得开,一旦别处混进一个
就会塌)。
+func TestNormalizeParagraphs_BareTextGetsWrapped(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"多段裸文本", "第一段。\n\n第二段。", "
第一段。
\n\n第二段。
"},
+ {"单段裸文本", "就一句。", "就一句。
"},
+ {"带动作的裸文本", "弯起眼睛当然看得到呀。\n\n第二段。",
+ "弯起眼睛当然看得到呀。
\n\n第二段。
"},
+ {"独立动作段", "正文。\n\n拍拍你",
+ "正文。
\n\n拍拍你
"},
+ {"旁白段", "心里有点甜\n\n正文。",
+ "心里有点甜
\n\n正文。
"},
+ {"段内单换行保持在同一段", "第一行\n第二行\n\n第二段。",
+ "第一行\n第二行
\n\n第二段。
"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ if got := NormalizeParagraphs(c.in); got != c.want {
+ t.Fatalf("规整 = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
+
+// TestNormalizeParagraphs_BlankAndFencedStayUntouched 是必测④与代码块边界:空文本、
+// 纯空白一字不动;含 ``` 围栏的文本**整条保守跳过**(把围栏里的空行当段落切开,
+// 会把一段代码拆成互不相连的段落,比不规整更糟)。
+func TestNormalizeParagraphs_BlankAndFencedStayUntouched(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ text string
+ }{
+ {"空文本", ""},
+ {"纯空白", " \n\t\n"},
+ {"空行围栏", "先看这段:\n\n```go\nfunc main() {}\n\nfunc other() {}\n```\n\n就这样。"},
+ {"行内围栏", "看看 ```code``` 这个词。\n\n第二段。"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ if got := NormalizeParagraphs(c.text); got != c.text {
+ t.Fatalf("不该被改动:\n in=%q\nout=%q", c.text, got)
+ }
+ })
+ }
+}
+
+// TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole 守住另一条边界:
+// 块**跨空行**时绝不能被切成分属两段 —— 那会让 Web 与 IM 都看到野生标签。
+func TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole(t *testing.T) {
+ t.Parallel()
+
+ const in = "把整卷纸条摊开;\n\n又轻轻卷回去。读到头也读到尾啦♪\n\n下一段。
"
+ const want = "把整卷纸条摊开;\n\n又轻轻卷回去。读到头也读到尾啦♪
\n\n下一段。
"
+ if got := NormalizeParagraphs(in); got != want {
+ t.Fatalf("规整 = %q, want %q", got, want)
+ }
+}
+
+// bareTextOutsideParagraphs 返回所有落在完整 块之外的**非空白**文本,用于
+// 断言「没有裸文本漏在
之外」。
+func bareTextOutsideParagraphs(text string) string {
+ rest := paragraphBlockRe.ReplaceAllString(text, " ")
+ return strings.TrimSpace(rest)
+}
diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go
index 5652904..d1a8a78 100644
--- a/internal/services/channelbridge/channelbridge_test.go
+++ b/internal/services/channelbridge/channelbridge_test.go
@@ -309,10 +309,12 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
recs[0].Mode != "chat" {
t.Fatalf("user record = %+v, want web/telegram/hello/telegram/chat", recs[0])
}
+ // 助手正文的落库内容是**规范形态**(写入侧段落包裹规整,见
+ // turnreply_paragraph_test.go):这里只关心归属字段,因此只要求包裹正确。
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
- recs[1].Content != "微信回复" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" ||
+ recs[1].Content != "
微信回复
" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" ||
recs[1].Mode != "chat" {
- t.Fatalf("assistant record = %+v, want web/telegram/微信回复/telegram/chat", recs[1])
+ t.Fatalf("assistant record = %+v, want web/telegram/微信回复
/telegram/chat", recs[1])
}
// 每条已持久化的消息都交给了广播钩子。
if len(persisted) != 2 {
@@ -359,7 +361,7 @@ func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) {
if recs[0].Role != history.RoleUser || recs[0].Content != "hello" {
t.Fatalf("user record = %+v", recs[0])
}
- if recs[1].Role != history.RoleAssistant || recs[1].Content != "微信回复" {
+ if recs[1].Role != history.RoleAssistant || recs[1].Content != "微信回复
" {
t.Fatalf("assistant record = %+v", recs[1])
}
}
diff --git a/internal/services/channelbridge/proactive.go b/internal/services/channelbridge/proactive.go
index c352eba..d3b1feb 100644
--- a/internal/services/channelbridge/proactive.go
+++ b/internal/services/channelbridge/proactive.go
@@ -7,6 +7,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
@@ -54,6 +55,10 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
out := msg
out.Platform = target.platform
out.ChannelType = adapter.ChannelDirect
+ // 主动推送的文本与回合回复走**同一份**段落包裹规整(同一函数、同一口径):
+ // 半包 的推送在 Web 历史里同样会塌成一整段,在 IM 侧同样只会得到
+ // 一个气泡(splitBubbles 只认完整
块)。
+ out.Content = turnreply.NormalizeParagraphs(msg.Content)
// 主人的账号 id 在每个适配器上都作私聊对端:telegram/qq 读
// ChannelID,kook/weixin 读 SenderID(ChannelID 作回退)。两者
// 都设置,无论适配器偏好哪个字段,私聊消息都可投达,
@@ -63,17 +68,18 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// 推送经共享的 IM 气泡派发器出口,因此多段主动消息
// 会被拆成气泡、角色扮演/旁白被剥离,并采用
// 与回复相同的 0.5-2s 间隔——WeChat 主动推送不再作为
- // 一整条未拆分消息发送。
+ // 一整条未拆分消息发送。切分吃的是**规整后**的同一份文本。
if err := b.sendIMBubbles(ctx, out, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return ad.SendProactive(ctx, msg)
- }, msg.Content); err != nil {
+ }, out.Content); err != nil {
return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err)
}
// 把主动消息镜像进主对话上下文(design/16
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
- // 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID。
- b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", "", target.platform,
+ // 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID,
+ // 落库的同样是规整后的那一份(Web 与 IM 看到的必须是同一份)。
+ b.persistToMain(ctx, history.RoleAssistant, out.Content, "", "", target.platform,
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
target.platform)
return nil
diff --git a/internal/services/channelbridge/turnreply.go b/internal/services/channelbridge/turnreply.go
index ea184de..ae54c9b 100644
--- a/internal/services/channelbridge/turnreply.go
+++ b/internal/services/channelbridge/turnreply.go
@@ -32,11 +32,15 @@ import (
// 一条按结束原因区分的诚实说明。
//
// 真实文本按渠道既有行为去掉首尾空白后交付(内容一字不改),与加入本保证之前
-// 的行为完全一致。
+// 的行为完全一致 —— **唯一例外**是与网关同一次(同一份)的段落包裹规整
+// (turnreply.NormalizeParagraphs):它给未被 `
` 包裹的段落补上包裹,
+// 于是**落库文本**(主人私聊会镜像进 Web 主会话)与**出站气泡切分**
+// (splitBubbles 按 `
` 切)吃的是同一份文本,客户端那侧「整条塌成一段」
+// 的同一条判据在 IM 侧不会再出现第二个答案。
func turnVisibleReply(ctx context.Context, res *loop.Result, runErr error) (string, bool) {
text := turnreply.Text(res)
if res != nil && res.Text == text {
- return strings.TrimSpace(text), true
+ return strings.TrimSpace(turnreply.NormalizeParagraphs(res.Text)), true
}
if errors.Is(runErr, context.Canceled) || ctx.Err() != nil {
return "", false
diff --git a/internal/services/channelbridge/turnreply_paragraph_test.go b/internal/services/channelbridge/turnreply_paragraph_test.go
new file mode 100644
index 0000000..13b4512
--- /dev/null
+++ b/internal/services/channelbridge/turnreply_paragraph_test.go
@@ -0,0 +1,165 @@
+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)
+ }
+}
diff --git a/internal/services/channelbridge/turnreply_test.go b/internal/services/channelbridge/turnreply_test.go
index f835c20..49da203 100644
--- a/internal/services/channelbridge/turnreply_test.go
+++ b/internal/services/channelbridge/turnreply_test.go
@@ -207,12 +207,16 @@ func assertNothingDelivered(t *testing.T, ad *fakeAdapter, hist *captureHistory)
}
// TestTurnReply_NormalReplyPassedThroughVerbatim 是必测③:正常回合(有正文)
-// 的出站与落库内容与模型正文**逐字相同**,绝不掺进兜底文案。
+// 的出站内容与模型正文**逐字相同**(段落标记由 IM 出口按既有行为剥除),落库则是
+// 同一条正文的**规范形态**(每段一个
,写入侧规整,见
+// turnreply_paragraph_test.go)——两者都不掺进兜底文案。
func TestTurnReply_NormalReplyPassedThroughVerbatim(t *testing.T) {
t.Parallel()
- const want = "你好,我在。今天想聊点什么?"
- chat := &stubChatter{res: &loop.Result{Text: want, Mood: "平静"}}
+ const reply = "你好,我在。今天想聊点什么?"
+ // 规范形态:单段正文也会被包上
,因此落库内容与模型原文相差的只有包裹。
+ const canonical = "
" + reply + "
"
+ chat := &stubChatter{res: &loop.Result{Text: reply, Mood: "平静"}}
b, ad, hist := replyTurnBridge(t, chat)
b.process(context.Background(), directMessage())
@@ -220,10 +224,10 @@ func TestTurnReply_NormalReplyPassedThroughVerbatim(t *testing.T) {
if len(ad.sends) != 1 {
t.Fatalf("出站消息 = %d 条, want 1", len(ad.sends))
}
- if got := ad.sends[0].Content; got != want {
- t.Fatalf("出站文案 = %q, want 逐字等于模型正文", got)
+ if got := ad.sends[0].Content; got != reply {
+ t.Fatalf("出站文案 = %q, want 逐字等于模型正文(段落标记不投给 IM)", got)
}
- rec := assertAssistantReply(t, hist, want)
+ rec := assertAssistantReply(t, hist, canonical)
if rec.Mood != "平静" {
t.Fatalf("assistant mood = %q, want 平静(真实结果的心情照旧带到消息上)", rec.Mood)
}
diff --git a/internal/services/gateway/history_turnid_test.go b/internal/services/gateway/history_turnid_test.go
index 077a33a..754f555 100644
--- a/internal/services/gateway/history_turnid_test.go
+++ b/internal/services/gateway/history_turnid_test.go
@@ -328,8 +328,8 @@ func TestSubmitWiresTurnIDForBothRows(t *testing.T) {
t.Fatalf("落库 %d 条, want 2(用户消息 + 助手回复): %+v", len(msgs), msgs)
}
turns := turnsOf(msgs)
- if turns["端到端的问题"] != sr.TurnID || turns["reply-1"] != sr.TurnID {
+ if turns["端到端的问题"] != sr.TurnID || turns[storedParagraph("reply-1")] != sr.TurnID {
t.Fatalf("两条 turn_id = %q / %q, want 都是提交回报的 %q",
- turns["端到端的问题"], turns["reply-1"], sr.TurnID)
+ turns["端到端的问题"], turns[storedParagraph("reply-1")], sr.TurnID)
}
}
diff --git a/internal/services/gateway/session_parallel_frames_test.go b/internal/services/gateway/session_parallel_frames_test.go
index 792c39d..f29609d 100644
--- a/internal/services/gateway/session_parallel_frames_test.go
+++ b/internal/services/gateway/session_parallel_frames_test.go
@@ -286,7 +286,7 @@ func TestInterruptTurnStopsOnlyTargetLeg(t *testing.T) {
if !waitIdle(f.o, "web") {
t.Fatal("另一路收尾后会话没有回到空闲")
}
- if got := assistantContents(f.history.records()); !slices.Equal(got, []string{"reply-1"}) {
- t.Fatalf("助手回复 = %v, want [reply-1](另一路继续作答,被取消路不补说明)", got)
+ if got := assistantContents(f.history.records()); !slices.Equal(got, []string{storedParagraph("reply-1")}) {
+ t.Fatalf("助手回复 = %v, want [reply-1
](另一路继续作答,被取消路不补说明)", got)
}
}
diff --git a/internal/services/gateway/session_parallel_turn_interrupt_test.go b/internal/services/gateway/session_parallel_turn_interrupt_test.go
index 4ab7541..6a6f528 100644
--- a/internal/services/gateway/session_parallel_turn_interrupt_test.go
+++ b/internal/services/gateway/session_parallel_turn_interrupt_test.go
@@ -177,8 +177,8 @@ func TestInterruptTurnOverWSStopsOnlyTargetLeg(t *testing.T) {
close(f.run.release)
waitUntil(t, "会话排空", func() bool { return !f.o.Busy("web") && f.o.QueueDepth("web") == 0 })
rows := assistantContents(f.hist.records())
- if !slices.Equal(rows, []string{"reply-A"}) {
- t.Fatalf("助手回复 = %v, want [reply-A](另一路继续作答;被取消路不补说明)", rows)
+ if !slices.Equal(rows, []string{storedParagraph("reply-A")}) {
+ t.Fatalf("助手回复 = %v, want [reply-A
](另一路继续作答;被取消路不补说明)", rows)
}
types := a2DrainTypes(t, f.conn, a2Settle)
@@ -221,7 +221,7 @@ func TestInterruptUnknownTurnIDIsDirectedErrorAndSparesAllLegs(t *testing.T) {
waitUntil(t, "会话排空", func() bool { return !f.o.Busy("web") && f.o.QueueDepth("web") == 0 })
rows := assistantContents(f.hist.records())
slices.Sort(rows)
- if want := []string{"reply-A", "reply-B"}; !slices.Equal(rows, want) {
+ if want := []string{storedParagraph("reply-A"), storedParagraph("reply-B")}; !slices.Equal(rows, want) {
t.Fatalf("助手回复 = %v, want %v(两路都必须照常作答)", rows, want)
}
diff --git a/internal/services/gateway/session_turn_reply.go b/internal/services/gateway/session_turn_reply.go
index 05c9a67..555b468 100644
--- a/internal/services/gateway/session_turn_reply.go
+++ b/internal/services/gateway/session_turn_reply.go
@@ -53,8 +53,10 @@ func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
// 步)。两条分支:
//
-// - 有真实文本:逐字落库,行为与加入本保证之前完全一致(被取消/陈旧代次的
-// 回合也照旧落 —— 内容是真的,与「不静默」无关);
+// - 有真实文本:逐字落库(**唯一例外**是幂等的段落包裹规整,见
+// turnreply.NormalizeParagraphs:只给未被 `` 包裹的段落补上包裹,已规范的
+// 文本一字不改;且**只在 chat 模式**做,见正文注释),行为与加入本保证之前完全
+// 一致(被取消/陈旧代次的回合也照旧落 —— 内容是真的,与「不静默」无关);
// - 没有可见文本(空回复 / 轮次用尽 / 内部报错 / 只剩安全网的内部文案):只有
// 「归本代次且不是被取消」才补一条诚实说明;其余情形返回原样、什么都不写。
//
@@ -74,7 +76,20 @@ func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.
}
text := turnreply.Text(res)
if res != nil && res.Text == text {
- // 真实文本:逐字落库,行为与加入本保证之前完全一致。
+ // 真实文本:除段落包裹规整外逐字落库。规整是**幂等的写入侧加法**(只给
+ // 未被
包裹的段落补上包裹,已规范的文本一字不改),它就地改写
+ // res.Text,因此落库内容、WS `message_persisted`、REST reply、续跑尾部
+ // 与语音拿到的是**同一份**文本。客户端的解析规则是「只要存在完整
块
+ // 就不再按空行回退切分」,半包的回复恰好在那一侧整条塌成一段;同一条
+ // 判据在 IM 侧(splitBubbles)也成立,两边必须吃同一份文本。
+ //
+ // **只在 chat 模式规整**:拆气泡这条判据只属于 chat 气泡;其余 mode
+ // (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的
+ // (design/07 §2),往里塞
只会在那些形态里露出野生标签。IM 永远是
+ // chat(channelbridge 的 modeChat),因此这条闸门不会让 Web 与 IM 分叉。
+ if modeOrDefault(mode) == modeChat {
+ res.Text = turnreply.NormalizeParagraphs(res.Text)
+ }
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID, turnID)
return res
}
diff --git a/internal/services/gateway/session_turn_reply_paragraph_test.go b/internal/services/gateway/session_turn_reply_paragraph_test.go
new file mode 100644
index 0000000..8af609a
--- /dev/null
+++ b/internal/services/gateway/session_turn_reply_paragraph_test.go
@@ -0,0 +1,153 @@
+package gateway
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
+)
+
+// 本文件锁定「助手回复落库前做段落包裹规整」这条写入侧修复(网关/Web 一侧)。
+//
+// 缺陷形状(安卓侧实测):模型有时**只包最后一段**,前面几段是裸文本用空行分隔。
+// 客户端的解析规则是「只要存在完整
块,就不再按空行回退切分」⇒ 半包恰好落在
+// 唯一会塌的一侧:整条消息被渲染成一整坨,而不是多个气泡。
+//
+// 断言走真路径:真 loop(脚本化模型适配器)+ 真 SQLite history + 真编排器
+// (复用 session_turn_reply_test.go 的 kbTurnFixture),因此这里看到的落库内容与
+// Web 客户端从历史接口拿到的是同一个东西。
+//
+// 两侧共用同一份样本字面量(渠道桥一侧见 channelbridge/turnreply_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" +
+ "第五段,我把它们按日子排好啦。
"
+
+// storedParagraph 返回一段**单段**助手正文落库后的规范形态。写入侧会给未被 ``
+// 包裹的段落补上包裹(权威断言是本文件的硬编码字面量),因此那些「按内容定位落库行」
+// 的既有测试要用这个形态去找,而不是模型原文。
+func storedParagraph(raw string) string {
+ return "
" + raw + "
"
+}
+
+// TestTurnEnd_HalfWrappedReplyRegularizedBeforePersist 是必测①(网关侧):半包回复
+// 落库前被规整 —— 每一段都在自己的 里(含 那一段),且结果与 IM 侧
+// 断言的字面量**完全相同**。
+func TestTurnEnd_HalfWrappedReplyRegularizedBeforePersist(t *testing.T) {
+ t.Parallel()
+
+ f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: halfWrappedTurnReply}, 0)
+ res, err := f.submit(t)
+ if err != nil {
+ t.Fatalf("回合不该报错: %v", err)
+ }
+ assistant := f.assertUserAndSingleAssistant(t)
+ if assistant.Content != wrappedTurnReply {
+ t.Fatalf("落库内容 =\n%q\nwant\n%q", assistant.Content, wrappedTurnReply)
+ }
+ // res.Text 与落库同值 ⇒ REST reply、WS 事件、续跑尾部与语音拿到的是同一份
+ // 文本(客户端不会在同一段回复上看到两套形状)。
+ if res.Text != wrappedTurnReply {
+ t.Fatalf("Result.Text =\n%q\nwant 与落库同一份\n%q", res.Text, wrappedTurnReply)
+ }
+ if strings.Count(assistant.Content, "") != 5 || strings.Count(assistant.Content, "
") != 5 {
+ t.Fatalf("段落包裹数不对:%q", assistant.Content)
+ }
+}
+
+// TestTurnEnd_CanonicalReplyPersistedByteIdentical 是必测②(网关侧):**已经规范**的
+// 回复逐字不变 —— 规整绝不能把好文本二次加工坏(幂等在写入路径上的那一半)。
+func TestTurnEnd_CanonicalReplyPersistedByteIdentical(t *testing.T) {
+ t.Parallel()
+
+ f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: wrappedTurnReply}, 0)
+ res, err := f.submit(t)
+ if err != nil {
+ t.Fatalf("回合不该报错: %v", err)
+ }
+ assistant := f.assertUserAndSingleAssistant(t)
+ if assistant.Content != wrappedTurnReply {
+ t.Fatalf("已规范的回复被改动:\n in=%q\nout=%q", wrappedTurnReply, assistant.Content)
+ }
+ if res.Text != wrappedTurnReply {
+ t.Fatalf("Result.Text 被改动:%q", res.Text)
+ }
+}
+
+// TestTurnEnd_FullyBareReplyIsRegularized 是必测③(网关侧):完全没有任何 的
+// 裸文本也被规整(它同样落在客户端的回退路径上,一旦别处混进一个
就会塌)。
+func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) {
+ t.Parallel()
+
+ const bare = "第一段。\n\n第二段。\n\n拍拍你"
+ const want = "
第一段。
\n\n第二段。
\n\n拍拍你
"
+ f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: bare}, 0)
+ if _, err := f.submit(t); err != nil {
+ t.Fatalf("回合不该报错: %v", err)
+ }
+ assistant := f.assertUserAndSingleAssistant(t)
+ if assistant.Content != want {
+ t.Fatalf("落库内容 = %q, want %q", assistant.Content, want)
+ }
+}
+
+// TestTurnEnd_NonChatModeReplyKeepsItsShape 守住规整的**作用范围**:非 chat 模式
+// (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的(design/07 §2),
+// 往里塞 `` 只会在那些形态里露出野生标签 —— 因此规整只对 chat 模式生效,
+// 其余模式的正文逐字不变。IM 永远是 chat,所以这条闸门不让 Web 与 IM 分叉。
+func TestTurnEnd_NonChatModeReplyKeepsItsShape(t *testing.T) {
+ t.Parallel()
+
+ f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: halfWrappedTurnReply}, 0)
+ sr, res := f.orch.SubmitSync(withMode(context.Background(), "code"), kbSessionID,
+ []model.Message{userMsg("改代码")}, loop.OwnerEnv())
+ if !sr.Accepted || !sr.Immediate {
+ t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
+ }
+ if res.Text != halfWrappedTurnReply {
+ t.Fatalf("非 chat 模式的 Result.Text 被改动:%q", res.Text)
+ }
+ assistant := f.assertUserAndSingleAssistant(t)
+ if assistant.Content != halfWrappedTurnReply {
+ t.Fatalf("非 chat 模式的落库内容被改动:\n%q\nwant 逐字不变", assistant.Content)
+ }
+ if assistant.Mode != "code" {
+ t.Fatalf("mode = %q, want code", assistant.Mode)
+ }
+}
+
+// TestTurnEnd_BlankReplyStaysUntouchedByRegularization 是必测④(网关侧):空回复
+// 走的是另一条分支(补诚实说明),规整不介入 —— 落库内容与共享包文案逐字相同,
+// 既没有被包上
,也没有被吞掉。
+func TestTurnEnd_BlankReplyStaysUntouchedByRegularization(t *testing.T) {
+ t.Parallel()
+
+ f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: " "}, 0)
+ res, err := f.submit(t)
+ if err != nil {
+ t.Fatalf("空回复不该报错: %v", err)
+ }
+ assistant := f.assertUserAndSingleAssistant(t)
+ if assistant.Content != turnreply.FallbackEmptyReply {
+ t.Fatalf("落库内容 = %q, want %q", assistant.Content, turnreply.FallbackEmptyReply)
+ }
+ if res.Text != turnreply.FallbackEmptyReply {
+ t.Fatalf("Result.Text = %q, want %q", res.Text, turnreply.FallbackEmptyReply)
+ }
+}
diff --git a/internal/services/gateway/session_turn_reply_test.go b/internal/services/gateway/session_turn_reply_test.go
index 41a3f71..94aa569 100644
--- a/internal/services/gateway/session_turn_reply_test.go
+++ b/internal/services/gateway/session_turn_reply_test.go
@@ -307,20 +307,24 @@ func TestTurnEnd_ModelErrorStillPersistsAssistant(t *testing.T) {
// TestTurnEnd_NormalReplyIsPersistedVerbatim 是必测③的回归项:正常回合(无工具
// 失败)的落库行为逐字不变 —— 恰好一条 assistant,内容与模型正文完全相同。
+//
+// 样本取**已规范**的正文:写入侧会给未被
包裹的段落补上包裹(幂等的加法,
+// 见 session_turn_reply_paragraph_test.go),因此「逐字不变」的守门断言要用规范
+// 形态来钉 —— 它保证规整绝不二次加工好文本。
func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) {
t.Parallel()
- ad := &kbScriptAdapter{toolSteps: 0, final: "你好,我在。"}
+ ad := &kbScriptAdapter{toolSteps: 0, final: "
你好,我在。
"}
f := newKBTurnFixture(t, ad, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("正常回合不该报错: %v", err)
}
- if res.Text != "你好,我在。" {
+ if res.Text != "你好,我在。
" {
t.Fatalf("Result.Text = %q", res.Text)
}
assistant := f.assertUserAndSingleAssistant(t)
- if assistant.Content != "你好,我在。" {
+ if assistant.Content != "你好,我在。
" {
t.Fatalf("落库内容 = %q, want 逐字等于模型正文", assistant.Content)
}
}
diff --git a/internal/services/gateway/session_turnid_wiring_test.go b/internal/services/gateway/session_turnid_wiring_test.go
index 2ce18e1..5572645 100644
--- a/internal/services/gateway/session_turnid_wiring_test.go
+++ b/internal/services/gateway/session_turnid_wiring_test.go
@@ -124,12 +124,12 @@ func TestAssistantTurnIDSurvivesInterruptAll(t *testing.T) {
}
close(runner.release)
waitUntil(t, "被中断路的正文落库", func() bool {
- _, ok := rig.turn(t, "取消前已经产出的正文")
+ _, ok := rig.turn(t, storedParagraph("取消前已经产出的正文"))
return ok
})
rig.assertTurns(t, map[string]string{
- "被中断的问题": sr.TurnID,
- "取消前已经产出的正文": sr.TurnID,
+ "被中断的问题": sr.TurnID,
+ storedParagraph("取消前已经产出的正文"): sr.TurnID,
})
}
@@ -159,10 +159,10 @@ func TestOrchestratorWiresTurnIDForParallelLegs(t *testing.T) {
rig.run.release(0)
waitActiveTurns(t, rig.o, history.MainSessionID, 0)
rig.assertTurns(t, map[string]string{
- "第一路的问题": first.TurnID,
- "reply-1": first.TurnID,
- "第二路的问题": second.TurnID,
- "reply-2": second.TurnID,
+ "第一路的问题": first.TurnID,
+ storedParagraph("reply-1"): first.TurnID,
+ "第二路的问题": second.TurnID,
+ storedParagraph("reply-2"): second.TurnID,
})
}
@@ -197,10 +197,10 @@ func TestOrchestratorWiresTurnIDForResumedLeg(t *testing.T) {
waitActiveTurns(t, rig.o, history.MainSessionID, 0)
rig.assertTurns(t, map[string]string{
- "先来的": first.TurnID,
- "reply-1": first.TurnID,
- "排队的那条": resumeTurn,
- "reply-2": resumeTurn,
+ "先来的": first.TurnID,
+ storedParagraph("reply-1"): first.TurnID,
+ "排队的那条": resumeTurn,
+ storedParagraph("reply-2"): resumeTurn,
})
}
@@ -241,8 +241,8 @@ func TestOrchestratorWiresTurnIDForSteeredRow(t *testing.T) {
rig.run.release(0)
waitActiveTurns(t, rig.o, history.MainSessionID, 0)
rig.assertTurns(t, map[string]string{
- "在飞的问题": first.TurnID,
- "reply-1": first.TurnID,
- "被吃进的那条": first.TurnID,
+ "在飞的问题": first.TurnID,
+ storedParagraph("reply-1"): first.TurnID,
+ "被吃进的那条": first.TurnID,
})
}
diff --git a/internal/services/gateway/voice_turn_test.go b/internal/services/gateway/voice_turn_test.go
index c7fe4af..962ed16 100644
--- a/internal/services/gateway/voice_turn_test.go
+++ b/internal/services/gateway/voice_turn_test.go
@@ -332,7 +332,7 @@ func TestVoiceTurnPersistsIntoHistoryEndToEnd(t *testing.T) {
if m.Platform != history.PlatformWeb {
t.Fatalf("用户消息 platform = %q, want %q", m.Platform, history.PlatformWeb)
}
- case m.Role == history.RoleAssistant && m.Content == "语音答复":
+ case m.Role == history.RoleAssistant && m.Content == storedParagraph("语音答复"):
assistantOK = true
}
}
diff --git a/internal/services/gateway/ws_role_gate_test.go b/internal/services/gateway/ws_role_gate_test.go
index df22a70..85c3044 100644
--- a/internal/services/gateway/ws_role_gate_test.go
+++ b/internal/services/gateway/ws_role_gate_test.go
@@ -280,8 +280,8 @@ func TestWSInboundChatDrivesOwnerTurn(t *testing.T) {
if got[0].Role != history.RoleUser || got[1].Role != history.RoleAssistant {
t.Fatalf("persisted roles = %q, %q, want user then assistant", got[0].Role, got[1].Role)
}
- if got[1].Content != "reply" {
- t.Fatalf("persisted reply = %q, want the runner's text", got[1].Content)
+ if got[1].Content != storedParagraph("reply") {
+ t.Fatalf("persisted reply = %q, want the runner's text in its canonical (paragraph-wrapped) form", got[1].Content)
}
}
diff --git a/internal/services/gateway/ws_route_chat_test.go b/internal/services/gateway/ws_route_chat_test.go
index ede860b..6b73222 100644
--- a/internal/services/gateway/ws_route_chat_test.go
+++ b/internal/services/gateway/ws_route_chat_test.go
@@ -116,7 +116,7 @@ func TestWSChatFrameSurvivesSaturatedBus(t *testing.T) {
if recs[0].Role != "user" || recs[0].Content != "第一条" {
t.Fatalf("user record = %+v, want the sent text persisted", recs[0])
}
- if recs[1].Role != "assistant" || recs[1].Content != "收到" {
+ if recs[1].Role != "assistant" || recs[1].Content != storedParagraph("收到") {
t.Fatalf("assistant record = %+v, want the reply persisted", recs[1])
}