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