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]) }