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

+245
View File
@@ -0,0 +1,245 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file covers design/22 §7-5 from the model's side: a conversation whose
// assistant messages were spoken by different roles must reach the provider with
// that attribution visible, without a single character being added to a message
// body and without disturbing the assembled prompt's established order.
// speakerMsg builds one conversation message labelled with the role that spoke
// it, exactly the way the gateway's transcript seeding labels an assistant turn
// (model.Message.SenderName carries the speaking role id).
func speakerMsg(role model.Role, personaID, text string) model.Message {
m := model.TextMessage(role, text)
m.SenderName = personaID
return m
}
// attributedLoop builds a two-role loop: instructions say which role answered, and
// the name resolver mirrors the persona registry's id→display-name mapping.
func attributedLoop(t *testing.T, ad model.Adapter) *Loop {
t.Helper()
xilian := personaAgent(t, "你是昔涟。", ad)
yuki := personaAgent(t, "你是由纪。", ad)
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
return New(Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
PersonaName: func(id string) string { return names[id] },
})
}
// seedMemory writes one persona-scoped memory item for personaID so the round's
// retrieval has something to inject.
func seedMemory(t *testing.T, store *memory.Store, personaID, content string) {
t.Helper()
if _, err := store.Add(context.Background(), memory.MemoryItem{
PersonaID: personaID, Content: content,
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
}); err != nil {
t.Fatalf("seed memory: %v", err)
}
}
// systemTexts returns the text of every system message of req, in order.
func systemTexts(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == model.RoleSystem {
out = append(out, messageText(m))
}
}
return out
}
// indexOfSystemContaining returns the index of the first message whose text
// contains want, or -1.
func indexOfSystemContaining(req model.Request, want string) int {
for i, m := range req.Messages {
if strings.Contains(messageText(m), want) {
return i
}
}
return -1
}
// TestAttribution_SpeakersReachTheModelRequest is the acceptance case of
// design/22 §7-5: the session now speaks as Y while the context still holds
// utterances by X. The request the adapter receives must (a) open with Y's own
// instructions, (b) declare which role is answering, (c) mark where the speaker
// changed so "第 N 条是谁说的" is decidable, and (d) leave every message body
// untouched — the attribution travels only in system messages.
func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}}
l := attributedLoop(t, ad)
msgs := []model.Message{
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
speakerMsg(model.RoleAssistant, "xilian", "还有一件事"),
model.TextMessage(model.RoleUser, "继续"),
speakerMsg(model.RoleAssistant, "yuki", "我在呢"),
model.TextMessage(model.RoleUser, "你好"),
}
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-y", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
// (a) the role that answers now still owns the first system message.
if got := messageText(req.Messages[0]); got != "你是由纪。" {
t.Fatalf("first system = %q, want the bound role's instructions", got)
}
sys := systemTexts(req)
// instructions + the role-context segment + one boundary note per speaker
// stretch (X's two assistant turns are one stretch).
if len(sys) != 4 {
t.Fatalf("system messages = %d (%q), want 4 (instructions + role context + two speaker boundaries)", len(sys), sys)
}
if sys[0] != "你是由纪。" {
t.Fatalf("system[0] = %q, want the instructions untouched at the head", sys[0])
}
if !strings.HasPrefix(sys[1], roleContextMarker) {
t.Fatalf("system[1] = %q, want the role context segment", sys[1])
}
for _, want := range []string{"「由纪」(yuki)", "不同角色相继发言"} {
if !strings.Contains(sys[1], want) {
t.Fatalf("role context %q missing %q", sys[1], want)
}
}
if strings.Contains(sys[1], speakerSwitchMarker) {
t.Fatalf("role context %q must not be mistaken for a boundary note", sys[1])
}
if !strings.HasPrefix(sys[2], speakerSwitchMarker) || !strings.Contains(sys[2], "「昔涟」(xilian)") || !strings.Contains(sys[2], "不是你说的") {
t.Fatalf("system[2] = %q, want X's boundary note marking its utterances as not yours", sys[2])
}
if !strings.HasPrefix(sys[3], speakerSwitchMarker) || !strings.Contains(sys[3], "来自你自己") || !strings.Contains(sys[3], "「由纪」(yuki)") {
t.Fatalf("system[3] = %q, want Y's own boundary note naming the answering role", sys[3])
}
// (c) the boundary sits directly before the first utterance it covers, and the
// second X utterance inherits the same speaker without a second note.
firstX := indexOfSystemContaining(req, "我记住了")
if firstX < 0 || req.Messages[firstX-1].Role != model.RoleSystem {
t.Fatalf("X's first utterance at %d is not preceded by a boundary note (%+v)", firstX, req.Messages)
}
if idx := indexOfSystemContaining(req, "「昔涟」(xilian)"); idx > firstX {
t.Fatalf("X boundary at %d must precede its utterance at %d", idx, firstX)
}
// The answering role's own history is re-opened by its own boundary note.
self := indexOfSystemContaining(req, "我在呢")
if self < 0 || !strings.Contains(messageText(req.Messages[self-1]), "来自你自己") {
t.Fatalf("Y's utterance is not preceded by its self boundary: %+v", req.Messages)
}
// (d) no body was rewritten: user text stays verbatim and carries no metadata,
// and the assistant's own words are unchanged.
var users []string
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
users = append(users, messageText(m))
}
if len(users) != 2 || users[0] != "继续" || users[1] != "你好" {
t.Fatalf("user bodies = %q, want the verbatim [继续 你好] (no system metadata in the user lane)", users)
}
for _, u := range users {
if strings.Contains(u, speakerSwitchMarker) || strings.Contains(u, roleContextMarker) {
t.Fatalf("user body %q carries system attribution metadata", u)
}
}
for _, body := range []string{"我记住了", "还有一件事", "我在呢"} {
if idx := indexOfSystemContaining(req, body); idx >= 0 && strings.Contains(messageText(req.Messages[idx]), "「") {
t.Fatalf("assistant body %q was rewritten with attribution", messageText(req.Messages[idx]))
}
}
}
// TestAssembleContext_AttributionKeepsBlockOrder pins the placement rule: the role
// declaration is added AFTER the established leading blocks (time, working memory,
// memory) and immediately before the conversation, so the memory/time block order
// and the instructions-first rule are untouched.
func TestAssembleContext_AttributionKeepsBlockOrder(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedMemory(t, store, "yuki", "她喜欢冰美式")
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "嗯"}}}}
xilian := personaAgent(t, "你是昔涟。", ad)
yuki := personaAgent(t, "你是由纪。", ad)
l := New(Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
PersonaName: func(id string) string { return map[string]string{"xilian": "昔涟", "yuki": "由纪"}[id] },
Retriever: memory.NewRetriever(store),
Timezone: "Asia/Shanghai",
})
msgs := []model.Message{
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
model.TextMessage(model.RoleUser, "继续"),
}
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-order", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
timeIdx := indexOfSystemContaining(req, "当前时间")
memIdx := indexOfSystemContaining(req, "冰美式")
declIdx := indexOfSystemContaining(req, roleContextMarker)
noteIdx := indexOfSystemContaining(req, speakerSwitchMarker)
lastUser := -1
for i, m := range req.Messages {
if m.Role == model.RoleUser {
lastUser = i
}
}
if timeIdx < 0 || memIdx < 0 || declIdx < 0 || noteIdx < 0 || lastUser < 0 {
t.Fatalf("missing block: time=%d mem=%d decl=%d note=%d user=%d (%+v)", timeIdx, memIdx, declIdx, noteIdx, lastUser, req.Messages)
}
if !(memIdx < declIdx && declIdx < noteIdx && noteIdx < lastUser) {
t.Fatalf("block order = time:%d mem:%d decl:%d note:%d user:%d, want instructions < time < memory < declaration < notes < user", timeIdx, memIdx, declIdx, noteIdx, lastUser)
}
if got := messageText(req.Messages[0]); got != "你是由纪。" {
t.Fatalf("first system = %q, want the instructions untouched at the head", got)
}
}
// TestAttribution_UnlabeledConversationIsUntouched locks the backward-compatible
// half: a caller that labels nothing (every single-role path, IM, backthink, the
// autonomous passes) gets exactly the prompt it had before, with no role segment
// and no boundary notes.
func TestAttribution_UnlabeledConversationIsUntouched(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}}
l := attributedLoop(t, ad)
msgs := []model.Message{
model.TextMessage(model.RoleAssistant, "我记住了"),
model.TextMessage(model.RoleUser, "继续"),
}
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-plain", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
req := lastChatRequest(t, ad)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("messages = %d, want %d (instructions + conversation, nothing added)", len(req.Messages), len(msgs)+1)
}
for _, s := range systemTexts(req) {
if strings.Contains(s, roleContextMarker) || strings.Contains(s, speakerSwitchMarker) {
t.Fatalf("unlabeled conversation got attribution %q", s)
}
}
}