主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
140 lines
4.9 KiB
Go
140 lines
4.9 KiB
Go
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)
|
||
}
|
||
}
|