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 fills the store with one memory per scope so a test can assert the // scope filtering of the memory gate. 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) } } } // TestInjectMemory_OwnerEnv_InjectsAllScopes proves the owner-full env keeps the // pre-existing full recall: user, persona and project memories are all injected. 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_OnlyProject proves the group-chat/stranger env is // read-only isolation: only the non-owner-private project memory is injected, the // owner's user/persona memory is excluded, and the block is marked 群聊隔离. func TestInjectMemory_RestrictedEnv_OnlyProject(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(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) view := l.assembleContext(ctx, msgs, 1) if len(view) != 2 { t.Fatalf("restricted view = %d messages, want 2 (project memory injected)", len(view)) } block := messageText(view[0]) if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "Cyrene 用 Go 编写") { t.Fatalf("restricted block missing project memory / marker:\n%s", block) } for _, banned := range []string{"喜欢冰美式", "喜欢爬山"} { if strings.Contains(block, banned) { t.Fatalf("restricted block leaked owner memory %q:\n%s", banned, block) } } } // TestInjectMemory_Restricted_NoProjectMemory_NoInject proves that when a group // or stranger context has no non-owner-private (project) memory, nothing is // injected — the conversation is not seeded with owner context. func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) // Only owner-private (user/persona) memory exists. for _, it := range []memory.MemoryItem{ {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, 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 (no project) view = %d messages, want 1 (no injected memory)", len(view)) } } // TestExtractMemory_OwnerEnv_Writes proves the owner-full env persists extracted // memory as before. 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 proves the group-chat/stranger env // never writes shared memory: extraction is skipped, the store stays empty. 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 locks the backward-compatible default: a // context with no attached env resolves to the owner-full environment, so callers // that do not set an env (Web main session, internal automated tasks) keep full // memory read+write. 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) } }