Files
Cyrene_v2/internal/runtime/loop/persona_test.go
T
AskaEth 91e8d7a029 docs(runtime): internal/runtime/loop 44 个文件注释改中文(逐行 1:1,代码零字节改动)
主人 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 改动前即未格式化,按铁律保留原样未动。
2026-10-03 22:28:21 +08:00

278 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}