Files
Cyrene_v2/internal/services/channelbridge/engage_test.go
T
AskaEth 1ae9158250 test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)
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 全绿。
2026-10-24 23:18:45 +08:00

403 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"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/user"
)
// engageScriptJudge 是参与度引擎测试用的可配置 LightJudge:
// 它按顺序回放一组裁决(以及可选的逐次调用错误),并记录
// 被咨询的次数以及最近一次看到的输入。
type engageScriptJudge struct {
responses []LightDecision
errs []error
calls int
last LightInput
}
func (s *engageScriptJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++
s.last = in
i := s.calls - 1
if i < len(s.errs) && s.errs[i] != nil {
return LightDecision{}, s.errs[i]
}
if i < len(s.responses) {
return s.responses[i], nil
}
return LightDecision{Interesting: true, WorthReply: true, Reason: "默认参与"}, nil
}
// dayNow 返回白天(非安静)的墙上时钟源,使引擎运行在
// 安静窗口之外。
func dayNow() func() time.Time {
return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
}
// engageTestBridge 构建一个已接好参与度引擎的 Bridge,使用给定的
// 判定器/配置/时钟/随机源。零值配置会被替换为默认值,
// 使调用方只需覆盖自己关心的参数。
func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock func() time.Time, rng func() float64) (*Bridge, *stubChatter, *captureHistory) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
if cfg.Probability == 0 && cfg.ActivityWindow == 0 && cfg.ActivityMessages == 0 && cfg.Cooldown == 0 {
cfg = DefaultEngageConfig()
}
if rng == nil {
rng = func() float64 { return 0.5 } // 默认:永不命中概率触发
}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge, TriggerWords: []string{"昔涟", "Cyrene"},
PersonaPrompt: "你是昔涟。", Engage: cfg, EngageRand: rng, Now: clock,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b, chat, hist
}
// cfgShortWindow 是冷却/活跃时长 1 小时、活跃窗口仅 2 条消息
// 的配置,便于确定性地驱动按条数计的窗口过期。
func cfgShortWindow() EngageConfig {
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 2
cfg.Cooldown = time.Hour
return cfg
}
func TestEngageRoleNameMentionProbesAndResponds(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
// 触发②:没有 @ 但提到「昔涟」会把该消息送入受成本控制的 PROBING。
b.process(context.Background(), nonOwnerGroupMsg("大家在聊昔涟的事"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (role-name mention → PROBING)", judge.calls)
}
if !judge.last.Engage {
t.Fatal("judge input Engage = false, want true for a participation judgment")
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (interested → ACTIVE reply)", chat.sessionID)
}
}
func TestEngageRoleNameMentionNotInterestedSilences(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{{Interesting: false, WorthReply: false, Reason: "无感"}}}
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("提一句昔涟而已"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after no-interest, session = %q", chat.sessionID)
}
// 不感兴趣 → 只登记、不回复。
if len(hist.records()) != 1 || hist.records()[0].Content != "提一句昔涟而已" {
t.Fatalf("records = %+v, want 1 registered user message", hist.records())
}
}
func TestEngageAvoidTopicStaysIdle(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{{AvoidTopic: true, Reason: "敏感话题"}}}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟怎么办"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after avoidTopic, session = %q", chat.sessionID)
}
}
func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) {
t.Parallel()
// 成本控制红线:未触发、也未命中概率的消息绝不调用
// 轻量判定器——不喂上下文、不走 LLM 轮次,只登记并静默。
judge := &engageScriptJudge{}
b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0 (untriggered must not call LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran without a trigger, session = %q", chat.sessionID)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered only)", len(hist.records()))
}
}
func TestEngageProbabilityTriggerResponds(t *testing.T) {
t.Parallel()
// 触发③:注入的随机值低于概率阈值(0.05 < 0.10),即使没有
// 角色名提及也会把消息送入 PROBING。
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.05 })
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (probability hit → PROBING)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge interested → reply)", chat.sessionID)
}
}
func TestEngageProbabilityMissSilences(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.5 })
b.process(context.Background(), nonOwnerGroupMsg("有件事想问问"))
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0 (probability miss → no LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran on probability miss, session = %q", chat.sessionID)
}
}
func TestEngageStateMachineActivityWindowExpiry(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE
{WorthReply: true, Reason: "跟随"}, // 跟进 1 → 仍然回复
}}
b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil)
// msg1:角色名提及 → 探测 → 感兴趣 → ACTIVE(回复)。
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if chat.sessionID != "sess-g" || judge.calls != 1 {
t.Fatalf("after probe: session=%q calls=%d, want sess-g/1", chat.sessionID, judge.calls)
}
// msg2:仍在窗口内 → 跟进复审,仍回复。
b.process(context.Background(), nonOwnerGroupMsg("周末去哪玩"))
if chat.sessionID != "sess-g" || judge.calls != 2 {
t.Fatalf("after follow-up: session=%q calls=%d, want sess-g/2", chat.sessionID, judge.calls)
}
// msg3:2 条消息的窗口已满 → 退出 ACTIVE 回到 IDLE。无触发/无概率
// (随机值 0.5)→ 静默,且关键是判定器不会第三次被咨询。
b.process(context.Background(), nonOwnerGroupMsg("接着聊"))
if judge.calls != 2 {
t.Fatalf("judge calls = %d, want 2 (window expired; no follow-up, no re-trigger)", judge.calls)
}
}
func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) {
t.Parallel()
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 100
cfg.Cooldown = time.Hour
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE
{Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 1 → 无兴趣
{Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 2 → 达到阈值 → 退出
}}
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if judge.calls != 1 || chat.sessionID != "sess-g" {
t.Fatalf("probe: calls=%d session=%q, want 1/sess-g", judge.calls, chat.sessionID)
}
// 连续两次无兴趣跟进之后,随后一条未触发的消息
// 绝不再调用判定器(她已退出 ACTIVE)。
b.process(context.Background(), nonOwnerGroupMsg("说说别的"))
b.process(context.Background(), nonOwnerGroupMsg("再说一点"))
if judge.calls != 3 {
t.Fatalf("judge calls = %d, want 3 (two no-interest follow-ups)", judge.calls)
}
b.process(context.Background(), nonOwnerGroupMsg("最后随便"))
if judge.calls != 3 {
t.Fatalf("judge calls = %d, want 3 (exited ACTIVE, no re-trigger)", judge.calls)
}
}
func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
t.Parallel()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
judge := &engageScriptJudge{} // 默认:感兴趣(若被咨询会回复)
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil)
// 冷避:「别刷屏」是负面态度 → COOLDOWN,她保持静默(只登记)。
b.process(context.Background(), nonOwnerGroupMsg("昔涟别刷屏了"))
if judge.calls != 0 {
t.Fatalf("judge calls = %d, want 0 (negative attitude → cooldown, no LLM)", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after negative attitude, session = %q", chat.sessionID)
}
// 冷却期间,即使是角色名提及也不回复(不做主动回复)。
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟"))
if judge.calls != 0 || chat.sessionID != "" {
t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID)
}
// M18(主人 2026-10-14 裁决「给」):冷却期内**连 @ 也不回** ——
// 冷避优先于「@她必回」,群成员的一句「少说点」不被随后的 @ 作废。
// (旧口径「@ 打断冷却」正是本片修掉的偏离;见 design/18 §5 与
// engage_coldshoulder_test.go 的端到端用例。)
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "" || judge.calls != 0 {
t.Fatalf("mention during cooldown: session=%q calls=%d, want \"\"/0 (@ no longer breaks the cold shoulder)",
chat.sessionID, judge.calls)
}
// 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。
now = now.Add(2 * time.Hour)
chat.sessionID = ""
b.process(context.Background(), nonOwnerGroupMsg("再提昔涟"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (cooldown expired → re-probe)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("session = %q, want sess-g (re-engaged after cooldown)", chat.sessionID)
}
}
func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) {
t.Parallel()
cfg := DefaultEngageConfig()
cfg.ActivityWindow = time.Hour
cfg.ActivityMessages = 100
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE
}}
b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil)
b.process(context.Background(), nonOwnerGroupMsg("聊昔涟"))
if chat.sessionID != "sess-g" {
t.Fatalf("probe: session=%q, want sess-g", chat.sessionID)
}
// ACTIVE 期间出现负面态度会立刻把她按进 COOLDOWN(design/18 §5)。
b.process(context.Background(), nonOwnerGroupMsg("别说了太吵"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (negative attitude must not consult the judge)", judge.calls)
}
b.process(context.Background(), nonOwnerGroupMsg("再聊昔涟"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1 (cooldown suppresses re-engagement)", judge.calls)
}
}
func TestEngageJudgeReceivesContextAndPersonaPrompt(t *testing.T) {
t.Parallel()
hist := &contextHistory{
recent: []history.Message{
{SessionID: "sess-g", Role: history.RoleUser, Content: "有人知道昔涟吗", SenderName: "张三"},
{SessionID: "sess-g", Role: history.RoleAssistant, Content: "在呢", SenderName: ""},
},
}
judge := &engageScriptJudge{}
chat := &stubChatter{res: &loop.Result{Text: "参与"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge, TriggerWords: []string{"昔涟"},
PersonaPrompt: "你是昔涟。", Engage: DefaultEngageConfig(), EngageRand: func() float64 { return 0.5 },
Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟嘛"))
if judge.calls != 1 {
t.Fatalf("judge calls = %d, want 1", judge.calls)
}
if judge.last.Engage != true || judge.last.PersonaPrompt != "你是昔涟。" {
t.Fatalf("judge input Engage=%v prompt=%q, want true/你是昔涟。", judge.last.Engage, judge.last.PersonaPrompt)
}
if judge.last.RecentContext == "" || judge.last.TriggerWords[0] != "昔涟" {
t.Fatalf("judge context=%q triggerWords=%v, want non-empty context and 昔涟", judge.last.RecentContext, judge.last.TriggerWords)
}
}
// contextHistory 是返回固定近期窗口的 captureHistory,便于
// 断言参与度上下文渲染器。
type contextHistory struct {
captureHistory
recent []history.Message
}
func (c *contextHistory) Recent(context.Context, string, int) ([]history.Message, error) {
return c.recent, nil
}
func TestParseEngageDecision(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want LightDecision
err bool
}{
{name: "pure", in: `{"interested":true,"worthReply":true,"avoidTopic":false,"reason":"想聊"}`, want: LightDecision{Interesting: true, WorthReply: true, Reason: "想聊"}},
{name: "fenced", in: "```json\n{\"interested\":false,\"worthReply\":false,\"avoidTopic\":false}\n```", want: LightDecision{}},
{name: "prose_wrapped", in: "分析一下:{\"interested\":true,\"worthReply\":false,\"avoidTopic\":true,\"reason\":\"敏感\"} 完毕", want: LightDecision{Interesting: true, WorthReply: false, AvoidTopic: true, Reason: "敏感"}},
{name: "malformed", in: "not json at all", err: true},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseEngageDecision(tt.in)
if tt.err {
if err == nil {
t.Fatalf("parseEngageDecision(%q) want error, got nil", tt.in)
}
return
}
if err != nil {
t.Fatalf("parseEngageDecision(%q) error: %v", tt.in, err)
}
if got.Interesting != tt.want.Interesting || got.WorthReply != tt.want.WorthReply ||
got.AvoidTopic != tt.want.AvoidTopic || got.Reason != tt.want.Reason {
t.Fatalf("parsed = %+v, want %+v", got, tt.want)
}
})
}
}