Files
Cyrene_v2/internal/services/channelbridge/light_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00

464 lines
18 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"
"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"
)
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次,
// 并保留最后一次收到的输入(用于断言 inQuiet 真值确实传到了下游判定器)。
type stubLightJudge struct {
resp LightDecision
err error
calls int
last LightInput
}
func (s *stubLightJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++
s.last = in
return s.resp, s.err
}
// nonOwnerGroupMsg 返回一条来自成员发送者的第三方群消息,带
// 指定的提及。内容不做加工,便于测试自行控制信号。
func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
Content: content, ContentType: adapter.ContentText,
}
}
// nonOwnerSession 是非主人测试所用的受限(群聊 / 陌生人)会话
// 绑定:记忆只读、无主人。
func nonOwnerSession() session.Session {
return session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}
}
func TestNonOwnerSilencedRegisteredOnly(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 无 @/关键词/名字信号的非主人消息被静默(设计/12 §6 策略二):
// 它只登记到隔离会话日志,绝不驱动循环。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for a no-signal non-owner message: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("records = %d, want 1 (inbound registered, no assistant reply)", len(recs))
}
if recs[0].SessionID != "sess-g" || recs[0].Role != history.RoleUser || recs[0].Content != "hello all" {
t.Fatalf("registered record = %+v, want isolated sess-g user 'hello all'", recs[0])
}
}
func TestNonOwnerMentionResponds(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 被点名(提及)的非主人消息值得回复(策略二),因此它
// 针对隔离群会话驱动完整循环。
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g", chat.SessionID())
}
// 受限群上下文 → 第三方边界系统消息加上
// 用户消息(design/16 §4.6 §1),与 TestGroupMessageThirdPartySenderNameAndBoundary 一致。
if len(chat.Messages()) != 2 || chat.Messages()[1].Role != model.RoleUser {
t.Fatalf("loop messages = %+v, want boundary + user", chat.Messages())
}
if chat.Messages()[1].Content[0].Text != "hello" {
t.Fatalf("loop user text = %q, want undecorated 'hello'", chat.Messages()[1].Content[0].Text)
}
if !strings.Contains(chat.Messages()[0].Content[0].Text, "群「Gang」") {
t.Fatalf("origin/boundary system = %q, want group Gang", chat.Messages()[0].Content[0].Text)
}
}
// memberLaneBridge 构建一条非主人群消息用的桥接器:主人档案为空(不命中白名单 ⇒
// 走非主人分级),未装配参与度引擎与 LightJudge ⇒ 规则得不出结论时失败关闭为静默,
// 使「必不必回」只由规则判据决定,用例因此确定。
func memberLaneBridge(t *testing.T, chat *stubChatter, hist *captureHistory) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
return b
}
// TestMemberLaneAddressedToHerCriterion 钉住 design/18 §2 触发①(2026-10-13 修订)
// 在**成员车道**上的落地。2026-10-14 真机报障:群员互相 @ 的消息也被她必回
// (`@3889001246`、`@867587435` 两条),病灶就是 `ruleLightJudge` 拿
// 「`Mentions` 非空」当「在叫她」。判据现在与 owner 车道同一份
// (`ownerAddressedToMe`):**指向她本人**(@她 / @全体)才必回。
//
// 「必不必回」用「完整循环有没有被驱动」(chat.SessionID())判定;
// 「不回」还必须是**登记但不回**(历史里恰好一条 user 记录),而不是消息被丢掉。
//
// 变异自证(把判据换回旧的 `if len(msg.Mentions) > 0`):
// - 「@ 别人」一例立刻变红(旧实现把 @ 别人也当成在叫她,循环被驱动);
// - 「AddressedToMe 已置位、提及表为空」一例同样变红(旧实现只认提及表,
// 结构化字段被无视 ⇒ @全体 那种「字段到位但 at 段没落表」的情形丢掉必回)。
func TestMemberLaneAddressedToHerCriterion(t *testing.T) {
t.Parallel()
const selfID = "3443115524" // 她自己的 self_id(2026-10-14 报障现场的同一个号)
cases := []struct {
name string
msg func() adapter.UnifiedMessage
wantReply bool
wantRecords int
}{
{
name: "mentioning someone else (wired) is not addressed to her",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("/今日猪猪", adapter.Mention{UserID: "3889001246"})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf() // @ 的不是她 ⇒ false
return m
},
wantReply: false,
wantRecords: 1,
},
{
name: "addressed to her by name (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@她 在吗", adapter.Mention{UserID: selfID})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
name: "mention all (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 都来看一眼", adapter.Mention{UserID: "all"})
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
// 结构化字段是**主判据**:@全体 的标记到位而 at 段没落进提及表时,
// AddressedToMe 仍为 true,必回语义不得丢。
name: "addressed flag set without mention list still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 集合")
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
hist := &captureHistory{}
b := memberLaneBridge(t, chat, hist)
b.process(context.Background(), tc.msg())
if got := chat.SessionID() != ""; got != tc.wantReply {
t.Fatalf("循环被驱动 = %v, want %v(session=%q)", got, tc.wantReply, chat.SessionID())
}
if !tc.wantReply && len(hist.records()) != tc.wantRecords {
t.Fatalf("records = %d, want %d(只登记不回,不是丢掉)", len(hist.records()), tc.wantRecords)
}
})
}
}
// TestMemberLaneUnwiredChannelKeepsMentionFallback 钉住判据的后半句在**成员车道**上
// 同样成立:未接线渠道(SelfID 为空 ⇒ AddressedToMe 恒 false)上的群 @ 按**旧判据**
// 必回。「没有信号」绝不能被当成「没在对我说话」——否则 kook / webhook 等尚未填
// SelfID 的平台,群 @ 会被一并打死。这是 owner 车道 M11 reviewer 给死的写法,
// 本片只是把它接到成员车道上。
func TestMemberLaneUnwiredChannelKeepsMentionFallback(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
b := memberLaneBridge(t, chat, &captureHistory{})
m := nonOwnerGroupMsg("@别人 你好", adapter.Mention{UserID: "someone-else"})
// SelfID 留空 = 未接线 ⇒ AddressedToMe 恒 false(fail-closed)。
if m.AddressedToMe {
t.Fatalf("未接线渠道的 AddressedToMe = true, want false(fail-closed)")
}
b.process(context.Background(), m)
if chat.SessionID() != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.SessionID())
}
}
func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 高价值关键词(求助)即使没有 @ 也值得回复(策略二)。
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.SessionID())
}
}
// TestNonOwnerQuietHoursRulesDoNotSilenceGroup 钉住裁决②(主人 2026-10-05):
// **安静时段不再是规则层的硬静默**——群里没被 @、无关键词的普通消息在 02:00
// 仍会走到轻思考(LightJudge),由它决定回不回,而不是连判定器都不咨询就被
// 静默。变异验证:把 `if inQuiet { return verdictSilent }` 加回 ruleLightJudge,
// 本用例立刻变红(judge.calls 会是 0、循环不会跑)。
func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "夜里这场值得聊"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 无 @、无关键词、无主人显示名的群消息,落在安静时段内。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1(安静时段不得在规则层硬静默,应交给轻思考)", judge.calls)
}
// 安静时段作为**信号**必须是真的(而非硬编码 false)传到下游判定器。
if !judge.last.InQuietHours {
t.Fatalf("light judge InQuietHours = false, want true at 02:00(安静时段信号失真)")
}
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g(轻思考说回就该回)", chat.SessionID())
}
}
// TestNonOwnerQuietHoursMentionResponds 保留「安静时段不是硬性全静默」的正面口径:
// 被直接点名的群消息在 02:00 仍直接回,且**根本不咨询轻思考**(规则已能给结论)。
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略二:被直接点名的消息在安静时段仍会得到回复。
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.SessionID())
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(@ 是规则可直接判定的强信号)", judge.calls)
}
}
// TestNonOwnerQuietHoursKeywordResponds 补齐另一条强信号:命中高价值关键词的群消息
// 在 02:00 也直接回,同样不咨询轻思考。
func TestNonOwnerQuietHoursKeywordResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (keyword should break quiet-hours silence)", chat.SessionID())
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(高价值关键词是规则可直接判定的强信号)", judge.calls)
}
}
func TestOwnerUnaffectedInQuietHours(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
// 主人消息绝不被拦,即使处于安静时段(策略五只管
// 非主人流量):主人私聊仍路由到 Web 主会话
// 并回复。
b.process(context.Background(), directMessage())
if chat.SessionID() != history.MainSessionID {
t.Fatalf("owner loop session = %q, want %q", chat.SessionID(), history.MainSessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "主人好" {
t.Fatalf("owner reply = %+v, want '主人好'", ad.sends)
}
}
func TestLightJudgeConsultedWhenInconclusive(t *testing.T) {
t.Parallel()
// 白天无信号的消息对规则而言无结论,因此注入的
// LightJudge 获得一次判断机会(设计/12 §6 策略二:规则不确定才轻 LLM)。
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "值得回"}}
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (inconclusive rules escalated)", judge.calls)
}
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge said respond)", chat.SessionID())
}
}
func TestLightJudgeSilenceIsRespected(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不值得"}}
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
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 the judge said silence: session=%q", chat.SessionID())
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}
func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{err: context.DeadlineExceeded}
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 轻量判定器出错时失败关闭为静默(不打扰为第一原则)。
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran after judge error: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}