package channelbridge // 本文件是 **M22 切片 LANEQUEUE** 的断言集:每条不变量至少一条断言,见 lane.go 顶部的 // 四条不变量清单。 // // - ① 顺序:同一会话内回复顺序 = 到达顺序(TestLaneSerializesOneSessionInArrivalOrder; // 主人私聊溢出区另有一条 TestLaneOwnerPrivateSpillKeepsArrivalOrder); // - ② 一致性:同一会话绝无两个回合同时跑,不同会话可并行 // (TestLaneSerializesOneSessionInArrivalOrder + TestLaneRunsDifferentSessionsInParallel // + TestLaneKeyNeverCollidesAcrossSessions —— 后者钉住「车道键与会话键同源」); // - ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留计数 + 审计 + 日志 // (TestLaneDropsStaleInboundAndTracesIt / TestLaneRejectsNewestWhenFull), // 主人私聊与非聊天事件另有豁免(TestLaneStaleGateExemptions); // - ④ 最新性:排队中的消息在**开跑之后**才装配上下文,因此看得到前一轮刚落的历史 // (TestLaneTurnSeesContextAssembledAtStart)。 // // 另有 TestLaneDeliveryNeverBlocksTheReadLoop:把「读循环 + 回合」的旧形状与新形状放在 // 一起对照 —— 全局回合槽占满时投递仍然立即返回,读循环照常读下一帧(心跳不再被堵)。 import ( "context" "fmt" "log/slog" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // laneAdapter 是并发安全的最小适配器:车道让多个会话**并行**发回复,因此这里必须带锁 // (channelbridge_test.go 的 fakeAdapter 是单线程夹具,不能复用)。 type laneAdapter struct { name string mu sync.Mutex sends []adapter.UnifiedMessage handler adapter.MessageHandler } func (a *laneAdapter) Name() string { return a.name } func (a *laneAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} } func (a *laneAdapter) Connect(context.Context) error { return nil } func (a *laneAdapter) Disconnect(context.Context) error { return nil } func (a *laneAdapter) IsConnected() bool { return true } func (a *laneAdapter) HealthCheck(context.Context) error { return nil } func (a *laneAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) { <-ctx.Done() } func (a *laneAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error { a.mu.Lock() defer a.mu.Unlock() a.sends = append(a.sends, resp.Messages...) return nil } func (a *laneAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error { return a.SendMessage(context.Background(), adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}}) } func (a *laneAdapter) SetMessageHandler(fn adapter.MessageHandler) { a.mu.Lock() a.handler = fn a.mu.Unlock() } func (a *laneAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() } func (a *laneAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil } // laneChatter 记录「谁在什么时候跑」并可在 Run 里阻塞,用来制造「回合还在跑」的窗口。 type laneChatter struct { // onRun 在**记录之后、返回之前**调用;非 nil 时可用它阻塞或推进注入的时钟。 onRun func(user string) mu sync.Mutex runs []laneRun inFlight int maxInFlight int perSession map[string]int maxPerSess map[string]int } // laneRun 是一个已开跑的回合(当次用户文本 + 会话 + 模型可见消息)。 type laneRun struct { user string session string messages []model.Message } func (c *laneChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) { use := lastUserText(msgs) c.mu.Lock() if c.perSession == nil { c.perSession = map[string]int{} c.maxPerSess = map[string]int{} } c.runs = append(c.runs, laneRun{user: use, session: sessionID, messages: msgs}) c.inFlight++ if c.inFlight > c.maxInFlight { c.maxInFlight = c.inFlight } c.perSession[sessionID]++ if c.perSession[sessionID] > c.maxPerSess[sessionID] { c.maxPerSess[sessionID] = c.perSession[sessionID] } c.mu.Unlock() if c.onRun != nil { c.onRun(use) } c.mu.Lock() c.inFlight-- c.perSession[sessionID]-- c.mu.Unlock() return &loop.Result{Text: "reply:" + use}, nil } func (c *laneChatter) userTexts() []string { c.mu.Lock() defer c.mu.Unlock() out := make([]string, 0, len(c.runs)) for _, r := range c.runs { out = append(out, r.user) } return out } func (c *laneChatter) runCount() int { c.mu.Lock() defer c.mu.Unlock() return len(c.runs) } func (c *laneChatter) maxConcurrent() int { c.mu.Lock() defer c.mu.Unlock() return c.maxInFlight } func (c *laneChatter) maxConcurrentIn(sessionID string) int { c.mu.Lock() defer c.mu.Unlock() return c.maxPerSess[sessionID] } func (c *laneChatter) runAt(i int) laneRun { c.mu.Lock() defer c.mu.Unlock() return c.runs[i] } // waitRuns 等到至少 n 个回合开跑(或超时失败)。 func (c *laneChatter) waitRuns(t *testing.T, n int, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if c.runCount() >= n { return } time.Sleep(time.Millisecond) } t.Fatalf("only %d of %d turns started within %s (lane never drained?)", c.runCount(), n, timeout) } // laneSessions 按消息给出**稳定且互不相同**的隔离会话(同一发送者恒同一个会话)。 type laneSessions struct { owner bool } func (s *laneSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) { return session.Session{ Platform: msg.Platform, ChannelID: msg.ChannelID, SessionID: "sess-" + msg.ChannelID + "-" + msg.SenderID, Owner: s.owner, IsGroup: msg.IsGroup(), MemWritable: s.owner, }, nil } // laneClock 是可推进的注入时钟(陈旧度闸据此判定,测试因此不必真的等 30s)。 type laneClock struct { mu sync.Mutex t time.Time } func newLaneClock() *laneClock { return &laneClock{t: time.Unix(1_700_000_000, 0)} } func (c *laneClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *laneClock) advance(d time.Duration) { c.mu.Lock() c.t = c.t.Add(d) 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 rejected []string } func (r *laneRecorder) record(_ adapter.UnifiedMessage, reason string) { r.mu.Lock() r.rejected = append(r.rejected, reason) r.mu.Unlock() } func (r *laneRecorder) reasons() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.rejected...) } func lastUserText(msgs []model.Message) string { for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].Role != model.RoleUser { continue } if t := lastText(msgs[i]); t != "" { return t } } return "" } func lastText(m model.Message) string { for _, c := range m.Content { if c.Type == model.ContentTypeText { return c.Text } } return "" } // strangerMsg 是一条**陌生人私聊**(非群、非主人):唯一闸门是流控,因此它必然进回合, // 且不同发送者 = 不同会话。它是「车道并行」与「陈旧度闸」最干净的夹具。 func strangerMsg(sender, content string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: sender, ChannelType: adapter.ChannelDirect, SenderID: sender, Sender: adapter.Actor{Role: adapter.RoleMember}, Content: content, ContentType: adapter.ContentText, } } // ownerMsg 是一条**主人私聊**:车道键恒为 main|web(inboundTargetFor 把它路由进主会话)。 func ownerMsg(content string) adapter.UnifiedMessage { m := directMessage() m.Content = content return m } // newLaneBridge 构造一个**只跑 M22 车道语义**的桥接器:本文件(lane_test.go)钉的是 // 车道本身(顺序/串行/背压/陈旧度),因此默认把 M23 的静默窗口**关掉** —— // 开着窗口时「2s 静默」会让这几条用例从毫秒级变成秒级,并且把「陈旧度何时判」的 // 时间口径搅在一起。窗口/合并/续跑自己的断言都在 coalesce_test.go。 // // 需要窗口的用例显式传 Options.Coalesce(见 newCoalesceBridge)。 func newLaneBridge(t *testing.T, opts Options, ad *laneAdapter) *Bridge { t.Helper() if opts.Coalesce.For == nil { opts.Coalesce.For = coalesceOffFor } b, err := New(opts) if err != nil { t.Fatalf("New: %v", err) } if ad != nil { b.Add(ad) } return b } // waitLaneIdle 等到注册表里没有活跃车道(队列已排空)或超时。 func waitLaneIdle(t *testing.T, b *Bridge, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if b.LaneStats().ActiveLanes == 0 { return } time.Sleep(time.Millisecond) } t.Fatalf("lanes never went idle: %+v", b.LaneStats()) } // TestLaneSerializesOneSessionInArrivalOrder 断言不变量 ①(顺序)与 ②(一致性): // 同一个会话(这里是主人私聊 ⇒ 同一条 main 车道)的五条消息**先来先跑、绝不同时跑**。 func TestLaneSerializesOneSessionInArrivalOrder(t *testing.T) { t.Parallel() chat := &laneChatter{} b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{owner: true}, Owner: testOwnerProfile("telegram", "100"), }, nil) ad := &laneAdapter{name: "telegram"} const n = 5 for i := 0; i < n; i++ { b.onMessage(context.Background(), ownerMsg(fmt.Sprintf("m%d", i)), ad) } chat.waitRuns(t, n, 5*time.Second) got := chat.userTexts() want := []string{"m0", "m1", "m2", "m3", "m4"} if len(got) != len(want) { t.Fatalf("ran %d turns, want %d: %v", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("turn order = %v, want %v", got, want) } } // ②:同一会话绝不并发(主人私聊跨平台共用 main 车道,因此这里是会话级而非账号级)。 if m := chat.maxConcurrent(); m != 1 { t.Errorf("max concurrent turns = %d, want 1 for one session", m) } if m := chat.maxConcurrentIn(history.MainSessionID); m != 1 { t.Errorf("max concurrent turns in %s = %d, want 1", history.MainSessionID, m) } } // TestLaneRunsDifferentSessionsInParallel 断言 ②的另一半:不同会话**互不排队** —— // 两个陌生人各自私聊她,两个回合必须同时在跑(串行的话下面会以 maxInFlight=1 失败)。 func TestLaneRunsDifferentSessionsInParallel(t *testing.T) { t.Parallel() var mu sync.Mutex entered := 0 both := make(chan struct{}) chat := &laneChatter{} chat.onRun = func(string) { mu.Lock() entered++ if entered == 2 { close(both) } mu.Unlock() select { case <-both: case <-time.After(3 * time.Second): // 串行了:返回让断言失败,而不是把测试挂死。 } } b := newLaneBridge(t, Options{Loop: chat, Sessions: &laneSessions{}, Owner: &user.OwnerProfile{}}, nil) ad := &laneAdapter{name: "telegram"} b.onMessage(context.Background(), strangerMsg("200", "a"), ad) b.onMessage(context.Background(), strangerMsg("201", "b"), ad) chat.waitRuns(t, 2, 5*time.Second) if m := chat.maxConcurrent(); m != 2 { t.Fatalf("max concurrent turns across two sessions = %d, want 2 (lanes must not share)", m) } } // TestLaneDropsStaleInboundAndTracesIt 断言 ③ 的第一类丢弃:**轮到时已超龄 ⇒ 不补答**, // 且丢得有声:计数 + onRejected 审计 + 带 source 的 WARN 日志。 func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) { t.Parallel() logs := &safeBuffer{} 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) } } b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{}, Owner: &user.OwnerProfile{}, Now: clock.Now, OnRejected: rec.record, Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})), }, nil) ad := &laneAdapter{name: "telegram"} 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) if stats := b.LaneStats(); stats.DroppedStale != 1 { t.Fatalf("dropped stale = %d, want 1 (stats=%+v)", stats.DroppedStale, stats) } if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonStale { t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonStale) } if got := chat.userTexts(); len(got) != 1 || got[0] != "first" { t.Fatalf("turns = %v, want only the fresh first one (the stale one must not run)", got) } if out := logs.String(); !strings.Contains(out, "inbound dropped by session lane") || !strings.Contains(out, laneDropReasonStale) { t.Fatalf("drop left no trace in the log:\n%s", out) } } // TestLaneRejectsNewestWhenFull 断言 ③ 的第二类丢弃:车道满时**在门前**拒收这一条 // (队列里已有的条目一条都不动 —— M23 的 takeAll 依赖队列完整),且拒收留痕 // (计数 + 审计)。深度 1 时:m1 在跑、m2 已排上,m3 被拒收。 // 注意语义是「拒收最新、队列不动」,**不是**「挤掉最旧」(复审 P2-2 的口径澄清)。 func TestLaneRejectsNewestWhenFull(t *testing.T) { t.Parallel() gate := make(chan struct{}) chat := &laneChatter{} chat.onRun = func(user string) { if user == "m1" { <-gate // 第一个回合卡住,后面的条目只能在车道上等 } } rec := &laneRecorder{} b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{}, Owner: &user.OwnerProfile{}, LaneDepth: 1, OnRejected: rec.record, }, nil) ad := &laneAdapter{name: "telegram"} b.onMessage(context.Background(), strangerMsg("200", "m1"), ad) chat.waitRuns(t, 1, 5*time.Second) // m1 已在跑(车道空出来) b.onMessage(context.Background(), strangerMsg("200", "m2"), ad) b.onMessage(context.Background(), strangerMsg("200", "m3"), ad) // 深度 1:m3 在门前被拒收 close(gate) chat.waitRuns(t, 2, 5*time.Second) waitLaneIdle(t, b, 2*time.Second) if stats := b.LaneStats(); stats.DroppedOverflow != 1 { t.Fatalf("dropped overflow = %d, want 1 (stats=%+v)", stats.DroppedOverflow, stats) } if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonOverflow { t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonOverflow) } if got := chat.userTexts(); len(got) != 2 || got[0] != "m1" || got[1] != "m2" { t.Fatalf("turns = %v, want [m1 m2] (m3 is the one rejected at the gate)", got) } } // 🔴 TestLaneOwnerPrivateNeverDroppedOnOverflow 守住主人 2026-10-24 的裁定: // **主人私聊永不因背压被吃掉**(「宁可晚回,不许不回」)。深度 1、第一轮卡住时连投四条, // 四条必须一条不落地按到达顺序跑完 —— 溢出区只排队、绝不拒收。 // 变异检验:把主人私聊塞进群聊那条拒收路径 ⇒ 本用例立刻红(只剩两条回合)。 func TestLaneOwnerPrivateNeverDroppedOnOverflow(t *testing.T) { t.Parallel() gate := make(chan struct{}) chat := &laneChatter{} chat.onRun = func(user string) { if user == "m1" { <-gate } } rec := &laneRecorder{} b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{owner: true}, Owner: testOwnerProfile("telegram", "100"), LaneDepth: 1, OnRejected: rec.record, }, nil) ad := &laneAdapter{name: "telegram"} b.onMessage(context.Background(), ownerMsg("m1"), ad) chat.waitRuns(t, 1, 5*time.Second) for _, m := range []string{"m2", "m3", "m4"} { b.onMessage(context.Background(), ownerMsg(m), ad) } close(gate) chat.waitRuns(t, 4, 5*time.Second) waitLaneIdle(t, b, 2*time.Second) if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 { t.Fatalf("owner private must never be dropped: %+v", stats) } if reasons := rec.reasons(); len(reasons) != 0 { t.Fatalf("owner private must not reach the audit drop track: %v", reasons) } got := chat.userTexts() want := []string{"m1", "m2", "m3", "m4"} if len(got) != len(want) { t.Fatalf("turns = %v, want all %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("turn order = %v, want %v", got, want) } } } // TestLaneStaleGateExemptions 钉住陈旧度闸的两类豁免:**主人私聊**(迟到的回答仍是回答) // 与**非聊天事件**(留存与审计的唯一来源)。两者都不许被算进丢弃计数。 func TestLaneStaleGateExemptions(t *testing.T) { t.Parallel() clock := newLaneClock() chat := &laneChatter{} chat.onRun = func(user string) { if user == "first" { clock.advance(defaultLaneStaleAfter + time.Second) } } b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{owner: true}, Owner: testOwnerProfile("telegram", "100"), Now: clock.Now, }, nil) ad := &laneAdapter{name: "telegram"} b.onMessage(context.Background(), ownerMsg("first"), ad) b.onMessage(context.Background(), ownerMsg("late owner private"), ad) chat.waitRuns(t, 2, 5*time.Second) // 非聊天事件(ContentAction)同样豁免:它不产生回复,但必须被登记/留存。 event := strangerMsg("200", "") event.ContentType = adapter.ContentAction b.onMessage(context.Background(), event, ad) waitLaneIdle(t, b, 2*time.Second) if stats := b.LaneStats(); stats.DroppedStale != 0 || stats.DroppedOverflow != 0 { t.Fatalf("exempt messages must never be counted as dropped: %+v", stats) } if got := chat.userTexts(); len(got) < 2 || got[1] != "late owner private" { t.Fatalf("owner private turns = %v, want the late one to run despite its age", got) } } // TestLaneTurnSeesContextAssembledAtStart 断言 ④(最新性):一条在车道上排队的消息, // 开跑时看到的是**当时**的历史 —— 前一轮刚落库的回复就在它的上下文里。 // 若上下文在入队时就被快照,第二条绝看不到 reply:first。 func TestLaneTurnSeesContextAssembledAtStart(t *testing.T) { t.Parallel() gate := make(chan struct{}) chat := &laneChatter{} chat.onRun = func(user string) { if user == "first" { <-gate // 让第二条在车道上等,第一轮的落库全在它之后 } } b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{owner: true}, Owner: testOwnerProfile("telegram", "100"), History: newLaneHistory(), }, nil) ad := &laneAdapter{name: "telegram"} b.onMessage(context.Background(), ownerMsg("first"), ad) chat.waitRuns(t, 1, 5*time.Second) b.onMessage(context.Background(), ownerMsg("second"), ad) close(gate) chat.waitRuns(t, 2, 5*time.Second) var assistant []string for _, m := range chat.runAt(1).messages { if m.Role == model.RoleAssistant { assistant = append(assistant, lastText(m)) } } // 转录会把历史行渲染成段落(`
…
`),因此按包含判定,不按字面相等。 joined := strings.Join(assistant, "\n") if !strings.Contains(joined, "reply:first") { t.Fatalf("second turn assistant transcript = %q, want it to contain reply:first", joined) } } // TestLaneDeliveryNeverBlocksTheReadLoop 是**读与回水解耦**的回归断言。 // // 旧形状(报障现场):适配器的读循环调用 onMessage,onMessage 同步抢 16 个全局回合槽, // 槽满即**停止读 socket** —— 心跳不被回、后续帧不被解析,消息堆在连接缓冲里,跑完一轮 // 再按条重放(「后面才回复前面的话」)。 // // 新形状:投递立刻返回(进车道排队),全局回合槽在车道 worker 里取。这里把 16 个槽全部 // 占满,然后走一遍「读循环 → onMessage(一帧) → 心跳帧」:心跳必须立刻被读到, // 而那一帧的回合一个都不许开跑(槽确实还满着)。 func TestLaneDeliveryNeverBlocksTheReadLoop(t *testing.T) { t.Parallel() chat := &laneChatter{} b := newLaneBridge(t, Options{ Loop: chat, Sessions: &laneSessions{owner: true}, Owner: testOwnerProfile("telegram", "100"), }, nil) ad := &laneAdapter{name: "telegram"} // 占满全局回合槽:此刻任何一个回合都无法开跑。 for i := 0; i < maxConcurrent; i++ { b.sem <- struct{}{} } // 读循环的形状:串行处理每一帧;「心跳」是同一循环里的一帧(真实的 qq 适配器在 // readConn 里对 opPing 回 opPong,位置就在 dispatch 之前两行)。 type frame struct { msg *adapter.UnifiedMessage pong chan struct{} } frames := make(chan frame, 4) loopCtx, stopReader := context.WithCancel(context.Background()) defer stopReader() readerDone := make(chan struct{}) go func() { defer close(readerDone) for { select { case <-loopCtx.Done(): return case f := <-frames: if f.pong != nil { close(f.pong) continue } b.onMessage(loopCtx, *f.msg, ad) // ← 投递必须立即返回 } } }() slow := ownerMsg("slow turn") frames <- frame{msg: &slow} pong := make(chan struct{}) frames <- frame{pong: pong} select { case <-pong: case <-time.After(2 * time.Second): t.Fatal("read loop never reached the heartbeat frame: delivery is blocking the reader") } // 槽还满着 ⇒ 回合一个都不该开跑;投递却已经返回并把消息排进了车道。 if n := chat.runCount(); n != 0 { t.Fatalf("turns started = %d while every global slot was taken, want 0", n) } // 让位:释放槽,回合照旧跑完(排队不等于丢弃)。 for i := 0; i < maxConcurrent; i++ { <-b.sem } chat.waitRuns(t, 1, 5*time.Second) stopReader() <-readerDone } // laneHistory 是最小的历史存储替身:只实现入站流水线真正用到的两条(AppendFull / Recent), // 其余方法由内嵌的 nil 接口兜住(被调用即 panic —— 测试因此不会静默走错路)。 type laneHistory struct { history.HistoryStore mu sync.Mutex rows map[string][]history.Message } func newLaneHistory() *laneHistory { return &laneHistory{rows: map[string][]history.Message{}} } func (h *laneHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) { h.mu.Lock() defer h.mu.Unlock() h.rows[msg.SessionID] = append(h.rows[msg.SessionID], msg) return msg, nil } func (h *laneHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) { h.mu.Lock() defer h.mu.Unlock() rows := h.rows[sessionID] if len(rows) > n { rows = rows[len(rows)-n:] } return append([]history.Message(nil), rows...), nil } // kookGroupMsg 是一条 kook 群消息的**生产形状**:`ChannelID` 是 text channel(频道), // `GroupID` 是 guild。发送者是白名单里的主人且这条**指向她本人**(`AddressedToMe`, // 结构化字段),因此它必定进回合 —— 与真人「主人在群里 @ 她」同一条路。 // // 它会进回合而**不受**非主人群聊的最小打扰分级影响,这正是我们要的:本用例要观察的 // 只有「车道怎么分」,不是「要不要回答」。 func kookGroupMsg(channelID, groupID, content string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "kook", ChannelID: channelID, ChannelType: adapter.ChannelGroup, GroupID: groupID, SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: content, ContentType: adapter.ContentText, AddressedToMe: true, } } // TestLaneKeyNeverCollidesAcrossSessions 钉住复审 P1-1 的修法:**车道键与会话键同源** // (`laneKey` 逐字就是 `session.Key`),因此「同一个 session」绝不可能有两条车道。 // // 表里必须含复审给出的两条**真实反例**(它们在旧实现 `a.Name()+"|"+ConversationKey` // 下会分成两条车道、两个回合并发): // - kook:同一 guild 的两个 text channel(c1/c2)—— 会话层按 `group: