package loop import ( "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // seedMemories 为每个作用域各填一条记忆到 store,使测试能断言 // 记忆门的作用域过滤。 func seedMemories(t *testing.T, store *memory.Store) { t.Helper() ctx := context.Background() seeds := []memory.MemoryItem{ {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, {PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent}, } for _, it := range seeds { if _, err := store.Add(ctx, it); err != nil { t.Fatalf("seed memory: %v", err) } } } // seedCrossRoleMemories 写入跨角色的共享域与主人私有记忆各一批: // 另一个角色(persona-b)写用户习惯 + 项目记忆(共享域,design/03 §3/§4 // 对所有角色可读),persona-a 写自己的角色日常(敏感项,仅属主可读)。 // 用于验证读取域的并集真的含他角色写入的共享项,且敏感项不外泄 // (裁决 1.9/04.4.2)。 func seedCrossRoleMemories(t *testing.T, store *memory.Store) { t.Helper() seeds := []memory.MemoryItem{ {PersonaID: "persona-b", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, {PersonaID: "persona-b", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent}, {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, } for _, it := range seeds { if _, err := store.Add(context.Background(), it); err != nil { t.Fatalf("seed memory: %v", err) } } } // TestInjectMemory_OwnerEnv_InjectsAllScopes 证明 owner 全权限环境保持 // 既有的完整召回:用户、角色与项目记忆都被注入。 func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) seedMemories(t, store) l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} ctx := WithSessionEnv(context.Background(), OwnerEnv()) view := l.assembleContext(ctx, msgs, 1) if len(view) != 2 { t.Fatalf("owner view = %d messages, want 2 (memory injected)", len(view)) } block := messageText(view[0]) for _, want := range []string{"喜欢冰美式", "喜欢爬山", "Cyrene 用 Go 编写", "相关记忆"} { if !strings.Contains(block, want) { t.Fatalf("owner block missing %q:\n%s", want, block) } } } // TestInjectMemory_RestrictedEnv_NonSensitiveOnly 证明群聊/陌生人环境是 // 只读的非敏感召回(裁决 1.9/04.4.2,design/03 §3/§4/§5):共享域由 // **别的角色**(persona-b)写入,本角色(persona-a)的群聊上下文依然 // 能查到用户习惯与项目记忆 —— 这正是读取域并集生效之处;主人私有的 // 角色日常记忆(ScopePersona,敏感项)被排除,且该块被标记为 群聊隔离。 func TestInjectMemory_RestrictedEnv_NonSensitiveOnly(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) seedCrossRoleMemories(t, store) l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) view := l.assembleContext(ctx, msgs, 1) if len(view) != 2 { t.Fatalf("restricted view = %d messages, want 2 (non-sensitive memory injected)", len(view)) } block := messageText(view[0]) // 非敏感项:他角色写的用户习惯 + 项目记忆 —— 可查(design/03 §3/§5)。 if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "喜欢冰美式") || !strings.Contains(block, "Cyrene 用 Go 编写") { t.Fatalf("restricted block missing non-sensitive memory / marker:\n%s", block) } // 敏感项:本角色私有的角色日常 —— 绝不注入。 if strings.Contains(block, "喜欢爬山") { t.Fatalf("restricted block leaked owner-sensitive memory %q:\n%s", "喜欢爬山", block) } } // TestGroupChat_SharedReadable_WriteRejected 证明裁决 1.9/04.4.2 的 // 「可查不可写」在同一群聊上下文里成对生效:共享域记忆被注入(可查), // 而同一次提取的写入被拒(store 一行都不新增)。 func TestGroupChat_SharedReadable_WriteRejected(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) seedCrossRoleMemories(t, store) l := New(Options{ Retriever: memory.NewRetriever(store), Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a", }) ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) block := l.memoryBlock(ctx, []model.Message{model.TextMessage(model.RoleUser, "go")}) if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "喜欢冰美式") { t.Fatalf("group chat must read the shared domain:\n%s", block) } l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我真的爱喝冰美式")}, 1) items, err := store.List(context.Background()) if err != nil { t.Fatalf("list: %v", err) } if len(items) != 3 { t.Fatalf("group chat wrote memory: %d rows, want 3 (writes must be rejected)", len(items)) } } // TestInjectMemory_Restricted_OnlySensitiveMemory_NoInject 证明当群聊 // 或陌生人上下文只有主人私有(角色日常)记忆时,什么也不 // 注入 —— 该对话不会用主人的敏感上下文来播种。 func TestInjectMemory_Restricted_OnlySensitiveMemory_NoInject(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) // 只存在主人私有(角色日常)记忆。 for _, it := range []memory.MemoryItem{ {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, } { if _, err := store.Add(context.Background(), it); err != nil { t.Fatalf("seed memory: %v", err) } } l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}) view := l.assembleContext(ctx, msgs, 1) if len(view) != 1 { t.Fatalf("restricted (only sensitive) view = %d messages, want 1 (no injected memory)", len(view)) } } // TestExtractMemory_OwnerEnv_Writes 证明 owner 全权限环境像从前 // 一样持久化提取出的记忆。 func TestExtractMemory_OwnerEnv_Writes(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) ctx := WithSessionEnv(context.Background(), OwnerEnv()) l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("list: %v", err) } if len(items) != 1 { t.Fatalf("owner extract persisted %d items, want 1", len(items)) } } // TestExtractMemory_RestrictedEnv_SkipsWrite 证明群聊/陌生人环境 // 绝不写共享记忆:提取被跳过,store 保持为空。 func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("list: %v", err) } if len(items) != 0 { t.Fatalf("restricted extract wrote %d items, want 0 (read-only isolation)", len(items)) } } // TestSessionEnvFrom_DefaultOwnerFull 钉住向后兼容的默认值:未挂载 // env 的 context 解析为 owner 全权限环境,因此未设置 env 的 // 调用方(Web 主会话、内部自动化任务)保持完整的 // 记忆读写。 func TestSessionEnvFrom_DefaultOwnerFull(t *testing.T) { t.Parallel() env := sessionEnvFrom(context.Background()) if env.MemWritable != true || env.Restricted() { t.Fatalf("default env = %+v, want owner-full (MemWritable=true, not restricted)", env) } if env.ActorEnv != restrict.ActorOwner { t.Fatalf("default env actor = %q, want %q", env.ActorEnv, restrict.ActorOwner) } }