Files
Cyrene_v2/internal/services/gateway/session_attribution_test.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

140 lines
5.0 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"
)
// This file covers the end-to-end half of design/22 §7-5: a session that switched
// roles must keep the conversation it had, and the model must be able to tell
// which utterances came from the earlier role. The chain asserted here is:
// chat_messages (per-message persona_id) → the orchestrator's seeded turn context
// → the loop's speaker attribution → the model.Request the adapter receives.
// requestTexts returns the text of every message of req, in order.
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 concatenates the text parts of 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 is design/22 §7-5 accepted
// from the process boundary: the session already holds turns spoken by role X, it
// is now bound to role Y, and one more chat turn arrives. The request must carry
// X's earlier utterance with its speaker attributed to X (not to Y), keep every
// stored body verbatim, and still open with Y's own instructions.
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)
}
// The conversation as it stood while the session spoke as X, with per-message
// persona ids exactly as the orchestrator persists them (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]
// The answering role still owns the head of the prompt.
if got := firstSystemText(req); got != "你是由纪。" {
t.Fatalf("first system = %q, want role Y's prompt", got)
}
// X's earlier utterance reached the model, verbatim (the turn is not amnesiac
// across the role switch).
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)
}
// The speaker boundary names X and marks the stretch as not Y's own words.
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)
}
// No stored body was rewritten, and the newly sent user text carries no system
// metadata (the user lane stays free text, 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)
}
}