feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-06 07:58:42 +08:00
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)
}
}