package channelbridge import ( "context" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件覆盖公域呈现块(审计 2026-10-30 §5.3 / P0-3)与第三方边界的 // 两条硬规则(P0-4)的**存在性与注入边界**: // // - 受限车道(群聊 / 陌生人私聊)的系统框架行必须带公域呈现块; // - 主人私聊与 Web 主会话**不得**带(那里没有第三方,呈现档位就是原档); // - 呈现块自己必须说清它是「呈现层投影」而不是人格切换(AGENTS §9)。 // // 这些用例在删掉注入点时必红(删实现打红实测见提交说明)。 // restrictedGroupEnv / restrictedStrangerEnv 与渠道桥为受限会话解析出的 // 环境同形:受限、记忆只读、不绑定显式角色(design/22 §7-4)。 func restrictedGroupEnv() loop.SessionEnv { return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false} } func restrictedStrangerEnv() loop.SessionEnv { return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false} } // ownerPrivateEnv 是主人私聊 / Web 主会话的环境(非受限)。 func ownerPrivateEnv() loop.SessionEnv { return loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true} } // systemFrameOf 返回消息列表里第一条系统行的正文;没有系统行时返回 ""。 func systemFrameOf(msgs []model.Message) string { for _, m := range msgs { if m.Role == model.RoleSystem { return turnMessageText(m) } } return "" } // frameMessageOf 返回消息列表里第一条系统消息(用于断言它确实是一条)。 func frameMessageOf(msgs []model.Message) (model.Message, bool) { for _, m := range msgs { if m.Role == model.RoleSystem { return m, true } } return model.Message{}, false } func TestRestrictedLanesCarryPublicPresenceBlock(t *testing.T) { t.Parallel() b := &Bridge{} ctx := context.Background() member := adapter.Actor{Role: adapter.RoleMember} owner := adapter.Actor{Role: adapter.RoleOwner} groupMsg := adapter.UnifiedMessage{ Platform: "kook", ChannelType: adapter.ChannelGroup, GroupID: "g1", GroupName: "翁法罗斯", } directMsg := adapter.UnifiedMessage{ Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "999", } webMsg := adapter.UnifiedMessage{Platform: "web", ChannelType: adapter.ChannelDirect} cases := []struct { name string env loop.SessionEnv actor adapter.Actor msg adapter.UnifiedMessage want bool }{ {"群聊里的第三方", restrictedGroupEnv(), member, groupMsg, true}, {"陌生人私聊", restrictedStrangerEnv(), member, directMsg, true}, // 主人在群里同样跑受限车道:公域里没有「只对主人的那份亲近」, // 呈现档位按会话而不是按对方的身份决定(呈现层,不动权限)。 {"主人在群里", restrictedGroupEnv(), owner, groupMsg, true}, {"主人私聊", ownerPrivateEnv(), owner, directMsg, false}, {"Web 主会话", ownerPrivateEnv(), owner, webMsg, false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() msgs := b.buildLoopMessages(ctx, "hello", tc.env, tc.actor, "", tc.msg) sys := systemFrameOf(msgs) if got := strings.Contains(sys, publicPresencePrompt); got != tc.want { t.Fatalf("公域呈现块存在 = %v, want %v;系统框架行 = %q", got, tc.want, sys) } // 呈现块与身份边界**并列**:受限车道缺任何一半都不算完整 // (边界管「谁是谁」,呈现块管「怎么称呼、能说什么」)。 if tc.want { if !strings.Contains(sys, thirdPartyBoundaryPrompt) { t.Fatalf("受限框架行缺第三方身份边界:%q", sys) } if got, ok := frameMessageOf(msgs); !ok || got.Role != model.RoleSystem { t.Fatalf("受限框架行不是一条系统消息:%+v", got) } } }) } } // TestPublicPresenceBlockIsPresentationalProjection 钉住呈现块的**定位** // (AGENTS §9:人格 = 身份不限制能力;行为模式 = 呈现可切档): // 它必须明说「身份与能力照旧」,并且档位取「保留语气风格、不指向第三方、 // 不外说私事」——不是换人格,也不是中性机械口吻。 func TestPublicPresenceBlockIsPresentationalProjection(t *testing.T) { t.Parallel() // 定位与档位:身份/能力不变、只调呈现、语气风格保留。 for _, want := range []string{"身份", "能力", "呈现", "语气"} { if !strings.Contains(publicPresencePrompt, want) { t.Fatalf("公域呈现块 = %q, want it to state the projection framing (%q)", publicPresencePrompt, want) } } // 三条约束:称呼、亲近动作、私事(住址/姓名/作息/行程/健康/家人/关系)。 for _, want := range []string{ "亲爱的", "昵称", "凑近", "靠过来", "住址", "姓名", "作息", "行程", "健康", "家人", "关系", "记忆", } { if !strings.Contains(publicPresencePrompt, want) { t.Fatalf("公域呈现块 = %q, want it to cover %q", publicPresencePrompt, want) } } } // TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules 钉住 P0-4: // 第三方边界提示词除了「谁是谁」,还必须带两条硬规则 —— 不用主人专属的 // 亲昵称呼称呼第三方、不向第三方外说主人的私事(即使记忆里读到)。 func TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules(t *testing.T) { t.Parallel() for _, want := range []string{"亲昵称呼", "亲爱的", "住址", "作息", "行程", "健康", "家人", "关系", "记忆"} { if !strings.Contains(thirdPartyBoundaryPrompt, want) { t.Fatalf("第三方边界 = %q, want the P0-4 hard rule keyword %q", thirdPartyBoundaryPrompt, want) } } // 边界仍然先讲归属(裁决 06.4.1),P0-4 只追加、不替换。 if !strings.Contains(thirdPartyBoundaryPrompt, "发言归属") { t.Fatalf("第三方边界 = %q, want the structured-attribution rule to survive", thirdPartyBoundaryPrompt) } }