Files
Cyrene_v2/internal/services/gateway/history_persona_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

379 lines
15 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"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"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(多角色并存 + 按会话选角)的网关侧一半:把
// 会话绑定到角色的 REST 契约,以及把 sessionID 转成该回合
// 所运行的 loop 环境的解析器。端到端断言的链条是:
// end is: sessionID → chat_sessions.persona_id → turnEnvResolver → loop.SessionEnv →
// 按角色的 agent(system prompt),因此任一环节回归都会表现为
// 错误的角色在应答。
// personaAdapter 记录 loop 构建的每一个请求,使测试能断言
// 究竟是哪个角色的 system prompt 抵达了模型。
type personaAdapter struct {
reqs []model.Request
}
func (p *personaAdapter) Name() string { return "persona-test" }
func (p *personaAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
p.reqs = append(p.reqs, req)
return model.Response{Content: "好呀"}, nil
}
func (p *personaAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return p.Chat(ctx, req, model.Options{})
}
// personaTestRegistry 注册两个角色(其中一个是默认),使网关能校验
// personaId 值并解析默认角色。
func personaTestRegistry(t *testing.T) *personareg.Registry {
t.Helper()
reg := personareg.New()
for _, p := range []*loader.Persona{
{ID: "xilian", Name: "昔涟"},
{ID: "yuki", Name: "由纪"},
} {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
t.Fatalf("RegisterCharacter(xilian): %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "由纪", PersonaID: "yuki"}); err != nil {
t.Fatalf("RegisterCharacter(yuki): %v", err)
}
return reg
}
// personaTestStore 打开一个内存聊天历史存储。
func personaTestStore(t *testing.T) *history.Store {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("history.NewStore: %v", err)
}
return st
}
// gatewayAgentFor 构建组合根会构建的按角色 agent:同一个
// 适配器,角色专属的指令字符串。
func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent.Agent {
t.Helper()
ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New(%q): %v", instructions, err)
}
return ag
}
// TestChatTurnAnswersAsTheBoundRole 是从进程边界看 design/22 验收 3:
// 一个创建并绑定到角色 Y 的子会话以 Y 应答(首条 system
// 消息是 Y 的提示词,且持久化的消息携带 Y)。其角色在
// 创建时即固定(§8-1),因此此前用于解绑的 PATCH 被拒绝,会话
// 继续以 Y 应答;要得到默认角色,唯一的办法是创建
// 一个不带绑定的会话。
func TestChatTurnAnswersAsTheBoundRole(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)
}
ad := &personaAdapter{}
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
yuki := gatewayAgentFor(t, "你是由纪。", ad)
l := loop.New(loop.Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
})
_, 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")
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是由纪。" {
t.Fatalf("bound session prompt = %q, want role Y's prompt", got)
}
// 编排器把用户回合与回复都归到该回合的角色名下,
// 这正是让已存转录能按角色阅读的原因(design/22 §7-5)。
msgs, err := st.List(ctx, sub, 0, 10, "")
if err != nil {
t.Fatalf("list messages: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("persisted %d messages, want 2 (user + assistant)", len(msgs))
}
for _, m := range msgs {
if m.PersonaID != "yuki" {
t.Fatalf("%s message persona_id = %q, want yuki", m.Role, m.PersonaID)
}
}
// 解绑被拒绝(design/22 §8-1):会话的角色在创建时
// 决定,因此该 PATCH 返回 400,紧接着的下一个回合仍以 Y 应答。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+sub, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unbind status = %d, want 400 (a session's role is immutable; body=%s)", resp.StatusCode, body)
}
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("second chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是由纪。" {
t.Fatalf("prompt after the refused unbind = %q, want role Y's prompt", got)
}
// 默认角色要通过创建时不带绑定的会话才能抵达 —— 这是
// 目前改变角色的唯一方式。
unbound, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": unbound, "text": "你好"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("default-role chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是昔涟。" {
t.Fatalf("unbound session prompt = %q, want the default role's prompt", got)
}
}
// firstSystemText 返回 req 中首条 system 消息的文本。
func firstSystemText(req model.Request) string {
for _, m := range req.Messages {
if m.Role != model.RoleSystem {
continue
}
text := ""
for _, part := range m.Content {
text += part.Text
}
return text
}
return ""
}
// TestSessionPersonaHTTPContract 覆盖经 §8-1 修订后的 design/22 §4.5 的
// REST 契约:带 personaId 创建即绑定它、会话列表报告它、
// 创建时的未知 personaId 被拒绝(400)而不是绑定一个无法
// 应答的角色、既无 title 也无 pinned 的 PATCH 是 400,且 PATCH 对
// personaId 键的任何取值一律拒绝 —— 会话角色在创建时固定,
// 因此没有受支持的重绑,该拒绝绝不会被误认为静默无操作。
func TestSessionPersonaHTTPContract(t *testing.T) {
t.Parallel()
st := personaTestStore(t)
reg := personaTestRegistry(t)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "yuki"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
PersonaID string `json:"personaId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create: %v (body=%s)", err, body)
}
if created.PersonaID != "yuki" {
t.Fatalf("create echoed personaId = %q, want yuki", created.PersonaID)
}
// 会话列表浮现该绑定(客户端据此渲染活动角色)。
var sessions []historySessionSummary
_, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
decodeDataSlice(t, body, &sessions)
found := ""
for _, s := range sessions {
if s.ID == created.SessionID {
found = s.PersonaID
}
}
if found != "yuki" {
t.Fatalf("listed persona_id = %q, want yuki (sessions=%+v)", found, sessions)
}
// 创建时的未知角色被拒绝,而不是被静默忽略。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("create with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// 不含任何受支持字段的请求体仍是 400。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// PATCH 对 personaId 键的任何取值都拒绝(design/22 §8-1):角色在
// 会话创建时固定,因此重绑 —— 绑到另一个角色、绑到已绑
// 角色、绑到 ""、或绑到 null —— 都不是受支持的操作。每次拒绝都必须点明
// personaId(使客户端能与「未给出字段」区分),并且必须让
// 已存绑定保持原样。
for _, bad := range []any{"xilian", "yuki", "nobody", "", " ", nil} {
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": bad}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("patch personaId=%#v status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
}
if !strings.Contains(string(body), "personaId") {
t.Fatalf("patch personaId=%#v body = %s, want it to name personaId", bad, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("patch personaId=%#v moved the binding to %q, want it left at yuki", bad, got)
}
}
// 该拒绝压过同一请求体中的合法字段:客户端不能借改名
// 夹带重绑,而改名也绝不能生效。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "偷偷改名", "personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("rename+personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("rename+personaId moved the binding to %q, want yuki", got)
}
metas, err := st.ListSessions(context.Background())
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
for _, m := range metas {
if m.ID == created.SessionID && m.Title != "" {
t.Fatalf("the refused patch applied title %q, want the row untouched", m.Title)
}
}
// personaId 检查在会话查找之前执行:携带该键的未知会话
// 返回 400(角色不可变),而不是 404 —— 而针对同一个未知 id
// 的纯标题补丁仍是存储的 404,这正是证明上面的 400
// 来自人格规则而非会话缺失的依据。空请求体是
// 同一张表的第三个角:字段缺失返回 400,同样
// 完全不查找会话。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown session + personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown session + empty body status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown session + title status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// title/pinned 不受该拒绝影响。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "新名字"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("title-only patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "yuki" {
t.Fatalf("title-only patch moved the binding to %q, want yuki", got)
}
}
// personaBindingOf 直接从存储读取会话的绑定角色,因此测试
// 断言的是持久化状态,而不只是响应回显。
func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string {
t.Helper()
personaID, err := st.SessionPersona(context.Background(), sessionID)
if err != nil {
t.Fatalf("SessionPersona(%s): %v", sessionID, err)
}
return personaID
}
// TestTurnEnvResolver 覆盖解析器本身(design/22 §4.5):会话的
// 绑定胜出,未知/未绑定的会话得到 ""(使 loop 回退到
// 全局默认),且身份/记忆字段保持 owner 全量 ——
// 角色绝不改变一个回合被允许做什么。
func TestTurnEnvResolver(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := personaTestStore(t)
bound, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
r := turnEnvResolver{history: st, personas: personaTestRegistry(t), log: testLogger()}
env := r.owner(ctx, bound)
if env.PersonaID != "yuki" {
t.Fatalf("bound session env persona = %q, want yuki", env.PersonaID)
}
if env.ActorEnv != loop.OwnerEnv().ActorEnv || !env.MemWritable {
t.Fatalf("resolver changed the identity lane: %+v", env)
}
unbound, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
if got := r.owner(ctx, unbound).PersonaID; got != "" {
t.Fatalf("unbound session env persona = %q, want '' (inherit the default)", got)
}
if got := r.owner(ctx, "sess-unknown").PersonaID; got != "" {
t.Fatalf("unknown session env persona = %q, want '' (inherit the default)", got)
}
// 没有 history 存储的网关无法解析绑定,也绝不能失败。
bare := turnEnvResolver{log: testLogger()}
if got := bare.owner(ctx, "web").PersonaID; got != "" {
t.Fatalf("no-store env persona = %q, want ''", got)
}
if !bare.owner(ctx, "web").MemWritable {
t.Fatal("no-store env lost the owner memory lane")
}
}
// TestDefaultPersonaIDFromRegistry 锁定 design/22 §4.5 要求的管理回退:
// 默认角色从库的默认角色读取,而不是硬编码的
// "cyrene",且未接线的注册表报告无默认。
func TestDefaultPersonaIDFromRegistry(t *testing.T) {
t.Parallel()
srv, _, _ := buildEnv(t, cfgDefault(), Deps{Personas: personaTestRegistry(t)})
if got := srv.defaultPersonaID(); got != "xilian" {
t.Fatalf("defaultPersonaID = %q, want xilian (the default character's persona)", got)
}
bare, _, _ := buildEnv(t, cfgDefault(), Deps{})
if got := bare.defaultPersonaID(); got != "" {
t.Fatalf("defaultPersonaID without a registry = %q, want ''", got)
}
}