Files
Cyrene_v2/internal/services/channelbridge/transcript_test.go
T
AskaEth 9b903a89bc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
2026-10-05 11:36:18 +08:00

223 lines
9.3 KiB
Go

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/history"
"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/runtime/transcript"
)
// 本文件覆盖「IM 主人私聊的回合环境与 Web 主会话完全一致」这条要求里**历史转录**那一半
// (见 transcript.go 的文件头、DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2):
//
// - 主人私聊车道的模型可见列表 = 主会话最近转录(同一个重建实现、同一条数上限)+
// 渠道来源系统行 + 当次用户消息;
// - 群聊/陌生人/隔离会话**绝不**注入:不读主会话历史、主人的话一个字都不进模型可见消息;
// - 车道环境由 loop.OwnerEnvFor 同源派生,因此与 Web 主会话逐字段相同(不靠各自手搓)。
//
// Web 侧的端到端对应断言在 gateway 的 TestChatTurnCarriesSpeakerAttributionFromTranscript
// (同一份 internal/runtime/transcript 产出的列表)。
// seededHistory 在 captureHistory(记录落库)之上把 Recent 换成「返回预置的转录行」,
// 使播种用例无需真库即可驱动历史。它记录每次请求的会话与条数上限 —— 前者断言
// 「只读主会话」,后者断言「条数取自共享常量」。
type seededHistory struct {
*captureHistory
rows []history.Message
mu sync.Mutex
asks []transcriptAsk
}
// transcriptAsk 是一次 Recent 调用的入参快照。
type transcriptAsk struct {
session string
n int
}
func (h *seededHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.asks = append(h.asks, transcriptAsk{session: sessionID, n: n})
rows := make([]history.Message, 0, len(h.rows))
for _, m := range h.rows {
if m.SessionID == sessionID {
rows = append(rows, m)
}
}
if n <= 0 {
return nil, nil
}
if len(rows) > n {
rows = rows[len(rows)-n:]
}
return rows, nil
}
// asksFor 返回已经发生的 Recent 调用(按发生顺序)。
func (h *seededHistory) asksFor() []transcriptAsk {
h.mu.Lock()
defer h.mu.Unlock()
return append([]transcriptAsk(nil), h.asks...)
}
// turnMessageText 拼接一条模型消息里的文本部件。
func turnMessageText(m model.Message) string {
out := ""
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
// groupMessage 是一条被点名的群消息(design/12 §6 策略二 ⇒ 会得到回复),
// 因此群/陌生人用例能走到循环,从而验证「转录没有被注入」。
func groupMessage(senderID string, sender adapter.Actor) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: "张三",
Sender: sender, Mentions: []adapter.Mention{{UserID: senderID}},
Content: "hello all", ContentType: adapter.ContentText,
}
}
func TestOwnerPrivateTurnSeedsMainSessionTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "喵"},
{ID: "m2", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "又用喵来敲门啦", PersonaID: "cyrene"},
// 隔离会话的行绝不进主会话车道的上下文。
{ID: "x1", SessionID: "sess-other", Role: history.RoleUser, Content: "群里的别人的话"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), directMessage())
// 与 Web 主会话是同一条车道:本轮跑在主会话上(design/16 §4.4)。
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
// 模型可见列表 = 主会话转录(最旧优先)+ 渠道来源系统行 + 当次用户消息。
// 除来源行外,它与 Web 主会话回合的列表逐条一致 —— 这正是「同一个她」的前提。
if len(chat.msgs) != 4 {
t.Fatalf("loop messages = %d, want 4 (transcript 2 + origin + user): %+v", len(chat.msgs), chat.msgs)
}
if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "喵" {
t.Fatalf("messages[0] = %+v, want the earlier user turn 喵", got)
}
if got := chat.msgs[1]; got.Role != model.RoleAssistant || turnMessageText(got) != "又用喵来敲门啦" || got.SenderName != "cyrene" {
t.Fatalf("messages[1] = %+v, want her earlier reply attributed to cyrene", got)
}
if got := chat.msgs[2]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), "来源:telegram · 私聊") {
t.Fatalf("messages[2] = %+v, want the telegram origin system row", got)
}
if got := chat.msgs[3]; got.Role != model.RoleUser || turnMessageText(got) != "hello" {
t.Fatalf("messages[3] = %+v, want this turn's user message", got)
}
// 当次消息只出现一次:转录是在本回合消息落库**之前**读的(与网关播种同一时刻)。
seen := 0
for _, m := range chat.msgs {
if m.Role == model.RoleUser && turnMessageText(m) == "hello" {
seen++
}
}
if seen != 1 {
t.Fatalf("current user message appears %d times, want exactly 1", seen)
}
// 只读主会话,且条数上限取自共享常量(本包不许另造一个数)。
asks := hist.asksFor()
if len(asks) != 1 || asks[0].session != history.MainSessionID {
t.Fatalf("transcript reads = %+v, want exactly one for %q", asks, history.MainSessionID)
}
if asks[0].n != transcript.ContextCount {
t.Fatalf("transcript window = %d, want the shared transcript.ContextCount (%d)", asks[0].n, transcript.ContextCount)
}
// 车道环境与 Web 主会话**同源派生**:本夹具主会话未绑定角色,因此逐字段等于 loop.OwnerEnv()。
if chat.env != loop.OwnerEnv() {
t.Fatalf("run env = %+v, want the shared owner env %+v", chat.env, loop.OwnerEnv())
}
}
func TestGroupTurnNeverSeedsMainSessionTranscript(t *testing.T) {
t.Parallel()
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事:明天九点体检", PersonaID: "cyrene"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
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(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
// 受限车道的形状与改动前逐字相同:第三方边界系统行 + 当次用户消息,没有转录。
if len(chat.msgs) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user): %+v", len(chat.msgs), chat.msgs)
}
// 主人的主会话历史一个字都不许出现在群聊的模型可见消息里。
for i, m := range chat.msgs {
if strings.Contains(turnMessageText(m), "主人的私事") {
t.Fatalf("messages[%d] leaked the owner's main-session transcript: %+v", i, m)
}
}
if asks := hist.asksFor(); len(asks) != 0 {
t.Fatalf("restricted lane read the transcript: %+v", asks)
}
if !chat.env.Restricted() || chat.env.MemWritable {
t.Fatalf("run env = %+v, want the restricted (read-only memory) env", chat.env)
}
}
func TestOwnerGroupTurnKeepsIsolatedLaneWithoutTranscript(t *testing.T) {
t.Parallel()
// 隔离会话自己有历史行也不播种:本切片只动主人私聊这条车道,群聊隔离行为不许改坏。
hist := &seededHistory{captureHistory: &captureHistory{}, rows: []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事", PersonaID: "cyrene"},
{ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "群里的上一句"},
}}
chat := &envChatter{}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "owner_uid"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), groupMessage("owner_uid", adapter.Actor{Role: adapter.RoleOwner}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID)
}
if len(chat.msgs) != 2 {
t.Fatalf("loop messages = %d, want 2 (origin/boundary + user): %+v", len(chat.msgs), chat.msgs)
}
for i, m := range chat.msgs {
text := turnMessageText(m)
if strings.Contains(text, "主人的私事") || strings.Contains(text, "群里的上一句") {
t.Fatalf("messages[%d] = %q, want no injected transcript on an isolated lane", i, text)
}
}
if asks := hist.asksFor(); len(asks) != 0 {
t.Fatalf("isolated lane read the transcript: %+v", asks)
}
}