Files
Cyrene_v2/internal/services/gateway/session_attribution_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

140 lines
4.9 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"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"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"
)
// 本文件覆盖 design/22 §7-5 的端到端一半:切换过角色的
// 会话必须保留它曾有过的对话,且模型必须能分辨
// 哪些话语来自更早的角色。这里断言的链条是:
// chat_messages(每消息 persona_id)→ 编排器播种的回合上下文
// → loop 的说话者归属 → 适配器收到的 model.Request。
// requestTexts 按顺序返回 req 每条消息的文本。
func requestTexts(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
out = append(out, messageTextOf(m))
}
return out
}
// messageTextOf 拼接 m 的文本部分。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
// TestChatTurnCarriesSpeakerAttributionFromTranscript 是从进程边界验收的
// design/22 §7-5:该会话已持有角色 X 说过的话,它现在
// 绑定到角色 Y,且又来了一个聊天回合。请求必须携带
// X 更早的话语且其说话者归属到 X(不是 Y),原样保留每个
// 已存消息体,并仍以 Y 自己的指令开头。
func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := personaTestStore(t)
reg := personaTestRegistry(t)
sub, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
// 该会话以 X 说话时的对话原貌,每消息
// persona id 与编排器持久化的完全一致(design/22 §7-5)。
for _, m := range []history.Message{
{SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000},
{SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed transcript: %v", err)
}
}
ad := &personaAdapter{}
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
yuki := gatewayAgentFor(t, "你是由纪。", ad)
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
l := loop.New(loop.Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
PersonaName: func(id string) string { return names[id] },
})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg, Loop: l})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "继续"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(ad.reqs) == 0 {
t.Fatal("the model adapter saw no request")
}
req := ad.reqs[len(ad.reqs)-1]
// 应答角色仍然占据提示词的开头。
if got := firstSystemText(req); got != "你是由纪。" {
t.Fatalf("first system = %q, want role Y's prompt", got)
}
// X 更早的话语原样抵达了模型(该回合没有
// 在角色切换处失忆)。
texts := requestTexts(req)
xIdx := -1
for i, txt := range texts {
if txt == "我记住了,你喜欢冰美式" {
xIdx = i
break
}
}
if xIdx < 0 {
t.Fatalf("X's earlier utterance missing from the request: %q", texts)
}
if xIdx == 0 || req.Messages[xIdx].Role == model.RoleSystem {
t.Fatalf("X's utterance must stay a plain assistant message, got %+v", req.Messages[xIdx])
}
if req.Messages[xIdx].SenderName != "xilian" {
t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName)
}
// 说话者边界点明 X,并把那一段标记为不是 Y 自己的话。
note := ""
for _, m := range req.Messages {
if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") {
note = messageTextOf(m)
break
}
}
if note == "" || !strings.Contains(note, "不是你说的") {
t.Fatalf("no speaker boundary for X found: %q", texts)
}
// 没有任何已存消息体被改写,且新发送的用户文本不携带 system
// 元数据(用户通道保持自由文本,design/11 §10)。
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
if txt := messageTextOf(m); txt != "继续" && txt != "你还记得吗" {
t.Fatalf("user body = %q, want a stored/verbatim user text", txt)
}
}
if xIdx+1 <= len(req.Messages) && strings.Contains(req.Messages[xIdx].Content[0].Text, "[") {
t.Fatalf("attribution leaked into X's body: %q", req.Messages[xIdx].Content[0].Text)
}
}