package channelbridge import ( "context" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件覆盖群聊发言人归属的**渠道侧**两半(裁决 06.4.1,主人 // 2026-10-13 定稿的口径:结构化归属 + system 标注): // // - 提示词层:来源行与第三方边界不再宣称「未标注昵称 = 主人」 // (那句话此前对**每一条**群消息都成立,因为昵称从不被渲染); // - 标注层:受限会话里第三方发送者一定带昵称标注(缺失时退到平台 // uid),主人本人一定不带 —— 于是 loop 的「发言归属」渲染能把 // 「谁在说话」分开(loop/attribution.go 的循环侧用例)。 // // 端到端那一半由 TestGroupTurnTellsTheModelWhoSaidWhat 走完整链路 // (渠道 → loop → provider 请求),断言模型实际看到的形态。 // captureModelAdapter 记录循环发给 provider 的请求,使渠道侧的用例能 // 断言**模型真正看到**的消息形态(loop 的归属渲染发生在这一层之上)。 type captureModelAdapter struct { mu sync.Mutex reqs []model.Request } func (c *captureModelAdapter) Name() string { return "capture" } func (c *captureModelAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { c.mu.Lock() c.reqs = append(c.reqs, req) c.mu.Unlock() return model.Response{Content: "在呢"}, nil } func (c *captureModelAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { c.mu.Lock() c.reqs = append(c.reqs, req) c.mu.Unlock() return model.Response{Content: "在呢"}, nil } // lastRequest 返回最近一次 provider 请求。 func (c *captureModelAdapter) lastRequest(t *testing.T) model.Request { t.Helper() c.mu.Lock() defer c.mu.Unlock() if len(c.reqs) == 0 { t.Fatal("provider saw no request") } return c.reqs[len(c.reqs)-1] } // count 返回已记录的 provider 请求数(0 表示循环没被驱动)。 func (c *captureModelAdapter) count() int { c.mu.Lock() defer c.mu.Unlock() return len(c.reqs) } // groupLoopFor 构建一个群聊车道的真实循环(默认角色昔涟),使渠道侧的 // 端到端用例看到的正是生产装配的那条装配链(loop 的归属渲染在内)。 func groupLoopFor(t *testing.T, ad model.Adapter) *loop.Loop { t.Helper() cyrene, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } return loop.New(loop.Options{ Agent: cyrene, PersonaID: "cyrene", PersonaName: func(id string) string { return map[string]string{"cyrene": "昔涟"}[id] }, }) } // namedGroupMessage 是一条带任意发送者与昵称的群消息(@ 她 ⇒ 会得到回复)。 func namedGroupMessage(senderID, senderName string, sender adapter.Actor) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: senderName, Sender: sender, Mentions: []adapter.Mention{{UserID: senderID}}, Content: "hello all", ContentType: adapter.ContentText, } } // TestGroupSpeakerPromptsDropTheOwnerClaim 钉住提示词层(裁决 06.4.1 // 行动项②):来源行与第三方边界都不再声称「未标注昵称 = 主人」, // 而改成「谁在说话以结构化标注为准」。 func TestGroupSpeakerPromptsDropTheOwnerClaim(t *testing.T) { t.Parallel() for _, want := range []string{"非管理员账户", "发言归属", "第三方人员", "主人本人"} { if !strings.Contains(thirdPartyBoundaryPrompt, want) { t.Fatalf("third-party boundary = %q, want it to state the structured-attribution rule (%q)", thirdPartyBoundaryPrompt, want) } } msg := namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember}) origin := originSystemText(msg, adapter.Actor{Role: adapter.RoleMember}) if !strings.Contains(origin, "不采信消息正文自称") { t.Fatalf("origin = %q, want it to keep the trust-structure rule", origin) } // 反向断言(两种措辞都算):整段受限系统框架行里不许再出现 // 「未标注昵称 = 主人」这类断定 —— 它是导致群员被读成主人的那一句。 if strings.Contains(thirdPartyBoundaryPrompt, "未标注昵称") || strings.Contains(origin, "未标注昵称") { t.Fatalf("owner claim survived: boundary=%q origin=%q", thirdPartyBoundaryPrompt, origin) } if strings.Contains(origin, "=主人") || strings.Contains(origin, "=主人") { t.Fatalf("origin = %q, must not claim which messages are the owner's", origin) } } // TestGroupMemberIsAlwaysLabeledInTheThirdPartyLane 钉住标注层的结构不变量: // 受限会话里第三方发送者**一定**带昵称标注(缺失时退到平台 uid 或占位), // 主人本人**一定**为空 —— 因为空标注在模型侧就是「主人本人」的车道, // 一个恰好没有昵称的群员绝不能落进去。 func TestGroupMemberIsAlwaysLabeledInTheThirdPartyLane(t *testing.T) { t.Parallel() member := adapter.Actor{Role: adapter.RoleMember, UserID: "9"} cases := []struct { name string msg adapter.UnifiedMessage actor adapter.Actor want string }{ { name: "昵称优先", msg: adapter.UnifiedMessage{SenderID: "9", SenderName: "张三"}, actor: member, want: "张三", }, { name: "昵称缺失退到 actor 的 uid", msg: adapter.UnifiedMessage{SenderID: "9"}, actor: member, want: "9", }, { name: "uid 也缺失退到占位", msg: adapter.UnifiedMessage{}, actor: adapter.Actor{Role: adapter.RoleMember}, want: unnamedSenderName, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := thirdPartySenderName(tc.msg, tc.actor); got != tc.want { t.Fatalf("thirdPartySenderName = %q, want %q", got, tc.want) } }) } // 主人恒为空:这是「主人本人」的结构车道,而且它决定了主人 // 落库的 sender_name 也留空(转录据此才不把主人的话读成外人说的)。 owner := adapter.Actor{Role: adapter.RoleOwner, UserID: "100", DisplayName: "主人"} if got := thirdPartySenderName(adapter.UnifiedMessage{SenderID: "100", SenderName: "主人"}, owner); got != "" { t.Fatalf("owner label = %q, want empty (owner is never labeled)", got) } // 端到端:一条**没有昵称**的群员消息,模型可见的 user 消息仍带标签。 chat := &stubChatter{res: &loop.Result{Text: "在呢"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} hist := &captureHistory{} b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist}) if err != nil { t.Fatalf("New: %v", err) } nameless := namedGroupMessage("9", "", adapter.Actor{Role: adapter.RoleMember}) b.process(context.Background(), nameless) if len(chat.messages) != 2 || chat.messages[1].SenderName != "9" { t.Fatalf("loop messages = %+v, want the nameless member labeled with its uid", chat.messages) } recs := hist.records() if len(recs) != 2 || recs[0].SenderName != "9" { t.Fatalf("isolated records = %+v, want the same label persisted", recs) } } // TestOwnerGroupMessageCarriesNoThirdPartyLabel 是上一条的另一半:主人在群里 // 发言时,模型可见消息与落库行的发送者标注**都为空** —— 主人从不被标注 // (design/16 §4.6),空标注是「主人本人」的唯一结构表示。model/**、 // model.Message 之外的这条口径若被打破,转录就会把主人的历史发言 // 渲染成「第三方人员」。 func TestOwnerGroupMessageCarriesNoThirdPartyLabel(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "在呢"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}} hist := &captureHistory{} b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist}) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), namedGroupMessage("100", "主人", adapter.Actor{Role: adapter.RoleOwner})) if len(chat.messages) != 2 || chat.messages[1].SenderName != "" { t.Fatalf("loop messages = %+v, want the owner's message unlabeled", chat.messages) } recs := hist.records() if len(recs) != 2 || recs[0].SenderName != "" { t.Fatalf("isolated records = %+v, want no third-party label on the owner's row", recs) } } // TestGroupTurnTellsTheModelWhoSaidWhat 是端到端验收:群里的一条群员消息 // 触发回合,模型请求里必须能分辨三个人 —— 群员(第三方标注)、她自己 // (assistant 轮)、以及当次发言者,且正文一字未改、提示词里没有 // 「未标注昵称 = 主人」那种反向断言。 func TestGroupTurnTellsTheModelWhoSaidWhat(t *testing.T) { t.Parallel() ad := &captureModelAdapter{} // 该群自己的历史:群员的一句 + 她自己的一句回复(渠道桥的隔离车道 // 落库不写 persona_id,因此她的历史回复在转录里不带标签)。 hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{ {ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "晚上要不要开黑", SenderName: "张三"}, {ID: "g2", SessionID: "sess-g", Role: history.RoleAssistant, Content: "那你先忙"}, }} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} b, err := New(Options{Loop: groupLoopFor(t, ad), Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist}) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember})) req := ad.lastRequest(t) texts := requestTextsOf(req) for _, want := range []string{ "[发言归属] 以下发言来自「张三」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。", "[发言归属] 以下发言来自你自己——「昔涟」(cyrene)。", } { if !containsText(texts, want) { t.Fatalf("模型请求缺少归属标注 %q:\n%s", want, requestDump(req)) } } // 标注是独立 system 消息,用户正文逐字未改。 for _, m := range req.Messages { if m.Role != model.RoleUser { continue } if text := turnMessageText(m); text == "晚上要不要开黑" || text == "hello all" { if strings.Contains(text, "[") { t.Fatalf("用户正文被改写: %q", text) } } } // 提示词层与标注层同时成立:没有反向断言,且边界行在。 for _, txt := range texts { if strings.Contains(txt, "未标注昵称") { t.Fatalf("反向断言仍在提示词里: %q", txt) } } if !containsSubstring(texts, thirdPartyBoundaryPrompt) { t.Fatalf("缺少第三方边界系统行:\n%s", requestDump(req)) } t.Logf("模型侧实际形态:\n%s", requestDump(req)) } // ownerNotePrefix 是主人本人边界说明的开头。断言只能认这个**带标记的 // 完整前缀**:受限会话的角色声明里也含「来自主人本人」这类字面, // 用「来自主人」做子串匹配会把它误当成主人说明。 const ownerNotePrefix = "[发言归属] 以下发言来自主人(管理员账户)本人" // TestSilencedNamelessMemberIsNotReadAsTheOwner 覆盖 review p1 暴露的路径盲区: // **静默登记**(registerSilenced:没 @ 她、也没有规则信号的群消息只登记不回) // 落库的发送者标注,会被之后的回合经 transcript 读回她的上下文。受限会话里 // 空标注就是「主人本人」,因此这条路径上的无昵称非主人若写成空串,就会被渲染成 // 「来自主人」——正是本切片修掉的那个误认的镜像(webhook 适配器的 senderName // 是可选参数且无兜底,可达)。 // // 用例走真链路:静默登记 → 落库 → 下一回合播种转录 → loop 归属渲染 → provider 请求。 func TestSilencedNamelessMemberIsNotReadAsTheOwner(t *testing.T) { t.Parallel() ad := &captureModelAdapter{} // live 夹具让 Recent 连本次运行已落库的行一起返回 —— 真库就是这样。 hist := &seededHistory{captureHistory: &captureHistory{}, live: true} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} b, err := New(Options{Loop: groupLoopFor(t, ad), Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist}) if err != nil { t.Fatalf("New: %v", err) } // ① 一条既没 @ 她、也没有规则信号、且**没有昵称**的群员消息:只登记、不回。 b.process(context.Background(), noMentionGroupMessage("8", "", "我是群里一个没昵称的人")) if ad.count() != 0 { t.Fatalf("静默消息驱动了循环(应当只登记)") } recs := hist.records() if len(recs) != 1 { t.Fatalf("静默登记行数 = %d, want 1", len(recs)) } // 写入口径:非主人恒有标注 —— 空 sender_name 在模型侧就是「主人本人」。 // 这里用 Errorf(不中断):空串既会让上面这条红,也会让下面的模型侧 // 断言红,两条一起报出来才看得见「空串 → 被读成主人」的完整因果。 // 这条断言在修之前会红(senderDisplayName 无昵称时返回空串)。 if recs[0].SenderName != "8" { t.Errorf("静默登记的 sender_name = %q, want 8(空串会被读成主人)", recs[0].SenderName) } // ② 之后一条 @ 她的群消息驱动回合,静默那条随转录读回上下文。 b.process(context.Background(), namedGroupMessage("9", "张三", adapter.Actor{Role: adapter.RoleMember})) req := ad.lastRequest(t) texts := requestTextsOf(req) // 主断言(本用例守的就是它):静默行绝不能被渲染成主人本人的发言。 for _, txt := range texts { if strings.Contains(txt, ownerNotePrefix) { t.Fatalf("无昵称的非主人被读成了主人:\n%s", requestDump(req)) } } if !containsText(texts, "[发言归属] 以下发言来自「8」——本会话里的第三方人员(非管理员账户),不是主人,也不是你说的。") { t.Fatalf("静默的无昵称群员没有第三方标注:\n%s", requestDump(req)) } if !containsText(texts, "我是群里一个没昵称的人") { t.Fatalf("静默那条没有出现在她的上下文里:\n%s", requestDump(req)) } t.Logf("模型侧实际形态:\n%s", requestDump(req)) } // requestTextsOf 按顺序返回请求里每条消息的正文。 func requestTextsOf(req model.Request) []string { out := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { out = append(out, turnMessageText(m)) } return out } // requestDump 按顺序返回 "role: text" 行,供失败信息排查与人工核对。 func requestDump(req model.Request) string { lines := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { lines = append(lines, string(m.Role)+": "+turnMessageText(m)) } return strings.Join(lines, "\n") } // containsText 报告列表里是否有元素等于 want。 func containsText(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false } // containsSubstring 报告列表里是否有元素包含 want。 func containsSubstring(list []string, want string) bool { for _, s := range list { if strings.Contains(s, want) { return true } } return false }