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 段内联一个 。
const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" +
"第二段,轻轻合上纸条。\n\n" +
"第三段,纸张有点卷边了。\n\n" +
" 第五段,我把它们按日子排好啦。 )。
const wrappedTurnReply = " 第一段,读到头也读到尾啦♪ 第二段,轻轻合上纸条。 第三段,纸张有点卷边了。 第五段,我把它们按日子排好啦。 `
// 包裹的段落补上包裹(权威断言是本文件的硬编码字面量),因此那些「按内容定位落库行」
// 的既有测试要用这个形态去找,而不是模型原文。
func storedParagraph(raw string) string {
return " " + raw + " 里(含 ") != 5 || strings.Count(assistant.Content, " 的
// 裸文本也被规整(它同样落在客户端的回退路径上,一旦别处混进一个 就会塌)。
func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) {
t.Parallel()
const bare = "第一段。\n\n第二段。\n\n 第一段。 第二段。 正文 `。
// 断言的就是客户端从历史接口拿到的那份 content —— 「任何按非贪婪 ` ` 契约解析的消费方
// 都会看到字面 ` `」这条症状在**产出侧**被掐掉。
//
// 字面量与 turnreply/paragraph_repair_test.go 的 reportedUnpairedReply / …Fixed
// 逐字相同(两侧共用同一份上报原串)。
func TestTurnEnd_UnpairedParagraphTagIsMergedBeforePersist(t *testing.T) {
t.Parallel()
const reported = " 真乖♪ 这可是今天最让人家放心的一句。 那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆? 真乖♪ 这可是今天最让人家放心的一句。 那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆? ") {
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)
}
}