package main import ( "bytes" "context" "log/slog" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件锁定 P0-10 的**收窄**(design/11 §0 红线): // // - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按 // **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字 // 都没有(因此也不会随请求发给模型供应商); // - 它不再进静态 system 指令(那会让它跟着角色进入每个会话); // - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。 // identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」, // 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。 const identityFixture = "# 身份说明书 · 我的「人」\n\n" + "- 称呼:你叫他「亲爱的」。\n" + "- 陌生账号 kook:stranger-999 与本文件无关。\n" + "- 本说明书只描述人设/关系,不授权任何权限。" // identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。 type identityProbeAdapter struct{ reqs []model.Request } func (a *identityProbeAdapter) Name() string { return "identity-probe" } func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { a.reqs = append(a.reqs, req) return model.Response{Content: "好呀♪"}, nil } func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { a.reqs = append(a.reqs, req) return model.Response{Content: "好呀♪"}, nil } // identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的 // owner 档案。identityFile 为空串表示「没配」。 func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) { t.Helper() return identityAppLogged(t, identityFile, testLog()) } // identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。 func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) { t.Helper() p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml")) if err != nil { t.Fatalf("loader.Load(cyrene.yaml): %v", err) } reg := personareg.New() if err := reg.RegisterPersona(p); err != nil { t.Fatalf("RegisterPersona: %v", err) } if err := reg.RegisterCharacter(&personareg.Character{ ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID, }); err != nil { t.Fatalf("RegisterCharacter: %v", err) } owner := &user.OwnerProfile{ ID: "owner", DisplayName: "叶酱", DefaultPersona: p.ID, Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}}, IdentityFile: identityFile, } a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}} return a, p } // writeIdentity 把 identityFixture 写进临时文件并返回路径。 func writeIdentity(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "owner_identity.md") if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil { t.Fatalf("write identity file: %v", err) } return path } // absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。 func absentIdentityEnvFile(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "absent.env") } // unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env // 里的取值),并在测试结束还原。 func unsetInjectionEnv(t *testing.T) { t.Helper() key := config.OwnerIdentityInjectionEnv prev, had := os.LookupEnv(key) if err := os.Unsetenv(key); err != nil { t.Fatalf("os.Unsetenv(%s): %v", key, err) } t.Cleanup(func() { if had { _ = os.Setenv(key, prev) return } _ = os.Unsetenv(key) }) } // envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致 // (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。 func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() } func envGroupTurn() loop.SessionEnv { return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false} } func envStrangerTurn() loop.SessionEnv { return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false} } // runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环, // 返回模型实际收到的消息列表。 func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message { t.Helper() adapter := &identityProbeAdapter{} ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{Agent: ag, PersonaKnowledgeFor: resolve, MaxRounds: 8}) if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess", []model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil { t.Fatalf("Run: %v", err) } if len(adapter.reqs) == 0 { t.Fatal("适配器没有收到任何请求") } return adapter.reqs[0].Messages } // identityMessagesContain 报告消息里是否有任一携带给定文本。 func identityMessagesContain(messages []model.Message, want string) bool { for _, m := range messages { for _, part := range m.Content { if strings.Contains(part.Text, want) { return true } } } return false } // TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的 // 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池 // 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面 // (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。 func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) { t.Parallel() a, p := identityApp(t, writeIdentity(t)) base := instructionsFromPersona(p) if got := a.defaultInstructions(); got != base { t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base) } if pooled := a.personaInstructions(p); pooled != base { t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base) } if strings.Contains(a.defaultInstructions(), identityFixture) { t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话") } llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog()) for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) { if ag.Instructions() != base { t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions()) } } } // TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件 // 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也 // 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。 func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) { t.Parallel() unsetInjectionEnv(t) missing := filepath.Join(t.TempDir(), "nope.md") for _, tc := range []struct { name string path string }{ {name: "文件不存在", path: missing}, {name: "路径是目录", path: t.TempDir()}, {name: "没配 identityFile", path: ""}, } { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() a, p := identityApp(t, tc.path) if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want { t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want) } if a.ownerIdentityKnowledge() != "" { t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge()) } if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" { t.Fatalf("没有正文时不该渲染出任何块:%q", block) } resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t)) if got := resolve(envOwnerTurn()); got != "" { t.Fatalf("没有正文时主人会话也被注入了:%q", got) } }) } } // TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心: // 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到 // 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。 func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) { t.Parallel() unsetInjectionEnv(t) a, _ := identityApp(t, writeIdentity(t)) resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t)) owner := resolve(envOwnerTurn()) if owner == "" || !strings.Contains(owner, identityFixture) { t.Fatalf("主人会话没有拿到身份说明书:%q", owner) } if !strings.HasPrefix(owner, identityKnowledgeHeader) { t.Fatalf("主人会话的知识块没有段头:%q", owner) } for _, tc := range []struct { name string env loop.SessionEnv }{ {name: "群聊会话", env: envGroupTurn()}, {name: "陌生人私聊会话", env: envStrangerTurn()}, } { if got := resolve(tc.env); got != "" { t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got) } } } // TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效: // always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。 func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) { cases := []struct { name string mode string env loop.SessionEnv wantContent bool }{ {name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true}, {name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true}, {name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false}, {name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { unsetInjectionEnv(t) t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode) a, _ := identityApp(t, writeIdentity(t)) got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env) if tc.wantContent != strings.Contains(got, identityFixture) { t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent) } }) } } // TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」: // 回退到最安全的 owner_only,且日志里带上原始取值与回退档。 func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) { unsetInjectionEnv(t) t.Setenv(config.OwnerIdentityInjectionEnv, "everyone") var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) a, _ := identityAppLogged(t, writeIdentity(t), log) resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t)) if got := resolve(envGroupTurn()); got != "" { t.Fatalf("非法取值下群聊被注入了:%q", got) } if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) { t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got) } logged := buf.String() for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} { if !strings.Contains(logged, want) { t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged) } } if strings.Contains(logged, identityFixture) { t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)") } } // TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文 // 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与 // 陌生人私聊的每条消息都不含它。 func TestOwnerIdentityInjection_EndToEnd(t *testing.T) { t.Parallel() unsetInjectionEnv(t) a, _ := identityApp(t, writeIdentity(t)) resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t)) ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn()) if !identityMessagesContain(ownerMsgs, identityFixture) { t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs) } if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) { t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs) } for _, tc := range []struct { name string env loop.SessionEnv }{ {name: "群聊会话", env: envGroupTurn()}, {name: "陌生人私聊会话", env: envStrangerTurn()}, } { msgs := runIdentityTurn(t, resolve, tc.env) if identityMessagesContain(msgs, identityFixture) { t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs) } if identityMessagesContain(msgs, identityKnowledgeHeader) { t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs) } } } // TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真 // 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据 // + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。 func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) { t.Parallel() a, _ := identityApp(t, writeIdentity(t)) // 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。 block := identityKnowledgeBlock(a.ownerIdentityKnowledge()) if !strings.Contains(block, "stranger-999") { t.Fatalf("测试前提不成立:说明书正文没被读出来") } if !a.owner.IsOwner("kook", "owner-1") { t.Fatal("白名单里的账号未被认成主人") } if a.owner.IsOwner("kook", "stranger-999") { t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右") } if a.owner.IsOwner("kook", "") { t.Fatal("空账号被判成了主人") } }