Files
AskaEth 8f6a75b7d7
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): IM 车道生成并写入真实 turn_id(user/assistant 成对 + 同源 WithTurnID;群聊隔离与静默语义不变)
2026-10-05 12:22:44 +08:00

138 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"strings"
"testing"
"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/user"
)
// 本文件断言 IM 入站车道的**回合标识接线**(design/15 §2.5.9 的缺口形状):
// IM 没有编排器,落库行过去一律是空 turn_id,于是前端按 (sessionId, turnId)
// 两级分桶时分不进任何回合,只能按时间平铺 —— 面板看着交错只是症状,数据缺
// 归属才是缺口。渠道桥自己就是这条车道的回合权威(turnid.go),因此一次入站
// 回合的两条行(用户消息 + 助手回复)必须带**同一个非空**的 turn id,且它与
// 挂到 loop ctx 上的那个值同源(流帧与历史行同值)。
//
// 主线(主人私聊镜像进主会话)与隔离线(群聊/陌生人留在 sess-xxx)都断言,
// 同时确认隔离语义没被破坏:受限消息仍绝不镜像进主会话。
// assertTurnIDsPaired 断言两条落库行带的 turn id 非空且同值。
func assertTurnIDsPaired(t *testing.T, userRow, assistantRow history.Message) {
t.Helper()
if userRow.TurnID == "" {
t.Fatalf("user row turn_id is empty; want a real turn id (record=%+v)", userRow)
}
if assistantRow.TurnID == "" {
t.Fatalf("assistant row turn_id is empty; want the same turn id as the user row")
}
if userRow.TurnID != assistantRow.TurnID {
t.Fatalf("turn ids differ: user=%q assistant=%q; want one turn id per turn",
userRow.TurnID, assistantRow.TurnID)
}
if !strings.HasPrefix(userRow.TurnID, "turn-") {
t.Fatalf("turn id = %q, want the gateway-compatible %q prefix", userRow.TurnID, "turn-")
}
}
// TestOwnerPrivateTurnIDPersistedAndPaired 覆盖主人私聊(镜像进 Web 主会话):
// 用户消息与助手回复落在主会话,且带同一个非空 turn id。
func TestOwnerPrivateTurnIDPersistedAndPaired(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), directMessage())
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
for _, r := range recs {
if r.SessionID != history.MainSessionID {
t.Fatalf("record session = %q, want %q (owner private mirrors to main)", r.SessionID, history.MainSessionID)
}
}
assertTurnIDsPaired(t, recs[0], recs[1])
// 流帧与历史行**同源**:loop ctx 上挂的 turn id 必须就是落库的那个值,
// 否则前端按帧分桶与按行分桶会得到两个答案。
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)
}
}
// TestIsolatedGroupTurnIDPersistedAndPaired 覆盖受限(群聊)车道:被放行的非主人
// 群消息仍留在其隔离会话(绝不镜像),同样带上成对的非空 turn id。
func TestIsolatedGroupTurnIDPersistedAndPaired(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "group ack"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 被点名 ⇒ 结构上被放行、驱动真回合(design/12 §6 策略二)。
b.process(context.Background(), nonOwnerGroupMsg("hello all", adapter.Mention{UserID: "9"}))
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
for _, r := range recs {
if r.SessionID != "sess-g" {
t.Fatalf("record session = %q, want sess-g (restricted lanes never mirror to main)", r.SessionID)
}
}
assertTurnIDsPaired(t, recs[0], recs[1])
}
// TestSilencedNonOwnerStillHasNoTurnID 断言隔离行为不变的那一侧:被静默登记、
// **根本不会被回答**的非主人消息不存在回合,turn id 必须仍为空 —— 给它编一个
// 标识就是把不属于任何回合的行说成有回合。它同时确认这类消息仍只落隔离会话。
func TestSilencedNonOwnerStillHasNoTurnID(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" {
t.Fatalf("loop ran for a silenced message: session=%q", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("appends = %d, want 1 (registered only, no reply)", len(recs))
}
if recs[0].SessionID != "sess-g" || recs[0].TurnID != "" {
t.Fatalf("registered record = %+v, want isolated sess-g with empty turn_id", recs[0])
}
}