按 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 交叉编译
140 lines
5.0 KiB
Go
140 lines
5.0 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"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
}
|