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 is a configurable LightJudge for the participation-engine tests: // it replays an ordered list of decisions (and optional per-call errors) and records // how often it was consulted plus the last input it saw. 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 returns a daytime (non-quiet) wall-clock source so the engine runs outside // the quiet window. func dayNow() func() time.Time { return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) } } // engageTestBridge builds a Bridge wired for the participation engine with the given // judge/config/clock/rand source. A zero-valued config is replaced by the defaults so // callers can override just the knob they care about. 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 } // default: never hit the probability trigger } 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 is a config with a 1h cooldown/activity and a small 2-message // activity window, so count-based window expiry is easy to drive deterministically. 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) // 触发②: naming 昔涟 without an @ routes the message into a cost-controlled 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) } // Not interested → registered but not answered. 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() // Cost-control red line: an untriggered, probability-missing message must NOT call // the light judge at all — no context is fed, no LLM turn, just registered silent. 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() // 触发③: an injected rand below the probability (0.05 < 0.10) routes the message // into PROBING even with no role-name mention. 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: "感兴趣"}, // probe → ACTIVE {WorthReply: true, Reason: "跟随"}, // follow-up 1 → still reply }} b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil) // msg1: role-name mention → probe → interested → ACTIVE (respond). 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: inside the window → follow-up review, still replies. 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: the 2-message window is now full → exit ACTIVE to IDLE. No trigger/probability // (rand 0.5) → silent, and crucially the judge is NOT consulted a third time. 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: "感兴趣"}, // probe → ACTIVE {Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 1 → no interest {Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 2 → threshold → exit }} 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) } // Two consecutive no-interest follow-ups, then a subsequent untriggered message // must no longer call the judge (she has dropped out of 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{} // default: interested (would reply if consulted) b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil) // 冷避: "别刷屏" is a negative attitude → COOLDOWN, and she is silent (registered only). 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) } // During cooldown, even a role-name mention is NOT answered (no proactive reply). 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) } // A direct @ still breaks cooldown (design/18 §5: 除非 @ 她). chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) if chat.sessionID != "sess-g" { t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID) } // After the cooldown timer elapses, a role-name mention re-engages (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: "感兴趣"}, // probe → 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) } // A negative attitude during ACTIVE parks her in COOLDOWN immediately (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 is a captureHistory that returns a fixed recent window, so the // engagement context renderer can be asserted. 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) } }) } }