Files
Cyrene_v2/internal/services/gateway/session_turn_reply_paragraph_test.go
T
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

154 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 一侧)。
//
// 缺陷形状(安卓侧实测):模型有时**只包最后一段**,前面几段是裸文本用空行分隔。
// 客户端的解析规则是「只要存在完整 <p> 块,就不再按空行回退切分」⇒ 半包恰好落在
// 唯一会塌的一侧:整条消息被渲染成一整坨,而不是多个气泡。
//
// 断言走真路径:真 loop(脚本化模型适配器)+ 真 SQLite history + 真编排器
// (复用 session_turn_reply_test.go 的 kbTurnFixture),因此这里看到的落库内容与
// Web 客户端从历史接口拿到的是同一个东西。
//
// 两侧共用同一份样本字面量(渠道桥一侧见 channelbridge/turnreply_paragraph_test.go,
// 两处的 halfWrappedTurnReply / wrappedTurnReply 必须逐字相同 —— 那正是「Web 与 IM
// 吃到同一份文本」的断言方式)。
// halfWrappedTurnReply 是上报的半包形状:4 段裸文本(第 4 段内联一个 <action>)+
// 只有第 5 段带 <p>。
const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" +
"第二段,轻轻合上纸条。\n\n" +
"第三段,纸张有点卷边了。\n\n" +
"<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// wrappedTurnReply 是 halfWrappedTurnReply 规整后的规范形态(每段一个 <p>)。
const wrappedTurnReply = "<p>第一段,读到头也读到尾啦♪</p>\n\n" +
"<p>第二段,轻轻合上纸条。</p>\n\n" +
"<p>第三段,纸张有点卷边了。</p>\n\n" +
"<p><action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。</p>\n\n" +
"<p>第五段,我把它们按日子排好啦。</p>"
// storedParagraph 返回一段**单段**助手正文落库后的规范形态。写入侧会给未被 `<p>`
// 包裹的段落补上包裹(权威断言是本文件的硬编码字面量),因此那些「按内容定位落库行」
// 的既有测试要用这个形态去找,而不是模型原文。
func storedParagraph(raw string) string {
return "<p>" + raw + "</p>"
}
// TestTurnEnd_HalfWrappedReplyRegularizedBeforePersist 是必测①(网关侧):半包回复
// 落库前被规整 —— 每一段都在自己的 <p> 里(含 <action> 那一段),且结果与 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, "<p>") != 5 || strings.Count(assistant.Content, "</p>") != 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 是必测③(网关侧):完全没有任何 <p> 的
// 裸文本也被规整(它同样落在客户端的回退路径上,一旦别处混进一个 <p> 就会塌)。
func TestTurnEnd_FullyBareReplyIsRegularized(t *testing.T) {
t.Parallel()
const bare = "第一段。\n\n第二段。\n\n<action>拍拍你</action>"
const want = "<p>第一段。</p>\n\n<p>第二段。</p>\n\n<p><action>拍拍你</action></p>"
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),
// 往里塞 `<p>` 只会在那些形态里露出野生标签 —— 因此规整只对 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 是必测④(网关侧):空回复
// 走的是另一条分支(补诚实说明),规整不介入 —— 落库内容与共享包文案逐字相同,
// 既没有被包上 <p>,也没有被吞掉。
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)
}
}