feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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 交叉编译
This commit is contained in:
AskaEth committed 2026-10-03 21:01:33 +08:00
1 parent 3732c538cc
commit 5969bba3bd
22 files changed
+1396 -23

No files matched your search

@@ -0,0 +1,139 @@
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)
}
}