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) } }