修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可): - 后端 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 成功再打活跃 ⇒ 全部打红
118 lines
4.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|