diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index 0b3465b..5cc1dd3 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -246,8 +246,8 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget { // runInboundTurn 为被放行的消息驱动 agent 轮次:解析 // 路由目标、把消息携带的文件类附件**落库并挂成文档部件** // (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路 -// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(主人私聊车道上再前置主会话的 -// 历史转录,见 transcript.go),在轮次之前持久化入站 +// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(再前置**该会话自己**最近的 +// 历史转录:主人私聊取主会话、群聊/陌生人取该隔离会话,见 transcript.go),在轮次之前持久化入站 // 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地, // 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行 // 循环并把结果交给 deliverReply。 diff --git a/internal/services/channelbridge/transcript.go b/internal/services/channelbridge/transcript.go index f3ff39f..fd7f778 100644 --- a/internal/services/channelbridge/transcript.go +++ b/internal/services/channelbridge/transcript.go @@ -4,23 +4,31 @@ import ( "context" "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 主会话回合的环境逐项一致(主人 2026-10-05 要求: -// 处理其他平台的主人消息时和主会话环境完全一致)。 +// 本文件让 IM 回合带上**该会话自己**最近的对话历史,使她看得懂一段连贯的对话,而不是只 +// 听得到当次那一条消息(主人 2026-10-05 反馈:「只有 @ 才有反应、不 @ 就不理解意图」—— +// 此前群里的回合只有「来源系统行 + 当次消息」,前几句她根本没看见)。 // -// 三条环境在 loop.SessionEnv 上早已一致(ActorEnv=owner、MemWritable=true、PersonaID 取 -// 主会话绑定,design/22 §7-3),缺的是**历史转录**:Web 主会话回合由编排器播种该会话最近 -// N 条转录(gateway/session_transcript.go),而 IM 回合此前只有「来源系统行 + 当次消息」。 -// 于是消息明明落在同一条会话流里,模型看到的历史却不同 —— 她像是第一次见到主人。 +// 会话环境在 loop.SessionEnv 上早已按车道解析(ActorEnv/MemWritable/PersonaID, +// design/22 §7-3/§7-4),缺的一直是**历史转录**:Web 主会话回合由编排器播种该会话最近 N 条 +// 转录(gateway/session_transcript.go),而 IM 回合此前没有。 // -// 本文件补上这一半:主人私聊回合在装配完本回合自己的消息之后、交给循环之前,前置**主会话** -// 的最近 N 条转录。重建逻辑与条数上限都取自 internal/runtime/transcript —— 与网关同一份 -// 实现、同一个常量,绝不另写第二套(两份映射必然漂移)。受限车道(群聊/陌生人)**绝不注入**: -// 那是隔离会话,只看得到自己会话里的当次消息,主人的主会话历史不得外泄(design/16 §4.6)。 +// - **主人私聊**:IM 回合镜像进主会话,因此播种**主会话**(`web`)的转录,与 Web 主会话 +// 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。 +// - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里 +// 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们 +// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。 +// +// 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript +// (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套 +// (两份映射必然漂移)。**受限车道结构上够不到主会话**:目标会话由 inboundTargetFor 结构化 +// 决定,受限车道拿到的是 `sess-*` 隔离会话,主人的主会话历史一个字都不外泄 +// (design/16 §4.6 §3、design/02 §3)。 // turnEnv 返回一条入站消息本轮运行所用的 loop 会话环境。 // @@ -37,18 +45,24 @@ func turnEnv(sess session.Session, target inboundTarget, personaID string) loop. return loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable, PersonaID: personaID} } -// seedTurnTranscript 在主人私聊车道上把**主会话**最近的转录置于本回合模型可见消息之前 -// (与网关编排器的 startTurnMessages 同序:转录在前、本次消息在后),使 IM 回合与 Web 主 -// 会话看到同一份对话历史。受限(群聊/陌生人)车道原样返回 —— 隔离上下文绝不注入主人的 -// 主会话历史。 +// seedTurnTranscript 在本回合模型可见消息之前前置**该会话自己**最近的转录 +// (与网关编排器的 startTurnMessages 同序:转录在前、本次消息在后): +// +// - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致; +// - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。 // // 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于 -// persistToMain),因此当次消息绝不会在上下文里出现两次。 +// persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。 // -// 转录读不出来(未接历史存储、读失败、空会话)时同样原样返回,这正是既有行为:主人私聊 -// 回合退化为「来源行 + 当次消息」,而不是让回合失败。 +// 转录读不出来(未接历史存储、读失败、空会话)时原样返回,这正是既有行为:回合退化为 +// 「来源行 + 当次消息」,而不是让回合失败。 +// +// 受限车道另设一道显式守卫:目标会话是主会话 id 时宁可不播种。这在生产里结构上不可能 +// (inboundTargetFor 只在主人私聊时给出 MainSessionID),但上下文隔离是安全红线 —— 万一 +// 将来多出别的构造点写错,也绝不能把主人的主会话历史灌进群聊/陌生人上下文 +// (design/02 §3、design/16 §4.6 §3)。 func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, msgs []model.Message) []model.Message { - if !target.private { + if !target.private && target.sessionID == history.MainSessionID { return msgs } seed := transcript.Read(ctx, b.history, target.sessionID) diff --git a/internal/services/channelbridge/transcript_test.go b/internal/services/channelbridge/transcript_test.go index 62a6a1a..1d569d2 100644 --- a/internal/services/channelbridge/transcript_test.go +++ b/internal/services/channelbridge/transcript_test.go @@ -2,6 +2,7 @@ package channelbridge import ( "context" + "errors" "strings" "sync" "testing" @@ -14,23 +15,33 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript" ) -// 本文件覆盖「IM 主人私聊的回合环境与 Web 主会话完全一致」这条要求里**历史转录**那一半 -// (见 transcript.go 的文件头、DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2): +// 本文件覆盖 IM 回合的**历史转录**那一半(见 transcript.go 的文件头、 +// DevDocs/工作小结/2026-10-05_QQ判主失效与脱敏日志.md 附 2): // // - 主人私聊车道的模型可见列表 = 主会话最近转录(同一个重建实现、同一条数上限)+ // 渠道来源系统行 + 当次用户消息; -// - 群聊/陌生人/隔离会话**绝不**注入:不读主会话历史、主人的话一个字都不进模型可见消息; -// - 车道环境由 loop.OwnerEnvFor 同源派生,因此与 Web 主会话逐字段相同(不靠各自手搓)。 +// - 群聊/陌生人(受限车道)= **该隔离会话自己**最近转录(含只被静默登记的发言, +// 带说话人归属)+ 第三方边界/来源系统行 + 当次用户消息; +// - 两条车道都**只读自己的会话**:主人的主会话历史、别的隔离会话的内容一个字都不进 +// 受限上下文,记忆仍只读(design/02 §3); +// - 车道环境由 loop.OwnerEnvFor 同源派生,因此主人私聊与 Web 主会话逐字段相同(不靠各自手搓)。 // // Web 侧的端到端对应断言在 gateway 的 TestChatTurnCarriesSpeakerAttributionFromTranscript // (同一份 internal/runtime/transcript 产出的列表)。 // seededHistory 在 captureHistory(记录落库)之上把 Recent 换成「返回预置的转录行」, // 使播种用例无需真库即可驱动历史。它记录每次请求的会话与条数上限 —— 前者断言 -// 「只读主会话」,后者断言「条数取自共享常量」。 +// 「读的是哪条会话」,后者断言「条数取自共享常量」。 type seededHistory struct { *captureHistory rows []history.Message + // live 把夹具变成「活存储」:Recent 除了预置行之外,还把本次运行**已经落库**的行 + // (captureHistory 记录的 Append)一并返回。真库就是这样 —— 写进去的行立刻出现在 + // Recent 里。因此「播种发生在当次消息落库之后」这类时刻错误会让当次消息在上下文里 + // 出现两次,而活夹具能把它数出来。 + live bool + // recentErr 让 Recent 失败,用来钉住「读不出来就退化为今天的行为」。 + recentErr error mu sync.Mutex asks []transcriptAsk @@ -46,8 +57,15 @@ func (h *seededHistory) Recent(_ context.Context, sessionID string, n int) ([]hi 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 h.recentErr != nil { + return nil, h.recentErr + } + all := h.rows + if h.live && len(h.captureHistory.records()) > 0 { + all = append(append([]history.Message(nil), h.rows...), h.captureHistory.records()...) + } + rows := make([]history.Message, 0, len(all)) + for _, m := range all { if m.SessionID == sessionID { rows = append(rows, m) } @@ -80,7 +98,20 @@ func turnMessageText(m model.Message) string { } // groupMessage 是一条被点名的群消息(design/12 §6 策略二 ⇒ 会得到回复), -// 因此群/陌生人用例能走到循环,从而验证「转录没有被注入」。 +// 因此群/陌生人用例能走到循环,从而验证转录的注入口径。 +// noMentionGroupMessage 是一条既没 @ 她、也没有任何规则信号的群消息(design/12 §6 ⇒ +// 只登记、不回复),用来验证「被静默的发言也落进同一会话、因而看得见」。 +func noMentionGroupMessage(senderID, senderName, text string) adapter.UnifiedMessage { + return adapter.UnifiedMessage{ + Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, + GroupID: "-20", GroupName: "Gang", SenderID: senderID, SenderName: senderName, + Sender: adapter.Actor{Role: adapter.RoleMember}, + Content: text, ContentType: adapter.ContentText, + } +} + +// groupMessage 是一条被点名的群消息(design/12 §6 策略二 ⇒ 会得到回复), +// 因此群/陌生人用例能走到循环,从而验证转录的注入口径。 func groupMessage(senderID string, sender adapter.Actor) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, @@ -169,31 +200,222 @@ func TestGroupTurnNeverSeedsMainSessionTranscript(t *testing.T) { 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) } + if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) { + t.Fatalf("messages[0] = %+v, want the third-party boundary system row", got) + } // 主人的主会话历史一个字都不许出现在群聊的模型可见消息里。 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) + // 受限车道读的是**它自己**的会话,绝不是主会话(design/02 §3 的上下文隔离)。 + asks := hist.asksFor() + if len(asks) != 1 || asks[0].session != "sess-g" { + t.Fatalf("transcript reads = %+v, want exactly one for the isolated sess-g", 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) { +// TestGroupTurnSeedsItsOwnSessionTranscript 是本切片的正面用例:受限(群聊)回合必须带上 +// **该隔离会话自己**最近的几条消息 —— 包括只被登记、没被回复的那几条 —— 并逐条保留结构化 +// 的说话人归属(SenderName),她据此才看得懂一段连贯对话的意图(主人 2026-10-05 反馈: +// 「不 @ 就不理解意图」)。 +func TestGroupTurnSeedsItsOwnSessionTranscript(t *testing.T) { + t.Parallel() + + hist := &seededHistory{captureHistory: &captureHistory{}, live: true, rows: []history.Message{ + // 主人的主会话历史:绝不进群聊上下文。 + {ID: "m1", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "主人的私事:明天九点体检", PersonaID: "cyrene"}, + // 该群自己的历史:两个不同说话人各归各,含一条只被静默登记的发言。 + {ID: "g1", SessionID: "sess-g", Role: history.RoleUser, Content: "晚上要不要开黑", SenderName: "张三"}, + {ID: "g2", SessionID: "sess-g", Role: history.RoleUser, Content: "我还在加班", SenderName: "李四"}, + {ID: "g3", SessionID: "sess-g", Role: history.RoleAssistant, Content: "那你先忙", PersonaID: "cyrene"}, + // 别的隔离会话:同一条受限车道上也不许串。 + {ID: "x1", SessionID: "sess-other", Role: history.RoleUser, Content: "别的群的话", SenderName: "王五"}, + }} + 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})) + + // 形状:该群最近 3 条转录(最旧优先)+ 第三方边界/来源系统行 + 当次用户消息。 + if len(chat.msgs) != 5 { + t.Fatalf("loop messages = %d, want 5 (transcript 3 + boundary + user): %+v", len(chat.msgs), chat.msgs) + } + // 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。 + if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" { + t.Fatalf("messages[0] = %+v, want 张三's earlier line attributed to 张三", got) + } + if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" { + t.Fatalf("messages[1] = %+v, want 李四's silenced line attributed to 李四", got) + } + if got := chat.msgs[2]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" { + t.Fatalf("messages[2] = %+v, want her earlier reply attributed to cyrene", got) + } + // 第三方身份边界与来源行照旧(内容一个字没变,仍在每轮注入)。 + if got := chat.msgs[3]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) || + !strings.Contains(turnMessageText(got), "群「Gang」") { + t.Fatalf("messages[3] = %+v, want the origin + third-party boundary system row", got) + } + if got := chat.msgs[4]; got.Role != model.RoleUser || turnMessageText(got) != "hello all" || got.SenderName != "张三" { + t.Fatalf("messages[4] = %+v, want this turn's user message", got) + } + // 当次消息只出现一次:转录是在本回合消息落库**之前**读的。活夹具把已经落库的行也返回, + // 因此播种时刻一旦挪到落库之后,这里立刻数出两次。 + seen := 0 + for _, m := range chat.msgs { + if m.Role == model.RoleUser && turnMessageText(m) == "hello all" { + seen++ + } + } + if seen != 1 { + t.Fatalf("current user message appears %d times, want exactly 1", seen) + } + // 绝不泄露:主会话与别的隔离会话的内容一个字都不进这个群聊上下文。 + for i, m := range chat.msgs { + text := turnMessageText(m) + if strings.Contains(text, "主人的私事") || strings.Contains(text, "别的群的话") { + t.Fatalf("messages[%d] = %q, leaked another conversation's transcript", i, text) + } + } + // 只读该会话自己,且条数上限取自共享常量(本包不许另造一个数)。 + asks := hist.asksFor() + if len(asks) != 1 || asks[0].session != "sess-g" { + t.Fatalf("transcript reads = %+v, want exactly one for sess-g", asks) + } + if asks[0].n != transcript.ContextCount { + t.Fatalf("transcript window = %d, want the shared transcript.ContextCount (%d)", asks[0].n, transcript.ContextCount) + } + // 转录不改隔离边界:记忆仍然只读、绝不写。 + if !chat.env.Restricted() || chat.env.MemWritable { + t.Fatalf("run env = %+v, want the restricted (read-only memory) env", chat.env) + } +} + +// TestGroupTurnSeesEarlierSilencedMessages 端到端证明「被静默的消息也看得见」:先走一条既没 @ +// 也没有任何规则信号的群消息(design/12 §6:只登记、不驱动循环),再让一条 @ 她的消息触发回合, +// 断言前一条**已经落库的静默发言**出现在模型可见上下文里,且归到它的说话人名下。 +func TestGroupTurnSeesEarlierSilencedMessages(t *testing.T) { + t.Parallel() + + hist := &seededHistory{captureHistory: &captureHistory{}, live: true} + 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(), noMentionGroupMessage("8", "李四", "我还在加班")) + if chat.got { + t.Fatalf("a no-signal message drove the loop: %+v", chat.msgs) + } + + b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember})) + + found := false + for i, m := range chat.msgs { + if turnMessageText(m) != "我还在加班" { + continue + } + found = true + if m.Role != model.RoleUser || m.SenderName != "李四" { + t.Fatalf("messages[%d] = %+v, want 李四's silenced line attributed to 李四", i, m) + } + } + if !found { + t.Fatalf("the earlier silenced line is missing from the turn context: %+v", chat.msgs) + } +} + +// TestRestrictedSeedDegradesToTodaysShape 钉住降级口径:空历史或读失败时,受限回合的模型 +// 可见消息与今天逐字相同(来源/边界系统行 + 当次用户消息)—— 一次元数据查找绝不拖垮回合。 +func TestRestrictedSeedDegradesToTodaysShape(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + hist *seededHistory + }{ + {name: "empty history", hist: &seededHistory{captureHistory: &captureHistory{}}}, + {name: "read failure", hist: &seededHistory{captureHistory: &captureHistory{}, recentErr: errors.New("boom")}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + 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: tc.hist}) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember})) + + if !chat.got { + t.Fatal("the loop was never driven") + } + if len(chat.msgs) != 2 || chat.msgs[0].Role != model.RoleSystem || chat.msgs[1].Role != model.RoleUser { + t.Fatalf("loop messages = %+v, want today's shape (boundary + user)", chat.msgs) + } + }) + } +} + +// TestRestrictedSeedRefusesMainSession 直接驱动播种函数,钉住上下文隔离的第二道守卫:受限车道 +// 即便(结构上不可能地)拿到主会话 id,也宁可不播种 —— 主人的主会话历史绝不进群聊/陌生人上下文 +// (design/02 §3、design/16 §4.6 §3)。主人私聊车道读的仍是主会话(既有行为逐字不变)。 +func TestRestrictedSeedRefusesMainSession(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: "群里的上一句"}, + }} + b, err := New(Options{Loop: &envChatter{}, Sessions: &stubSessions{}, Owner: testOwnerProfile("telegram", "100"), History: hist}) + if err != nil { + t.Fatalf("New: %v", err) + } + msgs := []model.Message{ + model.TextMessage(model.RoleSystem, thirdPartyBoundaryPrompt), + model.TextMessage(model.RoleUser, "hello"), + } + + got := b.seedTurnTranscript(context.Background(), inboundTarget{sessionID: history.MainSessionID}, msgs) + if len(got) != len(msgs) { + t.Fatalf("restricted seed = %+v, want the turn's own messages only", got) + } + if asks := hist.asksFor(); len(asks) != 0 { + t.Fatalf("restricted lane read %+v, want no read at all", asks) + } + + got = b.seedTurnTranscript(context.Background(), inboundTarget{sessionID: history.MainSessionID, private: true}, msgs) + if len(got) != len(msgs)+1 || turnMessageText(got[0]) != "主人的私事" { + t.Fatalf("owner private seed = %+v, want the main-session transcript in front", got) + } +} + +// TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript 是主人本人在群里的情形:它同样跑在隔离 +// 会话上(design/02 §3:主人群内发言只在这个群聊隔离上下文里处理,不并入主会话),因此转录 +// 只能来自该群会话 —— 主人的主会话历史仍不得进群上下文。 +func TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript(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: "群里的上一句", SenderName: "张三"}, }} chat := &envChatter{} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}} @@ -207,16 +429,20 @@ func TestOwnerGroupTurnKeepsIsolatedLaneWithoutTranscript(t *testing.T) { 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) + if len(chat.msgs) != 3 { + t.Fatalf("loop messages = %d, want 3 (isolated transcript + origin/boundary + user): %+v", len(chat.msgs), chat.msgs) + } + if got := chat.msgs[0]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" { + t.Fatalf("messages[0] = %+v, want the group's own earlier line", got) } 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 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("isolated lane read the transcript: %+v", asks) + // 只读该群会话,绝不读主会话。 + asks := hist.asksFor() + if len(asks) != 1 || asks[0].session != "sess-g" { + t.Fatalf("transcript reads = %+v, want exactly one for sess-g", asks) } }