fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
This commit is contained in:
AskaEth committed 2026-10-05 10:56:39 +08:00
1 parent d532eb3fcd
commit 5de708a560
17 files changed
+721 -46

No files matched your search

@@ -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)
}
}
@@ -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 [<p>reply-1</p>](另一路继续作答,被取消路不补说明)", got)
}
}
@@ -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 [<p>reply-A</p>](另一路继续作答;被取消路不补说明)", 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)
}
@@ -53,8 +53,10 @@ func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
// 步)。两条分支:
//
// - 有真实文本:逐字落库,行为与加入本保证之前完全一致(被取消/陈旧代次的
// 回合也照旧落 —— 内容是真的,与「不静默」无关);
// - 有真实文本:逐字落库(**唯一例外**是幂等的段落包裹规整,见
// turnreply.NormalizeParagraphs:只给未被 `<p>` 包裹的段落补上包裹,已规范的
// 文本一字不改;且**只在 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 {
// 真实文本:逐字落库,行为与加入本保证之前完全一致。
// 真实文本:除段落包裹规整外逐字落库。规整是**幂等的写入侧加法**(只给
// 未被 <p> 包裹的段落补上包裹,已规范的文本一字不改),它就地改写
// res.Text,因此落库内容、WS `message_persisted`、REST reply、续跑尾部
// 与语音拿到的是**同一份**文本。客户端的解析规则是「只要存在完整 <p> 块
// 就不再按空行回退切分」,半包的回复恰好在那一侧整条塌成一段;同一条
// 判据在 IM 侧(splitBubbles)也成立,两边必须吃同一份文本。
//
// **只在 chat 模式规整**:拆气泡这条判据只属于 chat 气泡;其余 mode
// (code/home/work/docs…)的消息在客户端是**按原样整体渲染**的
// (design/07 §2),往里塞 <p> 只会在那些形态里露出野生标签。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
}
@@ -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 一侧)。
//
// 缺陷形状(安卓侧实测):模型有时**只包最后一段**,前面几段是裸文本用空行分隔。
// 客户端的解析规则是「只要存在完整 <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)
}
}
@@ -307,20 +307,24 @@ func TestTurnEnd_ModelErrorStillPersistsAssistant(t *testing.T) {
// TestTurnEnd_NormalReplyIsPersistedVerbatim 是必测③的回归项:正常回合(无工具
// 失败)的落库行为逐字不变 —— 恰好一条 assistant,内容与模型正文完全相同。
//
// 样本取**已规范**的正文:写入侧会给未被 <p> 包裹的段落补上包裹(幂等的加法,
// 见 session_turn_reply_paragraph_test.go),因此「逐字不变」的守门断言要用规范
// 形态来钉 —— 它保证规整绝不二次加工好文本。
func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) {
t.Parallel()
ad := &kbScriptAdapter{toolSteps: 0, final: "你好,我在。"}
ad := &kbScriptAdapter{toolSteps: 0, final: "<p>你好,我在。</p>"}
f := newKBTurnFixture(t, ad, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("正常回合不该报错: %v", err)
}
if res.Text != "你好,我在。" {
if res.Text != "<p>你好,我在。</p>" {
t.Fatalf("Result.Text = %q", res.Text)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != "你好,我在。" {
if assistant.Content != "<p>你好,我在。</p>" {
t.Fatalf("落库内容 = %q, want 逐字等于模型正文", assistant.Content)
}
}
@@ -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,
})
}
+1 -1
View File
@@ -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
}
}
@@ -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)
}
}
@@ -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])
}