Files
Cyrene_v2/internal/runtime/loop/memory_gating_test.go
T
AskaEth 0ce498754a fix(memory,loop): 记忆作用域读写成对 + source_conversation 结构化(裁决 1.9/04.4.2/04.4.3)
- 04.4.2/1.9 读侧:Retrieve 候选集并集 = 本人 persona 域 ∪ user 共享域 ∪ project 共享域(他角色 persona 域仍排除),群聊/陌生人取受限收窄
- 写侧:群聊/陌生人整段跳过记忆提取(可查不可写),persona 域仅属主可写
- 04.4.3:source_conversation 不再由 LLM 自由生成(prompt 不索要、候选结构不含该键),落库时无条件盖真实 {session_id,message_id},编解码集中在 memory/source.go
- 新增跨角色种子用例(原用例即使关掉并集也会通过)+ 成对用例「同群聊上下文:读得到共享域、写不进 store」
- 同步 store.go/retrieve.go 文档注释,与 design/03 §3/§4 口径一致
2026-10-04 12:07:44 +08:00

205 lines
8.9 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/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)
}
}