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