Files
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

336 lines
14 KiB
Go
Raw Permalink 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"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 chat_messages.turn_id 的四条出口与两条写入路径(design/15 §2.5.9):
//
// - 出口:历史 DTO(REST `GET /sessions/{id}/messages`)、WS 握手 `history` 回放、
// 以及经 toHistoryMessage 构造的 `message_persisted` / `message_edited` 帧;
// - 写入:落库时已知所属路(新起的一路 + 它的助手回复,随 INSERT)、以及落库时
// 还不知道所属路(忙时入队 / 被转向吃进,由实际处理它的那一路用 assignTurnID
// 补写)。
//
// 语义口径全部是**可选**的:空 ⇒ 该键从 JSON 里消失,与同族可选字段
// (personaId / clientMsgId / documents)逐字同形。
// turnsOf 把一页消息按内容映射到它们的 turn_id,便于断言配对。
func turnsOf(msgs []history.Message) map[string]string {
out := make(map[string]string, len(msgs))
for _, m := range msgs {
out[m.Content] = m.TurnID
}
return out
}
// TestHistoryMessageTurnIDCarriedAndOmitted 钉住 DTO 形状:有标识就带出来,空则
// 整个键消失(客户端据「键在不在」区分「属于某一路」与「不属于任何已知回合」)。
func TestHistoryMessageTurnIDCarriedAndOmitted(t *testing.T) {
t.Parallel()
withTurn := toHistoryMessage(history.Message{ID: "m1", SessionID: "web", Role: history.RoleUser, Content: "问", TurnID: "turn-a"})
b, err := json.Marshal(withTurn)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded["turnId"] != "turn-a" {
t.Fatalf("turnId = %v, want turn-a (%s)", decoded["turnId"], b)
}
// 空标识(旧行、IM 镜像、落库时还不知道所属路的行)⇒ 键缺席。
legacy := toHistoryMessage(history.Message{ID: "m2", SessionID: "web", Role: history.RoleUser, Content: "问"})
b, err = json.Marshal(legacy)
if err != nil {
t.Fatalf("marshal legacy: %v", err)
}
if strings.Contains(string(b), `"turnId"`) {
t.Fatalf("空 turn_id 应当省略该键,得到 %s", b)
}
}
// TestHistoryMessagesEndpointCarriesTurnID 端到端演练读接口:同一路的两条消息
// 带同一个 turnId,旧行(空)连键都不出现 —— 客户端因此能在重连后按 turnId 把
// 「回复 ↔ 提问」配对起来,而不是靠顺序猜。
func TestHistoryMessagesEndpointCarriesTurnID(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, ts, jwt, st := newHistoryEnv(t)
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "这条路的问题", TurnID: "turn-leg1", CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "这条路的回答", TurnID: "turn-leg1", CreatedAt: 1500},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "另一路的问题", TurnID: "turn-leg2", CreatedAt: 2000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "本列之前的老行", CreatedAt: 2500},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 先按原始 JSON 断言「键存在与否」,再按 DTO 断言取值。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("messages status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var raw []map[string]any
decodeDataSlice(t, body, &raw)
seen := make(map[string]string, len(raw))
for _, m := range raw {
content, _ := m["content"].(string)
id, ok := m["turnId"]
if !ok {
seen[content] = "<absent>"
continue
}
seen[content], _ = id.(string)
}
for content, want := range map[string]string{
"这条路的问题": "turn-leg1",
"这条路的回答": "turn-leg1",
"另一路的问题": "turn-leg2",
"本列之前的老行": "<absent>",
} {
if got := seen[content]; got != want {
t.Fatalf("%q 的 turnId = %q, want %q(整页=%s)", content, got, want, body)
}
}
var page []historyMessage
decodeDataSlice(t, body, &page)
turns := turnsOfWire(page)
if turns["这条路的问题"] != "turn-leg1" || turns["这条路的回答"] != "turn-leg1" {
t.Fatalf("同一路两条 = %q / %q,want 都是 turn-leg1", turns["这条路的问题"], turns["这条路的回答"])
}
}
// turnsOfWire 是 historyMessage 版的内容 → turnId 映射。
func turnsOfWire(msgs []historyMessage) map[string]string {
out := make(map[string]string, len(msgs))
for _, m := range msgs {
out[m.Content] = m.TurnID
}
return out
}
// TestWSHandshakeReplayCarriesTurnID 钉住 WS 握手 `history` 回放这一面:它与读
// 接口共用同一份映射,因此客户端刚连上就能按 turnId 恢复回复侧的分组小标。
func TestWSHandshakeReplayCarriesTurnID(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, ts, jwt, st := newHistoryEnv(t)
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "回放里的提问", TurnID: "turn-replay", CreatedAt: 1000}); err != nil {
t.Fatalf("seed user: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "回放里的回答", TurnID: "turn-replay", CreatedAt: 1500}); err != nil {
t.Fatalf("seed reply: %v", err)
}
conn := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
defer conn.Close()
_, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read handshake frame: %v", err)
}
var frame struct {
Type string `json:"type"`
Data struct {
Messages []map[string]any `json:"messages"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("decode frame %s: %v", raw, err)
}
if frame.Type != "history" {
t.Fatalf("first frame = %q, want history (%s)", frame.Type, raw)
}
if len(frame.Data.Messages) != 2 {
t.Fatalf("replayed %d messages, want 2 (%s)", len(frame.Data.Messages), raw)
}
for _, m := range frame.Data.Messages {
if m["turnId"] != "turn-replay" {
t.Fatalf("回放消息 = %v, want turnId=turn-replay", m)
}
}
}
// TestPersistTurnCarriesTurnIDOnBothRows 锁定第一段写入:新起的一路的用户消息与
// 它的助手回复带**同一个** turn_id,且 `message_persisted` 那一帧(由同一条已存
// 消息构造)也带着它 —— 客户端因此不需要等一次历史拉取就能配对。
func TestPersistTurnCarriesTurnIDOnBothRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, _, _, st := newHistoryEnv(t)
var broadcast []history.Message
o := &Orchestrator{history: st, log: testLogger(), onPersisted: func(m history.Message) { broadcast = append(broadcast, m) }}
legCtx := withTurnID(ctx, "turn-solo")
o.persistUserMessages(legCtx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "单路的问题")}, "web:test", "chat", "cyrene")
o.persistAssistantReply(history.MainSessionID, &loop.Result{Text: "单路的回答"}, "web:test", "chat", "cyrene", "turn-solo")
msgs, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
turns := turnsOf(msgs)
if turns["单路的问题"] != "turn-solo" || turns["单路的回答"] != "turn-solo" {
t.Fatalf("两条 = %q / %q,want 都是 turn-solo(同一路两条同值)", turns["单路的问题"], turns["单路的回答"])
}
if len(broadcast) != 2 || broadcast[0].TurnID != "turn-solo" || broadcast[1].TurnID != "turn-solo" {
t.Fatalf("message_persisted 载荷 = %+v, want 两条都带 turn-solo", broadcast)
}
}
// TestPersistParallelLegsCarryDistinctTurnIDs 锁定并行两路:每一路的两条消息
// 同值,两路之间不同值 —— 这正是「回复 ↔ 提问」精确配对的前提(按顺序渲染会
// 把两条交错的回答配错)。
func TestPersistParallelLegsCarryDistinctTurnIDs(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, _, _, st := newHistoryEnv(t)
o := &Orchestrator{history: st, log: testLogger()}
o.persistUserMessages(withTurnID(ctx, "turn-1"), history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "第一路的问题")}, "web:test", "chat", "cyrene")
o.persistUserMessages(withTurnID(ctx, "turn-2"), history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "第二路的问题")}, "web:test", "chat", "cyrene")
o.persistAssistantReply(history.MainSessionID, &loop.Result{Text: "第一路的回答"}, "web:test", "chat", "cyrene", "turn-1")
o.persistAssistantReply(history.MainSessionID, &loop.Result{Text: "第二路的回答"}, "web:test", "chat", "cyrene", "turn-2")
msgs, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
turns := turnsOf(msgs)
for content, want := range map[string]string{
"第一路的问题": "turn-1",
"第一路的回答": "turn-1",
"第二路的问题": "turn-2",
"第二路的回答": "turn-2",
} {
if got := turns[content]; got != want {
t.Fatalf("%q 的 turn_id = %q, want %q(两路各自两条同值、两路不同值)", content, got, want)
}
}
}
// TestAssignTurnIDStampsQueuedAndSteeredRows 锁定第二段写入与它的归属口径:忙时
// 入队(以及被转向吃进)的消息在落库那一刻还不知道自己属于哪一路,由**实际处理
// 它的那一路**补写;已经带上标识的行不被后来者改写(先到先得,一行只属于一路)。
func TestAssignTurnIDStampsQueuedAndSteeredRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, _, _, st := newHistoryEnv(t)
o := &Orchestrator{history: st, log: testLogger()}
// 忙时入队:落库时 ctx 上没有回合标识 ⇒ 列里留空。
queued := o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "排队的一句")}, "web:test", "chat", "cyrene")
// 被转向吃进的一句:同样在它自己的那一轮之前就落了库。
steered := o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "被转向吃进的一句")}, "web:test", "chat", "cyrene")
if len(queued) != 1 || len(steered) != 1 {
t.Fatalf("落库条数 = %d / %d, want 各 1", len(queued), len(steered))
}
before, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
for _, m := range before {
if m.TurnID != "" {
t.Fatalf("消息 %q 在补写之前就有 turn_id %q,want 空", m.Content, m.TurnID)
}
}
// 这两条最终由同一路回答(转向把它并进了在飞的那一路)⇒ 补写同一个标识。
o.assignTurnID(history.MainSessionID, "turn-inflight", append(queued, steered...))
after, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after assign: %v", err)
}
for _, m := range after {
if m.TurnID != "turn-inflight" {
t.Fatalf("消息 %q 的 turn_id = %q, want turn-inflight(按实际处理它的那一路写)", m.Content, m.TurnID)
}
}
// 另一路(例如续跑)试图认领同一行:不得改写既有的归属。
o.assignTurnID(history.MainSessionID, "turn-later", queued)
again, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after re-assign: %v", err)
}
for _, m := range again {
if m.TurnID != "turn-inflight" {
t.Fatalf("消息 %q 的 turn_id 被改写成 %q, want 仍是 turn-inflight(先到先得)", m.Content, m.TurnID)
}
}
// 空标识 / 空列表是空操作(未接线的旧路径与「这一路没有落库任何消息」)。
o.assignTurnID(history.MainSessionID, "", queued)
o.assignTurnID(history.MainSessionID, "turn-x", nil)
}
// TestTurnIDOfLegResolvesInFlightLeg 钉住补写路径要用的那个查询:在途路的 gen →
// 该路的权威 turnId;不在途(被打断 / 已被取代 / 会话里没有它)⇒ 空串。
func TestTurnIDOfLegResolvesInFlightLeg(t *testing.T) {
t.Parallel()
o := &Orchestrator{log: testLogger(), states: map[string]*sessionState{
history.MainSessionID: {legs: map[uint64]*turnLeg{7: {turnID: "turn-7"}, 8: {turnID: "turn-8"}}},
}}
if got := o.turnIDOfLeg(history.MainSessionID, 8); got != "turn-8" {
t.Fatalf("turnIDOfLeg(8) = %q, want turn-8", got)
}
if got := o.turnIDOfLeg(history.MainSessionID, 9); got != "" {
t.Fatalf("turnIDOfLeg(未知 gen) = %q, want 空串", got)
}
if got := o.turnIDOfLeg("web:unknown", 7); got != "" {
t.Fatalf("turnIDOfLeg(未知会话) = %q, want 空串", got)
}
}
// TestSubmitWiresTurnIDForBothRows 是「单路回合 ⇒ 用户消息与助手回复两条 turn_id
// 同值且非空」的**端到端**验收:真存储 + 真编排器,走 Submit → 起路 → 落库 → 结算
// 的完整链路,断言提交回报的 TurnID 与落库那两条的 turn_id 完全一致。
//
// 它需要四个写入点全部接线才可能通过(session_parallel.go 的 publishLegStart 走
// ctx 通道、session_turn_reply.go 的 persistTurnReply 由 leg.turnID 透传、
// session_orchestrator.go 的续跑分支与 session_steer.go 的 Steer 走 assignTurnID
// 补写),因此它就是那一批接线的端到端验收。
func TestSubmitWiresTurnIDForBothRows(t *testing.T) {
ctx := context.Background()
_, _, _, st := newHistoryEnv(t)
run := newGatedLegRunner()
o := NewOrchestrator(OrchestratorOptions{Run: run.Run, Log: testLogger(), History: st})
sr := o.Submit(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "端到端的问题")}, loop.OwnerEnv())
if !sr.Immediate || sr.TurnID == "" {
t.Fatalf("Submit = %+v, want 立刻起一路并回报非空 TurnID", sr)
}
run.waitStarted(1)
run.release(0)
waitActiveTurns(t, o, history.MainSessionID, 0)
msgs, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("落库 %d 条, want 2(用户消息 + 助手回复): %+v", len(msgs), msgs)
}
turns := turnsOf(msgs)
if turns["端到端的问题"] != sr.TurnID || turns[storedParagraph("reply-1")] != sr.TurnID {
t.Fatalf("两条 turn_id = %q / %q, want 都是提交回报的 %q",
turns["端到端的问题"], turns[storedParagraph("reply-1")], sr.TurnID)
}
}