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_UnpairedParagraphTagIsMergedBeforePersist 是必测⑤(网关侧,真 loop + 真 // SQLite):上报的**未配对段落标签**形状(`

正文

`)落库前被合并成一个 `

`。 // 断言的就是客户端从历史接口拿到的那份 content —— 「任何按非贪婪 `

` 契约解析的消费方 // 都会看到字面 `

`」这条症状在**产出侧**被掐掉。 // // 字面量与 turnreply/paragraph_repair_test.go 的 reportedUnpairedReply / …Fixed // 逐字相同(两侧共用同一份上报原串)。 func TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist(t *testing.T) { t.Parallel() const reported = "

满意地点点头

\n" + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" const want = "

满意地点点头

\n" + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" f := newKBTurnFixture(t, &kbScriptAdapter{toolSteps: 0, final: reported}, 0) res, err := f.submit(t) if err != nil { t.Fatalf("回合不该报错: %v", err) } assistant := f.assertUserAndSingleAssistant(t) if assistant.Content != want { t.Fatalf("落库内容 =\n%q\nwant\n%q", assistant.Content, want) } // res.Text 与落库同值 ⇒ REST reply / WS 事件 / 续跑尾部拿到的是同一份。 if res.Text != want { t.Fatalf("Result.Text =\n%q\nwant 与落库同一份\n%q", res.Text, want) } if strings.Contains(assistant.Content, "

") { t.Fatalf("落库内容里仍有双开段落标签:%q", assistant.Content) } } // 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) } }