From da6a3652228706a032baeb4e06bc19805e2121bb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 22:03:13 +0800 Subject: [PATCH 1/4] tower: snapshot of uncommitted base checkout changes (worktree wt-33) --- Docs/api-client.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Docs/api-client.md b/Docs/api-client.md index f796239..4f66b10 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -159,7 +159,9 @@ | **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | | **合计** | — | **81** | `14 + 27 + 40`(v0.92) | -**管理路由总数 = 14 + 27 + 40 = 81 条。**(v0.92 起;`14 + 25 + 40 = 79` 是 v0.85–v0.91 的读数) +**管理路由总数 = 18 + 43 + 43 = 104 条(2026-10-24 实测,`routes.go` 逐条枚举)。** +> 🔴 **本节计数曾长期偏低,已按实测改正**:`14 + 25 + 40 = 79`(v0.85–v0.91)与 `14 + 27 + 40 = 81`(v0.92)**都对不上代码**。2026-10-24 由复审逐条解析 `routes.go` 得到:**base(含 M22 前)`total=102` → 分组 `18 / 41 / 43`**;**合入 M23(coalesce 两端点)后 `total=104` → 分组 `18 / 43 / 43`**(`writeAuth` 41 → **43**,新增 `GET/PUT /api/v1/admin/channels/coalesce`)。三组口径:`viewerAuth`=18 / `writeAuth`=43 / `ownerAuth`=43,另有 `anyAuth`=3、`unwrapped`=1 未计入本表三段。 +> ⚠️ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的条目尚未逐条登记**——本节计数已按实测更新,但**形状级条目仍待补**,见 §7.1。 > **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。 From 7d9b2132bb12a0fa35f994feed45342397653c85 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 22:24:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(testhealth):=20=E6=9B=BF=E8=BA=AB?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E5=8A=A0=E9=94=81=20+=20=E5=A2=99=E9=92=9F?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E7=A1=AC=E5=BA=A6=EF=BC=88M33=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四处测试健康债务,只改 *_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 纳入该断言。 --- .../memory/preinsert_gate_measure_test.go | 20 ++- .../backthink/selfwake_status_test.go | 38 ++++- internal/services/backthink/selfwake_test.go | 30 +++- .../channelbridge/channelbridge_test.go | 138 ++++++++++++++---- .../engage_coldshoulder_exception_test.go | 18 +-- .../channelbridge/engage_coldshoulder_test.go | 50 +++---- .../channelbridge/engage_session_test.go | 4 +- .../services/channelbridge/engage_test.go | 60 ++++---- .../services/channelbridge/events_test.go | 20 +-- .../channelbridge/inbound_documents_test.go | 10 +- .../channelbridge/inbound_speaker_test.go | 8 +- internal/services/channelbridge/light_test.go | 62 ++++---- .../services/channelbridge/notice_test.go | 46 +++--- .../services/channelbridge/ownerlane_test.go | 70 ++++----- .../services/channelbridge/stranger_test.go | 26 ++-- .../channelbridge/turnid_wiring_test.go | 6 +- .../services/channelbridge/turnreply_test.go | 4 +- plugins/tools/io/file_claims_rig_test.go | 39 ++++- 18 files changed, 407 insertions(+), 242 deletions(-) diff --git a/internal/runtime/memory/preinsert_gate_measure_test.go b/internal/runtime/memory/preinsert_gate_measure_test.go index 3ac1943..076a0b9 100644 --- a/internal/runtime/memory/preinsert_gate_measure_test.go +++ b/internal/runtime/memory/preinsert_gate_measure_test.go @@ -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 diff --git a/internal/services/backthink/selfwake_status_test.go b/internal/services/backthink/selfwake_status_test.go index 1bd3ac1..595368a 100644 --- a/internal/services/backthink/selfwake_status_test.go +++ b/internal/services/backthink/selfwake_status_test.go @@ -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) diff --git a/internal/services/backthink/selfwake_test.go b/internal/services/backthink/selfwake_test.go index ca7ccfc..e2204fb 100644 --- a/internal/services/backthink/selfwake_test.go +++ b/internal/services/backthink/selfwake_test.go @@ -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) diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index 6a215bb..d2f87da 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -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) } } diff --git a/internal/services/channelbridge/engage_coldshoulder_exception_test.go b/internal/services/channelbridge/engage_coldshoulder_exception_test.go index 63cdb06..f30bfec 100644 --- a/internal/services/channelbridge/engage_coldshoulder_exception_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_exception_test.go @@ -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()) } } diff --git a/internal/services/channelbridge/engage_coldshoulder_test.go b/internal/services/channelbridge/engage_coldshoulder_test.go index 5fac222..51a023f 100644 --- a/internal/services/channelbridge/engage_coldshoulder_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_test.go @@ -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()) } } diff --git a/internal/services/channelbridge/engage_session_test.go b/internal/services/channelbridge/engage_session_test.go index d1e7bca..75076de 100644 --- a/internal/services/channelbridge/engage_session_test.go +++ b/internal/services/channelbridge/engage_session_test.go @@ -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()) } } diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go index 4aa317f..5a2d37a 100644 --- a/internal/services/channelbridge/engage_test.go +++ b/internal/services/channelbridge/engage_test.go @@ -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)。 diff --git a/internal/services/channelbridge/events_test.go b/internal/services/channelbridge/events_test.go index 46eff5a..e7ff71f 100644 --- a/internal/services/channelbridge/events_test.go +++ b/internal/services/channelbridge/events_test.go @@ -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) diff --git a/internal/services/channelbridge/inbound_documents_test.go b/internal/services/channelbridge/inbound_documents_test.go index 4123618..1285912 100644 --- a/internal/services/channelbridge/inbound_documents_test.go +++ b/internal/services/channelbridge/inbound_documents_test.go @@ -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 } diff --git a/internal/services/channelbridge/inbound_speaker_test.go b/internal/services/channelbridge/inbound_speaker_test.go index 80c55c7..92d96fa 100644 --- a/internal/services/channelbridge/inbound_speaker_test.go +++ b/internal/services/channelbridge/inbound_speaker_test.go @@ -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 != "" { diff --git a/internal/services/channelbridge/light_test.go b/internal/services/channelbridge/light_test.go index 57d8660..1ce9a16 100644 --- a/internal/services/channelbridge/light_test.go +++ b/internal/services/channelbridge/light_test.go @@ -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())) diff --git a/internal/services/channelbridge/notice_test.go b/internal/services/channelbridge/notice_test.go index 0bc4438..62f14c7 100644 --- a/internal/services/channelbridge/notice_test.go +++ b/internal/services/channelbridge/notice_test.go @@ -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) } diff --git a/internal/services/channelbridge/ownerlane_test.go b/internal/services/channelbridge/ownerlane_test.go index 06623e6..964aa9f 100644 --- a/internal/services/channelbridge/ownerlane_test.go +++ b/internal/services/channelbridge/ownerlane_test.go @@ -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()) } }) } diff --git a/internal/services/channelbridge/stranger_test.go b/internal/services/channelbridge/stranger_test.go index 6a6b73e..f4ffb81 100644 --- a/internal/services/channelbridge/stranger_test.go +++ b/internal/services/channelbridge/stranger_test.go @@ -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) diff --git a/internal/services/channelbridge/turnid_wiring_test.go b/internal/services/channelbridge/turnid_wiring_test.go index b677561..4b60bc0 100644 --- a/internal/services/channelbridge/turnid_wiring_test.go +++ b/internal/services/channelbridge/turnid_wiring_test.go @@ -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 { diff --git a/internal/services/channelbridge/turnreply_test.go b/internal/services/channelbridge/turnreply_test.go index 49da203..4b4c26f 100644 --- a/internal/services/channelbridge/turnreply_test.go +++ b/internal/services/channelbridge/turnreply_test.go @@ -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)) diff --git a/plugins/tools/io/file_claims_rig_test.go b/plugins/tools/io/file_claims_rig_test.go index bb04e89..567c90b 100644 --- a/plugins/tools/io/file_claims_rig_test.go +++ b/plugins/tools/io/file_claims_rig_test.go @@ -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{} } From 1ae9158250c95c60769b8d6b482ca2ead4299170 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 23:18:45 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(channelbridge):=20=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=A4=B9=E5=85=B7=E6=8C=89=E6=88=AA=E6=AD=A2=E6=97=B6=E5=88=BB?= =?UTF-8?q?=E6=8E=A8=E9=92=9F=20+=20=E5=8E=BB=E6=8E=89=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=AF=81=E5=AE=9E=E7=9A=84=E6=9B=BF=E8=BA=AB=E9=94=81=EF=BC=88?= =?UTF-8?q?M33=20r1=20=E5=A4=8D=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态): - coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d, 推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并 按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、 绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。 - 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle): 时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。 - sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker 放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。 - fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致 推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。 - 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库; 准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设); stale 用例两条都投完再推时钟(车道不会在两条之间退休)。 P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器, 退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径 (b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑 (含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁 并补 -race 用例。 证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮 18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿; 全树 go build/vet/test -count=1 全绿。 --- .../channelbridge/channelbridge_test.go | 144 +++----- .../channelbridge/coalesce_admission_test.go | 73 +++-- .../coalesce_continuation_test.go | 8 +- .../channelbridge/coalesce_fixture_test.go | 308 +++++++++++++++--- .../channelbridge/coalesce_owner_test.go | 2 +- .../services/channelbridge/coalesce_test.go | 18 +- .../engage_coldshoulder_exception_test.go | 18 +- .../channelbridge/engage_coldshoulder_test.go | 50 +-- .../channelbridge/engage_session_test.go | 4 +- .../services/channelbridge/engage_test.go | 60 ++-- .../services/channelbridge/events_test.go | 20 +- .../channelbridge/inbound_documents_test.go | 10 +- .../channelbridge/inbound_speaker_test.go | 8 +- internal/services/channelbridge/lane_test.go | 18 + internal/services/channelbridge/light_test.go | 62 ++-- .../services/channelbridge/notice_test.go | 46 +-- .../services/channelbridge/ownerlane_test.go | 70 ++-- .../services/channelbridge/stranger_test.go | 26 +- .../channelbridge/turnid_wiring_test.go | 6 +- .../services/channelbridge/turnreply_test.go | 4 +- 20 files changed, 584 insertions(+), 371 deletions(-) diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index d2f87da..1ad0041 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -60,12 +60,13 @@ func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, erro // stubChatter 记录传给 agent 循环的会话 id 与消息。 // -// 🔴 字段一律经 mu 访问:Run 跑在**车道 worker** 上,测试 goroutine 在另一侧读记录 —— -// 「等一会儿再看循环有没有被驱动」这类负向断言之间**没有** happens-before。无锁时, -// 只要那一轮真被驱动了(正是负向断言要抓的失败形态),-race 就会把它报成 DATA RACE, -// 栈落在替身字段上、看起来像被测代码的问题;加锁不改并发度,只把记录的读写变成有序的。 +// 不加锁是**有意的**:用到它的 21 个用例全部走同步便捷路径 +// (`b.process → processFrom → runBatch → Loop.Run`,见 inbound.go 的 processFrom 注释), +// 写与读发生在同一个 goroutine 上,没有跨 goroutine 的 happens-before 缺口 +// (M33 实测:`-race` 数十次跑、含把锁删掉的变异,DATA RACE 恒为 0)。 +// 🔴 若将来有用例把它放上车道(laneWorker)再读这些字段,**必须**加锁(sync.Mutex) +// 并补一条 -race 用例 —— 届时的竞态是真的,不能只靠运气。 type stubChatter struct { - mu sync.Mutex sessionID string messages []model.Message runCtx context.Context @@ -74,60 +75,17 @@ 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 —— 记录必须过锁。 +// 与 stubChatter 同理:只在同步路径上被调用,因此不加锁;若将来从车道 worker 触碰, +// 必须补锁 + 补一条 -race 用例。 type stubSessions struct { - mu sync.Mutex msg adapter.UnifiedMessage owner *user.OwnerProfile sess session.Session @@ -135,36 +93,12 @@ 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 - err, sess := s.err, s.sess - s.mu.Unlock() - if err != nil { - return session.Session{}, err + if s.err != nil { + return session.Session{}, s.err } - 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 + return s.sess, nil } // directMessage 是一条主人私聊消息:platform telegram / 发送者 100。它是否是主人由**白名单** @@ -208,18 +142,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 { @@ -253,8 +187,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)) @@ -279,7 +213,7 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) { Content: "hello all", ContentType: adapter.ContentText, } - chat.setResult(&loop.Result{Text: "group ack"}) + chat.res = &loop.Result{Text: "group ack"} b.process(context.Background(), msg) @@ -399,8 +333,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) } } @@ -489,8 +423,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`——但消息本身 @@ -539,8 +473,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)) @@ -574,8 +508,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)) @@ -606,13 +540,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" { @@ -651,18 +585,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() @@ -679,8 +613,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) } } diff --git a/internal/services/channelbridge/coalesce_admission_test.go b/internal/services/channelbridge/coalesce_admission_test.go index 4d0af21..b9f9698 100644 --- a/internal/services/channelbridge/coalesce_admission_test.go +++ b/internal/services/channelbridge/coalesce_admission_test.go @@ -46,6 +46,17 @@ func coalesceOutboundReceipt(platform, groupID string) adapter.UnifiedMessage { return msg } +// latestDuration 取请求时长序列里**最新**的一个。 +// +// 用例关心的是「刚发的那条消息之后 worker 续的臂」,而不是历史第几条:臂的次数取决于 +// worker 什么时候把消息收进本批(调度决定),按下标取会引入时序假设。 +func latestDuration(d []time.Duration) time.Duration { + if len(d) == 0 { + return 0 + } + return d[len(d)-1] +} + // waitTimerCalls 等到定时器工厂至少被请求 n 次,返回到目前为止的**请求时长序列**。 func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration { t.Helper() @@ -60,22 +71,43 @@ func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration { return nil } -// fireLatest 按**最后一个**待定定时器自己的时长推进时钟并触发它。 +// fireLatest 把注入时钟推进到**最近一次**请求的截止时刻并触发它。 // -// 为什么是最后一个:窗口被重置/续臂时,旧的定时器通道留在那里(不会被触发), +// 为什么是最近一次:窗口被重置/续臂时,旧的定时器通道留在那里(再没人监听), // 真正决定发车时刻的是**最近一次**请求的那个。 -func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock) { +// +// 为什么按**截止时刻**推进而不是「再推一个请求时长」:worker 可能已经在这段时间里 +// 重新续过臂(例如一条系统通知落在窗口中途),把时长再累加一次会让时钟越过绝对上限, +// 循环头于是走「到点直接发车、不 takeAll」的分支;按截止时刻推进只表示「时间走到了 +// 它约定的那个点」。 +// +// 为什么要重试:登记与触发之间隔着 worker 的一次循环 —— 万一它刚续了新臂,被触发的 +// 那个通道就没人接(时钟已经推了,却没人醒)。这里在 done() 未成立、仍有待触发定时器时 +// 继续触发,直到 worker 真的发车;断言口径由调用方守。 +func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock, done func() bool) { tb.Helper() - t.mu.Lock() - if len(t.pending) == 0 { + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + t.mu.Lock() + if len(t.pending) == 0 { + t.mu.Unlock() + if done != nil && done() { + return + } + time.Sleep(time.Millisecond) + continue + } + timer := t.pending[len(t.pending)-1] + t.pending = t.pending[:len(t.pending)-1] t.mu.Unlock() - tb.Fatalf("no pending coalesce timer to fire") + clock.advanceTo(timer.at) + timer.ch <- timer.at + if done != nil && done() { + return + } + time.Sleep(time.Millisecond) } - timer := t.pending[len(t.pending)-1] - t.pending = t.pending[:len(t.pending)-1] - t.mu.Unlock() - clock.advance(timer.d) - timer.ch <- clock.Now() + tb.Fatalf("fireLatest: 3s 内没有把窗口推到发车(calls=%d)", t.callCount()) } // TestCoalesceActionFrameDoesNotResetWindow 断言**准入在窗口等待阶段就生效**: @@ -103,12 +135,14 @@ func TestCoalesceActionFrameDoesNotResetWindow(t *testing.T) { // 🔴 关键断言(状态,不是结果):下一次请求的**剩余**时长仍是 500ms。 // 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。 - d := waitTimerCalls(t, f, 2) - if d[1] != 500*time.Millisecond { - t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d[1]) + // 断言的是**最新一次**请求(= 这条帧之后 worker 续的臂)。旧写法取 d[1], + // 隐含「帧之前只续过一次臂」这个时序假设:worker 何时把 m1 收进本批、何时续臂 + // 都是调度决定的,于是 d[1] 有时是 m1 的那次续臂(2s),用例假红。 + if d := latestDuration(waitTimerCalls(t, f, 2)); d != 500*time.Millisecond { + t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d) } - f.timers.fireLatest(t, f.clock) + f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 }) f.waitRuns(t, 1) f.waitIdle(t) @@ -146,7 +180,7 @@ func TestCoalesceSystemNoticeDoesNotJoinBatch(t *testing.T) { t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d[1]) } - f.timers.fireLatest(t, f.clock) + f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 }) f.waitRuns(t, 1) f.waitIdle(t) @@ -215,10 +249,11 @@ func TestCoalesceChatStillResetsWindow(t *testing.T) { } f.clock.advance(1500 * time.Millisecond) f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) - if d := waitTimerCalls(t, f, 2); d[1] != 2000*time.Millisecond { - t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d[1]) + // 同 action-frame 用例:断言最新一次请求(本消息触发的续臂),不假设此前的臂数。 + if d := latestDuration(waitTimerCalls(t, f, 2)); d != 2000*time.Millisecond { + t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d) } - f.timers.fireLatest(t, f.clock) + f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 }) f.waitRuns(t, 1) if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 { t.Fatalf("batch user messages = %v, want both chat messages", got) diff --git a/internal/services/channelbridge/coalesce_continuation_test.go b/internal/services/channelbridge/coalesce_continuation_test.go index f87eb3c..298e154 100644 --- a/internal/services/channelbridge/coalesce_continuation_test.go +++ b/internal/services/channelbridge/coalesce_continuation_test.go @@ -88,8 +88,8 @@ func TestContinuationAfterTurnEnds(t *testing.T) { Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first")) - f.timers.advanceAndFire(t, f.clock) // 第一批到点发车 - chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住) + f.advanceAndFireUntil(t) // 第一批到点发车 + chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住) for i := 0; i < 3; i++ { f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i))) } @@ -132,7 +132,7 @@ func TestContinuationNoDuplicateAndNoLoss(t *testing.T) { Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first")) - f.timers.advanceAndFire(t, f.clock) // 第一批到点发车 + f.advanceAndFireUntil(t) // 第一批到点发车 chat.waitRuns(t, 1, 3*time.Second) for i := 0; i < 3; i++ { f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i))) @@ -247,7 +247,7 @@ func TestCoalesceStopsHonestly(t *testing.T) { f.b.Start() f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) - f.timers.take(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册) + f.timers.waitRegistered(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册) f.b.Stop(context.Background()) diff --git a/internal/services/channelbridge/coalesce_fixture_test.go b/internal/services/channelbridge/coalesce_fixture_test.go index b27cd98..6b39d7c 100644 --- a/internal/services/channelbridge/coalesce_fixture_test.go +++ b/internal/services/channelbridge/coalesce_fixture_test.go @@ -68,24 +68,46 @@ func newCoalesceClock() *laneClock { // // 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它 // (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。 +// +// **触发语义按「截止时刻」而不是「先进先出队列」**:每次请求记下「注册时的注入时钟 + 时长」, +// 测试推钟时**只把时钟推进到当前最早的截止时刻**,然后触发到期的那一个。 +// 为什么不能按队列逐个推(旧写法):`coalesceWait` 每个循环迭代都会重新 `after(wait)`, +// 于是每条新到的消息都会留下一个**再没人监听**的旧定时器;旧写法把它们也逐个触发, +// 每次照样推一个「请求时长」——实测 12 条消息内攒到 5 个,时钟被推 +10s(窗口 2s、 +// 绝对上限 6s)⇒ 时钟越过绝对上限 ⇒ 循环头在**没有 takeAll** 的分支上直接发车, +// 队列尾部留在下一批(「12 条 → 2 轮」)。按截止时刻推进让「推了多少时间」= 「真的过了 +// 多少时间」,旧定时器过期后被触发也不会把时钟推过头。 type coalesceTimers struct { mu sync.Mutex calls []time.Duration pending []coalesceTimer + now func() time.Time + // depth 返回「车道上还没被 worker 取走的条目数」(含 ch/spill/front)。 + // 推钟前必须等它归零,见 pump 的说明。 + depth func() int + // seq 是单调请求序号:worker 每次重新挂定时器都会 +1,因此「比某个序号更大的请求」 + // 才可能是它此刻**真正在等**的那一个(旧的都已被它放弃)。 + seq int64 } -// coalesceTimer 是一次定时器请求:通道 + 当时请求的时长(测试按它推进注入时钟, -// 因此「到点」与「请求的时长」永远一致,不存在测试把时钟推多了的伪影)。 +// coalesceTimer 是一次定时器请求:通道 + 请求时长 + **截止时刻**(注册时的注入时钟 + d)。 type coalesceTimer struct { - ch chan time.Time - d time.Duration + ch chan time.Time + d time.Duration + at time.Time + seq int64 } func (t *coalesceTimers) after(d time.Duration) <-chan time.Time { ch := make(chan time.Time, 1) + at := time.Time{} + if t.now != nil { + at = t.now().Add(d) + } t.mu.Lock() t.calls = append(t.calls, d) - t.pending = append(t.pending, coalesceTimer{ch: ch, d: d}) + t.seq++ + t.pending = append(t.pending, coalesceTimer{ch: ch, d: d, at: at, seq: t.seq}) t.mu.Unlock() return ch } @@ -104,47 +126,138 @@ func (t *coalesceTimers) durations() []time.Duration { return append([]time.Duration(nil), t.calls...) } -// take 取走**最早一个尚未触发**的定时器(等它有请求为止)。 -func (t *coalesceTimers) take(tb *testing.T, timeout time.Duration) coalesceTimer { - tb.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - t.mu.Lock() - if len(t.pending) > 0 { - timer := t.pending[0] - t.pending = t.pending[1:] - t.mu.Unlock() - return timer - } - t.mu.Unlock() - time.Sleep(time.Millisecond) - } - tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount()) - return coalesceTimer{} -} - -// advanceAndFire 按**该定时器请求的时长**推进注入时钟,然后触发它 -// (= 「静默窗口正好到点」)。 -func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock) { - tb.Helper() - timer := t.take(tb, 3*time.Second) - clock.advance(timer.d) - timer.ch <- clock.Now() -} - -// tryTake 非阻塞地取走最早一个尚未触发的定时器(没有就返回 false)。 -func (t *coalesceTimers) tryTake() (coalesceTimer, bool) { +// nextDue 取走**截止最早**的那一个未触发定时器(没有就返回 false)。 +func (t *coalesceTimers) nextDue() (coalesceTimer, bool) { t.mu.Lock() defer t.mu.Unlock() if len(t.pending) == 0 { return coalesceTimer{}, false } - timer := t.pending[0] - t.pending = t.pending[1:] + idx := 0 + for i := 1; i < len(t.pending); i++ { + if t.pending[i].at.Before(t.pending[idx].at) { + idx = i + } + } + timer := t.pending[idx] + t.pending = append(t.pending[:idx], t.pending[idx+1:]...) return timer, true } -// pump 反复「有定时器就按它请求的时长推进时钟并触发」,直到 done() 成立(或 3s 超时)。 +// waitRegistered 等到「至少有一次定时器请求挂着」(= worker 确实在等窗口)。 +// +// 它只做**存在性检查**,不触发、也不消耗定时器:旧夹具里有调用方用 take 来证明 +// 「它在等窗口」,却把那个定时器丢掉了 —— 按截止时刻推进之后那会丢掉唯一活着的那个。 +// seqNow 返回当前请求序号(投递前记下它,之后用 waitFresh 等「这次投递之后新挂的」定时器)。 +func (t *coalesceTimers) seqNow() int64 { + t.mu.Lock() + defer t.mu.Unlock() + return t.seq +} + +// waitFresh 等到「序号大于 after 的定时器已挂上」= worker 已经把这次投递收进本批并重新 +// 开始等窗口。旧请求(已被 worker 放弃、再没人监听)不算。 +func (t *coalesceTimers) waitFresh(tb *testing.T, after int64, timeout time.Duration) { + tb.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + t.mu.Lock() + fresh := false + for _, tm := range t.pending { + if tm.seq > after { + fresh = true + break + } + } + t.mu.Unlock() + if fresh { + return + } + time.Sleep(time.Millisecond) + } + tb.Fatalf("等不到投递之后新挂的定时器(seq>%d, calls=%d)", after, t.callCount()) +} + +func (t *coalesceTimers) waitRegistered(tb *testing.T, timeout time.Duration) { + tb.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + t.mu.Lock() + n := len(t.pending) + t.mu.Unlock() + if n > 0 { + return + } + time.Sleep(time.Millisecond) + } + tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount()) +} + +// advanceAndFire 把注入时钟推进到**当前所有未触发请求里最晚的那个截止时刻**, +// 然后触发它们(= 「这些窗口全部到点」)。 +// +// 推进量因此恒等于「真的过了多久」,不会再出现旧写法里「按每个请求时长累加」的推过头。 +func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock, done func() bool) { + tb.Helper() + deadline := time.Now().Add(3 * time.Second) + // 与 pump 同一条不变量:先把队列排空再推钟(否则循环头会按窗口到点发车, + // 而那条分支不 takeAll,尾部消息会落到下一批)。 + for t.depth != nil && t.depth() > 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + for time.Now().Before(deadline) { + due, ok := t.allDue() + if ok { + clock.advanceTo(due) + // 为什么要重试:worker 可能在「登记」与「触发」之间续了新臂, + // 被触发的旧通道于是没人接(时钟推了、没人醒)。done 成立即收手。 + if done == nil || done() { + return + } + time.Sleep(time.Millisecond) + continue + } + if done != nil && done() { + return + } + time.Sleep(time.Millisecond) + } + if done != nil && done() { + return + } + tb.Fatalf("advanceAndFire: 3s 内没有把窗口推到发车(calls=%d)", t.callCount()) +} + +// advanceAndFireUntil 推进并触发窗口,直到**回合数比调用时多一个**(或超时)。 +func (f *coalesceBridge) advanceAndFireUntil(t *testing.T) { + t.Helper() + before := f.chat.runCount() + f.timers.advanceAndFire(t, f.clock, func() bool { return f.chat.runCount() > before }) +} + +// allDue 取走全部未触发请求,把时钟推到其中最晚的截止时刻,并逐个触发。 +// 返回被推到的时间点(没有任何请求时 ok=false)。 +func (t *coalesceTimers) allDue() (time.Time, bool) { + t.mu.Lock() + pending := t.pending + t.pending = nil + t.mu.Unlock() + if len(pending) == 0 { + return time.Time{}, false + } + latest := pending[0] + for _, tm := range pending[1:] { + if tm.at.After(latest.at) { + latest = tm + } + } + for _, tm := range pending { + tm.ch <- latest.at + } + return latest.at, true +} + +// pump 反复「把时钟推进到最早截止时刻并触发它」,直到 done() 成立(或超时)。 // // 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限 // 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」, @@ -156,14 +269,23 @@ func (f *coalesceBridge) pump(t *testing.T, done func() bool) { if done() { return } - if timer, ok := f.timers.tryTake(); ok { - f.clock.advance(timer.d) - timer.ch <- f.clock.Now() + if timer, ok := f.timers.nextDue(); ok { + // 🔴 **推钟前必须等队列排空**:时钟一旦前进,`coalesceWait` 的循环头就可能 + // 直接按「窗口到点」发车(那条分支不 takeAll),还没被 worker 收进本批的 + // 尾部消息就会落到下一批 —— 「12 条 → 2 轮」正是这么来的。 + // 这里等的是**状态**(车道上没有待取条目),不是拿 sleep 掩盖时序: + // worker 一旦看到队列有货就会收(select 的 l.ch 分支),所以排空必然发生。 + if !f.awaitQueueDrained(deadline) { + break + } + f.clock.advanceTo(timer.at) + timer.ch <- timer.at continue } time.Sleep(time.Millisecond) } - t.Fatalf("condition never met within 3s (runs=%d, stats=%+v)", f.chat.runCount(), f.b.CoalesceStats()) + t.Fatalf("condition never met within 3s (runs=%d, queued=%d, stats=%+v)", + f.chat.runCount(), f.queueDepth(), f.b.CoalesceStats()) } // coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话 @@ -215,7 +337,8 @@ func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge { t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop) } clock := newCoalesceClock() - timers := &coalesceTimers{} + timers := &coalesceTimers{now: clock.Now} + _ = timers if opts.Now == nil { opts.Now = clock.Now } @@ -232,6 +355,7 @@ func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge { } ad := &laneAdapter{name: "telegram"} b.Add(ad) + timers.depth = func() int { return queueDepthOf(b) } return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers} } @@ -274,8 +398,104 @@ func (f *coalesceBridge) waitRuns(t *testing.T, n int) { f.chat.waitRuns(t, n, 3*time.Second) } +// runTexts 返回每个已开跑回合的 user 文本(按开跑顺序)。只用于失败信息: +// 「12 条 → 2 轮」这类断言失守时,得先看清每条落到了哪一批,才能判断是夹具相位问题 +// 还是合并真的拆了批。 +func runTexts(c *laneChatter) [][]string { + n := c.runCount() + out := make([][]string, 0, n) + for i := 0; i < n; i++ { + out = append(out, userTextsIn(c.runAt(i).messages)) + } + return out +} + // waitIdle 等到车道全部退休。 func (f *coalesceBridge) waitIdle(t *testing.T) { t.Helper() - waitLaneIdle(t, f.b, 2*time.Second) + // 「等车道全部退休」必须在**推动窗口**的前提下等:worker 若正停在一个还没到点的 + // 窗口里,就永远走不到「队列空 ⇒ 退休」那一行。这里因此一边等一边把挂着的定时器 + // 按截止时刻触发(与 pump 同一条语义),判据仍是 waitLaneIdle 自己那套断言。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if f.b.LaneStats().ActiveLanes == 0 { + return + } + if timer, ok := f.timers.nextDue(); ok { + f.clock.advanceTo(timer.at) + timer.ch <- timer.at + continue + } + time.Sleep(time.Millisecond) + } + waitLaneIdle(t, f.b, time.Millisecond) +} + +// awaitQueueDrained 等到车道上没有待取条目(= worker 已把该收的都收进本批)。 +// 返回 false 表示到 deadline 都没排空(由调用方的断言/超时报出来)。 +func (f *coalesceBridge) awaitQueueDrained(deadline time.Time) bool { + for time.Now().Before(deadline) { + if f.queueDepth() == 0 { + return true + } + time.Sleep(time.Millisecond) + } + return f.queueDepth() == 0 +} + +// queueDepth 返回所有车道里「还没被 worker 取走」的条目数(ch + spill + front)。 +func (f *coalesceBridge) queueDepth() int { return queueDepthOf(f.b) } + +func queueDepthOf(b *Bridge) int { + if b == nil || b.lanes == nil { + return 0 + } + b.lanes.mu.Lock() + defer b.lanes.mu.Unlock() + n := 0 + for _, l := range b.lanes.lanes { + n += len(l.ch) + len(l.spill) + len(l.front) + } + return n +} + +// sendSettled 投递一条,并等 worker 把它收进本批(车道排空)后才返回。 +// +// 给「投递与注入时钟交替推进」的用例用(如每隔 500ms 来一条的绝对上限用例): +// 时钟只能表示「时间过去了」,所以必须保证每条都已经到了 worker 手里再走时间, +// 否则「到点发车」会漏掉还没被取走的尾部(实测批次 8/12)。 +// 注意:**不要**在需要验证「队列能装下整批、不溢出」的用例里用它 —— 那里要的是 +// 真的把消息压进队列。 +func (f *coalesceBridge) sendSettled(t *testing.T, msg adapter.UnifiedMessage) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + before := f.timers.seqNow() + f.send(msg) + if !f.awaitQueueDrained(deadline) { + t.Fatalf("投递后车道没有排空(queued=%d)", f.queueDepth()) + } + // 还要等 worker **为这次投递重新挂上定时器**(= 它已回到 select 里等窗口)。 + // 必须按序号判新鲜度:pending 里可能还躺着此前那些「再没人监听」的旧请求, + // 拿它们当「已就绪」会让下一步推钟落在两轮循环之间(定时器换了新的、旧的被触发没人接)。 + f.timers.waitFresh(t, before, time.Until(deadline)) +} + +// waitRows 等到会话落了至少 want 行指定角色的记录(回合是异步跑完再落库的, +// 读历史前必须等它真的写完 —— 否则断言会读到「12 user + 0 assistant」的半成品)。 +func (h *laneHistory) waitRows(tb *testing.T, sessionID, role string, want int, timeout time.Duration) { + tb.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + n := 0 + for _, row := range h.snapshot(sessionID) { + if row.Role == role { + n++ + } + } + if n >= want { + return + } + time.Sleep(time.Millisecond) + } + tb.Fatalf("等了 %s 仍只有 %d 行 %s(want %d)", timeout, len(h.snapshot(sessionID)), role, want) } diff --git a/internal/services/channelbridge/coalesce_owner_test.go b/internal/services/channelbridge/coalesce_owner_test.go index 59c2772..021f1c1 100644 --- a/internal/services/channelbridge/coalesce_owner_test.go +++ b/internal/services/channelbridge/coalesce_owner_test.go @@ -123,7 +123,7 @@ func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) { // 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。 f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息")) - f.timers.advanceAndFire(t, f.clock) + f.advanceAndFireUntil(t) close(gate) chat.waitRuns(t, 2, 3*time.Second) f.waitIdle(t) diff --git a/internal/services/channelbridge/coalesce_test.go b/internal/services/channelbridge/coalesce_test.go index ef60208..67c5c42 100644 --- a/internal/services/channelbridge/coalesce_test.go +++ b/internal/services/channelbridge/coalesce_test.go @@ -36,7 +36,7 @@ func TestCoalesceWaitsForQuietThenFlushes(t *testing.T) { } // 窗口内的第二条:计时必须重置(新定时器 = 从 m2 起再等满一个窗口)。 f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) - f.timers.advanceAndFire(t, f.clock) + f.advanceAndFireUntil(t) f.waitRuns(t, 1) if n := chat.runCount(); n != 1 { @@ -65,7 +65,7 @@ func TestCoalesceFlushesOnWindowExpiry(t *testing.T) { if n := chat.runCount(); n != 0 { t.Fatalf("turns started = %d before the window expired, want 0", n) } - f.timers.advanceAndFire(t, f.clock) + f.advanceAndFireUntil(t) f.waitRuns(t, 1) if n := chat.runCount(); n != 1 { @@ -99,11 +99,15 @@ func TestCoalesceTwelveBurstOneTurn(t *testing.T) { f.pump(t, func() bool { return chat.runCount() >= 1 }) if got := chat.runCount(); got != 1 { - t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst", got) + t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst(各批=%v stats=%+v)", + got, runTexts(chat), f.b.CoalesceStats()) } if got := userTextsIn(chat.runAt(0).messages); len(got) != n { t.Fatalf("batch user messages = %d, want %d: %v", len(got), n, got) } + // 回合是异步跑完再落库的:先等 assistant 行出现,否则读到的是半成品 + // (实测「12 user + 0 assistant」)。 + hist.waitRows(t, "sess--20", "assistant", 1, 5*time.Second) rows := hist.snapshot("sess--20") userRows, assistantRows := 0, 0 turnIDs := map[string]struct{}{} @@ -285,14 +289,16 @@ func TestCoalesceMaxWaitForcesFlush(t *testing.T) { Loop: chat, Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) + // 每条投递后都等 worker 收走再推进时钟:时钟只表示「时间过去了」,它一转, + // 循环头就可能直接按到点发车(那条分支不 takeAll),没被收走的尾部会漏到下一批。 + f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) for i := 1; i < 12; i++ { f.clock.advance(500 * time.Millisecond) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1))) + f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1))) } // 最后那个定时器请求的正是「距离 6s 绝对上限还差多少」——按它推完最后 500ms, // 循环头因此看见 maxLeft<=0 并按 max_wait 发车(滑动窗口被一条条重置也压不住它)。 - f.timers.advanceAndFire(t, f.clock) + f.advanceAndFireUntil(t) f.waitRuns(t, 1) if n := chat.runCount(); n != 1 { t.Fatalf("turns started = %d, want 1 (max wait forces one flush)", n) diff --git a/internal/services/channelbridge/engage_coldshoulder_exception_test.go b/internal/services/channelbridge/engage_coldshoulder_exception_test.go index f30bfec..63cdb06 100644 --- a/internal/services/channelbridge/engage_coldshoulder_exception_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_exception_test.go @@ -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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("今晚吃点什么")) - if chat.SessionID() != "" { - t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.SessionID()) + if chat.sessionID != "" { + t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID) } } diff --git a/internal/services/channelbridge/engage_coldshoulder_test.go b/internal/services/channelbridge/engage_coldshoulder_test.go index 51a023f..5fac222 100644 --- a/internal/services/channelbridge/engage_coldshoulder_test.go +++ b/internal/services/channelbridge/engage_coldshoulder_test.go @@ -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.clearSessionID() + chat.sessionID = "" 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) } } diff --git a/internal/services/channelbridge/engage_session_test.go b/internal/services/channelbridge/engage_session_test.go index 75076de..d1e7bca 100644 --- a/internal/services/channelbridge/engage_session_test.go +++ b/internal/services/channelbridge/engage_session_test.go @@ -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) } } diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go index 5a2d37a..4aa317f 100644 --- a/internal/services/channelbridge/engage_test.go +++ b/internal/services/channelbridge/engage_test.go @@ -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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + 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()) + 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)。 diff --git a/internal/services/channelbridge/events_test.go b/internal/services/channelbridge/events_test.go index e7ff71f..46eff5a 100644 --- a/internal/services/channelbridge/events_test.go +++ b/internal/services/channelbridge/events_test.go @@ -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) diff --git a/internal/services/channelbridge/inbound_documents_test.go b/internal/services/channelbridge/inbound_documents_test.go index 1285912..4123618 100644 --- a/internal/services/channelbridge/inbound_documents_test.go +++ b/internal/services/channelbridge/inbound_documents_test.go @@ -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 } diff --git a/internal/services/channelbridge/inbound_speaker_test.go b/internal/services/channelbridge/inbound_speaker_test.go index 92d96fa..80c55c7 100644 --- a/internal/services/channelbridge/inbound_speaker_test.go +++ b/internal/services/channelbridge/inbound_speaker_test.go @@ -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 != "" { diff --git a/internal/services/channelbridge/lane_test.go b/internal/services/channelbridge/lane_test.go index de028fb..f75e9f8 100644 --- a/internal/services/channelbridge/lane_test.go +++ b/internal/services/channelbridge/lane_test.go @@ -204,6 +204,18 @@ func (c *laneClock) advance(d time.Duration) { c.mu.Unlock() } +// advanceTo 把时钟推到 t(只前进不后退)。 +// +// 窗口夹具用它表达「时间走到了某个截止时刻」:推进量 = 真的过了多久, +// 与「按请求时长累加」相比不会把时钟推过头(见 coalesceTimers 的说明)。 +func (c *laneClock) advanceTo(t time.Time) { + c.mu.Lock() + if t.After(c.t) { + c.t = t + } + c.mu.Unlock() +} + // laneRecorder 收集 onRejected 审计(并发安全)。 type laneRecorder struct { mu sync.Mutex @@ -379,8 +391,13 @@ func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) { clock := newLaneClock() rec := &laneRecorder{} chat := &laneChatter{} + // 两条都投递完再让时钟越过阈值:第一轮**卡住**等这个信号,车道因此不会在两条之间 + // 退休(退休后第二条会进一条新车,arrivedAt 取的是推进后的时钟 ⇒ 它永远不超龄, + // 用例假红)。这不是放宽断言 —— 「第二条超龄被丢」仍然由 stats/审计/日志三处断言。 + bothQueued := make(chan struct{}) chat.onRun = func(user string) { if user == "first" { + <-bothQueued // 第一轮跑着的时候时间越过阈值:排在后面的那条等到自己开跑时已超龄。 clock.advance(defaultLaneStaleAfter + time.Second) } @@ -397,6 +414,7 @@ func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) { b.onMessage(context.Background(), strangerMsg("200", "first"), ad) b.onMessage(context.Background(), strangerMsg("200", "stale by the time it runs"), ad) + close(bothQueued) chat.waitRuns(t, 1, 5*time.Second) waitLaneIdle(t, b, 2*time.Second) diff --git a/internal/services/channelbridge/light_test.go b/internal/services/channelbridge/light_test.go index 1ce9a16..57d8660 100644 --- a/internal/services/channelbridge/light_test.go +++ b/internal/services/channelbridge/light_test.go @@ -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())) diff --git a/internal/services/channelbridge/notice_test.go b/internal/services/channelbridge/notice_test.go index 62f14c7..0bc4438 100644 --- a/internal/services/channelbridge/notice_test.go +++ b/internal/services/channelbridge/notice_test.go @@ -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) } diff --git a/internal/services/channelbridge/ownerlane_test.go b/internal/services/channelbridge/ownerlane_test.go index 964aa9f..06623e6 100644 --- a/internal/services/channelbridge/ownerlane_test.go +++ b/internal/services/channelbridge/ownerlane_test.go @@ -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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" b.process(context.Background(), ownerLaneGroupMsg("第二条普通的话")) - if chat.SessionID() != "" { - t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.SessionID()) + if chat.sessionID != "" { + t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.sessionID) } // 间隔内**指向她**的消息:仍必回(硬保留不受频率闸约束)。 - chat.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" 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) } }) } diff --git a/internal/services/channelbridge/stranger_test.go b/internal/services/channelbridge/stranger_test.go index f4ffb81..6a6b73e 100644 --- a/internal/services/channelbridge/stranger_test.go +++ b/internal/services/channelbridge/stranger_test.go @@ -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.clearSessionID() + chat.sessionID = "" 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.clearSessionID() + chat.sessionID = "" 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) diff --git a/internal/services/channelbridge/turnid_wiring_test.go b/internal/services/channelbridge/turnid_wiring_test.go index 4b60bc0..b677561 100644 --- a/internal/services/channelbridge/turnid_wiring_test.go +++ b/internal/services/channelbridge/turnid_wiring_test.go @@ -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 { diff --git a/internal/services/channelbridge/turnreply_test.go b/internal/services/channelbridge/turnreply_test.go index 4b4c26f..49da203 100644 --- a/internal/services/channelbridge/turnreply_test.go +++ b/internal/services/channelbridge/turnreply_test.go @@ -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)) From f56d74c3633ec3871e1e6040be63014e379eff96 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 23:36:41 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(channelbridge):=20=E5=87=86=E5=85=A5?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E7=94=A8=E4=BE=8B=E6=94=B9=E6=8C=89=E3=80=8C?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E6=B6=88=E6=81=AF=E7=BB=AD=E5=87=BA=E7=9A=84?= =?UTF-8?q?=E8=87=82=E3=80=8D=E6=96=AD=E8=A8=80=EF=BC=88M33=20r2=20?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=20p2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestCoalesceSystemNoticeDoesNotJoinBatch 仍在用位置索引 d[1]:waitTimerCalls 只保证 「至少 2 次请求」,不保证顺序 —— 加压下会拿到 m1/m2 那次 2s 的续臂(实测 -race -count=200 有 2/200 假红,空载测不出来)。 改成与兄弟用例同一口径,但把相位做成确定的:新增 sendSettledAndDuration —— 投递后等 worker 把这条收进本批、**为它**续好臂(按请求序号认领),再返回那次续臂的 时长。仅用 latestDuration 仍有缝:worker 可能在帧/通知之后才补上一条消息的续臂, 最新那次就成了 2s。三条准入用例(action 帧 / 系统通知 / 聊天消息对照组)统一改用它。 断言口径未变:仍然断言「系统通知/action 帧不重置静默窗口(剩余 500ms)」、 「聊天消息重置成完整 2s」;waitTimerCalls 语义、请求次数均未改。 证据:-race -count=400 连跑 3 次(1200 次)全绿、DATA RACE=0; 兄弟两条 -count=200 全绿;整包 -race -count=3 全绿。 --- .../channelbridge/coalesce_admission_test.go | 44 +++++++++---------- .../channelbridge/coalesce_fixture_test.go | 11 +++++ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/internal/services/channelbridge/coalesce_admission_test.go b/internal/services/channelbridge/coalesce_admission_test.go index b9f9698..999acce 100644 --- a/internal/services/channelbridge/coalesce_admission_test.go +++ b/internal/services/channelbridge/coalesce_admission_test.go @@ -123,22 +123,19 @@ func TestCoalesceActionFrameDoesNotResetWindow(t *testing.T) { Loop: chat, Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) - // 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。 - if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond { - t.Fatalf("first window = %s, want 2s", d[0]) + // 投递用 sendSettled:它等到 worker 把这条收进本批、并**为它**续好臂才返回。 + // 这样「哪一次续臂属于哪条消息」是确定的 —— 只按「至少 N 次请求 + 取最新」仍有 + // 缝:worker 可能在帧之后才补上 m1 的续臂,最新那一次就是 2s(假红)。 + if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond { + // 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。 + t.Fatalf("first window = %s, want 2s", d) } // 窗口走到 1.5s 时来了一条平台帧。 f.clock.advance(1500 * time.Millisecond) - f.send(coalesceActionFrame("telegram", "-20", "notice.group_recall")) - - // 🔴 关键断言(状态,不是结果):下一次请求的**剩余**时长仍是 500ms。 - // 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。 - // 断言的是**最新一次**请求(= 这条帧之后 worker 续的臂)。旧写法取 d[1], - // 隐含「帧之前只续过一次臂」这个时序假设:worker 何时把 m1 收进本批、何时续臂 - // 都是调度决定的,于是 d[1] 有时是 m1 的那次续臂(2s),用例假红。 - if d := latestDuration(waitTimerCalls(t, f, 2)); d != 500*time.Millisecond { + if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_recall")); d != 500*time.Millisecond { + // 🔴 关键断言(状态,不是结果):这条帧续出的窗口**剩余**仍是 500ms。 + // 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。 t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d) } @@ -168,16 +165,17 @@ func TestCoalesceSystemNoticeDoesNotJoinBatch(t *testing.T) { Loop: chat, Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) - waitTimerCalls(t, f, 1) + // 每条都等「worker 收进本批 + 为它续好臂」再继续(见 sendSettledAndDuration): + // 只按「至少 N 次请求 + 取最新」有缝 —— worker 可能在通知之后才补上 m1/m2 的 + // 续臂,最新那一次就是 2s,用例假红。 + f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) + f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) f.clock.advance(1500 * time.Millisecond) - f.send(coalesceActionFrame("telegram", "-20", "notice.group_increase")) // 与 action 帧同理:通知也不许重置计时(剩余仍是 500ms)。 // 变异(准入失效)⇒ 这里拿到 2s,本用例红。 - if d := waitTimerCalls(t, f, 2); d[1] != 500*time.Millisecond { - t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d[1]) + if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_increase")); d != 500*time.Millisecond { + t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d) } f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 }) @@ -243,14 +241,12 @@ func TestCoalesceChatStillResetsWindow(t *testing.T) { Loop: chat, Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))}, }) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")) - if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond { - t.Fatalf("first window = %s, want 2s", d[0]) + if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond { + t.Fatalf("first window = %s, want 2s", d) } f.clock.advance(1500 * time.Millisecond) - f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")) - // 同 action-frame 用例:断言最新一次请求(本消息触发的续臂),不假设此前的臂数。 - if d := latestDuration(waitTimerCalls(t, f, 2)); d != 2000*time.Millisecond { + // 同 action-frame 用例:读的是**这条消息**续出的臂(sendSettledAndDuration 保证归属)。 + if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")); d != 2000*time.Millisecond { t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d) } f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 }) diff --git a/internal/services/channelbridge/coalesce_fixture_test.go b/internal/services/channelbridge/coalesce_fixture_test.go index 6b39d7c..2a02f2c 100644 --- a/internal/services/channelbridge/coalesce_fixture_test.go +++ b/internal/services/channelbridge/coalesce_fixture_test.go @@ -499,3 +499,14 @@ func (h *laneHistory) waitRows(tb *testing.T, sessionID, role string, want int, } tb.Fatalf("等了 %s 仍只有 %d 行 %s(want %d)", timeout, len(h.snapshot(sessionID)), role, want) } + +// sendSettledAndDuration 投递一条并返回**这条消息之后 worker 续出的那次窗口时长**。 +// +// 它把「投递 → worker 收进本批 → 为它续臂」做成一个确定的相位:续臂用请求序号认领 +// (`seq > 投递前`),因此不会把此前那条消息的续臂误当成这一次的 —— 只按「至少 N 次 +// 请求 + 取最新」在负载下仍会拿到上一条的时长(实测 -race -count=200 有 2/200 假红)。 +func (f *coalesceBridge) sendSettledAndDuration(t *testing.T, msg adapter.UnifiedMessage) time.Duration { + t.Helper() + f.sendSettled(t, msg) + return latestDuration(f.timers.durations()) +}