Files
Cyrene_v2/internal/services/backthink/push_active_delivery_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

118 lines
4.2 KiB
Go

package backthink
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 本文件锁定切片 P0「她主动找你」的**活跃客户端路由口径**:
//
// - PushResult.Detail 必须把「送到了几条连接」与「有没有落进主会话」**分开**
// 报告 —— 修复前那句「delivered to 2 active client(s)」被读者当成「已推送
// (刷新也在)」,而当时一行都没落库;
// - 一次推送**至多让一个 sink 成功**(IM 桥与活跃客户端互斥),这正是
// 「主会话里不会出现双份」的结构性依据:两条路由各自落库一次,而一次推送
// 只走其中一条。
// TestPusher_ActiveDetailReportsPersistence 验证 Detail 分别报告投递数与落库。
func TestPusher_ActiveDetailReportsPersistence(t *testing.T) {
t.Parallel()
tests := []struct {
name string
active *mockActive
wantPushed bool
wantIn string
wantNotIn string
}{
{
name: "投递成功且已落库",
active: &mockActive{n: 2, persisted: true},
wantPushed: true,
wantIn: "persisted to main session",
wantNotIn: "not persisted",
},
{
name: "投递成功但未落库(故障可见)",
active: &mockActive{n: 2, persisted: false},
wantPushed: true,
wantIn: "not persisted to main session",
},
{
name: "无在线客户端但已落库",
active: &mockActive{n: 0, persisted: true},
wantPushed: false,
wantIn: "recorded in main session",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := newPusher(tc.active, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{
ChannelHint: channelWeb,
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
})
if res.Pushed != tc.wantPushed {
t.Fatalf("Pushed = %v, want %v (res=%+v)", res.Pushed, tc.wantPushed, res)
}
if !strings.Contains(res.Detail, tc.wantIn) {
t.Fatalf("Detail = %q, want it to contain %q", res.Detail, tc.wantIn)
}
if tc.wantNotIn != "" && strings.Contains(res.Detail, tc.wantNotIn) {
t.Fatalf("Detail = %q, must not contain %q", res.Detail, tc.wantNotIn)
}
})
}
}
// TestPusher_OnePushSucceedsOnAtMostOneSink 验证一次推送**至多让一个 sink 成功**
// —— 互斥是「主会话里不会双份」的结构性依据。IM 失败后的活跃客户端降级是同一
// 次推送内的兜底:失败的 IM 什么都没写,因此整体仍只有一条记录。
func TestPusher_OnePushSucceedsOnAtMostOneSink(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hint string
imErr error
activeN int
wantIMCalls int
wantActiveCall int
}{
{name: "web 提示只打活跃客户端", hint: channelWeb, activeN: 1, wantIMCalls: 0, wantActiveCall: 1},
{name: "auto 提示 IM 可用时只打 IM", hint: channelAuto, activeN: 2, wantIMCalls: 1, wantActiveCall: 0},
{name: "IM 失败时降级到活跃客户端", hint: channelAuto, imErr: errors.New("no owner IM channel"), activeN: 1, wantIMCalls: 1, wantActiveCall: 1},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
active := &mockActive{n: tc.activeN, persisted: true}
im := &mockIMAdapter{err: tc.imErr}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{
ChannelHint: tc.hint,
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
})
if im.count() != tc.wantIMCalls || active.calls != tc.wantActiveCall {
t.Fatalf("sink calls: im=%d (want %d) active=%d (want %d)",
im.count(), tc.wantIMCalls, active.calls, tc.wantActiveCall)
}
// 成功的 sink 至多一个:能落库的路径因此也至多一条。
imOK := tc.imErr == nil && tc.wantIMCalls == 1
activeOK := tc.activeN > 0 && tc.wantActiveCall == 1
if imOK && activeOK {
t.Fatalf("both sinks succeeded in one push (res=%+v)", res)
}
if !imOK && !activeOK && res.Pushed {
t.Fatalf("Pushed = true without a successful sink (res=%+v)", res)
}
})
}
}