Files
Cyrene_v2/internal/runtime/loop/memory_gating_test.go
T
Aska adad4e1f62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(session): 群聊/陌生人记忆隔离——MemWritable 贯穿 loop(AGENTS.md 红线)
群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。

- loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话
  隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道
  消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。
- loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变;
  false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与
  ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写
  false 直接 return 跳过(不调用 extractor 不落库)。
- 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner
  行为不变);backthink/automations/orchestration 默认 owner 全量。
- 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿;
  loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。

遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope
过滤选项消除与 Inject 模板的重复(2处)。
2026-09-06 20:34:06 +08:00

154 lines
6.2 KiB
Go

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)
}
}