feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
(0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条
⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。
我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
This commit is contained in:
1 parent
cb232e7360
commit
fe4c702f87
6 files changed
+369
-8
No files matched your search
@@ -346,3 +346,68 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
|
||||
t.Fatal("read_at should be dropped by the 0010 down migration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreUnreadCount 锁定**单会话**未读数(契约 v0.76 的实时面用它换角标):
|
||||
// 它与 ListSessions 的列表口径同源 —— 同一个 `created_at > read_at` 谓词,
|
||||
// 因此同一时刻两条面给出同一个数字,不会有第二份未读定义。
|
||||
//
|
||||
// 四条边界一并钉住:空 id ⇒ 0;**没有会话行的消息流** ⇒ 0(不是「全部未读」,
|
||||
// 那种会话根本没有读游标、也不出现在已读游标面上);严格 `>`(游标压在 T 上的
|
||||
// 消息算已读);游标推进后归零、其后的新消息重新计入。
|
||||
func TestStoreUnreadCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
for _, m := range []Message{
|
||||
{SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000},
|
||||
{SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000},
|
||||
{SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500},
|
||||
// 没有 chat_sessions 行的消息流(隔离渠道会话 / 后台思考会话的形状):
|
||||
// 消息落得进去,会话行从不插入。
|
||||
{SessionID: "sess-backthink", Role: RoleAssistant, Content: "no session row", CreatedAt: 9000},
|
||||
} {
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("seed %q: %v", m.Content, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 与列表口径逐条同值 —— 这是「不许有第二份未读定义」的机器化断言。
|
||||
for _, sessionID := range []string{MainSessionID, sub} {
|
||||
got, err := st.UnreadCount(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("UnreadCount(%s): %v", sessionID, err)
|
||||
}
|
||||
if want := sessionOf(t, st, sessionID).UnreadCount; got != want {
|
||||
t.Fatalf("UnreadCount(%s) = %d, but the session list says %d", sessionID, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if got, err := st.UnreadCount(ctx, ""); err != nil || got != 0 {
|
||||
t.Fatalf("UnreadCount(\"\") = (%d, %v), want (0, nil)", got, err)
|
||||
}
|
||||
if got, err := st.UnreadCount(ctx, "sess-backthink"); err != nil || got != 0 {
|
||||
t.Fatalf("UnreadCount(sess-backthink) = (%d, %v), want (0, nil): 没有会话行就没有游标,不得把它的历史当成未读", got, err)
|
||||
}
|
||||
|
||||
// 严格 `>`:游标压在最新消息的毫秒上就把角标清零。
|
||||
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil {
|
||||
t.Fatalf("mark main read: %v", err)
|
||||
}
|
||||
if got, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 0 {
|
||||
t.Fatalf("UnreadCount(main) after cursor 2000 = (%d, %v), want (0, nil)", got, err)
|
||||
}
|
||||
// 游标之后的新消息重新计入,且**只**计它 —— 子会话的游标没被碰过。
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil {
|
||||
t.Fatalf("append main-3: %v", err)
|
||||
}
|
||||
if got, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 1 {
|
||||
t.Fatalf("UnreadCount(main) after a newer message = (%d, %v), want (1, nil)", got, err)
|
||||
}
|
||||
if got, err := st.UnreadCount(ctx, sub); err != nil || got != 1 {
|
||||
t.Fatalf("UnreadCount(sub) = (%d, %v), want (1, nil): 读主会话不得动子会话", got, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user