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