主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。 注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。 校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等; 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。 gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
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"
|
||
)
|
||
|
||
// 本文件从模型一侧覆盖 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)
|
||
}
|
||
}
|
||
}
|