Files
Cyrene_v2/internal/services/gateway/proactive_persist_test.go
T
AskaEth 84c124f5a9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来
- 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知)

后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库)
  载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」
前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级)
  + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表)
  + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result)
  + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑)
双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次
契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
2026-10-05 16:02:36 +08:00

281 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"errors"
"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/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定切片 P0 的**落库**那一半:主动消息无论走活跃客户端路由
// (gateway hub)还是 IM 桥路由(channelbridge),都必须成为主会话
// (`history.MainSessionID`)里**恰好一条** assistant 消息。
//
// 为什么需要它:修复前活跃客户端路由只广播 `proactive_message` 帧、一行都不写,
// 于是「帧被 2 个连接收下」被读成「已推送」,刷新后什么都不剩(主人报的正是
// 「后台思考的主动推送一个都没推出来」)。另一半风险是**双份** —— IM 适配器
// 出站消息本来就会被镜像回主会话(channelbridge/proactive.go 的 persistToMain),
// 若活跃路由也写一次、而一条推送同时走了两条路,主会话里就会出现两条。
// 因此这里两个路由各自跑一遍,逐个断言「恰好一条」,并额外断言
// 一条推送只会触发**一路**写入。
// proactiveStoreMsg 构造一条最小化的自主主动消息,等同于 backthink 的
// buildProactiveMessage 在无渠道决策(通用哨兵值)时的产物。
func proactiveStoreMsg(content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "cyrene-backthink",
ChannelType: adapter.ChannelDirect,
Content: content,
ContentType: adapter.ContentText,
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
}
}
// mainSessionRows 读回主会话的落库行(最新优先)。
func mainSessionRows(t *testing.T, store *history.Store) []history.Message {
t.Helper()
rows, err := store.List(context.Background(), history.MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list main session: %v", err)
}
return rows
}
// TestHub_SendProactive_PersistsAssistantMessageToMainSession 验证活跃客户端
// 路由把主动消息写成主会话里的一条 assistant 消息(「气泡恒落」的根:刷新后
// 仍在),且发出去的帧带上落库行的权威 id/时间戳 —— 客户端据此把实时气泡
// 对齐到历史行,而不是让它刷新即丢。
func TestHub_SendProactive_PersistsAssistantMessageToMainSession(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
delivery, err := hb.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦"))
if err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if delivery.Connections != 1 {
t.Fatalf("connections = %d, want 1", delivery.Connections)
}
if !delivery.Persisted {
t.Fatalf("Persisted = false, want true (主动消息必须落进主会话,否则刷新就没了)")
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
}
row := rows[0]
if row.Role != history.RoleAssistant {
t.Fatalf("role = %q, want %q(她单方面开口的话,绝不能冒充主人的发言)", row.Role, history.RoleAssistant)
}
if row.SessionID != history.MainSessionID {
t.Fatalf("session = %q, want %q", row.SessionID, history.MainSessionID)
}
if row.Content != "记得喝水哦" {
t.Fatalf("content = %q, want the proactive text verbatim", row.Content)
}
if row.TurnID != "" {
t.Fatalf("turn_id = %q, want empty(主动推送不属于任何一路回合)", row.TurnID)
}
ev := readFakeFrame(t, owner)
if ev.Type != wsProactiveMessage {
t.Fatalf("frame type = %q, want %q", ev.Type, wsProactiveMessage)
}
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
}
if data["id"] != row.ID {
t.Fatalf("frame id = %v, want the persisted row id %q", data["id"], row.ID)
}
if got, _ := data["createdAt"].(float64); int64(got) != row.CreatedAt {
t.Fatalf("frame createdAt = %v, want %d", data["createdAt"], row.CreatedAt)
}
}
// failingHistory 让 AppendFull 失败,用于验证「落库失败**可见**」:帧照发
// (实时气泡不能丢)、错误如实返回、Persisted=false(绝不谎报已推送)。
type failingHistory struct {
HistoryStore
err error
}
func (f failingHistory) AppendFull(context.Context, history.Message) (history.Message, error) {
return history.Message{}, f.err
}
// TestHub_SendProactive_PersistFailureIsVisible 验证落库失败时不做假成功:
// 错误被返回、Persisted=false、Detail 的措辞由调用方如实反映,而帧仍然发出。
func TestHub_SendProactive_PersistFailureIsVisible(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
hb.history = failingHistory{err: errors.New("db down")}
owner := newOwnerConn(hb, 8)
hb.register(owner)
delivery, err := hb.SendProactive(context.Background(), proactiveStoreMsg("在吗"))
if err == nil {
t.Fatalf("err = nil, want the persist failure to be reported")
}
if delivery.Persisted {
t.Fatalf("Persisted = true, want false(没落库就是没落库)")
}
if delivery.Connections != 1 {
t.Fatalf("connections = %d, want 1(落库失败不该吞掉实时帧)", delivery.Connections)
}
var frameData any
if ev := readFakeFrame(t, owner); ev.Type != wsProactiveMessage {
t.Fatalf("frame type = %q, want %q", ev.Type, wsProactiveMessage)
} else {
frameData = ev.Data
}
data, ok := frameData.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frameData)
}
if _, hasID := data["id"]; hasID {
t.Fatalf("frame carries id %v, want the key omitted(没有落库行就没有权威 id)", data["id"])
}
}
// fakeIMAdapter 是 IM 路由测试所需的最小适配器(只用到连接态与出站)。
type fakeIMAdapter struct {
platform string
sends []adapter.UnifiedMessage
}
func (f *fakeIMAdapter) Name() string { return f.platform }
func (f *fakeIMAdapter) Config() map[string]any { return map[string]any{} }
func (f *fakeIMAdapter) Connect(context.Context) error { return nil }
func (f *fakeIMAdapter) Disconnect(context.Context) error { return nil }
func (f *fakeIMAdapter) IsConnected() bool { return true }
func (f *fakeIMAdapter) HealthCheck(context.Context) error { return nil }
func (f *fakeIMAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (f *fakeIMAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
f.sends = append(f.sends, resp.Messages...)
return nil
}
func (f *fakeIMAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error {
f.sends = append(f.sends, msg)
return nil
}
func (f *fakeIMAdapter) SetMessageHandler(adapter.MessageHandler) {}
func (f *fakeIMAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (f *fakeIMAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
return nil, nil
}
// stubBridgeChatter 满足 channelbridge.Chatter(本测试不会跑回合)。
type stubBridgeChatter struct{}
func (stubBridgeChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) {
return &loop.Result{Text: "hi"}, nil
}
// stubBridgeSessions 满足 channelbridge.SessionStore(本测试不会走入站路径)。
type stubBridgeSessions struct{}
func (stubBridgeSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) {
return session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}, nil
}
// newProactiveTestBridge 装配一条接在**真历史存储**上的 IM 桥(一个已连接、
// 在白名单里的主人渠道),使 IM 路由的落库能被真库断言。
func newProactiveTestBridge(t *testing.T, store *history.Store, onPersisted *[]history.Message) (*channelbridge.Bridge, *fakeIMAdapter) {
t.Helper()
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}}
var hook func(history.Message)
if onPersisted != nil {
hook = func(m history.Message) { *onPersisted = append(*onPersisted, m) }
}
b, err := channelbridge.New(channelbridge.Options{
Log: gwTestLogger(),
Loop: stubBridgeChatter{},
Sessions: stubBridgeSessions{},
Owner: owner,
History: store,
OnPersisted: hook,
})
if err != nil {
t.Fatalf("new bridge: %v", err)
}
ad := &fakeIMAdapter{platform: "telegram"}
b.Add(ad)
return b, ad
}
// TestChannelBridge_SendProactive_MirrorsExactlyOnce 是**现状核查**的可执行
// 证据:IM 路由本来就会把主动消息镜像回主会话(且只镜像一次)。因此在这条
// 路由上再加一次落库就是双份 —— 活跃路由的落库正是靠「两条路由互斥」避开它的。
func TestChannelBridge_SendProactive_MirrorsExactlyOnce(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
var persisted []history.Message
bridge, ad := newProactiveTestBridge(t, store, &persisted)
if err := bridge.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦")); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if len(ad.sends) != 1 {
t.Fatalf("IM sends = %d, want 1", len(ad.sends))
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
}
if rows[0].Role != history.RoleAssistant || !strings.Contains(rows[0].Content, "记得喝水哦") {
t.Fatalf("row = %+v, want an assistant message carrying the proactive text", rows[0])
}
if len(persisted) != 1 || persisted[0].ID != rows[0].ID {
t.Fatalf("OnPersisted 应恰好收到刚落地的那一条(Web 端据此实时镜像),得到 %+v", persisted)
}
}
// TestProactiveRoutes_EachRouteAddsExactlyOneRow 把两条路由对**同一条**主动
// 消息各跑一次:主会话里应恰好两条(每条路由各一条)。它同时钉住两件事 ——
// ① 两条路由都真的落库(不再有「只发帧不落库」的那条);② 没有一条路由写两份
// (若活跃路由也走 IM 镜像那一次,这里会是 3 条)。
func TestProactiveRoutes_EachRouteAddsExactlyOneRow(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
hb.register(newOwnerConn(hb, 8))
bridge, _ := newProactiveTestBridge(t, store, nil)
msg := proactiveStoreMsg("两条路各跑一次")
if _, err := hb.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("active route err = %v", err)
}
if got := len(mainSessionRows(t, store)); got != 1 {
t.Fatalf("活跃路由之后主会话有 %d 条,want 1", got)
}
if err := bridge.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("im route err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 2 {
t.Fatalf("两条路由各跑一次后主会话有 %d 条,want 2(3 条 = 有一条路由写了双份)", len(rows))
}
for _, row := range rows {
if row.Role != history.RoleAssistant {
t.Fatalf("row role = %q, want assistant", row.Role)
}
}
}