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

+12
View File
@@ -32,6 +32,11 @@ func (s *Server) BroadcastPersisted(msg history.Message) {
// 「这是她主动说的话」的结构化判据 `proactive:true`(切片 P0b,见 proactive.go 的
// BroadcastProactivePersisted)。两条入口走**同一个**函数,因此帧类型推导、thinking
// 抹除与角色门(design/11 §7.1)逐字一致 —— 差别只有那一个键。
//
// 帧的 data 另带**可选**的 `unreadCount`(契约 v0.76):该会话此刻的绝对未读数,
// 与 GET /sessions 的 `unreadCount` 同一口径。它只在 >0 且真能算出来时出现(见
// unreadCountOf),缺席即老行为。Web 路径与 IM 路径共用本函数 ⇒ 两处口径天然一致,
// 不存在「Web 帧带、IM 帧不带」的分叉。
func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
wire := toHistoryMessage(msg)
wire.Thinking = ""
@@ -43,6 +48,13 @@ func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
if proactive {
data["proactive"] = true
}
// 未读数只在**确实算出来、且非零**时进帧(契约 v0.76)。缺席 = 老行为:老客户端
// 忽略未知键,新客户端落下这帧时角标已经是对的,不必再拉一次 GET /sessions。
// 不用 0 顶位:0 只能从「会话有游标」推出来,而算不出来(未接线/查询失败/
// 会话没有游标)时那个 0 就是编的(见 unreadCountOf)。
if n, ok := s.unreadCountOf(msg.SessionID); ok && n > 0 {
data["unreadCount"] = n
}
s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: data})
}