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 纳入该断言。
This commit is contained in:
AskaEth committed 2026-10-24 22:24:14 +08:00
1 parent da6a365222
commit 7d9b2132bb
18 files changed
+407 -242

No files matched your search

@@ -81,6 +81,18 @@ func candidateIDs(cands []ingestCandidate) []string {
return out
}
// measureGateBudget 是墙钟实测用例显式给闸的**总墙钟预算**。
//
// 这个用例要量的是「闸真扫真判」时写入路径多花多少墙钟,不是「闸会不会超预算」——
// 后者由 preinsert_gate_test.go 的预算/fail-open 用例守。旧形状沿用默认
// defaultReviewGateBudget(150ms):负载一高,一次候选召回(SQLite 查询 +
// context.WithTimeout)就会超时 ⇒ 闸 fail-open ⇒ GateFailed++/GateChecked 少一条,
// 用例的「每轮都真扫真判且不合并」前置断言因此炸(本轮空载也 2/3 复现)。
//
// 本机实测该场景单次闸耗时是**毫秒级**(p95 约 30ms);5s 是该量级的 ~150 倍,
// 只放开预算这条闸门,被测量的工作量一字不改,因此 p50/p95/批均值仍然可比。
const measureGateBudget = 5 * time.Second
// TestIngestGate_Add墙钟实测_闸开vs关 给出**报告里要那个数字**:闸开与闸关时
// `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。
//
@@ -131,7 +143,7 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
base := tidyTestTime(t)
var reviewer *PreInsertReviewer
if gateOn {
reviewer = newGateReviewer(t, store, ReviewConfig{}, nil)
reviewer = newGateReviewer(t, store, ReviewConfig{GateBudget: measureGateBudget}, nil)
store.AttachIngestReviewer(reviewer)
}
for i := 0; i < seeded; i++ {
@@ -149,8 +161,10 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
}
total := time.Since(batchStart)
if gateOn {
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateChecked != seeded+rounds {
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并):%+v", s)
// 「实测场景成立」= 每一轮都真扫真判、不合并、也**没有 fail-open**。预算本身由
// measureGateBudget 放开,因此 GateFailed 非零只可能是候选源不可用等真异常。
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateFailed != 0 || s.GateChecked != seeded+rounds {
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并、且不 fail-open):%+v", s)
}
}
return lat, total
@@ -18,10 +18,19 @@ import (
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
t.Parallel()
// worker 卡住 ⇒ 排进去的那一轮不会把槽取走,`pending` 因此可确定地断言。
// 让自排轮卡在 Think 里:worker 因此**忙**着,后面排进去的那一轮不会被取走,
// `pending` 才能确定地断言。
//
// 🔴 卡住 Think 只能让 Running 恒真,**不足以**占住待跑槽:worker 是先 take(把槽取空)
// 再进 Think 的。旧写法只卡 Think 就断言「排一轮后 Pending=true」,实际是在赌
// 「WakeNow 返回得比 worker 取槽快」—— -race 下 worker 常常先跑赢,Pending 已经是
// false(本轮实测 3/3 必红)。因此这里改成:第一轮先让 worker 忙起来,第二轮才断言
// 待跑槽被占用 —— 断言口径(快照如实报告待跑槽)一字未改,只是把它放到可确定的相位上。
w := newWakeThinker()
w.holdSelfWake = make(chan struct{})
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
// 放行卡住的那一轮,好让 Stop 的 t.Cleanup 能排空 worker。
defer close(w.holdSelfWake)
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
before := mustStatus(t, b)
@@ -54,21 +63,38 @@ func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
if !receipt.Accepted {
t.Fatalf("收据 = %+v, want Accepted", receipt)
}
// 等这一轮真的被 worker 取走并卡进 Think(= 槽已空、worker 已忙):这是下一段的**前置件**,
// 等不到说明 worker 没被调度(那是另一个失败,由此处明确报出)。
w.waitFor(t, 2, 5*time.Second)
after := mustStatus(t, b)
if after.Used != receipt.Used || after.Used != 1 {
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used)
}
if !after.Pending {
t.Fatalf("排入后待跑槽应报告占用:%+v", after)
// worker 正忙 ⇒ 这一轮留在待跑槽里:快照必须如实报告 Pending。
pending, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "占住待跑槽"})
if err != nil {
t.Fatalf("WakeNow 占槽: %v", err)
}
if !pending.Accepted {
t.Fatalf("收据 = %+v, want Accepted(worker 正忙,槽是空的)", pending)
}
occupied := mustStatus(t, b)
if !occupied.Pending {
t.Fatalf("排入后待跑槽应报告占用:%+v", occupied)
}
if occupied.Used != pending.Used || occupied.Used != 2 {
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 2(同一口径,槽满=额度用尽)",
occupied.Used, pending.Used)
}
// 第二次撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
// 撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil {
t.Fatalf("WakeNow 合并: %v", err)
}
merged := mustStatus(t, b)
if merged.Used != 1 {
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used)
if merged.Used != 2 {
t.Fatalf("合并后 Used = %d, want 2(合并不额外占额度,停在 Limit)", merged.Used)
}
if merged.Limit != 2 {
t.Fatalf("合并后 Limit = %d, want 2", merged.Limit)
+25 -5
View File
@@ -480,23 +480,43 @@ func TestWakeNow_ZeroLimitFallsBackToDefault(t *testing.T) {
}
// TestSelfWakeGate_WindowExpiry 覆盖「窗口过期后额度回来」。session.RateLimiter 内部
// 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此这里
// 用极小窗口 + 真实短等,只取下界判断(等过窗口必放行),不依赖上界精度。
// 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此窗口
// **无法注入假时钟** —— 这里只能把余量放大到与真实机器负载相称,断言口径不变:
// 窗口内第二次必被拒、等过窗口必放行且 Used 回到 1。
//
// 为什么是 6s(旧值 120ms):
// - 「窗口内第二次」这条断言要求「第一次受理 → worker 真的进入两轮(waitFor)→ 第二次
// 调用」整段落在窗口内;waitFor 那一段是纯调度等待,73 个测试包并行抢核时会被拉长,
// 120ms 一被拉长就必红(本轮其他证人实测红;本机 12 核上 30~60 次带载复跑未复现,
// 所以这里按**余量**加固而不是拿复现驱动)。窗口取 6s ⇒ 给 waitFor 的 4s 上界留下
// 2s 余量:哪怕 waitFor 用满上界,也仍在窗口内,失败点因此只会是「worker 没被调度」
// 而不是一条含义被负载改写的窗口断言。
// - 到期等待不再是盲睡 3×窗口(放大后那是 18s 白等):以**第一次受理时刻**为基准,
// 等到「窗口 + 250ms 结算余量」,既保留「等过窗口必放行」的下界语义,又不浪费墙钟。
func TestSelfWakeGate_WindowExpiry(t *testing.T) {
t.Parallel()
const window = 120 * time.Millisecond
const (
window = 6 * time.Second
// scheduleWait 是「worker 已被调度」这个前置件的上界,必须小于 window。
scheduleWait = 4 * time.Second
// expirySettle 是窗口到期后的结算余量(RateLimiter 用 t.After(cutoff) 严格比较)。
expirySettle = 250 * time.Millisecond
)
w := newWakeThinker()
b, _ := startSelfWake(t, Config{SelfWakeLimit: 1, SelfWakeWindow: window}, w)
firstAt := time.Now()
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "第一次"}); err != nil || !got.Accepted {
t.Fatalf("第一次收据 = %+v err = %v, want 排上", got, err)
}
w.waitFor(t, 2, 3*time.Second)
w.waitFor(t, 2, scheduleWait)
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口内第二次"}); err != nil || got.Accepted {
t.Fatalf("窗口内第二次收据 = %+v err = %v, want 被上限拒绝", got, err)
}
time.Sleep(3 * window)
if wait := window + expirySettle - time.Since(firstAt); wait > 0 {
time.Sleep(wait)
}
got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口过了"})
if err != nil {
t.Fatalf("窗口过后 WakeNow: %v", err)
@@ -59,7 +59,13 @@ func (f *fakeAdapter) Capabilities() adapter.Capabilities
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
// stubChatter 记录传给 agent 循环的会话 id 与消息。
//
// 🔴 字段一律经 mu 访问:Run 跑在**车道 worker** 上,测试 goroutine 在另一侧读记录 ——
// 「等一会儿再看循环有没有被驱动」这类负向断言之间**没有** happens-before。无锁时,
// 只要那一轮真被驱动了(正是负向断言要抓的失败形态),-race 就会把它报成 DATA RACE,
// 栈落在替身字段上、看起来像被测代码的问题;加锁不改并发度,只把记录的读写变成有序的。
type stubChatter struct {
mu sync.Mutex
sessionID string
messages []model.Message
runCtx context.Context
@@ -68,14 +74,60 @@ type stubChatter struct {
}
func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = sessionID
s.messages = msgs
s.runCtx = ctx
return s.res, s.err
}
// SessionID 返回最近一轮的会话 id(空串 = 循环从未被驱动)。
func (s *stubChatter) SessionID() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessionID
}
// Messages 返回最近一轮的消息副本:调用方拿到的切片不与替身共享,
// 锁外的读写因此不会互相污染。
func (s *stubChatter) Messages() []model.Message {
s.mu.Lock()
defer s.mu.Unlock()
if s.messages == nil {
return nil
}
return append([]model.Message(nil), s.messages...)
}
// RunCtx 返回最近一轮的 ctx(turn id 接线用例靠它取回合标识)。
func (s *stubChatter) RunCtx() context.Context {
s.mu.Lock()
defer s.mu.Unlock()
return s.runCtx
}
// setResult 换掉本轮返回值:Run 之外改它同样要过锁。
func (s *stubChatter) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.res = res
}
// clearSessionID 只清「循环被驱动过」这个记录,与旧用例里的 `chat.sessionID = ""`
// 逐字等价(消息与 ctx 照旧保留):让接下来的负向断言重新生效。
func (s *stubChatter) clearSessionID() {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = ""
}
// stubSessions 记录入站消息并返回固定会话。
//
// 与 stubChatter 同理:GetOrCreate 在车道 worker 上被调,测试 goroutine 读记录,
// 两侧没有 happens-before —— 记录必须过锁。
type stubSessions struct {
mu sync.Mutex
msg adapter.UnifiedMessage
owner *user.OwnerProfile
sess session.Session
@@ -83,12 +135,36 @@ type stubSessions struct {
}
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
s.mu.Lock()
s.msg = msg
s.owner = owner
if s.err != nil {
return session.Session{}, s.err
err, sess := s.err, s.sess
s.mu.Unlock()
if err != nil {
return session.Session{}, err
}
return s.sess, nil
return sess, nil
}
// Msg 返回最近一次解析的入站消息。
func (s *stubSessions) Msg() adapter.UnifiedMessage {
s.mu.Lock()
defer s.mu.Unlock()
return s.msg
}
// Owner 返回最近一次解析的主人档案(未进白名单时为 nil)。
func (s *stubSessions) Owner() *user.OwnerProfile {
s.mu.Lock()
defer s.mu.Unlock()
return s.owner
}
// Sess 返回替身固定给出的会话。
func (s *stubSessions) Sess() session.Session {
s.mu.Lock()
defer s.mu.Unlock()
return s.sess
}
// directMessage 是一条主人私聊消息:platform telegram / 发送者 100。它是否是主人由**白名单**
@@ -132,18 +208,18 @@ func TestProcessRunsPipelineAndReplies(t *testing.T) {
b.process(ctx, directMessage())
if sess.msg.SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
if sess.Msg().SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.Msg().SenderID)
}
// 主人私聊被路由进唯一的 Web 主会话(design/16 §4.4)。
if chat.sessionID != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
// IM 平台上的主人私聊会镜像到主会话。来源由
// 最前面的一行系统上下文承载,用户文本保持不加装饰
// (design/16 §4.6.1):元数据绝不进入用户的自由文本通道。
var sys, userText string
for _, m := range chat.messages {
for _, m := range chat.Messages() {
if m.Role == model.RoleSystem {
sys = m.Content[0].Text
} else if m.Role == model.RoleUser {
@@ -177,8 +253,8 @@ func TestProcessSkipsEmptyText(t *testing.T) {
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
if chat.SessionID() != "" {
t.Errorf("loop ran for empty content, session = %q", chat.SessionID())
}
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
@@ -203,7 +279,7 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
Content: "hello all",
ContentType: adapter.ContentText,
}
chat.res = &loop.Result{Text: "group ack"}
chat.setResult(&loop.Result{Text: "group ack"})
b.process(context.Background(), msg)
@@ -323,8 +399,8 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
t.Fatalf("persisted notifications = %d, want 2", len(persisted))
}
// 且循环针对主会话运行,而不是隔离的 sess-1。
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
}
@@ -413,8 +489,8 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Fatalf("record[%d] Mode = %q, want chat", i, rec.Mode)
}
}
if chat.sessionID != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.SessionID())
}
// 每次隔离写入也会交给广播钩子,使 gateway 能为 IM
// 适配器日志页发出 `channel_persisted`——但消息本身
@@ -463,8 +539,8 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) {
// 被拉黑的群消息绝不进入 agent 循环,也绝不发送
// 回复;拒绝原因暴露给审计轨迹。
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("blacklisted message sent %d replies, want 0", len(ad.sends))
@@ -498,8 +574,8 @@ func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) {
// 来自不在允许白名单中的用户的私聊消息被丢弃;主人
// 绝不受影响(这里是陌生人)。
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("non-whitelisted user sent %d replies, want 0", len(ad.sends))
@@ -530,13 +606,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
// 循环看到最前面有一条第三方边界系统消息,加上已带
// [第三方 · 张三] 前缀的用户消息(历史中保留原始
// 内容,因此不丢失来源数据)。
if len(chat.messages) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
if len(chat.Messages()) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
}
if chat.messages[0].Role != model.RoleSystem || !strings.Contains(chat.messages[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0])
if chat.Messages()[0].Role != model.RoleSystem || !strings.Contains(chat.Messages()[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.Messages()[0])
}
user := chat.messages[1]
user := chat.Messages()[1]
// 第三方说话者由结构化的 SenderName 字段承载,用户文本保持
// 不加装饰(design/16 §4.6.1);来源/边界放在系统消息里。
if user.SenderName != "张三" || user.Content[0].Text != "hello all" {
@@ -575,18 +651,18 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
b.process(context.Background(), msg)
// 受限上下文 → 仍会注入第三方边界系统消息。
if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
if len(chat.Messages()) != 2 || chat.Messages()[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
}
// 主人的消息不会被标为第三方,也不携带 SenderName;用户
// 文本保持不加装饰,群的来源放在系统消息里
// (design/16 §4.6.1)。
user := chat.messages[1]
user := chat.Messages()[1]
if user.SenderName != "" || user.Content[0].Text != "我在群里" {
t.Fatalf("owner group message = %+v, want undecorated text, no SenderName, no [第三方] prefix", user)
}
if !strings.Contains(chat.messages[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.messages[0].Content[0].Text)
if !strings.Contains(chat.Messages()[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.Messages()[0].Content[0].Text)
}
// 主人在群里的发言仍落在隔离会话(不是 Web 主会话)。
recs := hist.records()
@@ -603,8 +679,8 @@ func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
}
}
@@ -106,14 +106,14 @@ func TestColdShoulderCooldownMatrix(t *testing.T) {
b := coldShoulderBridge(t, chat, hist, func() time.Time { return now })
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("未能进入冷却:循环被驱动 = %q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("未能进入冷却:循环被驱动 = %q", chat.SessionID())
}
before := len(hist.records())
b.process(context.Background(), tc.msg())
if got := chat.sessionID != ""; got != tc.wantReply {
if got := chat.SessionID() != ""; got != tc.wantReply {
t.Fatalf("冷却期内循环被驱动 = %v, want %v(%s)", got, tc.wantReply, tc.bite)
}
// 静默 ≠ 丢弃:本条必须已登记(被放行的那条在回合里另有一份落库)。
@@ -140,16 +140,16 @@ func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) {
b.process(context.Background(), coldShoulderMention("减少文本输出量")) // 进冷却
now = now.Add(defaultCooldownDuration + time.Minute) // 推过冷却
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), nonOwnerGroupMsg("服务报错了"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过后的高价值关键词未回:session = %q, want sess-g", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("冷却过后的高价值关键词未回:session = %q, want sess-g", chat.SessionID())
}
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), nonOwnerGroupMsg("今晚吃点什么"))
if chat.sessionID != "" {
t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.SessionID())
}
}
@@ -102,8 +102,8 @@ func TestColdShoulderPrecedesAddressedToMe(t *testing.T) {
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.SessionID())
}
recs := hist.records()
if len(recs) != 1 || recs[0].Content != "减少文本输出量" {
@@ -137,21 +137,21 @@ func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) {
}
b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少"))
if chat.sessionID != "" {
t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.SessionID())
}
// 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "" {
t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID)
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.SessionID())
}
}
@@ -172,15 +172,15 @@ func TestColdShoulderNormalMentionNotCooled(t *testing.T) {
}
b.process(context.Background(), coldShoulderMention("晚上有空吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.SessionID())
}
// 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), coldShoulderMention("在不在"))
if chat.sessionID != "sess-g" {
t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.SessionID())
}
}
@@ -214,8 +214,8 @@ func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
Content: "闭嘴", ContentType: adapter.ContentText,
})
if chat.sessionID != "sess-p" {
t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID)
if chat.SessionID() != "sess-p" {
t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.SessionID())
}
})
@@ -237,8 +237,8 @@ func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
m.Content = "闭嘴"
b.process(context.Background(), m)
if chat.sessionID != history.MainSessionID {
t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID)
if chat.SessionID() != history.MainSessionID {
t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.SessionID(), history.MainSessionID)
}
})
@@ -251,8 +251,8 @@ func TestColdShoulderGroupOnlyBoundaries(t *testing.T) {
b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"}))
if chat.sessionID != "sess-og" {
t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.SessionID())
}
})
}
@@ -279,8 +279,8 @@ func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) {
}
b.process(context.Background(), coldShoulderMention("减少文本输出量"))
if chat.sessionID != "" {
t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("循环被驱动 = %q, want \"\"", chat.SessionID())
}
// ① 静默那条已被登记(不是被丢掉)。
@@ -297,11 +297,11 @@ func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) {
// ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。
now = now.Add(defaultCooldownDuration + time.Minute)
b.process(context.Background(), coldShoulderMention("在吗"))
if chat.sessionID != "sess-g" {
t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.SessionID())
}
if !loopMessagesContain(chat.messages, "减少文本输出量") {
t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages)
if !loopMessagesContain(chat.Messages(), "减少文本输出量") {
t.Fatalf("被静默登记的消息没进上下文:%+v", chat.Messages())
}
}
@@ -75,7 +75,7 @@ func TestEngageStateIsPerConversation(t *testing.T) {
if judge.calls != 1 {
t.Fatalf("B 群判定被 A 群 COOLDOWN 挤占,judge.calls = %d, want 1", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("B 群判定为想聊后应驱动 loop,session = %q", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("B 群判定为想聊后应驱动 loop,session = %q", chat.SessionID())
}
}
+30 -30
View File
@@ -90,8 +90,8 @@ func TestEngageRoleNameMentionProbesAndResponds(t *testing.T) {
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (interested → ACTIVE reply)", chat.SessionID())
}
}
@@ -106,8 +106,8 @@ func TestEngageRoleNameMentionNotInterestedSilences(t *testing.T) {
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 chat.SessionID() != "" {
t.Fatalf("loop ran after no-interest, session = %q", chat.SessionID())
}
// 不感兴趣 → 只登记、不回复。
if len(hist.records()) != 1 || hist.records()[0].Content != "提一句昔涟而已" {
@@ -126,8 +126,8 @@ func TestEngageAvoidTopicStaysIdle(t *testing.T) {
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)
if chat.SessionID() != "" {
t.Fatalf("loop ran after avoidTopic, session = %q", chat.SessionID())
}
}
@@ -144,8 +144,8 @@ func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) {
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 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()))
@@ -165,8 +165,8 @@ func TestEngageProbabilityTriggerResponds(t *testing.T) {
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge interested → reply)", chat.SessionID())
}
}
@@ -181,8 +181,8 @@ func TestEngageProbabilityMissSilences(t *testing.T) {
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)
if chat.SessionID() != "" {
t.Fatalf("loop ran on probability miss, session = %q", chat.SessionID())
}
}
@@ -197,14 +197,14 @@ func TestEngageStateMachineActivityWindowExpiry(t *testing.T) {
// 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)
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)
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。无触发/无概率
@@ -230,8 +230,8 @@ func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) {
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)
if judge.calls != 1 || chat.SessionID() != "sess-g" {
t.Fatalf("probe: calls=%d session=%q, want 1/sess-g", judge.calls, chat.SessionID())
}
// 连续两次无兴趣跟进之后,随后一条未触发的消息
// 绝不再调用判定器(她已退出 ACTIVE)。
@@ -259,36 +259,36 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) {
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)
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)
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 = ""
chat.clearSessionID()
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "" || judge.calls != 0 {
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)
chat.SessionID(), judge.calls)
}
// 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。
now = now.Add(2 * time.Hour)
chat.sessionID = ""
chat.clearSessionID()
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("session = %q, want sess-g (re-engaged after cooldown)", chat.SessionID())
}
}
@@ -304,8 +304,8 @@ func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) {
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("probe: session=%q, want sess-g", chat.SessionID())
}
// ACTIVE 期间出现负面态度会立刻把她按进 COOLDOWN(design/18 §5)。
+10 -10
View File
@@ -123,8 +123,8 @@ func TestRecordEvent_T1只计数不进上下文_正常(t *testing.T) {
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("心跳驱动了 loop:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("心跳驱动了 loop:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("心跳被写进了登记面(会话历史):%+v", recs)
@@ -174,8 +174,8 @@ func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) {
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("unknown 事件进了真会话:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("unknown 事件进了真会话:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("unknown 事件被登记进会话历史:%+v", recs)
@@ -225,8 +225,8 @@ func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) {
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("出站回声驱动了 loop:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("出站回声驱动了 loop:session=%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("出站回声被登记进会话历史:%+v", recs)
@@ -266,8 +266,8 @@ func TestRecordEvent_未接线时入站照常_异常(t *testing.T) {
b.processFrom(ctx, groupNoticeMessage(), ad)
if chat.sessionID != "sess-g1" {
t.Fatalf("未接线改变了入站行为:高价值事件没进真会话(session=%q)", chat.sessionID)
if chat.SessionID() != "sess-g1" {
t.Fatalf("未接线改变了入站行为:高价值事件没进真会话(session=%q)", chat.SessionID())
}
stats := b.EventFallback()
if stats.Frames != 1 {
@@ -316,8 +316,8 @@ func TestRecordEvent_落库失败仍不中断入站_异常(t *testing.T) {
if store.records != 1 {
t.Fatalf("留存面被调用 %d 次, want 1", store.records)
}
if chat.sessionID != "sess-g1" {
t.Fatalf("落库失败中断了入站(高价值事件没进真会话):session=%q", chat.sessionID)
if chat.SessionID() != "sess-g1" {
t.Fatalf("落库失败中断了入站(高价值事件没进真会话):session=%q", chat.SessionID())
}
if stats := b.EventFallback(); stats.Frames != 1 {
t.Fatalf("落库失败的帧没有进降级计数:%+v", stats)
@@ -290,8 +290,8 @@ func TestInboundFileMediaBlacklistedNotIngested(t *testing.T) {
if names := docDirNames(t, uploads); len(names) != 0 {
t.Fatalf("被拉黑的消息把附件落库了:%v", names)
}
if chat.sessionID != "" || chat.messages != nil {
t.Fatalf("被拉黑的消息仍驱动了循环:session=%q", chat.sessionID)
if chat.SessionID() != "" || chat.Messages() != nil {
t.Fatalf("被拉黑的消息仍驱动了循环:session=%q", chat.SessionID())
}
if len(rejected) != 1 || rejected[0] != "group_denied" {
t.Fatalf("拒绝原因 = %v, want [group_denied]", rejected)
@@ -329,8 +329,8 @@ func TestInboundGroupFileStaysInIsolatedSession(t *testing.T) {
if names := docDirNames(t, uploads); len(names) != 1 {
t.Fatalf("群聊附件落盘文件数 = %v, want 1(第三方附件同样走共享存储)", names)
}
if chat.sessionID != "sess-g" {
t.Fatalf("循环会话 = %q, want 隔离会话 sess-g", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("循环会话 = %q, want 隔离会话 sess-g", chat.SessionID())
}
recs := hist.records()
if len(recs) != 2 {
@@ -343,7 +343,7 @@ func TestInboundGroupFileStaysInIsolatedSession(t *testing.T) {
}
// 用户消息在循环里带着文档部件(图片/文档只进用户回合)。
var docParts int
for _, m := range chat.messages {
for _, m := range chat.Messages() {
if m.Role == model.RoleSystem {
continue
}
@@ -177,8 +177,8 @@ func TestGroupMemberIsAlwaysLabeledInTheThirdPartyLane(t *testing.T) {
nameless := namedGroupMessage("9", "", adapter.Actor{Role: adapter.RoleMember})
b.process(context.Background(), nameless)
if len(chat.messages) != 2 || chat.messages[1].SenderName != "9" {
t.Fatalf("loop messages = %+v, want the nameless member labeled with its uid", chat.messages)
if len(chat.Messages()) != 2 || chat.Messages()[1].SenderName != "9" {
t.Fatalf("loop messages = %+v, want the nameless member labeled with its uid", chat.Messages())
}
recs := hist.records()
if len(recs) != 2 || recs[0].SenderName != "9" {
@@ -203,8 +203,8 @@ func TestOwnerGroupMessageCarriesNoThirdPartyLabel(t *testing.T) {
}
b.process(context.Background(), namedGroupMessage("100", "主人", adapter.Actor{Role: adapter.RoleOwner}))
if len(chat.messages) != 2 || chat.messages[1].SenderName != "" {
t.Fatalf("loop messages = %+v, want the owner's message unlabeled", chat.messages)
if len(chat.Messages()) != 2 || chat.Messages()[1].SenderName != "" {
t.Fatalf("loop messages = %+v, want the owner's message unlabeled", chat.Messages())
}
recs := hist.records()
if len(recs) != 2 || recs[0].SenderName != "" {
+31 -31
View File
@@ -64,8 +64,8 @@ func TestNonOwnerSilencedRegisteredOnly(t *testing.T) {
// 它只登记到隔离会话日志,绝不驱动循环。
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)
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 {
@@ -94,19 +94,19 @@ func TestNonOwnerMentionResponds(t *testing.T) {
// 针对隔离群会话驱动完整循环。
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)
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 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 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)
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)
}
}
@@ -132,7 +132,7 @@ func memberLaneBridge(t *testing.T, chat *stubChatter, hist *captureHistory) *Br
// 「`Mentions` 非空」当「在叫她」。判据现在与 owner 车道同一份
// (`ownerAddressedToMe`):**指向她本人**(@她 / @全体)才必回。
//
// 「必不必回」用「完整循环有没有被驱动」(chat.sessionID)判定;
// 「必不必回」用「完整循环有没有被驱动」(chat.SessionID())判定;
// 「不回」还必须是**登记但不回**(历史里恰好一条 user 记录),而不是消息被丢掉。
//
// 变异自证(把判据换回旧的 `if len(msg.Mentions) > 0`):
@@ -208,8 +208,8 @@ func TestMemberLaneAddressedToHerCriterion(t *testing.T) {
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 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)
@@ -237,8 +237,8 @@ func TestMemberLaneUnwiredChannelKeepsMentionFallback(t *testing.T) {
b.process(context.Background(), m)
if chat.sessionID != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.SessionID())
}
}
@@ -257,8 +257,8 @@ func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
// 高价值关键词(求助)即使没有 @ 也值得回复(策略二)。
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.SessionID())
}
}
@@ -292,8 +292,8 @@ func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g(轻思考说回就该回)", chat.SessionID())
}
}
@@ -316,8 +316,8 @@ func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
// 策略二:被直接点名的消息在安静时段仍会得到回复。
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 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)
@@ -342,8 +342,8 @@ func TestNonOwnerQuietHoursKeywordResponds(t *testing.T) {
}
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 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)
@@ -371,8 +371,8 @@ func TestOwnerUnaffectedInQuietHours(t *testing.T) {
// 并回复。
b.process(context.Background(), directMessage())
if chat.sessionID != history.MainSessionID {
t.Fatalf("owner loop session = %q, want %q", chat.sessionID, history.MainSessionID)
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)
@@ -400,8 +400,8 @@ func TestLightJudgeConsultedWhenInconclusive(t *testing.T) {
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)
if chat.SessionID() != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge said respond)", chat.SessionID())
}
}
@@ -425,8 +425,8 @@ func TestLightJudgeSilenceIsRespected(t *testing.T) {
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 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()))
@@ -454,8 +454,8 @@ func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) {
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 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()))
+23 -23
View File
@@ -101,8 +101,8 @@ func TestNoticeIgnorableRegisteredOnly(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.SessionID())
}
recs := hist.records()
if len(recs) != 1 {
@@ -133,14 +133,14 @@ func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) {
if reviewed != 0 {
t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed)
}
if chat.sessionID != "sess-g1" {
t.Fatalf("高价值通知没进真会话:session=%q", chat.sessionID)
if chat.SessionID() != "sess-g1" {
t.Fatalf("高价值通知没进真会话:session=%q", chat.SessionID())
}
lines := systemLines(chat.messages)
lines := systemLines(chat.Messages())
if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) {
t.Fatalf("限定注入的系统行 = %v", lines)
}
for _, m := range chat.messages {
for _, m := range chat.Messages() {
if m.Role == model.RoleUser {
t.Errorf("系统通知被当成用户消息注入:%v", m.Content)
}
@@ -167,8 +167,8 @@ func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "sess-g1" {
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.sessionID)
if chat.SessionID() != "sess-g1" {
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.SessionID())
}
if probe.RawData != nil || len(probe.Media) != 0 {
t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media))
@@ -179,7 +179,7 @@ func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content)))
}
lines := systemLines(chat.messages)
lines := systemLines(chat.Messages())
if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") {
t.Fatalf("限定注入的系统行 = %v", lines)
}
@@ -200,8 +200,8 @@ func TestNoticeReviewDroppedStaysSilent(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.SessionID())
}
if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" {
t.Errorf("被剔噪后未登记:%+v", recs)
@@ -229,8 +229,8 @@ func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) {
if !hadDeadline {
t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)")
}
if chat.sessionID != "" {
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.SessionID())
}
}
@@ -246,8 +246,8 @@ func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未装配审查模型时未静默:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("未装配审查模型时未静默:%q", chat.SessionID())
}
}
@@ -261,10 +261,10 @@ func TestNoticeUnparsableFailsSafeSilent(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.SessionID())
}
for _, m := range chat.messages {
for _, m := range chat.Messages() {
if strings.Contains(m.Content[0].Text, "notice_type") {
t.Errorf("原始载荷进了上下文:%v", m.Content)
}
@@ -284,8 +284,8 @@ func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.SessionID())
}
}
@@ -299,8 +299,8 @@ func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) {
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("无归一化器时未静默:%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("无归一化器时未静默:%q", chat.SessionID())
}
}
@@ -353,7 +353,7 @@ func TestChatMessageIsNotAReviewSubject(t *testing.T) {
if ad.calls != 0 {
t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls)
}
for _, line := range systemLines(chat.messages) {
for _, line := range systemLines(chat.Messages()) {
if strings.HasPrefix(line, noticeSystemPrefix) {
t.Errorf("普通聊天消息被当成系统通知注入:%q", line)
}
@@ -60,7 +60,7 @@ func newOwnerLaneBridge(t *testing.T, chat *stubChatter, sess *stubSessions, his
// TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied 钉住默认口径「不对她说就不开口」:
// 主人群里一句不对她说的普通消息**只进上下文**(落进该群隔离会话)、**不驱动回合**。
// 变异自证:把 inbound.go 的 owner 短路恢复(`!actor.IsOwner() && !notice.Applicable`)⇒
// 本用例立刻变红(循环会被驱动、chat.sessionID 非空)。
// 本用例立刻变红(循环会被驱动、chat.SessionID() 非空)。
func TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied(t *testing.T) {
t.Parallel()
@@ -70,8 +70,8 @@ func TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied(t *testing.T) {
b.process(context.Background(), ownerLaneGroupMsg("大家好啊,我今天在家"))
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("不对她说的主人群消息驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("不对她说的主人群消息驱动了循环:session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("不对她说的主人群消息被回复了:%+v", ad.sends)
@@ -125,8 +125,8 @@ func TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled(t *testing.T) {
b.process(context.Background(), tc.msg())
if chat.sessionID != "sess-og" {
t.Fatalf("指向她的主人群消息被旋钮挡住了:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("指向她的主人群消息被旋钮挡住了:session=%q", chat.SessionID())
}
if len(ad.sends) != 1 || ad.sends[0].Content != "在的" {
t.Fatalf("指向她的主人群消息未回复:%+v", ad.sends)
@@ -151,8 +151,8 @@ func TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion(t *testing.T) {
// SelfID 留空 = 未接线;AddressedToMe 恒 false(fail-closed)。
b.process(context.Background(), m)
if chat.sessionID != "sess-og" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.SessionID())
}
})
@@ -166,8 +166,8 @@ func TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion(t *testing.T) {
m.AddressedToMe = m.AddressedToSelf() // 提及的不是她 ⇒ false
b.process(context.Background(), m)
if chat.sessionID != "" {
t.Fatalf("已接线渠道里 @ 别人被当成指向她:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("已接线渠道里 @ 别人被当成指向她:session=%q", chat.SessionID())
}
})
}
@@ -189,15 +189,15 @@ func TestOwnerLaneThrottleEffectiveAndRestorable(t *testing.T) {
t.Fatalf("SetOwnerLanePolicy: %v", err)
}
b.process(context.Background(), ownerLaneGroupMsg("大家今天都在忙什么"))
if chat.sessionID != "sess-og" {
t.Fatalf("打开开关后非指向消息仍不回:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("打开开关后非指向消息仍不回:session=%q", chat.SessionID())
}
// 2) 主人说「少说点」→ 落成结构化配置(降频),这一条自己也不再驱动回复。
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
if chat.sessionID != "" {
t.Fatalf("降频后仍回:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("降频后仍回:session=%q", chat.SessionID())
}
if p := b.OwnerLanePolicy("qq-obv11", "g1"); p.ReplyToUnaddressed {
t.Fatalf("「少说点」未把旋钮落到保守档:%+v", p)
@@ -207,10 +207,10 @@ func TestOwnerLaneThrottleEffectiveAndRestorable(t *testing.T) {
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil {
t.Fatalf("SetOwnerLanePolicy restore: %v", err)
}
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("我再说一句普通的话"))
if chat.sessionID != "sess-og" {
t.Fatalf("恢复旋钮后仍不回:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("恢复旋钮后仍不回:session=%q", chat.SessionID())
}
// 4) 可观测:旋钮变化留下带 source 的日志。
@@ -235,33 +235,33 @@ func TestOwnerLaneReplyGapThrottlesUnaddressedReplies(t *testing.T) {
// 第一条非指向消息:过闸、回。
b.process(context.Background(), ownerLaneGroupMsg("第一句普通的话"))
if chat.sessionID != "sess-og" {
t.Fatalf("第一条非指向消息未回:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("第一条非指向消息未回:session=%q", chat.SessionID())
}
// 间隔内第二条:被频率闸挡下,只进上下文。
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("第二条普通的话"))
if chat.sessionID != "" {
t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.SessionID())
}
// 间隔内**指向她**的消息:仍必回(硬保留不受频率闸约束)。
chat.sessionID = ""
chat.clearSessionID()
addressed := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"})
addressed.SelfID = "bot-10001"
addressed.AddressedToMe = addressed.AddressedToSelf()
b.process(context.Background(), addressed)
if chat.sessionID != "sess-og" {
t.Fatalf("间隔内指向她的消息被频率闸挡住:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("间隔内指向她的消息被频率闸挡住:session=%q", chat.SessionID())
}
// 时钟推进过间隔:非指向消息恢复。
now = now.Add(301 * time.Second)
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("过了五分钟再说一句"))
if chat.sessionID != "sess-og" {
t.Fatalf("过了间隔后非指向消息仍未回:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("过了间隔后非指向消息仍未回:session=%q", chat.SessionID())
}
}
@@ -293,12 +293,12 @@ func TestOwnerLanePolicySeamReadsAndPersists(t *testing.T) {
// 读接缝:外部配置决定该群档位 ⇒ 非指向消息也回。
b.process(context.Background(), ownerLaneGroupMsg("普通的一句话"))
if chat.sessionID != "sess-og" {
t.Fatalf("读接缝未生效:session=%q", chat.sessionID)
if chat.SessionID() != "sess-og" {
t.Fatalf("读接缝未生效:session=%q", chat.SessionID())
}
// 写接缝:主人说「少说点」⇒ 写入被外部存储收到。
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
if len(saved) != 1 || savedGroup != "g1" || saved[0].ReplyToUnaddressed {
t.Fatalf("写接缝未收到结构化降频:saved=%+v group=%q", saved, savedGroup)
@@ -328,8 +328,8 @@ func TestOwnerLanePrivateChatsUnaffected(t *testing.T) {
b.process(context.Background(), directMessage())
if chat.sessionID != history.MainSessionID || len(ad.sends) != 1 {
t.Fatalf("主人私聊被 owner 车道旋钮影响:session=%q sends=%+v", chat.sessionID, ad.sends)
if chat.SessionID() != history.MainSessionID || len(ad.sends) != 1 {
t.Fatalf("主人私聊被 owner 车道旋钮影响:session=%q sends=%+v", chat.SessionID(), ad.sends)
}
})
@@ -346,8 +346,8 @@ func TestOwnerLanePrivateChatsUnaffected(t *testing.T) {
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("非主人群消息的既有分级被 owner 车道影响:session=%q", chat.sessionID)
if chat.SessionID() != "sess-g" {
t.Fatalf("非主人群消息的既有分级被 owner 车道影响:session=%q", chat.SessionID())
}
})
}
@@ -82,13 +82,13 @@ func TestStrangerPrivateChatRespondsIncludingQuietHours(t *testing.T) {
msg := strangerDirectMsg("777", "在吗,普通的一句话")
actor := session.ResolveActor(msg, b.owner)
if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.sess, ad); !ok {
if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.Sess(), ad); !ok {
t.Fatalf("shouldRespond = false (%s), want true(陌生人私聊一律正常聊天)", reason)
}
b.process(context.Background(), msg)
if chat.sessionID != "sess-s" {
t.Fatalf("loop session = %q, want the isolated sess-s", chat.sessionID)
if chat.SessionID() != "sess-s" {
t.Fatalf("loop session = %q, want the isolated sess-s", chat.SessionID())
}
if len(ad.sends) != 1 || ad.sends[0].Content != "你好呀" {
t.Fatalf("stranger replies = %+v, want one text reply", ad.sends)
@@ -111,19 +111,19 @@ func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
b, sess, ad := newStrangerBridge(t, chat, hist, at, log)
for i := 0; i < adapter.FlowDirectDefault; i++ {
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), strangerDirectMsg("777", "第 "+itoa(i)+" 句"))
if chat.sessionID != "sess-s" {
t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.sessionID)
if chat.SessionID() != "sess-s" {
t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.SessionID())
}
}
// 第 N+1 条超限:不回,只登记,且有可见日志。
before := len(hist.records())
chat.sessionID = ""
chat.clearSessionID()
b.process(context.Background(), strangerDirectMsg("777", "刷屏第 N+1 句"))
if chat.sessionID != "" {
t.Fatalf("超限消息驱动了循环:session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("超限消息驱动了循环:session=%q", chat.SessionID())
}
if got := len(hist.records()) - before; got != 1 {
t.Fatalf("超限消息新增登记 %d 条,want 1(只登记不回)", got)
@@ -136,7 +136,7 @@ func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
// 另一条发送者:额度独立,第一条照常回答。
other := strangerDirectMsg("888", "我是另一个人")
if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.sess, ad); !ok {
if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.Sess(), ad); !ok {
t.Fatalf("另一个发送者被同一额度挤占:reason=%s", reason)
}
}
@@ -168,7 +168,7 @@ func TestStrangerPrivateSecondTurnAnsweredNotThrottled(t *testing.T) {
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID == "" {
if chat.SessionID() == "" {
t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回")
}
if got := len(hist.records()); got != 2 {
@@ -201,8 +201,8 @@ func TestNonOwnerLowValueSystemEventRegisteredOnly(t *testing.T) {
msg.Sender.Role = adapter.RoleSystem
b.process(context.Background(), msg)
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if got := len(hist.records()); got != 1 {
t.Fatalf("records = %d, want 1(只登记,不回复)", got)
@@ -71,7 +71,7 @@ func TestOwnerPrivateTurnIDPersistedAndPaired(t *testing.T) {
// 流帧与历史行**同源**:loop ctx 上挂的 turn id 必须就是落库的那个值,
// 否则前端按帧分桶与按行分桶会得到两个答案。
if got := loop.TurnIDFrom(chat.runCtx); got != recs[0].TurnID {
if got := loop.TurnIDFrom(chat.RunCtx()); got != recs[0].TurnID {
t.Fatalf("loop ctx turn id = %q, want %q (same as persisted rows)", got, recs[0].TurnID)
}
}
@@ -124,8 +124,8 @@ func TestSilencedNonOwnerStillHasNoTurnID(t *testing.T) {
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" {
t.Fatalf("loop ran for a silenced message: session=%q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("loop ran for a silenced message: session=%q", chat.SessionID())
}
recs := hist.records()
if len(recs) != 1 {
@@ -250,8 +250,8 @@ func TestTurnReply_EmptyInboundContentStillDropped(t *testing.T) {
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Fatalf("空内容消息驱动了循环, session = %q", chat.sessionID)
if chat.SessionID() != "" {
t.Fatalf("空内容消息驱动了循环, session = %q", chat.SessionID())
}
if len(ad.sends) != 0 {
t.Fatalf("空内容消息发了 %d 条消息, want 0", len(ad.sends))
+34 -5
View File
@@ -20,9 +20,38 @@ import (
// 带闸门的工具包装、以及等待与断言助手。夹具只搭台子,不写断言语义 —— 读共享 / 写独占的
// 判定一律由**真实的**进程内占用表给出(tool.NewFileClaimer),不拿自造的假表去测。
// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限:足够宽
// 以免依赖机器速度,又足够短以免偶发死锁拖成整包超时。
const claimProbeTimeout = 2 * time.Second
// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限。
//
// 这些等待**不含时间语义**:runLeg 起的那一路只要被调度到,就会立刻 close(entered)
// 或把结果写进 chan ——「等多久」与被测行为无关,唯一要防的是真死锁时永远等下去。
// 旧值 2s 却是一个**真实时间预算**:73 个测试包并行抢核时,一个刚起的 goroutine 被
// 饿上几秒并非不可想象(本轮其他证人据此报过假红;本机 12 核上 4 路并发 + 全树并行
// 压测未复现,故按余量加固,不拿复现驱动)。
//
// 现取值:默认 60s,即旧值的 30 倍余量(与「整树并行」量级相称:正常调度是微秒级,
// 需要 60s 才轮到调度意味着机器已经不可用);同时由 claimProbeWait 按 t.Deadline
// 的剩余预算收缩,真死锁仍以**断言失败**暴露,不会拖成整包超时。
const claimProbeTimeout = 60 * time.Second
// claimProbeFloor 是探针等待的下限:即使已经接近 t.Deadline,也至少等这么久再判失败
// (「断言失败 + 原因」比「整包超时」可读得多)。
const claimProbeFloor = 5 * time.Second
// claimProbeWait 返回本次探针的等待上限:默认 claimProbeTimeout,接近 t.Deadline 时
// 收缩到「剩余预算的一半」,使等待永远发生在包超时之内。
func claimProbeWait(t *testing.T) time.Duration {
t.Helper()
wait := claimProbeTimeout
if dl, ok := t.Deadline(); ok {
if half := time.Until(dl) / 2; half < wait {
wait = half
}
}
if wait < claimProbeFloor {
wait = claimProbeFloor
}
return wait
}
// legResult 是一路调用的结果。
type legResult struct {
@@ -228,7 +257,7 @@ func awaitEnter(t *testing.T, ch <-chan struct{}) {
t.Helper()
select {
case <-ch:
case <-time.After(claimProbeTimeout):
case <-time.After(claimProbeWait(t)):
t.Fatal("第一路没有进入执行")
}
}
@@ -239,7 +268,7 @@ func awaitDone(t *testing.T, ch <-chan legResult) legResult {
select {
case res := <-ch:
return res
case <-time.After(claimProbeTimeout):
case <-time.After(claimProbeWait(t)):
t.Fatal("这一路没有结束")
return legResult{}
}