fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 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 成功再打活跃 ⇒ 全部打红
This commit is contained in:
AskaEth committed 2026-10-05 16:02:36 +08:00
1 parent 1bf85a955b
commit 84c124f5a9
18 files changed
+1545 -46

No files matched your search

@@ -0,0 +1,280 @@
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)
}
}
}