按 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 交叉编译
246 lines
10 KiB
Go
246 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|