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" ) // 本文件从模型一侧覆盖 design/22 §7-5:一段其 assistant 消息 // 由不同角色说出的对话,必须在归属可见的情况下抵达 provider, // 且不向消息正文添加任何一个字符, // 也不打乱组装好的提示词的既定顺序。 // speakerMsg 构建一条标注了说出它的角色的对话消息, // 与 gateway 的 transcript 播种标注 assistant 轮的方式完全一致 // (model.Message.SenderName 携带发言角色 id)。 func speakerMsg(role model.Role, personaID, text string) model.Message { m := model.TextMessage(role, text) m.SenderName = personaID return m } // attributedLoop 构建一个双角色循环:指令说明是哪个角色作答, // 名字解析器镜像 persona registry 的 id→显示名映射。 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 为 personaID 写入一条按人格作用域的记忆条目,使该轮的 // 检索有东西可注入。 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 按顺序返回 req 中每条系统消息的文本。 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 返回文本包含 want 的第一条消息的索引, // 没有则返回 -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 是 design/22 §7-5 的 // 验收用例:会话现在以 Y 发言,而上下文中仍留有 // X 说过的话。适配器收到的请求必须 (a) 以 Y 自己的 // 指令开头,(b) 声明哪个角色在作答,(c) 标出发言人 // 何处变化,使「第 N 条是谁说的」可判定,且 (d) 保持每条消息正文 // 不动 —— 归属只在系统消息中传递。 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) 现在作答的角色仍然拥有第一条系统消息。 if got := messageText(req.Messages[0]); got != "你是由纪。" { t.Fatalf("first system = %q, want the bound role's instructions", got) } sys := systemTexts(req) // instructions + 角色上下文片段 + 每个发言人片段一条边界说明 // (X 的两个 assistant 轮是一个片段)。 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) 边界就位于它所覆盖的第一句话之前,且 // 第二句 X 的话继承同一发言人,无需第二条说明。 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) } // 作答角色自己的历史由它自己的边界说明重新开启。 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) 没有任何正文被改写:用户文本保持逐字且不携带元数据, // 且 assistant 自己的话也未变。 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 钉住放置规则:角色 // 声明被加在既定的前导块(时间、工作记忆、 // 记忆)之后、紧邻对话之前,因此记忆/时间块的顺序 // 与指令优先规则不受影响。 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 钉住向后兼容的 // 那一半:什么都不标注的调用方(每条单角色路径、IM、backthink、 // 自主流程)拿到与从前完全一样的提示词,没有角色片段 // 也没有边界说明。 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) } } }