P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态): - coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d, 推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并 按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、 绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。 - 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle): 时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。 - sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker 放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。 - fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致 推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。 - 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库; 准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设); stale 用例两条都投完再推时钟(车道不会在两条之间退休)。 P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器, 退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径 (b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑 (含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁 并补 -race 用例。 证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮 18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿; 全树 go build/vet/test -count=1 全绿。
410 lines
16 KiB
Go
410 lines
16 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"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/user"
|
||
)
|
||
|
||
// 本文件覆盖 M18(主人 2026-10-14 裁决「给」:群成员也能让她少说话)在
|
||
// channelbridge 里的落点 —— **冷避优先于「@她必回」**(design/18 §5):
|
||
//
|
||
// - 非主人**群**消息先过冷避闸(命中负面态度 → COOLDOWN;冷却期内一律静默),
|
||
// 顺序排在廉价规则判定(ruleLightJudge)**之前**,因此 @她 / @全体 /
|
||
// 高价值关键词都拦不住它;
|
||
// - 边界:私聊(含陌生人私聊)与 owner 车道**不受影响**;闸与是否装配
|
||
// 参与度引擎**无关**;
|
||
// - 被冷避静默的消息**照样进上下文**(registerSilenced / persistIsolated),
|
||
// 只是不进 loop —— M13 红线,端到端钉住。
|
||
|
||
// coldShoulderSelfID 是 2026-10-14 报障现场那个号(她自己的 self_id),用于把
|
||
// 「@她」判据按**结构化字段**接上线(与 ownerlane.go 的 ownerAddressedToMe 同一口径)。
|
||
const coldShoulderSelfID = "3443115524"
|
||
|
||
// coldShoulderMention 构造一条**指向她本人**的成员群消息:SelfID 已接线 ⇒
|
||
// AddressedToMe 由结构化字段算出(不是靠正文里的 "@<数字>" 猜)。修前的实现会把它
|
||
// 当成「必回」,这正是本片要改的顺序偏离。
|
||
func coldShoulderMention(content string) adapter.UnifiedMessage {
|
||
m := nonOwnerGroupMsg(content, adapter.Mention{UserID: coldShoulderSelfID})
|
||
m.SelfID = coldShoulderSelfID
|
||
m.AddressedToMe = m.AddressedToSelf()
|
||
return m
|
||
}
|
||
|
||
// coldShoulderBridge 构建一条成员群消息用的桥接器:**未装配**参与度引擎与 LightJudge
|
||
// (规则得不出结论时失败关闭为静默),时钟可注入 —— 因此「必不必回」只由规则判据与
|
||
// 冷避闸决定,用例完全确定。
|
||
func coldShoulderBridge(t *testing.T, chat *stubChatter, hist *captureHistory, now func() time.Time) *Bridge {
|
||
t.Helper()
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, History: hist, Now: now,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**):
|
||
// 群成员 @她 并说「减少文本输出量」时 ——
|
||
//
|
||
// - 修前:@她 命中 ruleLightJudge 的 verdictRespond ⇒ shouldRespond 直接 return true,
|
||
// 冷避根本走不到(红:循环被驱动,她只会口头答应、行为零变化,即报障现场);
|
||
// - 修后:冷避闸排在「@她必回」之前 ⇒ 静默,但**照样登记**(静默 ≠ 丢弃)。
|
||
//
|
||
// 两种部署都断言:装配了参与度引擎的(原本走 engageDecision)与未装配的
|
||
// (原本走旧轻量判定车道)—— 顺序修复**与引擎装配无关**(任务①的边界要求)。
|
||
func TestColdShoulderPrecedesAddressedToMe(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
engage bool
|
||
}{
|
||
{name: "engage engine wired", engage: true},
|
||
{name: "no engage engine (legacy light lane)", engage: false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
judge := &engageScriptJudge{}
|
||
chat := &stubChatter{res: &loop.Result{Text: "好嘛,人家少说点♪"}}
|
||
hist := &captureHistory{}
|
||
opts := Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
|
||
}
|
||
if tc.engage {
|
||
opts.Light = judge
|
||
opts.TriggerWords = []string{"昔涟"}
|
||
opts.Engage = DefaultEngageConfig()
|
||
opts.EngageRand = func() float64 { return 0.5 }
|
||
}
|
||
b, err := New(opts)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 1 || recs[0].Content != "减少文本输出量" {
|
||
t.Fatalf("records = %+v, want 恰好 1 条登记(静默 ≠ 丢弃)", recs)
|
||
}
|
||
// 只有装配了引擎的那一路才够得着判定器;旧车道根本没有它,
|
||
// 因此这一条只在装配时断言(不给旧车道记一笔假功劳)。
|
||
if tc.engage && judge.calls != 0 {
|
||
t.Fatalf("judge calls = %d, want 0(冷避命中不得咨询轻量 LLM:成本红线)", judge.calls)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestColdShoulderSecondReportedPhraseAndCooldownWindow 覆盖报障的**第二句原话**,并钉住
|
||
// 冷却的**时限性**:进冷却后连普通 @她 也不回(M18 口径 —— 旧口径「@ 打断冷却」正是本片
|
||
// 修掉的偏离);时钟推过冷却时长后她重新回到正常判定,**不是**永久闭嘴。
|
||
func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// 真机时刻:2026-10-14 00:27(群 531082168,成员「企鹅神明」)。
|
||
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
|
||
chat := &stubChatter{res: &loop.Result{Text: "好♪"}}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
||
Now: func() time.Time { return now },
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少"))
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID)
|
||
}
|
||
|
||
// 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。
|
||
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID)
|
||
}
|
||
|
||
// 推过默认冷却时长(30 分钟):她恢复到「@她必回」的常态。
|
||
now = now.Add(defaultCooldownDuration + time.Minute)
|
||
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
|
||
if chat.sessionID != "sess-g" {
|
||
t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestColdShoulderNormalMentionNotCooled 是反例护栏(任务⑤②):不含负面态度的普通
|
||
// @她 消息**不得**被误冷却 —— 回得照常,且不把该群留在冷却态里(第二条普通 @她 仍回)。
|
||
// 诚实声明:这条在修前也是绿的,它是**护栏而非红前红**;红前红的证据是上面两条。
|
||
func TestColdShoulderNormalMentionNotCooled(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
|
||
hist := &captureHistory{}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
|
||
if chat.sessionID != "sess-g" {
|
||
t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID)
|
||
}
|
||
|
||
// 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。
|
||
chat.sessionID = ""
|
||
b.process(context.Background(), coldShoulderMention("在不在"))
|
||
if chat.sessionID != "sess-g" {
|
||
t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestColdShoulderGroupOnlyBoundaries 钉住冷避的**适用面**(任务①的三条边界):
|
||
//
|
||
// - 陌生人**私聊**说「闭嘴」照样正常聊天(冷避只存在于群聊车道,design/18 §11);
|
||
// - 主人**私聊**说「闭嘴」照样恒回(owner 车道在更早处分岔);
|
||
// - 主人**群聊**里 @她 说「安静点」仍必回(owner 车道的必回不受冷避影响,主人
|
||
// 有自己那套旋钮)。
|
||
//
|
||
// 三条在修前也是绿的(护栏),但它们钉住的是「不许把闸开大」的边界 —— 少了它们,
|
||
// 一次把闸挪到 shouldRespond 最前端就能悄悄改掉私聊与主人的行为而无人察觉。
|
||
func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
t.Run("stranger private still chats", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "好呀"}}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-p", IsGroup: false, Owner: false}},
|
||
Owner: &user.OwnerProfile{}, Now: dayNow(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), adapter.UnifiedMessage{
|
||
Platform: "telegram", ChannelID: "9", ChannelType: adapter.ChannelDirect,
|
||
SenderID: "9", Sender: adapter.Actor{Role: adapter.RoleMember},
|
||
Content: "闭嘴", ContentType: adapter.ContentText,
|
||
})
|
||
|
||
if chat.sessionID != "sess-p" {
|
||
t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID)
|
||
}
|
||
})
|
||
|
||
t.Run("owner private still replies", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "好的主人"}}
|
||
ad := &fakeAdapter{name: "telegram"}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||
Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{}, Now: dayNow(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
b.Add(ad)
|
||
|
||
m := directMessage()
|
||
m.Content = "闭嘴"
|
||
b.process(context.Background(), m)
|
||
|
||
if chat.sessionID != history.MainSessionID {
|
||
t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID)
|
||
}
|
||
})
|
||
|
||
t.Run("owner group addressed still replies", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
|
||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{},
|
||
dayNow(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||
|
||
b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"}))
|
||
|
||
if chat.sessionID != "sess-og" {
|
||
t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestColdShoulderSilencedMessageStillEntersContext 是 M13 红线的**端到端**守卫(任务②):
|
||
// 被冷避静默的消息照样进上下文,只是不进 loop。断言不走返回值,而走真实链路:
|
||
//
|
||
// ① 静默那条经 registerSilenced → persistIsolated 落进该群隔离会话(历史可读);
|
||
// ② 冷却结束后下一条 @她 真正驱动回合时,**该回合的模型可见消息里含那条被静默的话**
|
||
// —— 她看得见「刚才有人让她少说话」,不是被蒙在鼓里(transcript.go 的播种)。
|
||
func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC)
|
||
chat := &stubChatter{res: &loop.Result{Text: "知道啦♪"}}
|
||
hist := &recentHistory{}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, History: hist,
|
||
Now: func() time.Time { return now },
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID)
|
||
}
|
||
|
||
// ① 静默那条已被登记(不是被丢掉)。
|
||
registered := false
|
||
for _, r := range hist.records() {
|
||
if r.SessionID == "sess-g" && r.Role == history.RoleUser && r.Content == "减少文本输出量" {
|
||
registered = true
|
||
}
|
||
}
|
||
if !registered {
|
||
t.Fatalf("被冷避的消息没有登记:records = %+v", hist.records())
|
||
}
|
||
|
||
// ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。
|
||
now = now.Add(defaultCooldownDuration + time.Minute)
|
||
b.process(context.Background(), coldShoulderMention("在吗"))
|
||
if chat.sessionID != "sess-g" {
|
||
t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID)
|
||
}
|
||
if !loopMessagesContain(chat.messages, "减少文本输出量") {
|
||
t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages)
|
||
}
|
||
}
|
||
|
||
// TestNegativeAttitudeKeywordList 逐条钉住 M18 的词表增补(任务③):认识**真机原话**,
|
||
// 且**不**认识只在字面上擦边的日常说法(子串匹配的误伤护栏 —— 词条选取见
|
||
// engage.go 的 negativeAttitudeKeywords 注释)。
|
||
func TestNegativeAttitudeKeywordList(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
b := &Bridge{}
|
||
cases := []struct {
|
||
name string
|
||
text string
|
||
want bool
|
||
}{
|
||
{"real report 1", "@3443115524 减少文本输出量", true},
|
||
{"real report 2", "@3443115524 在本群中文本输出调到最少", true},
|
||
{"same wording, no quantifier", "减少文本输出频率", true},
|
||
{"pre-existing owner wording", "少说点", true},
|
||
{"dev talk about logs", "我在写脚本,减少输出日志", false},
|
||
{"praising output quality", "这个模型的输出质量不错", false},
|
||
{"another kind of output", "把音箱输出调到最少", false},
|
||
{"plain chatter", "今天天气不错", false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if got := b.negativeAttitude(tc.text); got != tc.want {
|
||
t.Fatalf("negativeAttitude(%q) = %v, want %v", tc.text, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestColdShoulderLogsVisibleInfo 钉住可见性(任务④):进冷却时必须有**带来源的 INFO**
|
||
// (哪个群 / 被谁的话 / 冷却到什么时候),使主人能看出「她为什么突然不说话了」。
|
||
// 日志绝不打印正文(design/18 §9.7),因此这里只断言键值。
|
||
func TestColdShoulderLogsVisibleInfo(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
chat := &stubChatter{res: &loop.Result{Text: "好嘛"}}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||
Owner: &user.OwnerProfile{}, Log: log, Now: dayNow(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
|
||
|
||
out := buf.String()
|
||
for _, want := range []string{
|
||
"level=INFO",
|
||
"source=services/channelbridge",
|
||
"group asked her to stay quiet",
|
||
"group=telegram",
|
||
"sender=9",
|
||
"sender_name=张三",
|
||
"until=2026-01-01T12:30:00.000Z",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Fatalf("INFO 日志缺少 %q;got:\n%s", want, out)
|
||
}
|
||
}
|
||
if strings.Contains(out, "减少文本输出量") {
|
||
t.Fatalf("日志打印了正文(design/18 §9.7 禁止):\n%s", out)
|
||
}
|
||
}
|
||
|
||
// loopMessagesContain 报告本回合的模型可见消息里是否出现了 want(用于断言
|
||
// 「被静默登记的消息确实进了上下文」,而不是只看落库记录)。
|
||
func loopMessagesContain(msgs []model.Message, want string) bool {
|
||
for _, m := range msgs {
|
||
for _, c := range m.Content {
|
||
if strings.Contains(c.Text, want) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// recentHistory 是 captureHistory 的读取侧补齐:Recent 返回本会话已登记的消息,
|
||
// 使 seedTurnTranscript 真能把「被静默登记」的那条话播进下一个回合的上下文。
|
||
type recentHistory struct {
|
||
captureHistory
|
||
}
|
||
|
||
func (h *recentHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
|
||
var out []history.Message
|
||
for _, m := range h.records() {
|
||
if m.SessionID == sessionID {
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
if n > 0 && len(out) > n {
|
||
out = out[len(out)-n:]
|
||
}
|
||
return out, nil
|
||
}
|