package loop import ( "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "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 §4.2 的多角色行为:提示词、记忆 // 作用域、情绪人格与推理旋钮必须全部从同一个 // 按 turn 的角色解析,使会话绝不以角色 Y 的声音作答却用角色 X 的 // 记忆(design/22 §5 风险表 "提示词/情绪/记忆错配")。 // personaAgent 构建一个指令中指明角色的 agent,使测试能从 // 前导系统消息看出循环解析了哪个池条目。 func personaAgent(t *testing.T, instructions string, ad model.Adapter) *agent.Agent { t.Helper() ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New(%q): %v", instructions, err) } return ag } // roleEnv 是会话解析器为 personaID 产出的环境:全权限 // owner 身份(身份车道不变)加上显式绑定的角色。 func roleEnv(personaID string) SessionEnv { return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID} } // runPersonaTurn 在 env 下为 sessionID 驱动一个 turn,出错即让测试失败。 func runPersonaTurn(t *testing.T, l *Loop, env SessionEnv, sessionID, text string) *Result { t.Helper() res, err := l.Run(WithSessionEnv(context.Background(), env), sessionID, []model.Message{model.TextMessage(model.RoleUser, text)}) if err != nil { t.Fatalf("Run(%s): %v", sessionID, err) } return res } // lastChatRequest 返回非流式路径记录的最新请求。 func lastChatRequest(t *testing.T, ad *mockAdapter) model.Request { t.Helper() if len(ad.reqs) == 0 { t.Fatal("adapter recorded no request") } return ad.reqs[len(ad.reqs)-1] } // requestContains 报告 req 是否有任何消息携带 want。 func requestContains(req model.Request, want string) bool { for _, m := range req.Messages { if strings.Contains(messageText(m), want) { return true } } return false } // TestTurnResolvesSystemPromptFromSessionRole 是 design/22 验收 3:切换 // 会话角色即切换给模型的身份。第一条系统消息 // 必须是所绑定角色的提示词 —— 这正是按角色 agent // 池的全部意义 —— 而未绑定的会话与未知角色都留在默认 // 角色上,而不是让该 turn 失败。 func TestTurnResolvesSystemPromptFromSessionRole(t *testing.T) { t.Parallel() ad := &mockAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", }) runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好呀") req := lastChatRequest(t, ad) if got := messageText(req.Messages[0]); got != "你是角色Y。" { t.Fatalf("first system message = %q, want the bound role Y's prompt", got) } // 没有显式绑定的会话继承循环配置的默认角色。 runPersonaTurn(t, l, roleEnv(""), "web", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unbound session prompt = %q, want the default role X's prompt", got) } // 绑定到一个没有池中 agent 的角色(已删除/未知人格)会降级到 // 默认 agent,而不是报错。 runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unknown role prompt = %q, want the default role X's prompt", got) } } // TestPersonaScopeMemoryIsolationAndExtraction 是 design/22 验收 2 与 5: // 记忆命名空间跟随该 turn 的角色,因此绑定到不同角色的两个会话 // 各自只召回自己角色的记忆,而同一角色的两个会话看到 // 相同的记忆(口径 1: 记忆按人格作用域,而非按会话作用域);且 // 提取出的条目归到产出该 turn 的角色之下 —— 这也是 // 移除 extractor 构造期人格的回归护栏。 func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) ad := &mockAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", Memory: store, Retriever: memory.NewRetriever(store), // 该 extractor 构建时不带 persona,与 cmd/cyrene 的构建方式完全一致: // 循环负责把该 turn 的角色打到每个条目上。 Extractor: memory.NewManualExtractor("", memoryOwnerRoleIDForTest), }) runPersonaTurn(t, l, roleEnv("X"), "web:x1", "记住 我喜欢喝乌龙茶") runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "记住 我讨厌香菜") // 验收 5:每条提取出的记忆都归到其 turn 的角色之下。 byContent := map[string]string{} for _, it := range listMemories(t, store) { byContent[it.Content] = it.PersonaID } if got := byContent["我喜欢喝乌龙茶"]; got != "X" { t.Fatalf("memory extracted in an X turn is filed under %q, want X (items=%v)", got, byContent) } if got := byContent["我讨厌香菜"]; got != "Y" { t.Fatalf("memory extracted in a Y turn is filed under %q, want Y (items=%v)", got, byContent) } // 验收 2a:X 会话召回 X 的记忆,绝不召回 Y 的。 runPersonaTurn(t, l, roleEnv("X"), "web:x1", "乌龙茶怎么样") req := lastChatRequest(t, ad) if !requestContains(req, "我喜欢喝乌龙茶") { t.Fatalf("X's turn did not recall X's own memory: %+v", req.Messages) } if requestContains(req, "我讨厌香菜") { t.Fatalf("X's turn leaked Y's memory: %+v", req.Messages) } // 验收 2b:同一角色的另一个会话也能看到它(记忆按 // 人格作用域,因此新会话不是从空白记忆开始)。 runPersonaTurn(t, l, roleEnv("X"), "web:x2", "乌龙茶怎么样") if !requestContains(lastChatRequest(t, ad), "我喜欢喝乌龙茶") { t.Fatalf("a second X session did not see X's memory: %+v", lastChatRequest(t, ad).Messages) } // 验收 2c:Y 会话只召回 Y 的记忆。 runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "香菜怎么样") req = lastChatRequest(t, ad) if !requestContains(req, "我讨厌香菜") { t.Fatalf("Y's turn did not recall Y's own memory: %+v", req.Messages) } if requestContains(req, "我喜欢喝乌龙茶") { t.Fatalf("Y's turn leaked X's memory: %+v", req.Messages) } } // memoryOwnerRoleIDForTest 镜像组合根的作者角色 id,使该 // 测试以与生产相同的方式构建 extractor。 const memoryOwnerRoleIDForTest = "owner" // listMemories 返回每条已存记忆条目。 func listMemories(t *testing.T, store *memory.Store) []memory.MemoryItem { t.Helper() items, err := store.List(context.Background()) if err != nil { t.Fatalf("memory list: %v", err) } return items } // frameAdapter 流式发出一个思考帧与一个文本帧,使测试能在 // 流式路径上同时断言循环构建的请求(思考旋钮)与它广播的 // 内容(showToUser 门)。 type frameAdapter struct { mockAdapter } func (f *frameAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { f.streamReqs = append(f.streamReqs, req) if on != nil { on(model.StreamEvent{Kind: model.StreamKindThinkingDelta, Delta: "想一下"}) on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "答案"}) } return model.Response{Content: "答案"}, nil } // TestThinkingKnobsFollowTheTurnRole 钉住 design/22 §4.2 的后半:批次 A // 在接线时一次性解析的推理预算 / 强度 / 思考广播开关,现在 // 必须跟随该 turn 的角色,因此绑定到角色 Y 的会话以 // Y 的预算与 Y 的广播开关运行,而不是默认角色的。配置的 // 选项仍是未知角色的回退值。 func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { t.Parallel() ad := &frameAdapter{} x := personaAgent(t, "你是角色X。", ad) y := personaAgent(t, "你是角色Y。", ad) hide := false budgetX := &model.Thinking{BudgetTokens: 100} knobs := map[string]LoopKnobs{ "X": {Thinking: budgetX, ReasoningEffort: "high"}, "Y": {ThinkingShowToUser: &hide}, } var events []model.StreamEvent l := New(Options{ Agent: x, Agents: map[string]*agent.Agent{"X": x, "Y": y}, PersonaID: "X", Thinking: budgetX, ReasoningEffort: "high", OnStream: func(ev model.StreamEvent) { events = append(events, ev) }, PersonaKnobs: func(personaID string) (LoopKnobs, bool) { k, ok := knobs[personaID]; return k, ok }, }) // 角色 X:其预算到达请求,其思考帧被广播 // (showToUser 未设置 = 文档所述的「照常广播」)。 runPersonaTurn(t, l, roleEnv("X"), "web:x1", "你好") req := ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking == nil || req.Thinking.BudgetTokens != 100 { t.Fatalf("X's request thinking = %+v, want budget 100", req.Thinking) } if req.ReasoningEffort != "high" { t.Fatalf("X's reasoning effort = %q, want high", req.ReasoningEffort) } if !hasThinkingFrame(events) { t.Fatalf("X's thinking frames were withheld: %+v", events) } // 角色 Y:Y 未配置预算并隐藏思考帧,因此 Y 的请求必须 // 不携带任何思考指令(默认角色的预算不得渗入),且 // 只有文本帧可以抵达客户端。 events = nil runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking != nil { t.Fatalf("Y's request thinking = %+v, want nil (Y configures none)", req.Thinking) } if req.ReasoningEffort != "" { t.Fatalf("Y's reasoning effort = %q, want empty", req.ReasoningEffort) } if hasThinkingFrame(events) { t.Fatalf("Y's thinking frames were broadcast despite showToUser=false: %+v", events) } if len(events) == 0 { t.Fatal("Y's text frames were suppressed too, want the answer to still stream") } // 未知角色保持循环配置的默认值,而不是把它们清零。 events = nil runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking == nil || req.Thinking.BudgetTokens != 100 { t.Fatalf("unknown role thinking = %+v, want the configured default budget", req.Thinking) } if !hasThinkingFrame(events) { t.Fatalf("unknown role thinking frames were withheld: %+v", events) } } // hasThinkingFrame 报告广播集是否携带了推理帧。 func hasThinkingFrame(events []model.StreamEvent) bool { for _, ev := range events { if isThinkingKind(ev.Kind) { return true } } return false }